diff --git a/client/src/components/cart/CartDrawer.jsx b/client/src/components/cart/CartDrawer.jsx index 9bf903f..d34da35 100644 --- a/client/src/components/cart/CartDrawer.jsx +++ b/client/src/components/cart/CartDrawer.jsx @@ -7,12 +7,12 @@ import api from '../../lib/axios'; import toast from 'react-hot-toast'; const CartDrawer = () => { - const { cartItems, cartTotal, isCartOpen, setIsCartOpen, updateQuantity, clearCart, tableInfo, clearTableInfo } = - useCart(); + const { cartItems, cartTotal, isCartOpen, setIsCartOpen, updateQuantity, clearCart, tableInfo } = useCart(); const { selectedOutlet } = useOutlet(); const handleCheckout = async () => { - if (!selectedOutlet) { + const outletId = selectedOutlet?._id || selectedOutlet || tableInfo?.outletId; + if (!outletId) { toast.error('Please select an outlet first.'); return; } @@ -25,21 +25,30 @@ const CartDrawer = () => { modifiers: item.modifiers, })); + // Get existing session token if customer already placed an order + const existingToken = tableInfo?.tableId ? localStorage.getItem(`customerToken_${tableInfo.tableId}`) : null; + const payload = { - outletId: selectedOutlet._id, + outletId, items: orderItems, totalAmount: cartTotal, - ...(tableInfo && { tableId: tableInfo.tableId }), // Include tableId if customer selected a table + ...(tableInfo && { tableId: tableInfo.tableId }), + ...(existingToken && { customerToken: existingToken }), }; // API call to create order const res = await api.post('/api/public/orders', payload); if (res.data.success) { + // Always store server-provided token + if (res.data.customerToken && tableInfo?.tableId) { + localStorage.setItem(`customerToken_${tableInfo.tableId}`, res.data.customerToken); + } toast.success('Order Placed Successfully!'); clearCart(); - clearTableInfo(); setIsCartOpen(false); + // Trigger order refresh if there's a callback + if (window.refreshOrders) window.refreshOrders(); } } catch (error) { console.error('Checkout failed', error); @@ -95,9 +104,28 @@ const CartDrawer = () => { )}
-
-

{item.name}

-

${(item.price * item.quantity).toFixed(2)}

+
+
+

{item.name}

+ {Array.isArray(item.modifiers) && item.modifiers.length > 0 && ( +
+ {item.modifiers.map((mod, idx) => ( +
+ {mod.name}: + {mod.option || 'No selection'} + {mod.priceAdjustment ? ( + + {mod.priceAdjustment > 0 ? `+${mod.priceAdjustment}` : mod.priceAdjustment} + + ) : null} +
+ ))} +
+ )} +
+

+ ${(item.price * item.quantity).toFixed(2)} +

diff --git a/client/src/components/ui/MenuCard.jsx b/client/src/components/ui/MenuCard.jsx index bf445e7..f4ef24e 100644 --- a/client/src/components/ui/MenuCard.jsx +++ b/client/src/components/ui/MenuCard.jsx @@ -1,13 +1,51 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Button } from './button'; import { useCart } from '../../context/CartContextValues'; const MenuCard = ({ item }) => { const { addToCart } = useCart(); + const hasVariants = Array.isArray(item?.variants) && item.variants.length > 0; + const [showVariants, setShowVariants] = useState(false); + + // Calculate default selections based on variants (only when item changes) + const defaultSelections = useMemo(() => { + if (!hasVariants) return {}; + const defaults = {}; + item.variants.forEach((variant) => { + defaults[variant.name] = []; + }); + return defaults; + }, [item, hasVariants]); + + // Initialize with function to use memoized defaultSelections + const [selections, setSelections] = useState(() => defaultSelections); + + const selectedModifiers = useMemo(() => { + return Object.entries(selections).flatMap(([name, opts]) => { + if (!Array.isArray(opts) || opts.length === 0) return [{ name, option: null, priceAdjustment: 0 }]; + return opts.map((option) => ({ + name, + option: option?.label, + priceAdjustment: Number(option?.priceAdjustment) || 0, + })); + }); + }, [selections]); + + const finalPrice = useMemo(() => { + const base = Number(item.price) || 0; + const adjustments = selectedModifiers.reduce((acc, m) => acc + (m.priceAdjustment || 0), 0); + return base + adjustments; + }, [item.price, selectedModifiers]); + + const handleConfirmAdd = () => { + const enrichedItem = { ...item, price: finalPrice }; + addToCart(enrichedItem, 1, selectedModifiers); + setShowVariants(false); + }; return (
-
+
{item.image && ( { /> )}
-
-
-

{item.name}

- +
+
+
+
+ +

{item.name}

+
+

{item.description}

+ {Array.isArray(item.tags) && item.tags.length > 0 && ( +
+ {item.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {item.tags.length > 3 && +{item.tags.length - 3} more} +
+ )} +
+ {item.category}
-

{item.description}

-
- ${item.price} +
+ ${finalPrice.toFixed(2)}
+ + {showVariants && hasVariants && ( +
setShowVariants(false)} + > +
e.stopPropagation()} + > +
+
+

Customize

+

{item.name}

+
+ +
+ +
+ {item.variants.map((variant) => ( +
+

{variant.name}

+
+ {variant.options.map((option) => { + const selectedList = selections[variant.name] || []; + const isSelected = selectedList.some((o) => o.label === option.label); + return ( + + ); + })} +
+
+ ))} +
+ +
+
+

Total: ${finalPrice.toFixed(2)}

+
+ +
+
+
+ )}
); }; diff --git a/client/src/features/home/FeaturedItems.jsx b/client/src/features/home/FeaturedItems.jsx index 9c53636..35cf0b2 100644 --- a/client/src/features/home/FeaturedItems.jsx +++ b/client/src/features/home/FeaturedItems.jsx @@ -1,9 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import MenuCard from '../../components/ui/MenuCard'; import api from '../../lib/axios'; const FeaturedItems = ({ outletId, showAll = false }) => { const [items, setItems] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [category, setCategory] = useState('all'); + const [diet, setDiet] = useState('all'); // 'all' | 'veg' | 'non-veg' + const [tag, setTag] = useState('all'); useEffect(() => { const fetchMenu = async () => { @@ -28,19 +32,95 @@ const FeaturedItems = ({ outletId, showAll = false }) => { fetchMenu(); }, [outletId]); - if (!outletId) return null; + const filteredItems = useMemo(() => { + if (!outletId) return []; + const term = searchTerm.trim().toLowerCase(); + return items.filter((item) => { + const matchesCategory = category === 'all' || item.category === category; + const matchesDiet = diet === 'all' || (diet === 'veg' ? item.isVeg === true : item.isVeg === false); + const matchesTag = tag === 'all' || (Array.isArray(item.tags) && item.tags.includes(tag)); + const matchesSearch = + !term || item.name.toLowerCase().includes(term) || (item.description || '').toLowerCase().includes(term); + return matchesCategory && matchesDiet && matchesTag && matchesSearch; + }); + }, [items, searchTerm, category, diet, tag, outletId]); + + const categories = useMemo(() => { + const unique = Array.from(new Set(items.map((item) => item.category).filter(Boolean))); + return ['all', ...unique]; + }, [items]); + + const tags = useMemo(() => { + const unique = new Set(); + items.forEach((item) => { + if (Array.isArray(item.tags)) { + item.tags.forEach((t) => t && unique.add(t)); + } + }); + return ['all', ...Array.from(unique)]; + }, [items]); - // Display limited items (8) or all items based on showAll prop - const displayItems = showAll ? items : items.slice(0, 8); + const displayItems = showAll ? filteredItems : filteredItems.slice(0, 8); + + if (!outletId) return null; return ( -
- {displayItems.map((item) => ( - - ))} - {displayItems.length === 0 && ( -
No items available at this outlet yet.
- )} +
+
+
+ setSearchTerm(e.target.value)} + placeholder="Search dishes..." + className="w-full sm:w-1/2 bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 border border-white/10 focus:border-amber-500 focus:outline-none" + /> +
+ + +
+
+
+ +
+
+ +
+ {displayItems.map((item) => ( + + ))} + {displayItems.length === 0 && ( +
No items match your filters yet.
+ )} +
); }; diff --git a/client/src/pages/Order/OrderSession.jsx b/client/src/pages/Order/OrderSession.jsx index d170e7b..1328d76 100644 --- a/client/src/pages/Order/OrderSession.jsx +++ b/client/src/pages/Order/OrderSession.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import ClassicLoader from '@/components/ui/loader'; import { useOutlet } from '../../context/OutletContextValues'; @@ -6,7 +6,16 @@ import { useCart } from '../../context/CartContextValues'; import api from '../../lib/axios'; import toast from 'react-hot-toast'; import { AnimatePresence, motion } from 'framer-motion'; // eslint-disable-line no-unused-vars -import { ShoppingBag, UtensilsCrossed, Clock, ChevronRight, Search, Menu as MenuIcon, X } from 'lucide-react'; +import { + ShoppingBag, + UtensilsCrossed, + Clock, + ChevronRight, + ChevronDown, + Search, + Menu as MenuIcon, + X, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import CartDrawer from '../../components/cart/CartDrawer'; @@ -24,6 +33,9 @@ const OrderSession = () => { const [activeTab, setActiveTab] = useState('menu'); // 'menu' | 'orders' | 'bill' const [isLoading, setIsLoading] = useState(true); const [showFullMenu, setShowFullMenu] = useState(false); + const [orders, setOrders] = useState([]); + const [isOrdersLoading, setIsOrdersLoading] = useState(false); + const [expandedOrders, setExpandedOrders] = useState([]); // --- 1. Session Initialization --- useEffect(() => { @@ -46,11 +58,12 @@ const OrderSession = () => { } // Set Contexts - setOutlet(outletId); + setOutlet({ _id: String(outletId) }); setTableInfo({ tableId: table._id, tableName: table.label, floor: table.floor, + outletId: String(tableOutletId), }); toast.success(`Welcome to Table ${table.label}!`); @@ -69,6 +82,43 @@ const OrderSession = () => { } }, [outletId, tableId, navigate, setOutlet, setTableInfo]); + // Fetch orders for this table + const fetchOrders = useCallback(async () => { + if (!tableId) return; + try { + setIsOrdersLoading(true); + // Get stored customer token (set after first order) + const customerToken = localStorage.getItem(`customerToken_${tableId}`); + const url = customerToken + ? `/api/public/orders/table/${tableId}?customerToken=${customerToken}` + : `/api/public/orders/table/${tableId}`; + const res = await api.get(url); + if (res.data?.success) { + const fetched = res.data.data || []; + setOrders(fetched); + setExpandedOrders(fetched.map((o) => o._id)); // auto-expand to show items + } + } catch (err) { + console.error('Failed to fetch orders', err); + toast.error('Could not load orders'); + } finally { + setIsOrdersLoading(false); + } + }, [tableId]); + + useEffect(() => { + fetchOrders(); + // Set up refresh callback for cart drawer + window.refreshOrders = fetchOrders; + return () => { + delete window.refreshOrders; + }; + }, [tableId, fetchOrders]); + + const toggleOrder = (orderId) => { + setExpandedOrders((prev) => (prev.includes(orderId) ? prev.filter((id) => id !== orderId) : [...prev, orderId])); + }; + // --- Render Loading --- if (isLoading) { return ( @@ -81,118 +131,222 @@ const OrderSession = () => { // --- Main UI --- return ( -
- {/* 1. Header (Sticky) */} -
-
-

{selectedOutlet?.name || 'Indo Cafe'}

- - - Table {tableInfo?.tableName} - -
- -
- - {/* 2. Content Tabs */} -
- {/* Simple Tab Switcher */} -
- setActiveTab('menu')} - label="Menu" - icon={} - /> - setActiveTab('orders')} - label="My Orders" - icon={} - /> -
+
+
+ {/* 1. Header (Sticky) */} +
+
+

{selectedOutlet?.name || 'Indo Cafe'}

+ + + Table {tableInfo?.tableName} + +
+ +
- {/* Tab Content */} - - {activeTab === 'menu' && ( - -
-

Hungry? 😋

-

Select items to add to your table order.

-
+ {/* 2. Content Tabs */} +
+ {/* Simple Tab Switcher */} +
+ setActiveTab('menu')} + label="Menu" + icon={} + /> + setActiveTab('orders')} + label="My Orders" + icon={} + /> +
- {/* Trending Items Section - only show when not viewing full menu */} - {!showFullMenu && } - - {/* All Menu Items */} -
-
-

{showFullMenu ? 'Full Menu' : 'Our Menu'}

- + {/* Tab Content */} + + {activeTab === 'menu' && ( + +
+

Hungry? 😋

+

Select items to add to your table order.

- -
- - )} - - {activeTab === 'orders' && ( - - -

No active orders yet.

- +
+ +
+
+ )} + + {activeTab === 'orders' && ( + - Go to Menu - - - )} -
-
+
+
+

My Orders

+

Most recent first

+
+ +
- {/* 3. Floating Cart Button (FAB) */} - {cartItems.length > 0 && ( - - - - )} + {isOrdersLoading ? ( +
+ + Loading orders... +
+ ) : orders.length === 0 ? ( +
+ +

No orders yet for this table.

+ +
+ ) : ( +
+ {orders.map((order) => { + const isOpen = expandedOrders.includes(order._id); + return ( +
+ - {/* Drawers/Modals */} - + {!isOpen && (order.items || []).length > 0 && ( +
+ {(order.items || []) + .slice(0, 2) + .map((item) => item.name || 'Item') + .join(', ')} + {(order.items || []).length > 2 ? '…' : ''} +
+ )} + + {isOpen && ( +
+ {(order.items || []).map((item, idx) => ( +
+
+

+ {item.name || item.menuItem?.name || 'Item'} +

+ {Array.isArray(item.modifiers) && item.modifiers.length > 0 && ( +
+ {item.modifiers.map((mod, mIdx) => ( +
+ {mod} +
+ ))} +
+ )} +
+
+

x{item.quantity}

+ {item.price !== undefined && ( +

+ ${item.price?.toFixed?.(2) ?? item.price} +

+ )} +
+
+ ))} +
+ )} + +
+

Total

+

+ ${order.totalAmount?.toFixed?.(2) ?? order.totalAmount} +

+
+
+ ); + })} +
+ )} + + )} + +
+ + {/* 3. Floating Cart Button (FAB) */} + {cartItems.length > 0 && ( + + + + )} + + {/* Drawers/Modals */} + +
); }; diff --git a/server/controllers/orderController.js b/server/controllers/orderController.js index 8278350..e39590c 100644 --- a/server/controllers/orderController.js +++ b/server/controllers/orderController.js @@ -55,15 +55,61 @@ export const createOrder = async (req, res) => { } } + // Enrich items with snapshots (name, price, normalized modifiers) + const menuIds = items.map((i) => i.menuItem).filter(Boolean); + const menus = await MenuItem.find({ _id: { $in: menuIds } }).lean(); + const configs = await OutletItemConfig.find({ + outletId, + menuItemId: { $in: menuIds }, + }).lean(); + const configMap = new Map(); + configs.forEach((c) => configMap.set(c.menuItemId.toString(), c)); + + const normalizedItems = items.map((item) => { + const menu = menus.find( + (m) => m._id.toString() === String(item.menuItem) + ); + const cfg = configMap.get(String(item.menuItem)); + const priceFromConfig = + cfg && cfg.customPrice !== undefined && cfg.customPrice !== null + ? cfg.customPrice + : undefined; + const priceSnapshot = + typeof item.price === 'number' + ? item.price + : (priceFromConfig ?? menu?.basePrice ?? 0); + const nameSnapshot = item.name || menu?.name || 'Menu Item'; + const modifiersList = Array.isArray(item.modifiers) + ? item.modifiers.map((mod) => { + if (typeof mod === 'string') return mod; + const label = mod?.option || mod?.label || ''; + const key = mod?.name || mod?.variant || 'Option'; + const priceAdj = mod?.priceAdjustment + ? ` (${mod.priceAdjustment > 0 ? '+' : ''}${mod.priceAdjustment})` + : ''; + return `${key}: ${label}${priceAdj}`.trim(); + }) + : []; + + return { + menuItem: item.menuItem, + name: nameSnapshot, + price: priceSnapshot, + quantity: item.quantity || 1, + modifiers: modifiersList, + }; + }); + // Generate unique token for this customer-table combination const newCustomerToken = generateSessionToken(); const newOrder = await Order.create({ outletId, - items, + items: normalizedItems, totalAmount, status: 'placed', tableId: tableId || null, + tableSessionId: table?.sessionId || null, notes: notes || '', customerToken: newCustomerToken, // Store the unique token for this customer takenBy: req.user ? req.user._id : null, // If logged in @@ -130,13 +176,29 @@ export const getOutletOrders = async (req, res) => { }; // @desc Get orders for a specific table -// @route GET /api/waiter/table/:tableId/orders -// @access Private (Waiter) +// @route GET /api/waiter/table/:tableId/orders OR GET /api/public/orders/table/:tableId?customerToken=xxx +// @access Private (Waiter) or Public (with customerToken) export const getTableOrders = async (req, res) => { try { const { tableId } = req.params; + const { customerToken } = req.query; - const orders = await Order.find({ tableId }) + // Get current table session + const table = await Table.findById(tableId); + if (!table) { + return res + .status(404) + .json({ success: false, message: 'Table not found' }); + } + + const query = { tableId, tableSessionId: table.sessionId }; + // If customerToken is provided, filter by it (for returning customers) + if (customerToken) { + query.customerToken = customerToken; + } + // Otherwise return all orders for current table session (new customers see all current session orders) + + const orders = await Order.find(query) .sort({ createdAt: -1 }) .populate('items.menuItem', 'name'); diff --git a/server/controllers/tableController.js b/server/controllers/tableController.js index e863279..c1a2a8c 100644 --- a/server/controllers/tableController.js +++ b/server/controllers/tableController.js @@ -161,9 +161,12 @@ export const releaseTable = async (req, res) => { try { const { tableId } = req.params; + // Generate new sessionId to invalidate old customer sessions + const newSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const table = await Table.findByIdAndUpdate( tableId, - { isOccupied: false, currentOrderId: null }, + { isOccupied: false, currentOrderId: null, sessionId: newSessionId }, { new: true } ); diff --git a/server/models/Order.js b/server/models/Order.js index 89609bb..2aadac8 100644 --- a/server/models/Order.js +++ b/server/models/Order.js @@ -47,6 +47,11 @@ const orderSchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, ref: 'Table', }, + // Table session ID at time of order (changes when table is released) + tableSessionId: { + type: String, + default: null, + }, // Customer identifier (for unauthenticated orders) customerId: { type: String, diff --git a/server/models/Table.js b/server/models/Table.js index c7c27ae..289c5a1 100644 --- a/server/models/Table.js +++ b/server/models/Table.js @@ -39,6 +39,11 @@ const tableSchema = new mongoose.Schema( ref: 'Order', default: null, }, + sessionId: { + type: String, + default: () => + `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + }, assignedWaiterId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', diff --git a/server/routes/orderRoutes.js b/server/routes/orderRoutes.js index 0eec674..42c364d 100644 --- a/server/routes/orderRoutes.js +++ b/server/routes/orderRoutes.js @@ -2,6 +2,7 @@ import express from 'express'; import { createOrder, getOutletOrders, + getTableOrders, updateOrderStatus, } from '../controllers/orderController.js'; import { protect } from '../middleware/authMiddleware.js'; @@ -11,6 +12,7 @@ const router = express.Router(); // Public router.post('/public/orders', createOrder); +router.get('/public/orders/table/:tableId', getTableOrders); // Manager, Waiter & Kitchen router.get(