From d48e7de83863426482662863479f0cff1250a131 Mon Sep 17 00:00:00 2001 From: TanishqMehrunkarIIPSDAVV Date: Thu, 22 Jan 2026 20:31:48 +0530 Subject: [PATCH] customer page and menu item --- client/src/components/cart/CartDrawer.jsx | 69 ++-- client/src/components/ui/MenuCard.jsx | 9 +- client/src/context/CartContext.jsx | 53 ++- client/src/features/home/FeaturedItems.jsx | 71 ++-- client/src/features/home/HeroSection.jsx | 10 +- client/src/features/home/TrendingItems.jsx | 57 ++++ client/src/main.jsx | 13 +- client/src/pages/Home/Home.jsx | 18 +- client/src/pages/Order/OrderSession.jsx | 41 ++- client/src/pages/admin/GlobalMenu.jsx | 362 +++++++++++++++------ docs/MENU_VARIANTS_GUIDE.md | 111 +++++++ server/controllers/menuController.js | 104 ++++++ server/models/MenuItem.js | 25 ++ server/routes/menuRoutes.js | 4 +- 14 files changed, 720 insertions(+), 227 deletions(-) create mode 100644 client/src/features/home/TrendingItems.jsx create mode 100644 docs/MENU_VARIANTS_GUIDE.md diff --git a/client/src/components/cart/CartDrawer.jsx b/client/src/components/cart/CartDrawer.jsx index babfce8..9bf903f 100644 --- a/client/src/components/cart/CartDrawer.jsx +++ b/client/src/components/cart/CartDrawer.jsx @@ -52,7 +52,13 @@ const CartDrawer = () => { return (
{/* Backdrop */} -
setIsCartOpen(false)}>
+
{ + e.stopPropagation(); + setIsCartOpen(false); + }} + >
{/* Drawer */}
@@ -75,36 +81,45 @@ const CartDrawer = () => {
) : ( - cartItems.map((item) => ( -
-
- {item.name} -
-
-
-

{item.name}

-

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

+ cartItems.map((item) => { + const imageUrl = typeof item.image === 'string' ? item.image.trim() : ''; + const hasImage = Boolean(imageUrl); + + return ( +
+
+ {hasImage ? ( + {item.name} + ) : ( + No image + )}
-
-
- - {item.quantity} - +
+
+

{item.name}

+

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

+
+
+
+ + {item.quantity} + +
-
- )) + ); + }) )}
diff --git a/client/src/components/ui/MenuCard.jsx b/client/src/components/ui/MenuCard.jsx index 506033b..bf445e7 100644 --- a/client/src/components/ui/MenuCard.jsx +++ b/client/src/components/ui/MenuCard.jsx @@ -26,7 +26,14 @@ const MenuCard = ({ item }) => {

{item.description}

${item.price} -
diff --git a/client/src/context/CartContext.jsx b/client/src/context/CartContext.jsx index 073c085..5aad923 100644 --- a/client/src/context/CartContext.jsx +++ b/client/src/context/CartContext.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { CartContext } from './CartContextValues'; export const CartProvider = ({ children }) => { @@ -35,25 +35,56 @@ export const CartProvider = ({ children }) => { } }, [tableInfo]); - const addToCart = (item, quantity = 1, modifiers = []) => { + const addGuardRef = useRef(new Map()); + const timeoutRef = useRef(null); + + const addToCart = useCallback((item, quantity = 1, modifiers = []) => { + if (!item?._id) { + return; + } + + const normalizedModifiers = Array.isArray(modifiers) ? modifiers : []; + const guardKey = `${item._id}-${JSON.stringify(normalizedModifiers)}`; + + // Check if this exact item+modifiers combo is already being added + const lastAddTime = addGuardRef.current.get(guardKey); + if (lastAddTime && Date.now() - lastAddTime < 1000) { + return; + } + + // Record this add attempt + addGuardRef.current.set(guardKey, Date.now()); + setCartItems((prevItems) => { - // Check if item with same ID and modifiers exists - // For simplicity, we'll just check ID for now. - // Should ideally check modifiers too (deep equal). + const normalizedImage = typeof item.image === 'string' && item.image.trim() ? item.image.trim() : null; + const safeQuantity = Math.max(1, quantity); const existingItemIndex = prevItems.findIndex( - (i) => i._id === item._id && JSON.stringify(i.modifiers) === JSON.stringify(modifiers) + (i) => i._id === item._id && JSON.stringify(i.modifiers) === JSON.stringify(normalizedModifiers) ); if (existingItemIndex > -1) { const newItems = [...prevItems]; - newItems[existingItemIndex].quantity += quantity; + newItems[existingItemIndex].quantity += safeQuantity; return newItems; - } else { - return [...prevItems, { ...item, quantity, modifiers }]; } + + return [ + ...prevItems, + { ...item, image: normalizedImage, quantity: safeQuantity, modifiers: normalizedModifiers }, + ]; }); - setIsCartOpen(true); - }; + + // Open cart drawer + setTimeout(() => setIsCartOpen(true), 0); + + // Clear guard after 1 second to allow new adds + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + addGuardRef.current.delete(guardKey); + }, 1000); + }, []); const removeFromCart = (itemId, modifiers = []) => { setCartItems((prevItems) => diff --git a/client/src/features/home/FeaturedItems.jsx b/client/src/features/home/FeaturedItems.jsx index c11eda8..9c53636 100644 --- a/client/src/features/home/FeaturedItems.jsx +++ b/client/src/features/home/FeaturedItems.jsx @@ -1,70 +1,47 @@ import React, { useEffect, useState } from 'react'; import MenuCard from '../../components/ui/MenuCard'; -import { Button } from '../../components/ui/button'; -import { useOutlet } from '../../context/OutletContextValues'; import api from '../../lib/axios'; -const FeaturedItems = () => { - const { selectedOutlet } = useOutlet(); +const FeaturedItems = ({ outletId, showAll = false }) => { const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); useEffect(() => { const fetchMenu = async () => { - if (!selectedOutlet?._id) return; + if (!outletId) return; - setLoading(true); try { - const res = await api.get(`/api/public/menu/${selectedOutlet._id}`); + const res = await api.get(`/api/menu/public/${outletId}`); + if (res.data.success) { - // For featured items, maybe we just take the first 4 or random? - // Or we can filter by a 'featured' tag if we add it later. - // For now, let's just slice the top 4. - setItems(res.data.data.slice(0, 4)); + const allItems = res.data.data; + + // Filter to show only available items + const availableItems = allItems.filter((item) => item.isAvailable !== false); + + setItems(availableItems); } } catch (error) { - console.error('Failed to fetch menu:', error); - } finally { - setLoading(false); + console.error('Error fetching menu:', error); } }; fetchMenu(); - }, [selectedOutlet]); + }, [outletId]); - if (!selectedOutlet) return null; + if (!outletId) return null; - return ( -
-
-
-

Our Signature Dishes

-

- Discover the most loved dishes from our kitchen at{' '} - {selectedOutlet.name}. -

-
- - {loading ? ( -
Loading menu...
- ) : ( -
- {items.map((item) => ( - - ))} - {items.length === 0 && ( -
No items available at this outlet yet.
- )} -
- )} + // Display limited items (8) or all items based on showAll prop + const displayItems = showAll ? items : items.slice(0, 8); -
- -
-
-
+ return ( +
+ {displayItems.map((item) => ( + + ))} + {displayItems.length === 0 && ( +
No items available at this outlet yet.
+ )} +
); }; diff --git a/client/src/features/home/HeroSection.jsx b/client/src/features/home/HeroSection.jsx index 570af71..20d84c4 100644 --- a/client/src/features/home/HeroSection.jsx +++ b/client/src/features/home/HeroSection.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button } from '../../components/ui/button'; -const HeroSection = ({ onBookTable }) => { +const HeroSection = () => { return (
{/* Background Image with Overlay */} @@ -21,16 +21,12 @@ const HeroSection = ({ onBookTable }) => { Modern Dining Experience

- Experience the rich heritage of Indonesian cuisine. Whether you want a cozy dine-in experience or quick - delivery to your doorstep, we serve happiness. + Experience the rich heritage of Indonesian cuisine with our exquisite menu selection.

-
+
-
diff --git a/client/src/features/home/TrendingItems.jsx b/client/src/features/home/TrendingItems.jsx new file mode 100644 index 0000000..eef7377 --- /dev/null +++ b/client/src/features/home/TrendingItems.jsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { useCart } from '../../context/CartContextValues'; +import api from '../../lib/axios'; +import { Flame } from 'lucide-react'; +import MenuCard from '../../components/ui/MenuCard'; + +const TrendingItems = ({ outletId }) => { + const { addToCart } = useCart(); + const [trendingItems, setTrendingItems] = useState([]); + + useEffect(() => { + const fetchTrendingItems = async () => { + if (!outletId) return; + + try { + const res = await api.get(`/api/menu/public/trending/${outletId}`); + if (res.data.success) { + setTrendingItems(res.data.data || []); + } + } catch (err) { + console.error('Error fetching trending items:', err); + setTrendingItems([]); + } + }; + + fetchTrendingItems(); + }, [outletId]); + + // Don't show section if no trending items found + if (trendingItems.length === 0) { + return null; + } + + return ( +
+
+ +

Trending Now

+ Popular +
+ +
+ {trendingItems.map((item) => ( + { + addToCart(item); + }} + /> + ))} +
+
+ ); +}; + +export default TrendingItems; diff --git a/client/src/main.jsx b/client/src/main.jsx index b9a1a6d..93caa2d 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,10 +1,5 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.jsx'; -createRoot(document.getElementById('root')).render( - - - , -) +createRoot(document.getElementById('root')).render(); diff --git a/client/src/pages/Home/Home.jsx b/client/src/pages/Home/Home.jsx index 7124728..89aeb83 100644 --- a/client/src/pages/Home/Home.jsx +++ b/client/src/pages/Home/Home.jsx @@ -3,25 +3,18 @@ import React, { useState } from 'react'; import Navbar from '../../components/layout/Navbar'; import Footer from '../../components/layout/Footer'; import HeroSection from '../../features/home/HeroSection'; -import ServiceTypeSelector from '../../features/home/ServiceTypeSelector'; import FeaturedItems from '../../features/home/FeaturedItems'; import OutletSelector from '../../components/layout/OutletSelector'; -import ReservationModal from '../../components/reservation/ReservationModal'; -import DineInModal from '../../components/layout/DineInModal'; import { useOutlet } from '../../context/OutletContextValues'; const Home = () => { const { selectedOutlet, isLoading } = useOutlet(); const [showSelector, setShowSelector] = useState(false); - const [showReservation, setShowReservation] = useState(false); - const [showDineIn, setShowDineIn] = useState(false); if (isLoading) { return ( -
- Preparing your table... -
+
Loading menu...
); } @@ -29,15 +22,10 @@ const Home = () => {
{(!selectedOutlet || showSelector) && setShowSelector(false)} />} - {showReservation && setShowReservation(false)} />} - - {showDineIn && setShowDineIn(false)} />} - setShowSelector(true)} />
- setShowReservation(true)} /> - setShowDineIn(true)} /> - + +
diff --git a/client/src/pages/Order/OrderSession.jsx b/client/src/pages/Order/OrderSession.jsx index 5d0c5e4..d170e7b 100644 --- a/client/src/pages/Order/OrderSession.jsx +++ b/client/src/pages/Order/OrderSession.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import ClassicLoader from '@/components/ui/loader'; import { useOutlet } from '../../context/OutletContextValues'; @@ -11,18 +11,19 @@ import { Button } from '@/components/ui/button'; import CartDrawer from '../../components/cart/CartDrawer'; import FeaturedItems from '../../features/home/FeaturedItems'; +import TrendingItems from '../../features/home/TrendingItems'; // Reusing FeaturedItems for now, but ideally this should be a "MenuSection" component that takes categories. -// Since FeaturedItems fetches its own data, we might need a dedicated MenuList component. -// For this MVP step, let's create a wrapper that looks mobile-native. const OrderSession = () => { const { outletId, tableId } = useParams(); const navigate = useNavigate(); const { setOutlet, selectedOutlet } = useOutlet(); const { setTableInfo, tableInfo, setIsCartOpen, cartItems } = useCart(); + const initializationRef = useRef(false); const [activeTab, setActiveTab] = useState('menu'); // 'menu' | 'orders' | 'bill' const [isLoading, setIsLoading] = useState(true); + const [showFullMenu, setShowFullMenu] = useState(false); // --- 1. Session Initialization --- useEffect(() => { @@ -31,15 +32,17 @@ const OrderSession = () => { setIsLoading(true); // Fetch Table Details const res = await api.get(`/api/public/table/${tableId}`); + const table = res.data?.data || res.data; if (!table) throw new Error('Table not found'); // Validate Outlet const tableOutletId = typeof table.outletId === 'string' ? table.outletId : table.outletId?._id; - if (tableOutletId !== outletId) { - toast.error('Table code mismatch'); - // Redirect or handle error + + // Compare as strings to handle ObjectId comparison + if (String(tableOutletId) !== String(outletId)) { + throw new Error('Table code mismatch - This table belongs to a different outlet'); } // Set Contexts @@ -52,7 +55,7 @@ const OrderSession = () => { toast.success(`Welcome to Table ${table.label}!`); } catch (err) { - console.error(err); + console.error('Session init error:', err); toast.error('Invalid session. Redirecting...'); navigate('/home'); } finally { @@ -60,7 +63,8 @@ const OrderSession = () => { } }; - if (outletId && tableId) { + if (outletId && tableId && !initializationRef.current) { + initializationRef.current = true; initSession(); } }, [outletId, tableId, navigate, setOutlet, setTableInfo]); @@ -124,9 +128,24 @@ const OrderSession = () => {

Select items to add to your table order.

- {/* Reusing FeaturedItems for content population for now. - Ideally refactor this to be a pure MenuList later. */} - + {/* Trending Items Section - only show when not viewing full menu */} + {!showFullMenu && } + + {/* All Menu Items */} +
+
+

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

+ +
+ +
)} diff --git a/client/src/pages/admin/GlobalMenu.jsx b/client/src/pages/admin/GlobalMenu.jsx index 444c668..51d68f8 100644 --- a/client/src/pages/admin/GlobalMenu.jsx +++ b/client/src/pages/admin/GlobalMenu.jsx @@ -63,6 +63,7 @@ const GlobalMenu = () => { image: '', isVeg: true, pieces: '', + variants: [], }); const categories = ['Starters', 'Mains', 'Desserts', 'Beverages']; @@ -114,6 +115,64 @@ const GlobalMenu = () => { setFilterTags((prev) => (prev.includes(tagId) ? prev.filter((t) => t !== tagId) : [...prev, tagId])); }; + // Variant management functions + const addVariant = () => { + setFormData((prev) => ({ + ...prev, + variants: [...prev.variants, { name: '', options: [{ label: '', priceAdjustment: 0 }] }], + })); + }; + + const removeVariant = (variantIndex) => { + setFormData((prev) => ({ + ...prev, + variants: prev.variants.filter((_, idx) => idx !== variantIndex), + })); + }; + + const updateVariantName = (variantIndex, name) => { + setFormData((prev) => ({ + ...prev, + variants: prev.variants.map((v, idx) => (idx === variantIndex ? { ...v, name } : v)), + })); + }; + + const addVariantOption = (variantIndex) => { + setFormData((prev) => ({ + ...prev, + variants: prev.variants.map((v, idx) => + idx === variantIndex ? { ...v, options: [...v.options, { label: '', priceAdjustment: 0 }] } : v + ), + })); + }; + + const removeVariantOption = (variantIndex, optionIndex) => { + setFormData((prev) => ({ + ...prev, + variants: prev.variants.map((v, idx) => + idx === variantIndex ? { ...v, options: v.options.filter((_, oIdx) => oIdx !== optionIndex) } : v + ), + })); + }; + + const updateVariantOption = (variantIndex, optionIndex, field, value) => { + setFormData((prev) => ({ + ...prev, + variants: prev.variants.map((v, idx) => + idx === variantIndex + ? { + ...v, + options: v.options.map((opt, oIdx) => + oIdx === optionIndex + ? { ...opt, [field]: field === 'priceAdjustment' ? Number(value) || 0 : value } + : opt + ), + } + : v + ), + })); + }; + const handleImageChange = (e) => { const file = e.target.files[0]; if (!file) return; @@ -203,6 +262,7 @@ const GlobalMenu = () => { category: 'Starters', image: '', isVeg: true, + variants: [], }); setSelectedTags([]); setImageFile(null); @@ -285,6 +345,7 @@ const GlobalMenu = () => { image: '', isVeg: true, pieces: '', + variants: [], }); setIsModalOpen(true); }} @@ -456,6 +517,7 @@ const GlobalMenu = () => { Name Description Tags + Variants Type Base Price @@ -491,6 +553,22 @@ const GlobalMenu = () => { ))}
+ + {(item.variants || []).length === 0 ? ( + - + ) : ( +
+ {item.variants.map((variant, vIdx) => ( +
+ {variant.name}:{' '} + + {variant.options.map((opt) => opt.label).join(', ')} + +
+ ))} +
+ )} + { image: item.image || '', isVeg: !!item.isVeg, pieces: item.pieces ?? '', + variants: item.variants || [], }); setSelectedTags(item.tags || []); setImageFile(null); @@ -536,131 +615,218 @@ const GlobalMenu = () => { {/* Add Item Modal */} {isModalOpen && (
-
+

{isEditing ? 'Edit Menu Item' : 'Add New Menu Item'}

-
-
- - -
- -
- -