From 8749740e6cc15acb6ef75486b8b6168cf1f3f813 Mon Sep 17 00:00:00 2001 From: Jaskaran Date: Sun, 15 Mar 2026 14:54:36 -0400 Subject: [PATCH] latest chagnes --- .../backend/src/modules/auth/auth.service.ts | 5 +- .../restaurants/restaurants.controller.ts | 40 +- .../restaurants/restaurants.service.ts | 103 ++++ .../web/src/app/restaurant/dashboard/page.tsx | 3 + packages/web/src/app/restaurant/menu/page.tsx | 479 ++++++++++++++++++ 5 files changed, 627 insertions(+), 3 deletions(-) create mode 100644 packages/web/src/app/restaurant/menu/page.tsx diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index 45f96de..a105d76 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -71,8 +71,9 @@ export class AuthService { const valid = await bcrypt.compare(password, user.password_hash); if (!valid) throw new UnauthorizedException('Invalid credentials'); - const { password_hash, ...safeUser } = user; - return { user: safeUser, token: this.signToken(user) }; + // Return full profile including restaurantId / driverId so frontend can use them + const fullUser = await this.validateToken({ sub: user.id, role: user.role }); + return { user: fullUser, token: this.signToken(user) }; } async validateToken(payload: { sub: string; role: string }) { diff --git a/packages/backend/src/modules/restaurants/restaurants.controller.ts b/packages/backend/src/modules/restaurants/restaurants.controller.ts index 593dac1..4a346d6 100644 --- a/packages/backend/src/modules/restaurants/restaurants.controller.ts +++ b/packages/backend/src/modules/restaurants/restaurants.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, Request, BadRequestException } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, Request, BadRequestException } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard, Roles } from '../auth/guards/roles.guard'; import { RestaurantsService } from './restaurants.service'; @@ -62,6 +62,44 @@ export class RestaurantsController { return this.restaurantsService.updateHours(req.user.restaurantId, isOpen); } + // ---- Menu management (restaurant owner) ---- + // Must be before :slug to avoid route conflict + + @Get('menu/items') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('restaurant_owner') + getMenu(@Request() req) { + return this.restaurantsService.getMenu(req.user.restaurantId); + } + + @Post('menu/categories') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('restaurant_owner') + addCategory(@Request() req, @Body('name') name: string) { + return this.restaurantsService.addCategory(req.user.restaurantId, name); + } + + @Post('menu/items') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('restaurant_owner') + addMenuItem(@Request() req, @Body() dto: any) { + return this.restaurantsService.addMenuItem(req.user.restaurantId, dto); + } + + @Patch('menu/items/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('restaurant_owner') + updateMenuItem(@Request() req, @Param('id') id: string, @Body() dto: any) { + return this.restaurantsService.updateMenuItem(req.user.restaurantId, id, dto); + } + + @Delete('menu/items/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('restaurant_owner') + deleteMenuItem(@Request() req, @Param('id') id: string) { + return this.restaurantsService.deleteMenuItem(req.user.restaurantId, id); + } + @Get(':slug') findBySlug(@Param('slug') slug: string) { return this.restaurantsService.findBySlug(slug); diff --git a/packages/backend/src/modules/restaurants/restaurants.service.ts b/packages/backend/src/modules/restaurants/restaurants.service.ts index 820f399..f4f1b63 100644 --- a/packages/backend/src/modules/restaurants/restaurants.service.ts +++ b/packages/backend/src/modules/restaurants/restaurants.service.ts @@ -185,6 +185,109 @@ export class RestaurantsService { return { lat: 43.6389, lng: -79.4196 }; // fallback: Liberty Village centre } + // ============================================================ + // MENU MANAGEMENT (restaurant owner) + // ============================================================ + + async getMenu(restaurantId: string) { + const categories = await this.db.queryMany( + `SELECT mc.id, mc.name, mc.sort_order, mc.is_active + FROM menu_categories mc + WHERE mc.restaurant_id = $1 + ORDER BY mc.sort_order, mc.name`, + [restaurantId], + ); + + const items = await this.db.queryMany( + `SELECT mi.id, mi.category_id, mi.name, mi.description, mi.price, + mi.is_available, mi.is_featured, mi.dietary_tags, mi.sort_order + FROM menu_items mi + JOIN menu_categories mc ON mc.id = mi.category_id + WHERE mc.restaurant_id = $1 + ORDER BY mi.sort_order, mi.name`, + [restaurantId], + ); + + return categories.map((cat) => ({ + ...cat, + items: items.filter((i) => i.category_id === cat.id), + })); + } + + async addCategory(restaurantId: string, name: string) { + const existing = await this.db.queryMany( + `SELECT sort_order FROM menu_categories WHERE restaurant_id = $1`, + [restaurantId], + ); + const nextSort = existing.length + 1; + return this.db.queryOne( + `INSERT INTO menu_categories (restaurant_id, name, sort_order) + VALUES ($1, $2, $3) RETURNING *`, + [restaurantId, name.trim(), nextSort], + ); + } + + async addMenuItem(restaurantId: string, dto: { + categoryId: string; + name: string; + description?: string; + price: number; + dietaryTags?: string[]; + }) { + // Verify category belongs to this restaurant + const cat = await this.db.queryOne( + `SELECT id FROM menu_categories WHERE id = $1 AND restaurant_id = $2`, + [dto.categoryId, restaurantId], + ); + if (!cat) throw new Error('Category not found'); + + const count = await this.db.queryOne( + `SELECT COUNT(*) AS n FROM menu_items WHERE category_id = $1`, + [dto.categoryId], + ); + const nextSort = Number(count?.n || 0) + 1; + + return this.db.queryOne( + `INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, sort_order, is_available) + VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE) RETURNING *`, + [restaurantId, dto.categoryId, dto.name.trim(), dto.description || null, dto.price, dto.dietaryTags || [], nextSort], + ); + } + + async updateMenuItem(restaurantId: string, itemId: string, dto: { + name?: string; + description?: string; + price?: number; + isAvailable?: boolean; + isFeatured?: boolean; + }) { + const sets: string[] = []; + const params: any[] = [itemId, restaurantId]; + let idx = 3; + + if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name.trim()); } + if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); } + if (dto.price !== undefined) { sets.push(`price = $${idx++}`); params.push(dto.price); } + if (dto.isAvailable !== undefined) { sets.push(`is_available = $${idx++}`); params.push(dto.isAvailable); } + if (dto.isFeatured !== undefined) { sets.push(`is_featured = $${idx++}`); params.push(dto.isFeatured); } + + if (sets.length === 0) return this.db.queryOne(`SELECT * FROM menu_items WHERE id = $1`, [itemId]); + + sets.push(`updated_at = NOW()`); + return this.db.queryOne( + `UPDATE menu_items SET ${sets.join(', ')} + WHERE id = $1 AND restaurant_id = $2 RETURNING *`, + params, + ); + } + + async deleteMenuItem(restaurantId: string, itemId: string) { + return this.db.queryOne( + `DELETE FROM menu_items WHERE id = $1 AND restaurant_id = $2 RETURNING id`, + [itemId, restaurantId], + ); + } + // ============================================================ // SAVINGS CALCULATOR (standalone - for marketing page) // ============================================================ diff --git a/packages/web/src/app/restaurant/dashboard/page.tsx b/packages/web/src/app/restaurant/dashboard/page.tsx index 9981ac7..acc82a6 100644 --- a/packages/web/src/app/restaurant/dashboard/page.tsx +++ b/packages/web/src/app/restaurant/dashboard/page.tsx @@ -171,6 +171,9 @@ export default function RestaurantDashboardPage() {

Restaurant Dashboard

+ + Menu + ([]) + const [loading, setLoading] = useState(true) + const [showAddCategory, setShowAddCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [addingCategory, setAddingCategory] = useState(false) + const [addingToCategory, setAddingToCategory] = useState(null) + const [editingItem, setEditingItem] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const load = async () => { + try { + const { data } = await api.get('/restaurants/menu/items') + setCategories(data) + } catch {} + setLoading(false) + } + + useEffect(() => { load() }, []) + + const addCategory = async () => { + if (!newCategoryName.trim()) return + setAddingCategory(true) + try { + const { data } = await api.post('/restaurants/menu/categories', { name: newCategoryName }) + setCategories((prev) => [...prev, { ...data, items: [] }]) + setNewCategoryName('') + setShowAddCategory(false) + } catch {} + setAddingCategory(false) + } + + const toggleAvailability = async (item: MenuItem) => { + try { + const { data } = await api.patch(`/restaurants/menu/items/${item.id}`, { + isAvailable: !item.is_available, + }) + setCategories((prev) => prev.map((cat) => ({ + ...cat, + items: cat.items.map((i) => i.id === item.id ? { ...i, is_available: data.is_available } : i), + }))) + } catch {} + } + + const saveItem = async (catId: string, form: NewItemForm) => { + setSaving(true) + setError('') + try { + const { data } = await api.post('/restaurants/menu/items', { + categoryId: catId, + name: form.name, + description: form.description || undefined, + price: parseFloat(form.price), + dietaryTags: form.dietaryTags.length > 0 ? form.dietaryTags : undefined, + }) + setCategories((prev) => prev.map((cat) => + cat.id === catId ? { ...cat, items: [...cat.items, data] } : cat + )) + setAddingToCategory(null) + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to add item') + } + setSaving(false) + } + + const saveEdit = async (form: EditItemForm) => { + if (!editingItem) return + setSaving(true) + setError('') + try { + const { data } = await api.patch(`/restaurants/menu/items/${editingItem.id}`, { + name: form.name, + description: form.description || null, + price: parseFloat(form.price), + }) + setCategories((prev) => prev.map((cat) => ({ + ...cat, + items: cat.items.map((i) => i.id === editingItem.id ? { ...i, ...data } : i), + }))) + setEditingItem(null) + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to update item') + } + setSaving(false) + } + + const deleteItem = async (item: MenuItem) => { + if (!confirm(`Delete "${item.name}"?`)) return + try { + await api.delete(`/restaurants/menu/items/${item.id}`) + setCategories((prev) => prev.map((cat) => ({ + ...cat, + items: cat.items.filter((i) => i.id !== item.id), + }))) + } catch {} + } + + const totalItems = categories.reduce((sum, c) => sum + c.items.length, 0) + const liveItems = categories.reduce((sum, c) => sum + c.items.filter((i) => i.is_available).length, 0) + + return ( +
+ {/* Header */} +
+
+
+ ← Dashboard + | +

Menu

+ {liveItems}/{totalItems} items live +
+ +
+
+ +
+ + {/* Add category inline */} + {showAddCategory && ( +
+

New Category

+
+ setNewCategoryName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowAddCategory(false) }} + placeholder="e.g. Burgers, Starters, Drinks..." + className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> + + +
+
+ )} + + {loading ? ( +
+ {[1, 2, 3].map((i) =>
)} +
+ ) : categories.length === 0 ? ( +
+
🍽️
+

No menu yet

+

Add a category to get started — e.g. "Burgers", "Drinks"

+ +
+ ) : ( + categories.map((cat) => ( + { setAddingToCategory(cat.id); setError('') }} + onCancelAdd={() => setAddingToCategory(null)} + onSaveItem={(form) => saveItem(cat.id, form)} + onToggle={toggleAvailability} + onEdit={(item) => { setEditingItem(item); setError('') }} + onCancelEdit={() => setEditingItem(null)} + onSaveEdit={saveEdit} + onDelete={deleteItem} + /> + )) + )} +
+ + {/* Edit modal */} + {editingItem && ( + setEditingItem(null)} + /> + )} +
+ ) +} + +// ──────────────────────────────────────────────────────────────── + +interface NewItemForm { name: string; description: string; price: string; dietaryTags: string[] } +interface EditItemForm { name: string; description: string; price: string } + +const DIETARY_OPTIONS = ['Vegan', 'Vegetarian', 'Gluten-Free', 'Dairy-Free', 'Nut-Free', 'Halal', 'Spicy'] + +function CategorySection({ category, addingItem, saving, error, editingItem, onStartAdd, onCancelAdd, onSaveItem, onToggle, onEdit, onCancelEdit, onSaveEdit, onDelete }: { + category: Category + addingItem: boolean + saving: boolean + error: string + editingItem: MenuItem | null + onStartAdd: () => void + onCancelAdd: () => void + onSaveItem: (form: NewItemForm) => void + onToggle: (item: MenuItem) => void + onEdit: (item: MenuItem) => void + onCancelEdit: () => void + onSaveEdit: (form: EditItemForm) => void + onDelete: (item: MenuItem) => void +}) { + const [form, setForm] = useState({ name: '', description: '', price: '', dietaryTags: [] }) + + const liveCount = category.items.filter((i) => i.is_available).length + + return ( +
+ {/* Category header */} +
+
+

{category.name}

+

+ {category.items.length} items · {liveCount} live +

+
+ +
+ + {/* Add item form */} + {addingItem && ( +
+

New Item in {category.name}

+
+
+
+ setForm({ ...form, name: e.target.value })} + placeholder="Item name *" + className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> +
+
+
+ $ + setForm({ ...form, price: e.target.value })} + placeholder="0.00" + className="w-full border border-slate-200 rounded-xl pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> +
+
+
+