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>
944 lines
39 KiB
JavaScript
944 lines
39 KiB
JavaScript
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>
|
||
)
|
||
}
|