Files
xenia-pos-cloud/connect_frontend/menu-app/src/pages/MenuPage.jsx
bonamin d87540e08f feat: per-site QR menu mode (order vs view-only) + editable branding
Adds a sysadmin-configurable toggle so sites can disable online ordering
on the public QR menu until it's fully supported, plus editable
tagline/hours and a header image (replacing the hardcoded "Our Menu"
placeholder) — all previously hardcoded frontend strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 09:59:35 +03:00

944 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<div
className="absolute inset-0 animate-fade bg-[#2d2a1f]/40 backdrop-blur-[2px]"
onClick={onClose}
/>
<div
className="relative z-10 w-full sm:max-w-[960px] animate-slideup overflow-hidden rounded-t-[24px] bg-[#faf7f0] shadow-[0_-12px_40px_-12px_rgba(45,42,31,0.4)]"
style={{ maxHeight: maxH }}
>
{children}
</div>
</div>
)
}
function SheetHandle() {
return (
<div className="flex justify-center pt-2.5">
<div className="h-1 w-10 rounded-full bg-[#ddd5c0]" />
</div>
)
}
// ── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ lang, setLang, restaurant }) {
const r = { ...RESTAURANT_FALLBACK, ...restaurant }
return (
<header className="relative px-6 pt-7 pb-6 text-center">
{/* Language toggle */}
<div className="absolute right-5 top-6">
<div className="flex items-center rounded-full bg-white/70 p-0.5 text-[11px] font-semibold ring-1 ring-[#e3dcc9] backdrop-blur">
{['en', 'gr'].map(l => (
<button
key={l}
onClick={() => setLang(l)}
className={`rounded-full px-2.5 py-1 uppercase tracking-wider transition ${
lang === l ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'text-[#8a8266]'
}`}
>
{l === 'en' ? 'EN' : 'ΕΛ'}
</button>
))}
</div>
</div>
{r.location?.[lang] && (
<div className="mx-auto inline-flex items-center gap-1.5 rounded-full bg-white/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#8a7f5e] ring-1 ring-[#e8e1d1]">
<MapPin className="h-3 w-3" strokeWidth={2} />
{r.location[lang]}
</div>
)}
{r.headerImageUrl ? (
<img
src={r.headerImageUrl}
alt={r.name}
className="mx-auto mt-4 block max-w-[80%] max-h-[120px] w-auto object-contain"
/>
) : (
<h1 className="mt-4 font-display text-[42px] font-semibold leading-[0.95] tracking-tight text-[#2d3b2d]">
{r.name}
</h1>
)}
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
{r.tagline?.[lang] ?? r.tagline ?? ''}
</div>
{/* Ornament */}
<div className="mx-auto my-4 flex w-40 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
<p className="mx-auto max-w-[300px] text-[13px] leading-relaxed text-[#7d7660]">
{r.blurb?.[lang] ?? r.blurb ?? ''}
</p>
{r.hours?.[lang] && (
<div className="mt-2.5 inline-flex items-center gap-1.5 text-[12px] font-medium text-[#3f7d4e]">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#3f7d4e] opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[#3f7d4e]" />
</span>
{r.hours[lang]}
</div>
)}
</header>
)
}
// ── 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 (
<div className={`sticky top-0 z-30 bg-[#faf7f0]/95 backdrop-blur transition-shadow ${scrolled ? 'shadow-[0_6px_20px_-12px_rgba(45,59,45,0.35)]' : ''}`}>
<div className="flex items-center gap-2 px-3 py-2.5">
<button
onClick={onSearch}
aria-label="Search"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-[#2d3b2d] ring-1 ring-[#e8e1d1] transition active:scale-90"
>
<Search className="h-[18px] w-[18px]" strokeWidth={1.9} />
</button>
<div ref={railRef} className="flex gap-1.5 overflow-x-auto no-scrollbar py-[3px]">
{categories.map(cat => {
const on = cat.id === active
const label = typeof cat.name === 'object' ? (cat.name[lang] ?? cat.name.en) : cat.name
return (
<button
key={cat.id}
data-pill={cat.id}
onClick={() => onPick(cat.id)}
className={`whitespace-nowrap rounded-full px-3.5 py-1.5 text-[13px] font-medium transition ${
on
? 'bg-[#2d3b2d] text-[#f0e9d6] shadow-sm'
: 'bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]'
}`}
>
{label}
</button>
)
})}
</div>
</div>
</div>
)
}
// ── 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 (
<div
onClick={unavailable ? undefined : () => 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'}`}
>
<div className="flex items-stretch gap-3.5 p-3.5">
{/* Left: photo or placeholder art */}
{product.image_url ? (
<img
src={product.image_url}
alt={name}
className="w-[100px] min-h-[100px] self-stretch shrink-0 rounded-[13px] object-cover"
/>
) : (
<DishArt product={product} category={category} size="hero" />
)}
{/* Right: content column */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start gap-2">
<h3 className="min-w-0 flex-1 font-display text-[19px] font-semibold leading-[1.12] tracking-[-0.01em] text-[#2d3b2d]">
{name}
</h3>
<TagIcons product={product} />
</div>
<p className="mt-1.5 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-[#857e69]">
{desc}
</p>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[#ece4d2] pt-3 mt-3">
<div className="flex items-center gap-2">
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
{!viewOnly && (
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
className="flex h-8 items-center gap-1 rounded-full bg-[#2d3b2d] pl-2.5 pr-3 text-[12px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-95 hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
</button>
)}
</div>
</div>
</div>
</div>
)
}
// ── 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 (
<section ref={sectionRef} data-section={category.id} className="scroll-mt-[64px] px-3 pt-3">
<div
className="rounded-[22px] px-3 pb-3 pt-2.5"
style={{ background: `linear-gradient(180deg, hsl(${hue} 44% 90% / 0.65) 0%, hsl(${hue} 40% 90% / 0) 62%)` }}
>
<div className="mb-2.5 flex items-baseline justify-between px-2 pt-1">
<h2 className="font-sans text-[21px] font-semibold tracking-[-0.01em] text-[#2a2a2a]">{label}</h2>
<span className="font-sans text-[15px] font-medium tabular-nums text-[#2a2a2a]/45">{products.length}</span>
</div>
<div className="flex flex-col gap-3">
{products.map(p => (
<ProductCard
key={p.id}
product={p}
category={category}
lang={lang}
t={t}
onOpen={onOpen}
onAdd={onAdd}
qty={cart[p.id] || 0}
viewOnly={viewOnly}
/>
))}
</div>
</div>
</section>
)
}
// ── 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 (
<Sheet open={!!product} onClose={onClose}>
<SheetHandle />
<button
onClick={onClose}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#6d6a59] ring-1 ring-[#e8e1d1]"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
<div className="max-h-[82vh] overflow-y-auto px-5 pb-5">
{/* Hero art */}
{product.image_url ? (
<div className="mt-2 aspect-square w-full overflow-hidden rounded-2xl">
<img
src={product.image_url}
alt={name}
className="h-full w-full object-cover"
/>
</div>
) : (
<div
className="mt-2 aspect-square w-full flex items-center justify-center overflow-hidden rounded-2xl"
style={{ background: `linear-gradient(135deg, hsl(${hue} 34% 90%), hsl(${hue} 30% 80%))` }}
>
<GlyphIcon
className="h-16 w-16"
style={{ color: `hsl(${hue} 32% 42%)`, opacity: 0.6 }}
strokeWidth={1.2}
/>
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{badge && <Badge kind={badge} t={t} />}
<DiscountFlag product={product} t={t} />
</div>
<h2 className="mt-2 font-display text-[28px] font-semibold leading-tight text-[#2d3b2d]">{name}</h2>
{desc && <p className="mt-1 text-[14px] leading-relaxed text-[#7d7660]">{desc}</p>}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.map(tag => <DietChip key={tag} tag={tag} t={t} />)}
</div>
)}
{ingredients.length > 0 && (
<div className="mt-5">
<div className="font-sans text-[11px] font-semibold uppercase tracking-[0.14em] text-[#b3aa90]">{t.ingredients}</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{ingredients.map((ing, i) => (
<span key={i} className="rounded-lg bg-white px-2.5 py-1 text-[12.5px] text-[#5f5a48] ring-1 ring-[#ece5d5]">{ing}</span>
))}
</div>
</div>
)}
{allergens.length > 0 && (
<div className="mt-4 flex items-start gap-2 rounded-xl bg-[#f7e6dc]/60 px-3 py-2.5 ring-1 ring-[#eccab3]">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[#c2602f]" strokeWidth={2} />
<div className="text-[12.5px] leading-snug text-[#9a5732]">
<span className="font-semibold">{t.contains}: </span>
{allergens.map(a => ALLERGEN_EN[a] ?? a).join(', ')}
</div>
</div>
)}
</div>
{/* Sticky add bar */}
<div className="flex items-center gap-3 border-t border-[#ece5d5] bg-[#faf7f0] px-5 py-3.5">
{!viewOnly && qty > 0 ? (
<Stepper qty={qty} onInc={() => onInc(product)} onDec={() => onDec(product)} />
) : (
<div className="flex items-baseline gap-2">
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
)}
{!viewOnly && (
<button
onClick={() => { onAdd(product); onClose() }}
className="ml-auto flex h-11 flex-1 items-center justify-center gap-2 rounded-full bg-[#2d3b2d] px-5 text-[14px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-[0.98] hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{t.add} · {eur(discountedPrice(product))}
</button>
)}
</div>
</Sheet>
)
}
// ── 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 (
<div className="fixed inset-0 z-50 mx-auto flex w-full sm:max-w-[960px] flex-col bg-[#faf7f0] animate-fade">
<div className="flex items-center gap-2 px-3 py-3">
<div className="flex flex-1 items-center gap-2 rounded-full bg-white px-3.5 py-2.5 ring-1 ring-[#e8e1d1]">
<Search className="h-[18px] w-[18px] text-[#b3aa90]" strokeWidth={1.9} />
<input
ref={inputRef}
value={q}
onChange={e => setQ(e.target.value)}
placeholder={t.searchPlaceholder}
className="w-full bg-transparent text-[15px] text-[#2d3b2d] outline-none placeholder:text-[#b3aa90]"
/>
{q && (
<button onClick={() => setQ('')}>
<X className="h-4 w-4 text-[#b3aa90]" />
</button>
)}
</div>
<button onClick={onClose} className="px-1 text-[14px] font-medium text-[#6d6a59]">
{t.back}
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-6">
{q && results.length === 0 && (
<div className="mt-24 text-center">
<SearchX className="mx-auto h-10 w-10 text-[#cfc6ad]" strokeWidth={1.4} />
<div className="mt-3 font-display text-[20px] text-[#2d3b2d]">{t.noResults}</div>
<div className="mt-1 text-[13px] text-[#9a917a]">{t.noResultsSub}</div>
</div>
)}
{results.length > 0 && (
<div className="flex flex-col gap-3 pt-1">
{results.map(p => {
const cat = categories.find(c => c.id === p.cat)
return (
<ProductCard
key={p.id}
product={p}
category={cat}
lang={lang}
t={t}
onOpen={prod => { onClose(); onOpen(prod) }}
onAdd={onAdd}
qty={cart[p.id] || 0}
viewOnly={viewOnly}
/>
)
})}
</div>
)}
{!q && (
<div className="mt-24 text-center text-[#b3aa90]">
<UtensilsCrossed className="mx-auto h-10 w-10" strokeWidth={1.3} />
<div className="mt-3 text-[13px]">{t.searchPlaceholder}</div>
</div>
)}
</div>
</div>
)
}
// ── 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 (
<Sheet open onClose={close}>
<SheetHandle />
<div className="flex items-center justify-between px-5 pb-1 pt-3">
<h2 className="font-display text-[24px] font-semibold text-[#2d3b2d]">{t.yourOrder}</h2>
<button onClick={close} className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]">
<X className="h-4 w-4" strokeWidth={2} />
</button>
</div>
{lines.length === 0 ? (
<div className="flex flex-col items-center px-6 py-16 text-center">
<ShoppingBag className="h-12 w-12 text-[#cfc6ad]" strokeWidth={1.3} />
<div className="mt-4 font-display text-[20px] text-[#2d3b2d]">{t.emptyCart}</div>
<div className="mt-1 text-[13px] text-[#9a917a]">{t.emptyCartSub}</div>
</div>
) : (
<>
<div className="max-h-[56vh] overflow-y-auto px-4 py-3">
<div className="flex flex-col gap-2.5">
{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 (
<div key={l.product.id} className="flex items-center gap-3 rounded-2xl bg-white p-2.5 ring-1 ring-[#ece5d5]">
<DishArt product={l.product} category={cat} size="sm" />
<div className="min-w-0 flex-1">
<div
onClick={() => { close(); onOpenProduct(l.product) }}
className="cursor-pointer font-display text-[16px] font-semibold leading-tight text-[#2d3b2d]"
>
{pname}
</div>
<div className="mt-0.5 font-display text-[14px] text-[#857e69]">{eur(discountedPrice(l.product))}</div>
</div>
<Stepper qty={l.qty} onInc={() => inc(l.product)} onDec={() => dec(l.product)} />
</div>
)
})}
</div>
</div>
<div className="border-t border-[#ece5d5] px-5 py-3.5">
<div className="mb-2.5 flex items-baseline justify-between">
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
</div>
<button
onClick={() => setStage('checkout')}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-[#2d3b2d] text-[15px] font-semibold text-[#f0e9d6] transition active:scale-[0.98]"
>
{t.continue}<ArrowRight className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
</>
)}
</Sheet>
)
}
// Checkout view
if (stage === 'checkout') {
const valid = form.name.trim() && form.table.trim()
return (
<Sheet open onClose={close}>
<SheetHandle />
<div className="flex items-center gap-3 px-5 pb-1 pt-3">
<button
onClick={() => setStage('cart')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]"
>
<ChevronLeft className="h-4 w-4" strokeWidth={2.2} />
</button>
<h2 className="font-display text-[22px] font-semibold text-[#2d3b2d]">{t.placeOrder}</h2>
</div>
<div className="max-h-[62vh] overflow-y-auto px-5 pb-2 pt-3">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.name}</span>
<input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder={t.namePh}
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
/>
</label>
<label className="block">
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.table}</span>
<input
value={form.table}
onChange={e => setForm(f => ({ ...f, table: e.target.value }))}
placeholder={t.tablePh}
inputMode="numeric"
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
/>
</label>
</div>
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.orderSummary}</div>
<div className="mt-2 overflow-hidden rounded-2xl bg-white ring-1 ring-[#ece5d5]">
{lines.map((l, i) => {
const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name
return (
<div key={l.product.id} className={`flex items-center justify-between px-4 py-2.5 ${i > 0 ? 'border-t border-[#f0ebdd]' : ''}`}>
<span className="text-[13.5px] text-[#5f5a48]">
<b className="font-display text-[#2d3b2d]">{l.qty}×</b> {pname}
</span>
<span className="font-display text-[14px] font-semibold tabular-nums text-[#2d3b2d]">
{eur(discountedPrice(l.product) * l.qty)}
</span>
</div>
)
})}
</div>
</div>
<div className="border-t border-[#ece5d5] px-5 py-3.5">
<div className="mb-2.5 flex items-baseline justify-between">
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
</div>
<button
disabled={!valid || sending}
onClick={submit}
className={`flex h-12 w-full items-center justify-center gap-2 rounded-full text-[15px] font-semibold transition active:scale-[0.98] ${
valid && !sending ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'bg-[#d8d2c1] text-[#a39c87] cursor-not-allowed'
}`}
>
{sending
? <><Loader2 className="h-4 w-4 animate-spin" /> {t.sending}</>
: <><Send className="h-4 w-4" strokeWidth={2} /> {t.placeOrder}</>
}
</button>
</div>
</Sheet>
)
}
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 (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 mx-auto w-full sm:max-w-[960px] px-4 pb-4">
<button
onClick={onClick}
className={`pointer-events-auto flex h-14 w-full items-center justify-between rounded-full bg-[#2d3b2d] px-5 text-[#f0e9d6] shadow-cart transition active:scale-[0.98] ${bump ? 'animate-pop' : ''}`}
>
<span className="flex items-center gap-2.5">
<span className="relative">
<ShoppingBag className="h-[22px] w-[22px]" strokeWidth={1.8} />
<span className="absolute -right-2 -top-2 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-[#c9a24b] px-1 text-[11px] font-bold text-[#2d3b2d] tabular-nums">
{count}
</span>
</span>
<span className="text-[14px] font-semibold">{t.yourOrder}</span>
</span>
<span className="font-display text-[18px] font-semibold tabular-nums">{eur(total)}</span>
</button>
</div>
)
}
// ── 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 (
<div className="min-h-dvh flex items-center justify-center bg-[#faf7f0]">
<div className="w-8 h-8 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
</div>
)
if (error) return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-3 p-8 text-center bg-[#faf7f0]">
<AlertCircle className="text-[#c2602f]" size={40} />
<p className="text-[#7d7660]">{error}</p>
</div>
)
return (
<div className="relative mx-auto min-h-dvh w-full sm:max-w-[960px] bg-[#faf7f0] sm:shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)]">
<Hero lang={lang} setLang={setLang} restaurant={restaurant} />
<CategoryBar
categories={categories}
active={active}
onPick={pick}
onSearch={() => setSearchOpen(true)}
lang={lang}
/>
<main className="pb-32">
{categories.map(cat => (
<Section
key={cat.id}
category={cat}
lang={lang}
t={t}
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
sectionRef={el => { sectionRefs.current[cat.id] = el }}
viewOnly={viewOnly}
/>
))}
<footer className="mt-10 px-6 text-center">
<div className="mx-auto flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
</footer>
</main>
{!viewOnly && <CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />}
<ProductSheet
product={activeProduct}
category={activeProductCat}
lang={lang}
t={t}
onClose={() => setActiveProduct(null)}
onAdd={addToCart}
qty={activeProduct ? (cart[activeProduct.id] || 0) : 0}
onInc={incCart}
onDec={decCart}
viewOnly={viewOnly}
/>
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
categories={categories}
lang={lang}
t={t}
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
viewOnly={viewOnly}
/>
{!viewOnly && (
<CartFlow
stage={stage}
setStage={setStage}
cart={cart}
setCart={setCart}
categories={categories}
lang={lang}
t={t}
onOpenProduct={openProduct}
siteSlug={siteSlug}
navigate={navigate}
/>
)}
</div>
)
}