Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cdb911b
feat(types): add Order, OrderItem, CreateOrderRequest interfaces
Jun 30, 2026
096b63d
feat(strapi): add Order content-type scoped to the authenticated user
Jun 30, 2026
b94cf15
feat(backend): add JWT-protected orders endpoints proxying Strapi
Jun 30, 2026
3251876
feat(frontend): order API helpers, pending-nav key, cart re-order map…
Jun 30, 2026
44e258e
feat(frontend): auth-gated checkout/history nav with post-login auto-…
Jun 30, 2026
58490c5
feat(checkout): checkout page with delivery+payment form, order summary
Jun 30, 2026
3488a46
feat(checkout): order-received success modal with delivery countdown
Jun 30, 2026
1b9bbca
feat(orders): order history page listing the user's past orders
Jun 30, 2026
f0b14bb
feat(orders): order summary modal with order-again and cart open
Jun 30, 2026
43ca7c0
fix: address code review findings
Jul 1, 2026
a822a6a
feat: scope cart to user ID, conflict resolution on login
Jul 1, 2026
b2d52ed
fix(checkout): layout polish, expiry auto-format, and admin token lo…
Jul 1, 2026
1bb28c4
fix(orders): robust admin token loading and orders 401 no longer logs…
Jul 1, 2026
92b9d2f
fix(orders): resolve caller user id instead of relying on admin token…
Jul 2, 2026
8459955
fix(checkout): success modal shows over home page with redesigned layout
Jul 2, 2026
9ffe4c2
feat(orders): move order summary close button outside, replace window…
Jul 2, 2026
e5c7d84
feat(cart): custom replace-cart confirm, polish conflict modal, gate …
Jul 2, 2026
d640bdc
feat(checkout): mobile-only success dropdown, header X, order history…
Jul 2, 2026
7dff50c
fix(orders): address code review findings — auth resilience, cart bug…
Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/backend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { DishesModule } from './dishes/dishes.module';
import { SearchModule } from './search/search.module';
import { AuthModule } from './auth/auth.module';
import { NavigationModule } from './navigation/navigation.module';
import { OrdersModule } from './orders/orders.module';

@Module({
imports: [RestaurantsModule, ChefsModule, DishesModule, SearchModule, AuthModule, NavigationModule],
imports: [RestaurantsModule, ChefsModule, DishesModule, SearchModule, AuthModule, NavigationModule, OrdersModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
40 changes: 40 additions & 0 deletions apps/backend/src/app/orders/create-order.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsNumber,
IsOptional,
IsPositive,
IsString,
ValidateNested,
} from 'class-validator';

export class DeliveryDto {
@IsString() @IsNotEmpty() name!: string;
@IsString() @IsNotEmpty() address!: string;
@IsString() @IsNotEmpty() phone!: string;
}

export class OrderItemDto {
@IsNumber() dishId!: number;
@IsString() @IsNotEmpty() name!: string;
@IsNumber() @IsPositive() price!: number;
@IsNumber() @IsPositive() quantity!: number;
@IsString() @IsNotEmpty() imageUrl!: string;
@IsOptional() @IsString() selectedSide?: string;
@IsArray() @IsString({ each: true }) selectedChanges!: string[];
}

export class CreateOrderDto {
@IsNumber() restaurantId!: number;
@IsString() @IsNotEmpty() restaurantName!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items!: OrderItemDto[];
@IsOptional() @IsString() comment?: string;
@IsNumber() @IsPositive() total!: number;
@ValidateNested()
@Type(() => DeliveryDto)
delivery!: DeliveryDto;
}
24 changes: 24 additions & 0 deletions apps/backend/src/app/orders/orders.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller, Get, Post, Body, Headers, UnauthorizedException } from '@nestjs/common';
import type { Order } from '@org/shared-types';
import { CreateOrderDto } from './create-order.dto';
import { OrdersService } from './orders.service';

@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}

@Post()
create(@Headers('authorization') auth: string | undefined, @Body() body: CreateOrderDto): Promise<Order> {
return this.ordersService.create(this.extractToken(auth), body);
}

@Get()
findAll(@Headers('authorization') auth: string | undefined): Promise<Order[]> {
return this.ordersService.findForUser(this.extractToken(auth));
}

private extractToken(auth?: string): string {
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authentication token');
return auth.slice('Bearer '.length);
}
}
11 changes: 11 additions & 0 deletions apps/backend/src/app/orders/orders.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { StrapiClientModule } from '../strapi-client/strapi-client.module';

@Module({
imports: [StrapiClientModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
63 changes: 63 additions & 0 deletions apps/backend/src/app/orders/orders.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import type { Order, CreateOrderRequest } from '@org/shared-types';
import { StrapiClientService } from '../strapi-client/strapi-client.service';
import type { StrapiOrder, StrapiSingleResponse } from '../strapi-client/strapi-types';

@Injectable()
export class OrdersService {
constructor(private readonly strapiClient: StrapiClientService) {}

// Use admin token from env for the actual Strapi read/write — the admin token authenticates
// as no particular Strapi user, so we still resolve the caller's real user id via
// getUserId() and pass it explicitly for ownership/filtering.
// Note: getUserId() itself still requires the caller's JWT to resolve against a live user
// record in Strapi. It does NOT survive a Strapi DB reset — a JWT for a user id that no
// longer exists fails here before the admin token is ever used. That's intentional: the
// admin token protects the write/read from becoming misauthenticated, not from serving a
// stale session. After a DB reset, users must log out and back in to get a fresh JWT.
private get adminToken(): string | undefined {
return process.env['STRAPI_ADMIN_TOKEN'] ?? undefined;
}

async create(userToken: string, req: CreateOrderRequest): Promise<Order> {
const userId = await this.strapiClient.getUserId(userToken);
const payload = {
data: {
restaurantId: req.restaurantId,
restaurantName: req.restaurantName,
items: req.items,
comment: req.comment,
total: req.total,
deliveryName: req.delivery.name,
deliveryAddress: req.delivery.address,
deliveryPhone: req.delivery.phone,
user: userId,
},
};
const token = this.adminToken ?? userToken;
const res = await this.strapiClient.post<StrapiSingleResponse<StrapiOrder>>('/api/orders', payload, token);
return this.transform(res.data);
}

async findForUser(userToken: string): Promise<Order[]> {
const userId = await this.strapiClient.getUserId(userToken);
const token = this.adminToken ?? userToken;
const items = await this.strapiClient.get<StrapiOrder>(
`/api/orders?sort=createdAt:desc&pagination[pageSize]=100&filters[user]=${userId}`,
token,
);
return items.map(o => this.transform(o));
}

private transform(o: StrapiOrder): Order {
return {
id: o.id,
restaurantId: o.restaurantId,
restaurantName: o.restaurantName,
items: o.items ?? [],
comment: o.comment,
total: o.total,
createdAt: o.createdAt,
};
}
}
41 changes: 30 additions & 11 deletions apps/backend/src/app/strapi-client/strapi-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@ import {
HttpException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import type { StrapiSingleResponse } from './strapi-types';

const STRAPI_URL = process.env['STRAPI_URL'] ?? 'http://localhost:1337';

interface StrapiListResponse<T> {
data: T[];
}

interface StrapiSingleResponse<T> {
data: T;
}

@Injectable()
export class StrapiClientService {
private async request(path: string, init?: RequestInit): Promise<Response> {
Expand All @@ -33,15 +31,33 @@ export class StrapiClientService {
}
}

async get<T>(path: string): Promise<T[]> {
const res = await this.request(path);
if (!res.ok) {
throw new ServiceUnavailableException(`Strapi returned ${res.status}`);
}
// Not special-casing 401 here: this method is shared by public content reads
// (restaurants/chefs/dishes/search — never pass a token) and orders.findForUser
// (which does). A 401 propagated as UnauthorizedException would make the frontend's
// fetchApi fire onUnauthorized() and log the user out — wrong for a Strapi permissions
// misconfiguration on public content. Orders' own frontend calls (fetchOrders/createOrder)
// already bypass fetchApi/onUnauthorized and only care about the error message, not the
// status code, so falling through to the generic failure below changes nothing for them.
async get<T>(path: string, token?: string): Promise<T[]> {
const res = await this.request(path, token ? { headers: { Authorization: `Bearer ${token}` } } : undefined);
if (!res.ok) throw new ServiceUnavailableException(`Strapi returned ${res.status}`);
const body = await this.parseJson<StrapiListResponse<T>>(res);
return body.data ?? [];
}

// Resolves + validates the caller's user id via Strapi's own JWT check, independent
// of whichever token (user or admin) is used for the actual read/write that follows.
// A 401 here means the caller's session no longer resolves to a live Strapi user —
// most commonly a stale JWT after a Strapi DB reset — so the message tells them to
// re-authenticate rather than surfacing Strapi's generic rejection.
async getUserId(token: string): Promise<number> {
const res = await this.request('/api/users/me', { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) throw new UnauthorizedException('Your session has expired — please log in again');
if (!res.ok) throw new ServiceUnavailableException(`Strapi returned ${res.status}`);
const body = await this.parseJson<{ id: number }>(res);
return body.id;
}

async getById<T>(path: string): Promise<T> {
const res = await this.request(path);
if (res.status === 404) throw new NotFoundException('Resource not found');
Expand All @@ -50,10 +66,13 @@ export class StrapiClientService {
return body.data;
}

async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
async post<T>(path: string, body: Record<string, unknown>, token?: string): Promise<T> {
const res = await this.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/src/app/strapi-client/strapi-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import type { StrapiImage } from '@org/shared-types';
import type { StrapiImage, OrderItem } from '@org/shared-types';

export interface StrapiOrder {
id: number;
restaurantId: number;
restaurantName: string;
items: OrderItem[];
comment?: string;
total: number;
createdAt: string;
}

export interface StrapiSingleResponse<T> {
data: T;
}

export interface StrapiChef {
id: number;
Expand Down
17 changes: 12 additions & 5 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/
import { config } from 'dotenv';
import { existsSync } from 'fs';
import { resolve } from 'path';

const envCandidates = [
resolve(process.cwd(), 'apps/backend', '.env'),
resolve(process.cwd(), '.env'),
resolve(__dirname, '..', '.env'),
];
const envFile = envCandidates.find(p => existsSync(p));
if (envFile) config({ path: envFile });

import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
Expand All @@ -10,7 +17,7 @@ import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:3000' });
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/public/icons/lock-close.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apps/frontend/public/icons/lock-open.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions apps/frontend/src/app/checkout/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { TEXT } from '../../lib/text';

export default function CheckoutLayout({ children }: { children: ReactNode }) {
return (
<div className="epicure-checkout-layout">
<header className="epicure-checkout-header">
{/* Mobile only — desktop keeps the logo+title layout unchanged */}
<Link href="/" className="epicure-checkout-header__close" aria-label={TEXT.checkout.leaveAriaLabel}>
<img src="/icons/x.svg" alt="" aria-hidden="true" width={16} height={16} />
</Link>
<Link href="/" className="epicure-checkout-header__logo-link">
<img src="/icons/logo.svg" alt="" aria-hidden="true" width={34} height={34} className="epicure-checkout-header__logo" />
<span className="epicure-checkout-header__brand">{TEXT.nav.brandName}</span>
</Link>
<span className="epicure-checkout-header__title">{TEXT.checkout.pageTitle}</span>
</header>
{children}
</div>
);
}
Loading
Loading