diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index 48fb444..f182a10 100644 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -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], }) diff --git a/apps/backend/src/app/orders/create-order.dto.ts b/apps/backend/src/app/orders/create-order.dto.ts new file mode 100644 index 0000000..812cf09 --- /dev/null +++ b/apps/backend/src/app/orders/create-order.dto.ts @@ -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; +} diff --git a/apps/backend/src/app/orders/orders.controller.ts b/apps/backend/src/app/orders/orders.controller.ts new file mode 100644 index 0000000..3c4415b --- /dev/null +++ b/apps/backend/src/app/orders/orders.controller.ts @@ -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 { + return this.ordersService.create(this.extractToken(auth), body); + } + + @Get() + findAll(@Headers('authorization') auth: string | undefined): Promise { + 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); + } +} diff --git a/apps/backend/src/app/orders/orders.module.ts b/apps/backend/src/app/orders/orders.module.ts new file mode 100644 index 0000000..6563fb2 --- /dev/null +++ b/apps/backend/src/app/orders/orders.module.ts @@ -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 {} diff --git a/apps/backend/src/app/orders/orders.service.ts b/apps/backend/src/app/orders/orders.service.ts new file mode 100644 index 0000000..1925484 --- /dev/null +++ b/apps/backend/src/app/orders/orders.service.ts @@ -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 { + 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>('/api/orders', payload, token); + return this.transform(res.data); + } + + async findForUser(userToken: string): Promise { + const userId = await this.strapiClient.getUserId(userToken); + const token = this.adminToken ?? userToken; + const items = await this.strapiClient.get( + `/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, + }; + } +} diff --git a/apps/backend/src/app/strapi-client/strapi-client.service.ts b/apps/backend/src/app/strapi-client/strapi-client.service.ts index b7fbfa0..dfd2b6c 100644 --- a/apps/backend/src/app/strapi-client/strapi-client.service.ts +++ b/apps/backend/src/app/strapi-client/strapi-client.service.ts @@ -3,7 +3,9 @@ import { HttpException, NotFoundException, ServiceUnavailableException, + UnauthorizedException, } from '@nestjs/common'; +import type { StrapiSingleResponse } from './strapi-types'; const STRAPI_URL = process.env['STRAPI_URL'] ?? 'http://localhost:1337'; @@ -11,10 +13,6 @@ interface StrapiListResponse { data: T[]; } -interface StrapiSingleResponse { - data: T; -} - @Injectable() export class StrapiClientService { private async request(path: string, init?: RequestInit): Promise { @@ -33,15 +31,33 @@ export class StrapiClientService { } } - async get(path: string): Promise { - 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(path: string, token?: string): Promise { + 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>(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 { + 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(path: string): Promise { const res = await this.request(path); if (res.status === 404) throw new NotFoundException('Resource not found'); @@ -50,10 +66,13 @@ export class StrapiClientService { return body.data; } - async post(path: string, body: Record): Promise { + async post(path: string, body: Record, token?: string): Promise { 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) { diff --git a/apps/backend/src/app/strapi-client/strapi-types.ts b/apps/backend/src/app/strapi-client/strapi-types.ts index 24a74b9..1ed96d5 100644 --- a/apps/backend/src/app/strapi-client/strapi-types.ts +++ b/apps/backend/src/app/strapi-client/strapi-types.ts @@ -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 { + data: T; +} export interface StrapiChef { id: number; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index ecc2a58..b08013a 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -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'; @@ -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; diff --git a/apps/frontend/public/icons/lock-close.svg b/apps/frontend/public/icons/lock-close.svg new file mode 100644 index 0000000..dbdd7ea --- /dev/null +++ b/apps/frontend/public/icons/lock-close.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/frontend/public/icons/lock-open.svg b/apps/frontend/public/icons/lock-open.svg new file mode 100644 index 0000000..379fa7d --- /dev/null +++ b/apps/frontend/public/icons/lock-open.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/frontend/src/app/checkout/layout.tsx b/apps/frontend/src/app/checkout/layout.tsx new file mode 100644 index 0000000..8768f9f --- /dev/null +++ b/apps/frontend/src/app/checkout/layout.tsx @@ -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 ( +
+
+ {/* Mobile only — desktop keeps the logo+title layout unchanged */} + + + + + + {TEXT.nav.brandName} + + {TEXT.checkout.pageTitle} +
+ {children} +
+ ); +} diff --git a/apps/frontend/src/app/checkout/page.tsx b/apps/frontend/src/app/checkout/page.tsx new file mode 100644 index 0000000..8644a91 --- /dev/null +++ b/apps/frontend/src/app/checkout/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useCart } from '../../context/CartContext'; +import { useAuth } from '../../context/AuthContext'; +import { createOrder } from '../../lib/api'; +import { TEXT } from '../../lib/text'; +import { isCheckoutValid, type CheckoutFormState } from '../../lib/checkoutValidation'; +import { CheckoutForm } from '../../components/checkout/CheckoutForm'; +import { CheckoutOrderSummary } from '../../components/checkout/CheckoutOrderSummary'; +import type { CreateOrderRequest, OrderItem } from '@org/shared-types'; + +const EMPTY_FORM: CheckoutFormState = { + fullName: '', address: '', phone: '', cardNumber: '', nameOnCard: '', cvv: '', expiry: '', +}; + +export default function CheckoutPage() { + const router = useRouter(); + const { user } = useAuth(); + const { cartItems, restaurantId, restaurantName, totalPrice, comment, setComment, showOrderSuccess, clearCart } = useCart(); + const [form, setForm] = useState(EMPTY_FORM); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [mounted, setMounted] = useState(false); + + const committed = cartItems.filter(c => !c.pendingRemove); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (mounted && (!user || committed.length === 0)) router.replace('/'); + }, [mounted, user, committed.length, router]); + + // Return null until mounted so server and client first-render both produce null. + if (!mounted) return null; + if (!user || committed.length === 0) return null; + + const canPay = isCheckoutValid(form) && !submitting; + + async function handlePay() { + if (!canPay || restaurantId == null || restaurantName == null) return; + setSubmitting(true); + setError(null); + const items: OrderItem[] = committed.map(c => ({ + dishId: c.dish.id, name: c.dish.name, price: c.dish.price, + quantity: c.quantity, imageUrl: c.imageUrl, + selectedSide: c.selectedSide, selectedChanges: c.selectedChanges, + })); + const body: CreateOrderRequest = { + restaurantId, restaurantName, items, comment: comment || undefined, total: totalPrice, + delivery: { name: form.fullName, address: form.address, phone: form.phone }, + }; + try { + await createOrder(body); + showOrderSuccess({ items: committed, total: totalPrice }); + // Not calling router.push here — clearCart() below empties `committed`, + // which the redirect-guard effect above already reacts to by navigating home. + // Pushing here too would race with that effect's router.replace('/'). + clearCart(); + } catch (err) { + setError(err instanceof Error ? err.message : TEXT.checkout.submitError); + setSubmitting(false); + } + } + + return ( +
+
+ + {TEXT.checkout.orderSaved} +
+
+ setForm(prev => ({ ...prev, ...patch }))} /> +
+ + {/* Mobile only — desktop keeps the price inside the button */} +

+ {TEXT.checkout.total} —{' '} + + {totalPrice} +

+

{error ?? ''}

+ +
+
+
+ ); +} diff --git a/apps/frontend/src/app/orders/page.tsx b/apps/frontend/src/app/orders/page.tsx new file mode 100644 index 0000000..6b7a0dd --- /dev/null +++ b/apps/frontend/src/app/orders/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { Order } from '@org/shared-types'; +import { useAuth } from '../../context/AuthContext'; +import { fetchOrders } from '../../lib/api'; +import { TEXT } from '../../lib/text'; +import { OrderRow } from '../../components/orders/OrderRow'; +import { OrderSummaryModal } from '../../components/orders/OrderSummaryModal'; + +export default function OrdersPage() { + const router = useRouter(); + const { user } = useAuth(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (!user) { + router.replace('/'); + return; + } + let active = true; + fetchOrders() + .then(data => { if (active) setOrders(data); }) + .catch(() => { if (active) setOrders([]); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [user, router]); + + // Return null until mounted so server and client first-render both produce null. + if (!mounted) return null; + if (!user) return null; + + return ( +
+
+

{TEXT.orders.pageTitle}

+ {loading ? ( +
+ ) : orders.length === 0 ? ( +

{TEXT.orders.empty}

+ ) : ( +
+ {orders.map(order => ( + + ))} +
+ )} + {selected && setSelected(null)} />} +
+
+ ); +} diff --git a/apps/frontend/src/components/CartConflictModal.tsx b/apps/frontend/src/components/CartConflictModal.tsx new file mode 100644 index 0000000..c287840 --- /dev/null +++ b/apps/frontend/src/components/CartConflictModal.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { TEXT } from '../lib/text'; + +interface Props { + onLoadSaved: () => void; + onKeepCurrent: () => void; +} + +export function CartConflictModal({ onLoadSaved, onKeepCurrent }: Props) { + return ( + <> +
+
+ +

{TEXT.cartConflict.title}

+

{TEXT.cartConflict.message}

+

{TEXT.cartConflict.question}

+ + +
+ + ); +} diff --git a/apps/frontend/src/components/CartPanel.tsx b/apps/frontend/src/components/CartPanel.tsx index 235456f..d517951 100644 --- a/apps/frontend/src/components/CartPanel.tsx +++ b/apps/frontend/src/components/CartPanel.tsx @@ -9,9 +9,11 @@ import { CartFooter } from './cart-panel/CartFooter'; interface CartPanelProps { onClose: () => void; + onCheckout: () => void; + onOrderHistory: () => void; } -export function CartPanel({ onClose }: CartPanelProps) { +export function CartPanel({ onClose, onCheckout, onOrderHistory }: CartPanelProps) { const { cartItems, restaurantName, totalPrice, totalItems, comment, setComment } = useCart(); useEffect(() => { @@ -61,7 +63,7 @@ export function CartPanel({ onClose }: CartPanelProps) {
)} - 0} /> + 0} onCheckout={onCheckout} onOrderHistory={onOrderHistory} /> ); diff --git a/apps/frontend/src/components/Header.tsx b/apps/frontend/src/components/Header.tsx index ed04b97..058cd78 100644 --- a/apps/frontend/src/components/Header.tsx +++ b/apps/frontend/src/components/Header.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { NavDrawer } from './NavDrawer'; import { SearchOverlay } from './SearchOverlay'; import { CartPanel } from './CartPanel'; @@ -16,7 +16,7 @@ import { useSearch } from '../hooks/useSearch'; import { useClickOutside } from '../hooks/useClickOutside'; import type { NavLink } from '@org/shared-types'; import { strapiImageUrl } from '../lib/api'; -import { dispatchCuisineFilter, dispatchChefHighlight } from '../lib/events'; +import { dispatchCuisineFilter, dispatchChefHighlight, PENDING_NAV_KEY, OPEN_CART_EVENT } from '../lib/events'; type ActivePanel = 'none' | 'drawer' | 'search' | 'cart' | 'signin' | 'signup' | 'userdropdown'; @@ -30,6 +30,7 @@ interface HeaderProps { export default function Header({ brandName, logoUrl, navLinks, footerLinks }: HeaderProps) { const [activePanel, setActivePanel] = useState('none'); const pathname = usePathname(); + const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const { totalItems } = useCart(); const { user } = useAuth(); @@ -63,6 +64,22 @@ export default function Header({ brandName, logoUrl, navLinks, footerLinks }: He toggle(user ? 'userdropdown' : 'signin'); } + function gatedNav(dest: string) { + setActivePanel('none'); + if (user) { + router.push(dest); + return; + } + sessionStorage.setItem(PENDING_NAV_KEY, dest); + setActivePanel('signin'); + } + + useEffect(() => { + const handler = () => setActivePanel('cart'); + window.addEventListener(OPEN_CART_EVENT, handler); + return () => window.removeEventListener(OPEN_CART_EVENT, handler); + }, []); + return ( <>
@@ -215,7 +232,7 @@ export default function Header({ brandName, logoUrl, navLinks, footerLinks }: He )} - diff --git a/apps/frontend/src/components/checkout/CheckoutForm.tsx b/apps/frontend/src/components/checkout/CheckoutForm.tsx new file mode 100644 index 0000000..7a64963 --- /dev/null +++ b/apps/frontend/src/components/checkout/CheckoutForm.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { TEXT } from '../../lib/text'; +import type { CheckoutFormState } from '../../lib/checkoutValidation'; + +interface Props { + form: CheckoutFormState; + onChange: (patch: Partial) => void; +} + +export function CheckoutForm({ form, onChange }: Props) { + function handleExpiryChange(raw: string) { + const deleting = raw.length < form.expiry.length; + if (deleting) { + // Respect exactly what's left after the user's edit — don't recompute the + // slash position from a flattened digit string. Reformatting from scratch + // reshuffles digits across the MM/YY boundary and corrupts edits made + // anywhere but the very end of the field. + onChange({ expiry: raw.replace(/[^\d/]/g, '').slice(0, 5) }); + return; + } + const digits = raw.replace(/\D/g, '').slice(0, 4); + onChange({ expiry: digits.length >= 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits }); + } + + return ( +
+
+

{TEXT.checkout.deliveryDetails}

+ + + +
+ +
+

{TEXT.checkout.paymentDetails}

+ + + + +
+
+ ); +} diff --git a/apps/frontend/src/components/checkout/CheckoutOrderSummary.tsx b/apps/frontend/src/components/checkout/CheckoutOrderSummary.tsx new file mode 100644 index 0000000..0e83b1f --- /dev/null +++ b/apps/frontend/src/components/checkout/CheckoutOrderSummary.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { CartItem } from '@org/shared-types'; +import { TEXT } from '../../lib/text'; + +interface Props { + restaurantName: string | null; + items: CartItem[]; + comment: string; + onCommentChange: (value: string) => void; +} + +export function CheckoutOrderSummary({ restaurantName, items, comment, onCommentChange }: Props) { + return ( +
+

{TEXT.checkout.yourOrder}

+

{restaurantName}

+
+ {items.map((item, i) => ( +
+ {item.dish.name} +
+ {item.quantity} +
+

{item.dish.name}

+

+ + {item.dish.price.toFixed(2)} +

+ {(item.selectedSide || (item.selectedChanges ?? []).length > 0) && ( +

+ {[item.selectedSide, ...(item.selectedChanges ?? [])].filter(Boolean).join(' | ')} +

+ )} +
+ + + {(item.dish.price * item.quantity).toFixed(0)} + +
+
+ ))} +
+
+

{TEXT.checkout.addComment}

+