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 (
)
}
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.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 ? (

) : (
)}
{/* 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}
)
}
// ── 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 ? (
) : (
)}
{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 (
{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 && (
)}
)
}
// ── 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.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 (
)
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 && (
)}
)
}