From cdb911bc42db1f68ce1a97429cb75f44a6e0e1fb Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 22:56:59 +0300 Subject: [PATCH 01/19] feat(types): add Order, OrderItem, CreateOrderRequest interfaces --- .../shared-types/src/lib/strapi.types.spec.ts | 17 +++++++++++ libs/shared-types/src/lib/strapi.types.ts | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 libs/shared-types/src/lib/strapi.types.spec.ts diff --git a/libs/shared-types/src/lib/strapi.types.spec.ts b/libs/shared-types/src/lib/strapi.types.spec.ts new file mode 100644 index 0000000..a221d7a --- /dev/null +++ b/libs/shared-types/src/lib/strapi.types.spec.ts @@ -0,0 +1,17 @@ +import type { Order, OrderItem, CreateOrderRequest } from './strapi.types'; + +describe('order types', () => { + it('compose into a valid order request', () => { + const item: OrderItem = { + dishId: 1, name: 'Pad Ki Mao', price: 88, quantity: 2, + imageUrl: '/x.jpg', selectedChanges: [], + }; + const req: CreateOrderRequest = { + restaurantId: 3, restaurantName: 'Mashya', items: [item], + total: 176, delivery: { name: 'A', address: 'B', phone: '050' }, + }; + const order: Order = { id: 9, ...req, createdAt: new Date().toISOString() }; + expect(order.items[0].name).toBe('Pad Ki Mao'); + expect(order.total).toBe(176); + }); +}); diff --git a/libs/shared-types/src/lib/strapi.types.ts b/libs/shared-types/src/lib/strapi.types.ts index f7bf272..635fa77 100644 --- a/libs/shared-types/src/lib/strapi.types.ts +++ b/libs/shared-types/src/lib/strapi.types.ts @@ -76,6 +76,35 @@ export interface SearchResults { cuisines: { label: string }[]; } +export interface OrderItem { + dishId: number; + name: string; + price: number; + quantity: number; + imageUrl: string; + selectedSide?: string; + selectedChanges: string[]; +} + +export interface Order { + id: number; + restaurantId: number; + restaurantName: string; + items: OrderItem[]; + comment?: string; + total: number; + createdAt: string; +} + +export interface CreateOrderRequest { + restaurantId: number; + restaurantName: string; + items: OrderItem[]; + comment?: string; + total: number; + delivery: { name: string; address: string; phone: string }; +} + export interface AuthUser { id: number; name: string; From 096b63d8d6403c97c99cf8eec44a183ef30eb3f6 Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 22:58:02 +0300 Subject: [PATCH 02/19] feat(strapi): add Order content-type scoped to the authenticated user --- .../api/order/content-types/order/schema.json | 28 +++++++++++++++++ strapi/src/api/order/controllers/order.ts | 30 +++++++++++++++++++ strapi/src/api/order/routes/order.ts | 3 ++ strapi/src/api/order/services/order.ts | 3 ++ 4 files changed, 64 insertions(+) create mode 100644 strapi/src/api/order/content-types/order/schema.json create mode 100644 strapi/src/api/order/controllers/order.ts create mode 100644 strapi/src/api/order/routes/order.ts create mode 100644 strapi/src/api/order/services/order.ts diff --git a/strapi/src/api/order/content-types/order/schema.json b/strapi/src/api/order/content-types/order/schema.json new file mode 100644 index 0000000..62b2cb5 --- /dev/null +++ b/strapi/src/api/order/content-types/order/schema.json @@ -0,0 +1,28 @@ +{ + "kind": "collectionType", + "collectionName": "orders", + "info": { + "singularName": "order", + "pluralName": "orders", + "displayName": "Order" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "restaurantId": { "type": "integer", "required": true }, + "restaurantName": { "type": "string", "required": true }, + "items": { "type": "json", "required": true }, + "comment": { "type": "text" }, + "total": { "type": "decimal", "required": true }, + "deliveryName": { "type": "string" }, + "deliveryAddress": { "type": "string" }, + "deliveryPhone": { "type": "string" }, + "user": { + "type": "relation", + "relation": "manyToOne", + "target": "plugin::users-permissions.user" + } + } +} diff --git a/strapi/src/api/order/controllers/order.ts b/strapi/src/api/order/controllers/order.ts new file mode 100644 index 0000000..f7b8da5 --- /dev/null +++ b/strapi/src/api/order/controllers/order.ts @@ -0,0 +1,30 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreController('api::order.order', ({ strapi: _strapi }) => ({ + async create(ctx) { + const user = ctx.state.user; + if (!user) return ctx.unauthorized('You must be logged in to place an order'); + ctx.request.body.data = { ...(ctx.request.body.data ?? {}), user: user.id }; + return await super.create(ctx); + }, + + async find(ctx) { + const user = ctx.state.user; + if (!user) return ctx.unauthorized('You must be logged in to view orders'); + ctx.query = { + ...ctx.query, + filters: { ...((ctx.query.filters as object) ?? {}), user: user.id }, + }; + return await super.find(ctx); + }, + + async findOne(ctx) { + const user = ctx.state.user; + if (!user) return ctx.unauthorized('You must be logged in to view orders'); + ctx.query = { + ...ctx.query, + filters: { ...((ctx.query.filters as object) ?? {}), user: user.id }, + }; + return await super.findOne(ctx); + }, +})); diff --git a/strapi/src/api/order/routes/order.ts b/strapi/src/api/order/routes/order.ts new file mode 100644 index 0000000..032bc24 --- /dev/null +++ b/strapi/src/api/order/routes/order.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreRouter('api::order.order'); diff --git a/strapi/src/api/order/services/order.ts b/strapi/src/api/order/services/order.ts new file mode 100644 index 0000000..60dedd8 --- /dev/null +++ b/strapi/src/api/order/services/order.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreService('api::order.order'); From b94cf15c1257ef8262599ce0e45ab57603be7b9a Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 23:01:07 +0300 Subject: [PATCH 03/19] feat(backend): add JWT-protected orders endpoints proxying Strapi --- apps/backend/src/app/app.module.ts | 3 +- .../src/app/orders/orders.controller.ts | 23 ++++++++++ apps/backend/src/app/orders/orders.module.ts | 11 +++++ .../src/app/orders/orders.service.spec.ts | 45 +++++++++++++++++++ apps/backend/src/app/orders/orders.service.ts | 43 ++++++++++++++++++ .../strapi-client/strapi-client.service.ts | 11 +++-- .../src/app/strapi-client/strapi-types.ts | 16 ++++++- 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/app/orders/orders.controller.ts create mode 100644 apps/backend/src/app/orders/orders.module.ts create mode 100644 apps/backend/src/app/orders/orders.service.spec.ts create mode 100644 apps/backend/src/app/orders/orders.service.ts 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/orders.controller.ts b/apps/backend/src/app/orders/orders.controller.ts new file mode 100644 index 0000000..1f184dd --- /dev/null +++ b/apps/backend/src/app/orders/orders.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Post, Body, Headers, UnauthorizedException } from '@nestjs/common'; +import type { Order, CreateOrderRequest } from '@org/shared-types'; +import { OrdersService } from './orders.service'; + +@Controller('orders') +export class OrdersController { + constructor(private readonly ordersService: OrdersService) {} + + @Post() + create(@Headers('authorization') auth: string | undefined, @Body() body: CreateOrderRequest): 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.spec.ts b/apps/backend/src/app/orders/orders.service.spec.ts new file mode 100644 index 0000000..03a1fd9 --- /dev/null +++ b/apps/backend/src/app/orders/orders.service.spec.ts @@ -0,0 +1,45 @@ +import { OrdersService } from './orders.service'; +import type { StrapiClientService } from '../strapi-client/strapi-client.service'; +import type { CreateOrderRequest } from '@org/shared-types'; + +describe('OrdersService', () => { + function makeClient(overrides: Partial): StrapiClientService { + return { get: jest.fn(), post: jest.fn(), getById: jest.fn(), ...overrides } as unknown as StrapiClientService; + } + + const req: CreateOrderRequest = { + restaurantId: 3, restaurantName: 'Mashya', + items: [{ dishId: 1, name: 'Pad Ki Mao', price: 88, quantity: 2, imageUrl: '/x.jpg', selectedChanges: [] }], + comment: 'no peanuts', total: 176, + delivery: { name: 'Moshe', address: 'Har Sinai 1', phone: '052123456' }, + }; + + it('create maps the request into a Strapi payload and forwards the token', async () => { + const post = jest.fn().mockResolvedValue({ + data: { id: 9, restaurantId: 3, restaurantName: 'Mashya', items: req.items, comment: 'no peanuts', total: 176, createdAt: '2026-06-29T08:00:00.000Z' }, + }); + const service = new OrdersService(makeClient({ post })); + + const result = await service.create('tok123', req); + + expect(post).toHaveBeenCalledWith( + '/api/orders', + { data: { restaurantId: 3, restaurantName: 'Mashya', items: req.items, comment: 'no peanuts', total: 176, deliveryName: 'Moshe', deliveryAddress: 'Har Sinai 1', deliveryPhone: '052123456' } }, + 'tok123', + ); + expect(result).toEqual({ id: 9, restaurantId: 3, restaurantName: 'Mashya', items: req.items, comment: 'no peanuts', total: 176, createdAt: '2026-06-29T08:00:00.000Z' }); + }); + + it('findForUser forwards the token and sorts by createdAt desc', async () => { + const get = jest.fn().mockResolvedValue([ + { id: 9, restaurantId: 3, restaurantName: 'Mashya', items: [], total: 176, createdAt: '2026-06-29T08:00:00.000Z' }, + ]); + const service = new OrdersService(makeClient({ get })); + + const result = await service.findForUser('tok123'); + + expect(get).toHaveBeenCalledWith('/api/orders?sort=createdAt:desc', 'tok123'); + expect(result).toHaveLength(1); + expect(result[0].restaurantName).toBe('Mashya'); + }); +}); 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..fe8dd74 --- /dev/null +++ b/apps/backend/src/app/orders/orders.service.ts @@ -0,0 +1,43 @@ +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) {} + + async create(token: string, req: CreateOrderRequest): Promise { + 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, + }, + }; + const res = await this.strapiClient.post>('/api/orders', payload, token); + return this.transform(res.data); + } + + async findForUser(token: string): Promise { + const items = await this.strapiClient.get('/api/orders?sort=createdAt:desc', 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..1700443 100644 --- a/apps/backend/src/app/strapi-client/strapi-client.service.ts +++ b/apps/backend/src/app/strapi-client/strapi-client.service.ts @@ -33,8 +33,8 @@ export class StrapiClientService { } } - async get(path: string): Promise { - const res = await this.request(path); + 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}`); } @@ -50,10 +50,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; From 3251876b9cd00e7bd713811322df27f071c5f087 Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 23:03:41 +0300 Subject: [PATCH 04/19] feat(frontend): order API helpers, pending-nav key, cart re-order mapping --- apps/frontend/src/context/CartContext.tsx | 14 +++++++++++- apps/frontend/src/lib/api.ts | 10 ++++++++- apps/frontend/src/lib/events.ts | 7 ++++++ apps/frontend/src/lib/orderMapping.spec.ts | 26 ++++++++++++++++++++++ apps/frontend/src/lib/orderMapping.ts | 13 +++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/lib/orderMapping.spec.ts create mode 100644 apps/frontend/src/lib/orderMapping.ts diff --git a/apps/frontend/src/context/CartContext.tsx b/apps/frontend/src/context/CartContext.tsx index 3c2aa19..9322e5d 100644 --- a/apps/frontend/src/context/CartContext.tsx +++ b/apps/frontend/src/context/CartContext.tsx @@ -1,7 +1,8 @@ 'use client'; import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; -import type { CartItem } from '@org/shared-types'; +import type { CartItem, Order } from '@org/shared-types'; +import { orderToCartItems } from '../lib/orderMapping'; import { loadCart, saveCart } from '../lib/cartStorage'; interface CartState { @@ -19,6 +20,7 @@ interface CartContextValue extends CartState { cancelRemove: (item: CartItem) => void; clearCart: () => void; setComment: (comment: string) => void; + replaceCart: (order: Order) => void; conflictsWithCart: (restaurantId: number) => boolean; totalPrice: number; totalItems: number; @@ -100,6 +102,15 @@ export function CartProvider({ children }: { children: ReactNode }) { setState(EMPTY_STATE); } + function replaceCart(order: Order) { + setState({ + cartItems: orderToCartItems(order), + restaurantId: order.restaurantId, + restaurantName: order.restaurantName, + comment: order.comment ?? '', + }); + } + function setComment(comment: string) { setState(prev => ({ ...prev, comment })); } @@ -147,6 +158,7 @@ export function CartProvider({ children }: { children: ReactNode }) { cancelRemove, clearCart, setComment, + replaceCart, conflictsWithCart, totalPrice, totalItems, diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index c720d9f..9e984d3 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Restaurant, SearchResults } from '@org/shared-types'; +import type { Restaurant, SearchResults, Order, CreateOrderRequest } from '@org/shared-types'; export const BACKEND_URL = process.env['NEXT_PUBLIC_BACKEND_URL'] ?? 'http://localhost:3333'; export const STRAPI_URL = process.env['NEXT_PUBLIC_STRAPI_URL'] ?? 'http://localhost:1337'; @@ -83,3 +83,11 @@ export function strapiImageUrl(url?: string): string { if (url.startsWith('http')) return url; return `${STRAPI_URL}${url}`; } + +export async function createOrder(body: CreateOrderRequest): Promise { + return postApi('/api/orders', body as unknown as Record); +} + +export async function fetchOrders(): Promise { + return fetchApi('/api/orders'); +} diff --git a/apps/frontend/src/lib/events.ts b/apps/frontend/src/lib/events.ts index 25c8dc5..ab7806d 100644 --- a/apps/frontend/src/lib/events.ts +++ b/apps/frontend/src/lib/events.ts @@ -1,3 +1,10 @@ +export const PENDING_NAV_KEY = 'epicure_pending_nav'; +export const OPEN_CART_EVENT = 'epicure:open-cart'; + +export function dispatchOpenCart(): void { + window.dispatchEvent(new CustomEvent(OPEN_CART_EVENT)); +} + export const CUISINE_FILTER_EVENT = 'epicure:cuisine-filter'; export const CHEF_HIGHLIGHT_EVENT = 'epicure:chef-highlight'; export const PENDING_CUISINE_KEY = 'epicure_pending_cuisine_filter'; diff --git a/apps/frontend/src/lib/orderMapping.spec.ts b/apps/frontend/src/lib/orderMapping.spec.ts new file mode 100644 index 0000000..5c74e01 --- /dev/null +++ b/apps/frontend/src/lib/orderMapping.spec.ts @@ -0,0 +1,26 @@ +import { orderToCartItems } from './orderMapping'; +import type { Order } from '@org/shared-types'; + +describe('orderToCartItems', () => { + const order: Order = { + id: 1, restaurantId: 3, restaurantName: 'Mashya', + items: [ + { dishId: 7, name: 'Pad Ki Mao', price: 88, quantity: 2, imageUrl: '/p.jpg', selectedSide: 'White bread', selectedChanges: ['Less spicy'] }, + ], + comment: 'no peanuts', total: 176, createdAt: '2026-06-29T08:00:00.000Z', + }; + + it('maps order items to cart items carrying restaurant context', () => { + const result = orderToCartItems(order); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + dish: { id: 7, name: 'Pad Ki Mao', price: 88 }, + imageUrl: '/p.jpg', + quantity: 2, + selectedSide: 'White bread', + selectedChanges: ['Less spicy'], + restaurantId: 3, + restaurantName: 'Mashya', + }); + }); +}); diff --git a/apps/frontend/src/lib/orderMapping.ts b/apps/frontend/src/lib/orderMapping.ts new file mode 100644 index 0000000..cd8abe6 --- /dev/null +++ b/apps/frontend/src/lib/orderMapping.ts @@ -0,0 +1,13 @@ +import type { Order, CartItem } from '@org/shared-types'; + +export function orderToCartItems(order: Order): CartItem[] { + return order.items.map(item => ({ + dish: { id: item.dishId, name: item.name, price: item.price }, + imageUrl: item.imageUrl, + quantity: item.quantity, + selectedSide: item.selectedSide, + selectedChanges: item.selectedChanges, + restaurantId: order.restaurantId, + restaurantName: order.restaurantName, + })); +} From 44e258eca8686627b161cc362bb81fcf92d0e65c Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 23:06:30 +0300 Subject: [PATCH 05/19] feat(frontend): auth-gated checkout/history nav with post-login auto-continue --- apps/frontend/src/components/CartPanel.tsx | 6 ++- apps/frontend/src/components/Header.tsx | 41 +++++++++++++++++-- .../src/components/cart-panel/CartFooter.tsx | 8 ++-- apps/frontend/src/context/AuthContext.tsx | 13 ++++++ apps/frontend/src/lib/text.ts | 32 +++++++++++++++ .../src/styles/components/_header.scss | 17 ++++++++ 6 files changed, 109 insertions(+), 8 deletions(-) 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..a1b5e89 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,34 @@ 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); + }, []); + + if (pathname === '/checkout') { + return ( +
+ + + {brandName ?? TEXT.nav.brandName} + + {TEXT.checkout.pageTitle} +
+ ); + } + return ( <>
@@ -228,7 +257,13 @@ export default function Header({ brandName, logoUrl, navLinks, footerLinks }: He {activePanel === 'drawer' && setActivePanel('none')} navLinks={resolvedNavLinks} footerLinks={footerLinks} />} {activePanel === 'search' && setActivePanel('none')} />} - {activePanel === 'cart' && setActivePanel('none')} />} + {activePanel === 'cart' && ( + setActivePanel('none')} + onCheckout={() => gatedNav('/checkout')} + onOrderHistory={() => gatedNav('/orders')} + /> + )} {activePanel === 'signin' && ( setActivePanel('none')} diff --git a/apps/frontend/src/components/cart-panel/CartFooter.tsx b/apps/frontend/src/components/cart-panel/CartFooter.tsx index b0f3e86..a0fa86d 100644 --- a/apps/frontend/src/components/cart-panel/CartFooter.tsx +++ b/apps/frontend/src/components/cart-panel/CartFooter.tsx @@ -3,9 +3,11 @@ import { TEXT } from '../../lib/text'; interface CartFooterProps { totalPrice: number; hasItems: boolean; + onCheckout: () => void; + onOrderHistory: () => void; } -export function CartFooter({ totalPrice, hasItems }: CartFooterProps) { +export function CartFooter({ totalPrice, hasItems, onCheckout, onOrderHistory }: CartFooterProps) { return (
{hasItems && ( @@ -15,7 +17,7 @@ export function CartFooter({ totalPrice, hasItems }: CartFooterProps) { {totalPrice}
- )} - diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 2a35558..fb8a850 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -1,7 +1,9 @@ 'use client'; import { createContext, useContext, useState, useEffect, useRef, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; import { postApi, setAuthToken, setOnUnauthorized } from '../lib/api'; +import { PENDING_NAV_KEY } from '../lib/events'; import type { AuthUser, AuthResponse } from '@org/shared-types'; const AUTH_STORAGE_KEY = 'epicure_auth'; @@ -31,6 +33,7 @@ function isValidAuthState(val: unknown): val is AuthState { } export function AuthProvider({ children }: { children: ReactNode }) { + const router = useRouter(); const [state, setState] = useState(() => { if (typeof window === 'undefined') return { user: null, token: null }; const stored = localStorage.getItem(AUTH_STORAGE_KEY); @@ -55,14 +58,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(next)); } + function redirectPending() { + const pending = sessionStorage.getItem(PENDING_NAV_KEY); + if (pending) { + sessionStorage.removeItem(PENDING_NAV_KEY); + router.push(pending); + } + } + async function login(email: string, password: string): Promise { const response = await postApi('/api/auth/login', { email, password }); persist({ user: response.user, token: response.jwt }); + redirectPending(); } async function register(name: string, email: string, password: string): Promise { const response = await postApi('/api/auth/register', { name, email, password }); persist({ user: response.user, token: response.jwt }); + redirectPending(); } function logout(): void { diff --git a/apps/frontend/src/lib/text.ts b/apps/frontend/src/lib/text.ts index cd43330..03972ec 100644 --- a/apps/frontend/src/lib/text.ts +++ b/apps/frontend/src/lib/text.ts @@ -41,6 +41,7 @@ export const TEXT = { removeConfirm: 'Remove item?', removeYes: 'Yes', removeNo: 'Cancel', + replaceConfirm: 'Replace your current cart with this order?', }, signUp: { dialogAriaLabel: 'Sign up', @@ -173,4 +174,35 @@ export const TEXT = { map: { recenterAriaLabel: 'Center map on my location', }, + checkout: { + pageTitle: 'CHECKOUT', + deliveryDetails: 'DELIVERY DETAILS', + paymentDetails: 'PAYMENT DETAILS', + fullName: 'Full Name', + address: 'Address', + phone: 'Phone', + cardNumber: 'Card Number', + nameOnCard: 'Name On Card', + cvv: 'CVV', + expiry: 'Expiry Date', + yourOrder: 'YOUR ORDER', + addComment: 'Add A Comment', + commentPlaceholder: 'Special requests, allergies, dietary restrictions, etc.', + pay: 'PAY', + submitError: 'Something went wrong placing your order. Please try again.', + successTitle: 'ORDER RECEIVED', + successSubtitle: 'Your food is in process', + arrivingIn: 'Arrives in', + total: 'TOTAL', + closeAriaLabel: 'Close', + }, + orders: { + pageTitle: 'YOUR ORDER HISTORY', + empty: "You haven't placed any orders yet.", + summaryTitle: 'ORDER SUMMARY', + orderAgain: 'ORDER AGAIN', + orderHistory: 'ORDER HISTORY', + rowAriaLabel: (name: string) => `View order from ${name}`, + closeAriaLabel: 'Close', + }, } as const; diff --git a/apps/frontend/src/styles/components/_header.scss b/apps/frontend/src/styles/components/_header.scss index 0ec2bfb..1e3b1f6 100644 --- a/apps/frontend/src/styles/components/_header.scss +++ b/apps/frontend/src/styles/components/_header.scss @@ -255,3 +255,20 @@ justify-content: center; line-height: 1; } + +.epicure-header--checkout { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + + .epicure-header__checkout-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 1.25rem; + font-weight: 300; + letter-spacing: 0.08em; + color: $black-primary; + } +} From 58490c57b144a47e0c5d1274ab7c39327d01a449 Mon Sep 17 00:00:00 2001 From: Amit Oved Date: Tue, 30 Jun 2026 23:11:58 +0300 Subject: [PATCH 06/19] feat(checkout): checkout page with delivery+payment form, order summary Adds /checkout page with two-column layout (form + order summary), card/delivery validation helpers, and SCSS for checkout, success modal, order summary modal, and order history page stubs. --- apps/frontend/src/app/checkout/page.tsx | 92 +++++++++++++++ .../src/components/checkout/CheckoutForm.tsx | 51 ++++++++ .../checkout/CheckoutOrderSummary.tsx | 48 ++++++++ .../src/lib/checkoutValidation.spec.ts | 35 ++++++ apps/frontend/src/lib/checkoutValidation.ts | 36 ++++++ .../styles/components/_checkout-success.scss | 43 +++++++ .../src/styles/components/_checkout.scss | 110 ++++++++++++++++++ .../components/_order-summary-modal.scss | 82 +++++++++++++ apps/frontend/src/styles/main.scss | 4 + .../src/styles/pages/_order-history.scss | 59 ++++++++++ 10 files changed, 560 insertions(+) create mode 100644 apps/frontend/src/app/checkout/page.tsx create mode 100644 apps/frontend/src/components/checkout/CheckoutForm.tsx create mode 100644 apps/frontend/src/components/checkout/CheckoutOrderSummary.tsx create mode 100644 apps/frontend/src/lib/checkoutValidation.spec.ts create mode 100644 apps/frontend/src/lib/checkoutValidation.ts create mode 100644 apps/frontend/src/styles/components/_checkout-success.scss create mode 100644 apps/frontend/src/styles/components/_checkout.scss create mode 100644 apps/frontend/src/styles/components/_order-summary-modal.scss create mode 100644 apps/frontend/src/styles/pages/_order-history.scss diff --git a/apps/frontend/src/app/checkout/page.tsx b/apps/frontend/src/app/checkout/page.tsx new file mode 100644 index 0000000..2b696bc --- /dev/null +++ b/apps/frontend/src/app/checkout/page.tsx @@ -0,0 +1,92 @@ +'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, CartItem } 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 } = useCart(); + const [form, setForm] = useState(EMPTY_FORM); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const [confirmedItems, setConfirmedItems] = useState([]); + const [confirmedTotal, setConfirmedTotal] = useState(0); + + const committed = cartItems.filter(c => !c.pendingRemove); + + useEffect(() => { + if (!submitted && (!user || committed.length === 0)) router.replace('/'); + }, [user, committed.length, router, submitted]); + + if (!submitted && (!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); + setConfirmedItems(committed); + setConfirmedTotal(totalPrice); + setSubmitted(true); + } catch { + setError(TEXT.checkout.submitError); + } finally { + setSubmitting(false); + } + } + + if (submitted) { + // CheckoutSuccessModal added in Task 7 + return
; + } + + return ( +
+
+ setForm(prev => ({ ...prev, ...patch }))} /> +
+ + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/checkout/CheckoutForm.tsx b/apps/frontend/src/components/checkout/CheckoutForm.tsx new file mode 100644 index 0000000..9c13171 --- /dev/null +++ b/apps/frontend/src/components/checkout/CheckoutForm.tsx @@ -0,0 +1,51 @@ +'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) { + 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..cc5d927 --- /dev/null +++ b/apps/frontend/src/components/checkout/CheckoutOrderSummary.tsx @@ -0,0 +1,48 @@ +'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}

+