feat(menu-app): full redesign to Olive & Thyme design spec

Rebuild the public-facing digital menu from the design handoff bundle.
Matches high-fidelity spec: Bricolage Grotesque + Hanken Grotesk fonts,
cream/green/gold palette, per-category tinted panels, product cards with
gradient art, tag icon badges, bottom-sheet product detail, search overlay,
cart → checkout bottom-sheet flow posting to real API, floating cart button
with bump animation, and restyled order-confirm page.

- New: src/components/primitives.jsx (DishArt, Badge, DietChip, TagIcons,
  Price, DiscountFlag, Stepper, price helpers)
- Rewrite: MenuPage.jsx — all screens in one component tree, API-driven,
  lang toggle (EN/GR) persisted to localStorage, scroll-spy category bar
- Rewrite: OrderConfirm.jsx — design-system styling, brand colors
- Update: App.jsx — remove /order route (cart flow is now a bottom sheet)
- Update: tailwind.config.js — font families, brand tokens, animations
- Update: index.html — Google Fonts for Bricolage Grotesque + Hanken Grotesk
- Delete: CartPage, ProductModal, ProductCard, CategoryNav, CartButton

Cart checkout POSTs to real submitOrder API and redirects to /confirm/:ref
for live order status polling. Restaurant info falls back to hardcoded
placeholder until backend includes it in the fetchMenu response (see
REWORK_REVISIT.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:55:52 +03:00
parent 17fb3a2589
commit ffaeab136d
13 changed files with 4306 additions and 545 deletions

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="el">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia Menu</title>
<title>Menu</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

3087
connect_frontend/menu-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import MenuPage from './pages/MenuPage'
import CartPage from './pages/CartPage'
import OrderConfirm from './pages/OrderConfirm'
export default function App() {
return (
<Routes>
<Route path="/:siteSlug" element={<MenuPage />} />
<Route path="/:siteSlug/order" element={<CartPage />} />
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,25 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { ShoppingCart } from 'lucide-react'
export default function CartButton({ cart, siteSlug }) {
const navigate = useNavigate()
const count = cart.reduce((s, i) => s + i.quantity, 0)
const total = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
if (!count) return null
return (
<div className="fixed bottom-6 left-0 right-0 flex justify-center px-4 z-30">
<button
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
className="bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl px-6 py-3.5 shadow-lg flex items-center gap-3 w-full max-w-sm transition-colors"
>
<span className="bg-emerald-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
{count}
</span>
<span className="flex-1 font-semibold text-left">View order</span>
<span className="font-bold">{total.toFixed(2)}</span>
</button>
</div>
)
}

View File

@@ -1,19 +0,0 @@
export default function CategoryNav({ categories, active, onSelect }) {
return (
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1 scrollbar-hide">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => onSelect(cat.id)}
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
active === cat.id
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{cat.name}
</button>
))}
</div>
)
}

View File

@@ -1,55 +0,0 @@
export default function ProductCard({ product, onSelect }) {
const unavailable = !product.digital_available
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
return (
<button
onClick={unavailable ? undefined : onSelect}
className={`w-full bg-white rounded-2xl shadow-sm p-4 flex gap-4 text-left transition-shadow ${
unavailable ? 'opacity-60 cursor-default' : 'hover:shadow-md active:scale-[0.99]'
}`}
>
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-slate-800 leading-tight">
{product.digital_name || product.name}
</h3>
{unavailable && (
<span className="flex-shrink-0 text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full">
Out of stock
</span>
)}
</div>
{product.digital_description && (
<p className="text-xs text-slate-500 line-clamp-2">{product.digital_description}</p>
)}
<div className="flex items-baseline gap-2 pt-1">
<span className="font-bold text-emerald-600">{displayPrice.toFixed(2)}</span>
{hasDiscount && (
<>
<span className="text-xs text-slate-400 line-through">{basePrice.toFixed(2)}</span>
<span className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
</>
)}
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,209 @@
import {
Leaf, Sprout, Wheat, Flame, Star, ChefHat,
Minus, Plus,
} from 'lucide-react'
// ── Money helpers ────────────────────────────────────────────────────────────
export function eur(n) {
return '€' + Number(n).toFixed(2)
}
export function discountedPrice(product) {
const base = product.digital_price ?? product.base_price ?? product.price ?? 0
const pct = product.digital_discount ?? product.discountPct ?? 0
if (!pct) return base
return Math.round(base * (1 - pct / 100) * 100) / 100
}
export function basePrice(product) {
return product.digital_price ?? product.base_price ?? product.price ?? 0
}
export function hasDiscount(product) {
const pct = product.digital_discount ?? product.discountPct ?? 0
return pct > 0
}
export function discountPct(product) {
return product.digital_discount ?? product.discountPct ?? 0
}
// ── Placeholder gradient art ─────────────────────────────────────────────────
export function DishArt({ product, category, size = 'card' }) {
const hue = category?.hue ?? 40
const GlyphIcon = category?.GlyphIcon ?? null
const id = product?.id ?? 'x'
let seed = 0
for (let i = 0; i < id.length; i++) seed += id.charCodeAt(i)
const lift = (seed % 5) - 2
const c1 = `hsl(${hue} 34% ${90 + lift}%)`
const c2 = `hsl(${hue} 30% ${80 + lift}%)`
const glyphColor = `hsl(${hue} 32% 42%)`
const firstName = product?.digital_name || product?.name || '?'
const letter = typeof firstName === 'object' ? (firstName.en?.[0] ?? '?') : (firstName[0] ?? '?')
const sizeClass =
size === 'hero'
? 'self-stretch min-h-[100px] w-[100px]'
: size === 'sm'
? 'h-16 w-16'
: 'h-[92px] w-[92px]'
const iconClass = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'
return (
<div
className={`relative flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-[13px]`}
style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}
>
<span
className="absolute -right-2 -top-3 font-display text-[56px] leading-none opacity-[0.14] select-none"
style={{ color: glyphColor }}
>
{letter}
</span>
{GlyphIcon && (
<GlyphIcon
className={iconClass}
style={{ color: glyphColor, opacity: 0.62 }}
strokeWidth={1.4}
/>
)}
</div>
)
}
// ── Badge (Popular / Chef's pick) ────────────────────────────────────────────
export function Badge({ kind, t }) {
if (kind === 'popular') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#f4ecd8] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#a9842f] ring-1 ring-inset ring-[#e4d4a8]">
<Flame className="h-3 w-3" strokeWidth={2.2} />
{t?.popular ?? 'Popular'}
</span>
)
}
if (kind === 'chefs') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#2d3b2d] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#f0e9d6]">
<ChefHat className="h-3 w-3" strokeWidth={2} />
{t?.chefs ?? "Chef's pick"}
</span>
)
}
return null
}
// ── Dietary chips (with text labels) ────────────────────────────────────────
const DIET_STYLE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#c9e2cb' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#d8e2bd' },
'gluten-free': { Icon: Wheat, fg: '#a9842f', bg: '#f5edd8', ring: '#e6d6a6' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f7e6dc', ring: '#eccab3' },
}
export function DietChip({ tag, t }) {
const s = DIET_STYLE[tag]
if (!s) return null
const { Icon } = s
const label = t?.dietary?.[tag] ?? tag
return (
<span
className="inline-flex items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2} />
{label}
</span>
)
}
// ── Compact tag icon badges (card title row) ─────────────────────────────────
const TAG_BADGE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#bcdcc0' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#cfe0b0' },
'gluten-free': { Icon: Wheat, fg: '#9a7726', bg: '#f6edd6', ring: '#e6d49e' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f8e6da', ring: '#eec4ac' },
}
const PRIORITY_BADGE = {
popular: { Icon: Star, fg: '#a9842f', bg: '#f6edd6', ring: '#e6d49e' },
chefs: { Icon: ChefHat, fg: '#f0e9d6', bg: '#2d3b2d', ring: '#2d3b2d' },
}
export function TagIcons({ product }) {
const items = []
const badge = product.badge ?? product.digital_badge
if (badge && PRIORITY_BADGE[badge]) items.push(PRIORITY_BADGE[badge])
const tags = product.tags ?? product.digital_tags ?? []
tags.forEach(tag => { if (TAG_BADGE[tag]) items.push(TAG_BADGE[tag]) })
const shown = items.slice(0, 3)
if (!shown.length) return null
return (
<div className="flex shrink-0 items-center gap-1 pt-[3px]">
{shown.map((s, i) => {
const { Icon } = s
return (
<span
key={i}
className="flex h-[19px] w-[19px] items-center justify-center rounded-full ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2.2} />
</span>
)
})}
</div>
)
}
// ── Price display ─────────────────────────────────────────────────────────────
export function Price({ product, large }) {
const base = basePrice(product)
const now = discountedPrice(product)
const discounted = hasDiscount(product)
return (
<div className="flex items-baseline gap-1.5">
{discounted && (
<span className="font-display text-[15px] text-[#b3aa97] line-through">{eur(base)}</span>
)}
<span
className={`font-display ${large ? 'text-[22px]' : 'text-[19px]'} font-semibold ${discounted ? 'text-[#c2602f]' : 'text-[#2d3b2d]'}`}
>
{eur(now)}
</span>
</div>
)
}
// ── Discount flag ─────────────────────────────────────────────────────────────
export function DiscountFlag({ product, t }) {
const pct = discountPct(product)
if (!pct) return null
return (
<span className="inline-flex items-center rounded-md bg-[#c2602f] px-1.5 py-[2px] font-sans text-[10px] font-bold tracking-[0.05em] text-white">
{pct}% {t?.off ?? 'OFF'}
</span>
)
}
// ── Quantity stepper ─────────────────────────────────────────────────────────
export function Stepper({ qty, onInc, onDec }) {
return (
<div className="flex items-center gap-3 rounded-full bg-[#f3efe5] p-1 ring-1 ring-inset ring-[#e8e1d1]">
<button
onClick={onDec}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#2d3b2d] shadow-sm ring-1 ring-[#e8e1d1] transition active:scale-90"
>
<Minus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
<span className="min-w-[16px] text-center font-display text-[16px] font-semibold tabular-nums text-[#2d3b2d]">
{qty}
</span>
<button
onClick={onInc}
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#2d3b2d] text-white shadow-sm transition active:scale-90"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
</div>
)
}

View File

@@ -3,4 +3,16 @@
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }
body {
margin: 0;
font-family: 'Hanken Grotesk', system-ui, sans-serif;
background: radial-gradient(ellipse at top, #f3eedf 0%, #ece4d2 50%, #e6ddc8 100%);
min-height: 100dvh;
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.font-display { font-family: 'Bricolage Grotesque', serif; }
}

View File

@@ -1,178 +0,0 @@
import { useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
import { submitOrder } from '../api'
import toast from 'react-hot-toast'
export default function CartPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const { state } = useLocation()
const cart = state?.cart || []
const [orderType, setOrderType] = useState('dine_in')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [address, setAddress] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const subtotal = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const deliveryFee = orderType === 'delivery' ? 2.0 : 0.0
const total = subtotal + deliveryFee
if (!cart.length) {
navigate(`/${siteSlug}`, { replace: true })
return null
}
async function handleSubmit(e) {
e.preventDefault()
if (!name.trim()) { toast.error('Please enter your name'); return }
if (orderType === 'delivery' && !address.trim()) {
toast.error('Please enter your delivery address')
return
}
setSubmitting(true)
try {
const result = await submitOrder(siteSlug, {
order_type: orderType,
customer_name: name.trim(),
customer_phone: phone.trim() || null,
customer_address: orderType === 'delivery' ? address.trim() : null,
customer_notes: notes.trim() || null,
items: cart.map(i => ({
product_id: i.product_id,
name: i.name,
quantity: i.quantity,
unit_price: i.unit_price,
options: i.options || [],
})),
subtotal,
delivery_fee: deliveryFee,
total,
})
navigate(`/${siteSlug}/confirm/${result.public_ref}`, { replace: true })
} catch (err) {
toast.error(err.response?.data?.detail || 'Failed to place order. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 shadow-sm">
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-500 hover:text-slate-800">
<ArrowLeft size={22} />
</button>
<h1 className="text-lg font-bold text-slate-800">Your Order</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="max-w-lg mx-auto px-4 py-5 space-y-5">
{/* Order type */}
<div className="bg-white rounded-2xl p-4 space-y-3 shadow-sm">
<p className="text-sm font-semibold text-slate-700">Order type</p>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed },
{ id: 'delivery', label: 'Delivery', Icon: Truck },
].map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setOrderType(id)}
className={`flex flex-col items-center gap-2 py-4 rounded-xl border-2 transition-colors ${
orderType === id
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
<Icon size={22} />
<span className="text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
{/* Cart summary */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
{cart.map((item, idx) => (
<div key={idx} className="flex justify-between text-sm">
<span className="text-slate-700">{item.quantity}× {item.name}</span>
<span className="text-slate-600 font-medium">{(item.unit_price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
<div className="flex justify-between text-sm text-slate-500">
<span>Subtotal</span><span>{subtotal.toFixed(2)}</span>
</div>
{deliveryFee > 0 && (
<div className="flex justify-between text-sm text-slate-500">
<span>Delivery fee</span><span>{deliveryFee.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-800">
<span>Total</span><span>{total.toFixed(2)}</span>
</div>
</div>
</div>
{/* Customer details */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-sm font-semibold text-slate-700">Your details</p>
{[
{ label: 'Name *', value: name, set: setName, type: 'text', placeholder: 'Full name' },
{ label: 'Phone', value: phone, set: setPhone, type: 'tel', placeholder: 'Optional' },
].map(({ label, value, set, type, placeholder }) => (
<div key={label}>
<label className="text-xs text-slate-500 font-medium">{label}</label>
<input
type={type}
value={value}
onChange={e => set(e.target.value)}
placeholder={placeholder}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100"
/>
</div>
))}
{orderType === 'delivery' && (
<div>
<label className="text-xs text-slate-500 font-medium">Delivery address *</label>
<textarea
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Street, number, city"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
)}
<div>
<label className="text-xs text-slate-500 font-medium">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Allergies, special requests…"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-4 rounded-xl font-bold text-base transition-colors"
>
{submitting ? 'Placing order…' : `Place order · €${total.toFixed(2)}`}
</button>
</form>
</div>
)
}

View File

@@ -1,119 +1,920 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ShoppingCart, AlertCircle } from 'lucide-react'
import { fetchMenu } from '../api'
import CategoryNav from '../components/CategoryNav'
import ProductCard from '../components/ProductCard'
import CartButton from '../components/CartButton'
import ProductModal from './ProductModal'
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 max-w-[480px] 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>
)}
<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 }) {
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-2.5">
<div className="flex items-center gap-2">
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
className="flex h-9 items-center gap-1 rounded-full bg-[#2d3b2d] pl-3 pr-3.5 text-[13px] 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 }) {
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.85) 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}
/>
))}
</div>
</div>
</section>
)
}
// ── Product Detail Sheet ──────────────────────────────────────────────────────
function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec }) {
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 ? (
<img
src={product.image_url}
alt={name}
className="mt-2 h-44 w-full rounded-2xl object-cover"
/>
) : (
<div
className="mt-2 flex h-44 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">
{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>
)}
<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 }) {
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 max-w-[480px] 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}
/>
)
})}
</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 max-w-[480px] 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 [menu, setMenu] = useState(null)
const [categories, setCategories] = useState([])
const [restaurant, setRestaurant] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const [activeCat, setActiveCat] = useState(null)
const [cart, setCart] = useState([])
const [selected, setSelected] = useState(null) // product for modal
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 => {
setMenu(data)
if (data.categories?.length) setActiveCat(data.categories[0].id)
const cats = normaliseCategories(data.categories || [])
setCategories(cats)
setRestaurant(data.restaurant ?? null)
if (cats.length) setActive(cats[0].id)
})
.catch(() => setError('Menu not available. Please try again.'))
.finally(() => setLoading(false))
}, [siteSlug])
function addToCart(product, quantity, options) {
const price = product.digital_price ?? product.base_price
const discounted = product.digital_discount > 0 && !product.digital_price
? price * (1 - product.digital_discount / 100)
: price
setCart(prev => {
const key = product.id
const existing = prev.find(i => i.product_id === key)
if (existing) {
return prev.map(i => i.product_id === key
? { ...i, quantity: i.quantity + quantity }
: i)
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
}
return [...prev, {
product_id: product.id,
name: product.digital_name || product.name,
quantity,
unit_price: discounted,
options,
}]
})
setSelected(null)
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 activeCategory = menu?.categories?.find(c => c.id === activeCat)
const cartCount = cart.reduce((s, i) => s + i.quantity, 0)
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-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
<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-screen flex flex-col items-center justify-center gap-3 p-8 text-center">
<AlertCircle className="text-red-400" size={40} />
<p className="text-slate-600">{error}</p>
<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="min-h-screen bg-slate-50 pb-28">
{/* Header */}
<div className="sticky top-0 z-20 bg-white border-b border-slate-100 shadow-sm">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<h1 className="text-lg font-bold text-slate-800">Menu</h1>
{cartCount > 0 && (
<button
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
className="flex items-center gap-2 bg-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold"
>
<ShoppingCart size={16} />
{cartCount} items
</button>
)}
</div>
<CategoryNav
categories={menu.categories}
active={activeCat}
onSelect={setActiveCat}
<div className="relative mx-auto min-h-dvh max-w-[480px] bg-[#faf7f0] 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}
/>
</div>
{/* Products */}
<div className="max-w-2xl mx-auto px-4 pt-4 space-y-3">
{activeCategory?.products.map(p => (
<ProductCard
key={p.id}
product={p}
onSelect={() => setSelected(p)}
<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 }}
/>
))}
{!activeCategory?.products.length && (
<p className="text-center text-slate-400 py-12">No items in this category.</p>
)}
<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>
<CartButton cart={cart} siteSlug={siteSlug} />
<CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />
{selected && (
<ProductModal
product={selected}
onClose={() => setSelected(null)}
<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}
/>
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
categories={categories}
lang={lang}
t={t}
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
/>
<CartFlow
stage={stage}
setStage={setStage}
cart={cart}
setCart={setCart}
categories={categories}
lang={lang}
t={t}
onOpenProduct={openProduct}
siteSlug={siteSlug}
navigate={navigate}
/>
)}
</div>
)
}

View File

@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } from 'lucide-react'
import { Check, Clock, X, ChefHat, Truck, Package, Leaf } from 'lucide-react'
import { fetchOrderStatus } from '../api'
const STATUS_CONFIG = {
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' },
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: '#c9a24b', bg: '#f6edd6' },
accepted: { label: 'Order accepted!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
rejected: { label: 'Order declined', Icon: X, color: '#c2602f', bg: '#f7e6dc' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: '#2d3b2d', bg: '#e7ede7' },
ready: { label: 'Ready for pickup!', Icon: Package, color: '#3f7d4e', bg: '#e7f1e7' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: '#2d3b2d', bg: '#e7ede7' },
delivered: { label: 'Delivered!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
}
const TERMINAL = new Set(['rejected', 'delivered'])
@@ -42,48 +42,73 @@ export default function OrderConfirm() {
return () => clearTimeout(timer)
}, [ref])
const cfg = STATUS_CONFIG[orderStatus] || STATUS_CONFIG['pending_acceptance']
const cfg = STATUS_CONFIG[orderStatus] ?? STATUS_CONFIG.pending_acceptance
const { label, Icon, color, bg } = cfg
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
<div className="relative mx-auto min-h-dvh max-w-[480px] bg-[#faf7f0] shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)] flex flex-col items-center justify-center px-6 py-12">
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
<Icon className={color} size={40} />
{/* Ornament top */}
<div className="mb-8 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.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
<div className="space-y-1">
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
<h1 className="text-2xl font-bold text-slate-800">
{/* Status icon */}
<div
className="flex h-20 w-20 items-center justify-center rounded-full"
style={{ background: bg }}
>
<Icon size={38} style={{ color }} strokeWidth={2.2} />
</div>
{/* Ref number */}
<p className="mt-5 font-sans text-[11px] font-semibold uppercase tracking-[0.22em] text-[#b3aa90]">
{ref}
</p>
{/* Status label */}
<h1 className="mt-2 font-display text-[28px] font-semibold leading-tight text-center text-[#2d3b2d]">
{error ? 'Could not load status' : label}
</h1>
{rejectionReason && (
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
)}
</div>
{/* Rejection reason */}
{rejectionReason && (
<p className="mt-2 text-[14px] text-center text-[#7d7660]">
Reason: {rejectionReason}
</p>
)}
{/* Subtext */}
{orderStatus === 'delivered' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
Thank you for your order! Enjoy your meal.
</p>
)}
{orderStatus === 'rejected' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
We're sorry we couldn't take your order this time. Please try again or speak to staff.
</p>
)}
{!TERMINAL.has(orderStatus) && !error && (
<p className="text-xs text-slate-400">
<p className="mt-3 text-[13px] text-[#9a917a]">
We'll update this page automatically. Keep it open.
</p>
)}
{orderStatus === 'rejected' && (
<p className="text-sm text-slate-500">
We're sorry we couldn't take your order this time. Please try again or visit us in person.
</p>
)}
{orderStatus === 'delivered' && (
<p className="text-sm text-slate-500">Thank you for your order! Enjoy your meal.</p>
)}
{/* Spinner */}
{!TERMINAL.has(orderStatus) && !error && (
<div className="flex justify-center">
<div className="w-5 h-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
<div className="mt-6 flex justify-center">
<div className="h-5 w-5 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
</div>
)}
{/* Ornament bottom */}
<div className="mt-10 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>
</div>
)

View File

@@ -1,136 +0,0 @@
import { useState } from 'react'
import { X, Plus, Minus } from 'lucide-react'
export default function ProductModal({ product, onClose, onAdd }) {
const [quantity, setQuantity] = useState(1)
const [selectedOptions, setSelectedOptions] = useState([])
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
function toggleOption(opt) {
setSelectedOptions(prev =>
prev.find(o => o.id === opt.id)
? prev.filter(o => o.id !== opt.id)
: [...prev, opt]
)
}
const optionsTotal = selectedOptions.reduce((s, o) => s + (o.price || 0), 0)
const lineTotal = (displayPrice + optionsTotal) * quantity
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[90vh] overflow-y-auto">
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-full h-48 object-cover rounded-t-2xl sm:rounded-t-2xl"
/>
)}
<button
onClick={onClose}
className="absolute top-3 right-3 bg-white/90 rounded-full p-1.5 shadow"
>
<X size={18} />
</button>
<div className="p-5 space-y-4">
<div>
<h2 className="text-xl font-bold text-slate-800">
{product.digital_name || product.name}
</h2>
{product.digital_description && (
<p className="text-sm text-slate-500 mt-1">{product.digital_description}</p>
)}
</div>
{/* Price */}
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-emerald-600">
{displayPrice.toFixed(2)}
</span>
{hasDiscount && (
<span className="text-sm text-slate-400 line-through">
{basePrice.toFixed(2)}
</span>
)}
{hasDiscount && (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
)}
</div>
{/* Quick options */}
{product.quick_options?.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-semibold text-slate-700">Options</p>
<div className="flex flex-wrap gap-2">
{product.quick_options.map(opt => {
const active = selectedOptions.find(o => o.id === opt.id)
return (
<button
key={opt.id}
onClick={() => toggleOption(opt)}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-emerald-500 text-white border-emerald-500'
: 'bg-white text-slate-700 border-slate-200 hover:border-emerald-300'
}`}
>
{opt.name}{opt.price > 0 ? ` +€${opt.price.toFixed(2)}` : ''}
</button>
)
})}
</div>
</div>
)}
{/* Quantity */}
<div className="flex items-center gap-4">
<span className="text-sm font-semibold text-slate-700">Quantity</span>
<div className="flex items-center gap-3">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Minus size={16} />
</button>
<span className="text-lg font-bold w-6 text-center">{quantity}</span>
<button
onClick={() => setQuantity(q => q + 1)}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Plus size={16} />
</button>
</div>
</div>
{/* Add to cart */}
{product.digital_available ? (
<button
onClick={() => onAdd(product, quantity, selectedOptions)}
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3.5 rounded-xl font-semibold flex items-center justify-between px-5 transition-colors"
>
<span>Add to order</span>
<span>{lineTotal.toFixed(2)}</span>
</button>
) : (
<div className="w-full bg-slate-100 text-slate-400 py-3.5 rounded-xl font-semibold text-center">
Currently unavailable
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -3,7 +3,49 @@ export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
fontFamily: {
display: ['Bricolage Grotesque', 'serif'],
sans: ['Hanken Grotesk', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
dark: '#2d3b2d',
hover: '#26331f',
},
cream: '#faf7f0',
card: '#fcfbf7',
gold: '#c9a24b',
terracotta: '#c2602f',
sage: '#9caf88',
success: '#3f7d4e',
},
borderRadius: {
card: '20px',
section: '22px',
sheet: '24px',
},
boxShadow: {
card: '0 5px 16px -10px rgba(45,42,31,0.45)',
'card-hover': '0 10px 24px -12px rgba(45,42,31,0.5)',
cart: '0 12px 28px -8px rgba(45,59,45,0.55)',
},
keyframes: {
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
slideup: {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
pop: {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.06)' },
'100%': { transform: 'scale(1)' },
},
},
animation: {
fade: 'fade 0.2s ease',
slideup: 'slideup 0.28s cubic-bezier(0.22,1,0.36,1)',
pop: 'pop 0.32s ease',
},
},
},
plugins: [],