import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { MapPin, Search, Leaf, X, Plus, ShoppingBag, ChevronLeft, ArrowRight, Send, Loader2, Info, Armchair, Check, SearchX, UtensilsCrossed, AlertCircle, Soup, Salad, Wheat, IceCream2, Wine, } from 'lucide-react' import { fetchMenu, submitOrder } from '../api' import { DishArt, Badge, DietChip, TagIcons, Price, DiscountFlag, Stepper, eur, discountedPrice, discountPct, } from '../components/primitives' // TODO: Replace with real data from API once backend includes restaurant info const RESTAURANT_FALLBACK = { name: 'Our Menu', tagline: { en: 'Kitchen & Bar', gr: 'Κουζίνα & Μπαρ' }, blurb: { en: 'Fresh seasonal plates, served with care.', gr: 'Εποχιακά πιάτα, με αγάπη.' }, hours: { en: 'Open today · 12:00 – 23:30', gr: 'Ανοιχτά σήμερα · 12:00 – 23:30' }, location: { en: '', gr: '' }, } // Category glyph icons — mapped by category id or index const GLYPH_BY_ID = { starters: Soup, salads: Salad, mains: UtensilsCrossed, sides: Wheat, desserts: IceCream2, drinks: Wine } const GLYPH_FALLBACK = [Soup, Salad, UtensilsCrossed, Wheat, IceCream2, Wine] const CATEGORY_HUES = [96, 78, 18, 40, 340, 176] // Allergen label fallbacks const ALLERGEN_EN = { gluten: 'gluten', dairy: 'dairy', egg: 'egg', fish: 'fish', shellfish: 'shellfish', nuts: 'nuts', soy: 'soy', sesame: 'sesame', sulphites: 'sulphites' } const I18N = { en: { searchPlaceholder: 'Search the menu…', add: 'Add', back: 'Back', total: 'Total', yourOrder: 'Your order', emptyCart: 'Your cart is empty', emptyCartSub: 'Add a few dishes to get started.', continue: 'Continue', placeOrder: 'Place order', sending: 'Sending…', orderPlaced: 'Order placed!', orderPlacedSub: 'Your order has been sent to the kitchen.', newOrder: 'Back to menu', table: 'Table no.', name: 'Your name', namePh: 'e.g. Alex', tablePh: 'e.g. 12', orderSummary: 'Order summary', contains: 'Contains', ingredients: 'Ingredients', noResults: 'No dishes found', noResultsSub: 'Try a different search.', popular: 'Popular', chefs: "Chef's pick", off: 'OFF', dietary: { vegan: 'Vegan', vegetarian: 'Vegetarian', 'gluten-free': 'Gluten-free', spicy: 'Spicy' }, }, gr: { searchPlaceholder: 'Αναζήτηση στο μενού…', add: 'Προσθήκη', back: 'Πίσω', total: 'Σύνολο', yourOrder: 'Η παραγγελία σου', emptyCart: 'Το καλάθι σου είναι άδειο', emptyCartSub: 'Πρόσθεσε μερικά πιάτα για να ξεκινήσεις.', continue: 'Συνέχεια', placeOrder: 'Αποστολή παραγγελίας', sending: 'Αποστολή…', orderPlaced: 'Η παραγγελία στάλθηκε!', orderPlacedSub: 'Η παραγγελία σου εστάλη στην κουζίνα.', newOrder: 'Πίσω στο μενού', table: 'Τραπέζι', name: 'Το όνομά σου', namePh: 'π.χ. Αλέξης', tablePh: 'π.χ. 12', orderSummary: 'Σύνοψη παραγγελίας', contains: 'Περιέχει', ingredients: 'Συστατικά', noResults: 'Δεν βρέθηκαν πιάτα', noResultsSub: 'Δοκίμασε διαφορετική αναζήτηση.', popular: 'Δημοφιλές', chefs: 'Επιλογή Σεφ', off: 'ΕΚΠΤΩΣΗ', dietary: { vegan: 'Vegan', vegetarian: 'Χορτοφαγικό', 'gluten-free': 'Χωρίς γλουτένη', spicy: 'Πικάντικο' }, }, } // ── Normalise a backend product to the shape the UI expects ────────────────── function normaliseProduct(p, catId) { return { ...p, id: String(p.id), cat: catId, name: p.digital_name || p.name || '', desc: p.digital_description || p.description || '', price: p.digital_price ?? p.base_price ?? 0, badge: p.digital_badge || p.badge || null, tags: p.digital_tags || p.tags || [], allergens: p.allergens || [], ingredients: p.ingredients || null, discountPct: p.digital_discount || p.discountPct || 0, image_url: p.digital_image_url || p.image_url || null, digital_available: p.digital_available !== false, } } function normaliseCategories(rawCats) { return rawCats.map((cat, idx) => { const id = cat.id ? String(cat.id) : `cat-${idx}` return { ...cat, id, hue: cat.hue ?? CATEGORY_HUES[idx % CATEGORY_HUES.length], GlyphIcon: GLYPH_BY_ID[id] ?? GLYPH_FALLBACK[idx % GLYPH_FALLBACK.length], products: (cat.products || []).map(p => normaliseProduct(p, id)), } }) } // ── Bottom Sheet shell ──────────────────────────────────────────────────────── function Sheet({ open, onClose, children, maxH = '88vh' }) { if (!open) return null return (
{children}
) } function SheetHandle() { return (
) } // ── Hero ────────────────────────────────────────────────────────────────────── function Hero({ lang, setLang, restaurant }) { const r = { ...RESTAURANT_FALLBACK, ...restaurant } return (
{/* Language toggle */}
{['en', 'gr'].map(l => ( ))}
{r.location?.[lang] && (
{r.location[lang]}
)} {r.headerImageUrl ? ( {r.name} ) : (

{r.name}

)}
{r.tagline?.[lang] ?? r.tagline ?? ''}
{/* Ornament */}

{r.blurb?.[lang] ?? r.blurb ?? ''}

{r.hours?.[lang] && (
{r.hours[lang]}
)}
) } // ── Category Bar ────────────────────────────────────────────────────────────── function CategoryBar({ categories, active, onPick, onSearch, lang }) { const [scrolled, setScrolled] = useState(false) const railRef = useRef(null) useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 220) window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, []) useEffect(() => { const rail = railRef.current if (!rail) return const el = rail.querySelector(`[data-pill="${active}"]`) if (el) { const target = el.offsetLeft - rail.clientWidth / 2 + el.clientWidth / 2 rail.scrollTo({ left: target, behavior: 'smooth' }) } }, [active]) return (
{categories.map(cat => { const on = cat.id === active const label = typeof cat.name === 'object' ? (cat.name[lang] ?? cat.name.en) : cat.name return ( ) })}
) } // ── Product Card ────────────────────────────────────────────────────────────── function ProductCard({ product, category, lang, t, onOpen, onAdd, qty, viewOnly }) { const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc const unavailable = product.digital_available === false return (
onOpen(product)} className={`group overflow-hidden rounded-[20px] bg-[#fcfbf7] ring-1 ring-[#e7e1d1] shadow-card transition hover:shadow-card-hover active:scale-[0.992] ${unavailable ? 'opacity-50' : 'cursor-pointer'}`} >
{/* Left: photo or placeholder art */} {product.image_url ? ( {name} ) : ( )} {/* Right: content column */}

{name}

{desc}

{!viewOnly && ( )}
) } // ── Menu Section ────────────────────────────────────────────────────────────── function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef, viewOnly }) { const { hue, products } = category const label = typeof category.name === 'object' ? (category.name[lang] ?? category.name.en) : category.name return (

{label}

{products.length}
{products.map(p => ( ))}
) } // ── Product Detail Sheet ────────────────────────────────────────────────────── function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec, viewOnly }) { if (!product) return null const hue = category?.hue ?? 40 const GlyphIcon = category?.GlyphIcon ?? UtensilsCrossed const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc const ingredients = product.ingredients ? (typeof product.ingredients === 'object' && !Array.isArray(product.ingredients) ? (product.ingredients[lang] ?? product.ingredients.en ?? []) : product.ingredients) : [] const allergens = product.allergens ?? [] const badge = product.badge ?? product.digital_badge const tags = product.tags ?? product.digital_tags ?? [] return (
{/* Hero art */} {product.image_url ? (
{name}
) : (
)}
{badge && }

{name}

{desc &&

{desc}

} {tags.length > 0 && (
{tags.map(tag => )}
)} {ingredients.length > 0 && (
{t.ingredients}
{ingredients.map((ing, i) => ( {ing} ))}
)} {allergens.length > 0 && (
{t.contains}: {allergens.map(a => ALLERGEN_EN[a] ?? a).join(', ')}
)}
{/* Sticky add bar */}
{!viewOnly && qty > 0 ? ( onInc(product)} onDec={() => onDec(product)} /> ) : (
)} {!viewOnly && ( )}
) } // ── Search Overlay ──────────────────────────────────────────────────────────── function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart, viewOnly }) { const [q, setQ] = useState('') const inputRef = useRef(null) useEffect(() => { if (open && inputRef.current) inputRef.current.focus() }, [open]) useEffect(() => { if (!open) setQ('') }, [open]) const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories]) const results = useMemo(() => { const term = q.trim().toLowerCase() if (!term) return [] return allProducts.filter(p => { const name = typeof p.name === 'object' ? Object.values(p.name).join(' ') : (p.name ?? '') const desc = typeof p.desc === 'object' ? Object.values(p.desc).join(' ') : (p.desc ?? '') const ings = p.ingredients ? (Array.isArray(p.ingredients) ? p.ingredients : Object.values(p.ingredients).flat()).join(' ') : '' return (name + ' ' + desc + ' ' + ings).toLowerCase().includes(term) }) }, [q, allProducts]) if (!open) return null return (
setQ(e.target.value)} placeholder={t.searchPlaceholder} className="w-full bg-transparent text-[15px] text-[#2d3b2d] outline-none placeholder:text-[#b3aa90]" /> {q && ( )}
{q && results.length === 0 && (
{t.noResults}
{t.noResultsSub}
)} {results.length > 0 && (
{results.map(p => { const cat = categories.find(c => c.id === p.cat) return ( { onClose(); onOpen(prod) }} onAdd={onAdd} qty={cart[p.id] || 0} viewOnly={viewOnly} /> ) })}
)} {!q && (
{t.searchPlaceholder}
)}
) } // ── Cart Flow (bottom sheet) ────────────────────────────────────────────────── function CartFlow({ stage, setStage, cart, setCart, categories, lang, t, onOpenProduct, siteSlug, navigate }) { const [form, setForm] = useState({ name: '', table: '' }) const [sending, setSending] = useState(false) const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories]) const lines = useMemo(() => Object.entries(cart) .map(([id, qty]) => ({ product: allProducts.find(p => p.id === id), qty })) .filter(l => l.product && l.qty > 0), [cart, allProducts] ) const total = lines.reduce((s, l) => s + discountedPrice(l.product) * l.qty, 0) const inc = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 })) const dec = p => setCart(c => { const n = (c[p.id] || 0) - 1 const next = { ...c } if (n <= 0) delete next[p.id]; else next[p.id] = n return next }) const submit = async () => { setSending(true) try { const result = await submitOrder(siteSlug, { order_type: 'dine_in', customer_name: form.name.trim(), customer_table: form.table.trim(), items: lines.map(l => ({ product_id: l.product.id, name: typeof l.product.name === 'object' ? l.product.name.en : l.product.name, quantity: l.qty, unit_price: discountedPrice(l.product), })), subtotal: Math.round(total * 100) / 100, total: Math.round(total * 100) / 100, lang, placed_at: new Date().toISOString(), }) setCart({}) setForm({ name: '', table: '' }) setStage(null) navigate(`/${siteSlug}/confirm/${result.public_ref}`) } catch { setSending(false) } } const close = () => setStage(null) // Cart view if (stage === 'cart') { return (

{t.yourOrder}

{lines.length === 0 ? (
{t.emptyCart}
{t.emptyCartSub}
) : ( <>
{lines.map(l => { const cat = categories.find(c => c.id === l.product.cat) const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name return (
{ close(); onOpenProduct(l.product) }} className="cursor-pointer font-display text-[16px] font-semibold leading-tight text-[#2d3b2d]" > {pname}
{eur(discountedPrice(l.product))}
inc(l.product)} onDec={() => dec(l.product)} />
) })}
{t.total} {eur(total)}
)}
) } // Checkout view if (stage === 'checkout') { const valid = form.name.trim() && form.table.trim() return (

{t.placeOrder}

{t.orderSummary}
{lines.map((l, i) => { const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name return (
0 ? 'border-t border-[#f0ebdd]' : ''}`}> {l.qty}× {pname} {eur(discountedPrice(l.product) * l.qty)}
) })}
{t.total} {eur(total)}
) } return null } // ── Floating Cart Button ────────────────────────────────────────────────────── function CartButton({ count, total, t, onClick }) { const [bump, setBump] = useState(false) const prev = useRef(count) useEffect(() => { if (count !== prev.current && count > 0) { setBump(true) const tm = setTimeout(() => setBump(false), 320) prev.current = count return () => clearTimeout(tm) } prev.current = count }, [count]) if (count === 0) return null return (
) } // ── Main MenuPage ───────────────────────────────────────────────────────────── export default function MenuPage() { const { siteSlug } = useParams() const navigate = useNavigate() const [categories, setCategories] = useState([]) const [restaurant, setRestaurant] = useState(null) const [viewOnly, setViewOnly] = useState(false) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [lang, setLang] = useState(() => { try { return localStorage.getItem('ot_lang') || 'en' } catch { return 'en' } }) const [cart, setCart] = useState(() => { try { return JSON.parse(localStorage.getItem('ot_cart')) || {} } catch { return {} } }) const [active, setActive] = useState(null) const [activeProduct, setActiveProduct] = useState(null) const [activeProductCat, setActiveProductCat] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [stage, setStage] = useState(null) const t = I18N[lang] ?? I18N.en useEffect(() => { try { localStorage.setItem('ot_lang', lang) } catch {} }, [lang]) useEffect(() => { try { localStorage.setItem('ot_cart', JSON.stringify(cart)) } catch {} }, [cart]) useEffect(() => { fetchMenu(siteSlug) .then(data => { const cats = normaliseCategories(data.categories || []) setCategories(cats) setRestaurant(data.restaurant ?? null) setViewOnly(data.menu_mode === 'view_only') if (cats.length) setActive(cats[0].id) }) .catch(() => setError('Menu not available. Please try again.')) .finally(() => setLoading(false)) }, [siteSlug]) const sectionRefs = useRef({}) const clickLock = useRef(false) // Scroll-spy useEffect(() => { const onScroll = () => { if (clickLock.current) return let current = categories[0]?.id for (const c of categories) { const el = sectionRefs.current[c.id] if (el && el.getBoundingClientRect().top <= 90) current = c.id } if (current) setActive(a => a === current ? a : current) } window.addEventListener('scroll', onScroll, { passive: true }) onScroll() return () => window.removeEventListener('scroll', onScroll) }, [categories]) const pick = id => { const el = sectionRefs.current[id] if (!el) return setActive(id) clickLock.current = true const top = el.getBoundingClientRect().top + window.scrollY - 64 window.scrollTo({ top, behavior: 'smooth' }) setTimeout(() => { clickLock.current = false }, 700) } const addToCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 })) const incCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 })) const decCart = p => setCart(c => { const n = (c[p.id] || 0) - 1 const next = { ...c } if (n <= 0) delete next[p.id]; else next[p.id] = n return next }) const openProduct = p => { setActiveProduct(p) setActiveProductCat(categories.find(c => c.id === p.cat) ?? null) } const count = Object.values(cart).reduce((s, q) => s + q, 0) const total = useMemo(() => { const allProducts = categories.flatMap(c => c.products) return Object.entries(cart).reduce((s, [id, q]) => { const p = allProducts.find(x => x.id === id) return p ? s + discountedPrice(p) * q : s }, 0) }, [cart, categories]) if (loading) return (
) if (error) return (

{error}

) return (
setSearchOpen(true)} lang={lang} />
{categories.map(cat => (
{ sectionRefs.current[cat.id] = el }} viewOnly={viewOnly} /> ))}
{!viewOnly && setStage('cart')} />} setActiveProduct(null)} onAdd={addToCart} qty={activeProduct ? (cart[activeProduct.id] || 0) : 0} onInc={incCart} onDec={decCart} viewOnly={viewOnly} /> setSearchOpen(false)} categories={categories} lang={lang} t={t} onOpen={openProduct} onAdd={addToCart} cart={cart} viewOnly={viewOnly} /> {!viewOnly && ( )}
) }