Compare commits

...

7 Commits

Author SHA1 Message Date
0cad6a76d3 change the QR Code URl. Just a quick fix 2026-06-01 20:06:46 +03:00
a6f759bf49 fix(menu-app): 6 UI adjustments
1. Font consistency: add Noto Sans as fallback for both display and body
   fonts so Greek characters render in the same visual weight as Latin
2. Category panel gradients: reduce top opacity 0.85 → 0.65 (20% less)
3. Mobile full-width: remove max-w cap on mobile; sm:max-w-[960px] on
   desktop (2× wider than before); shadow only on sm+ breakpoint
4. Smaller Add button (h-9→h-8, text-13→12) and price (22px→18px)
5. More spacing between description and footer hairline (pt-3 mt-3)
6. Square hero image in product detail sheet (aspect-square instead of h-44)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:23:40 +03:00
ffaeab136d 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>
2026-06-01 15:55:52 +03:00
17fb3a2589 fix(sysadmin): use site_id UUID string instead of integer PK throughout manager endpoints
The sysadmin panel URL uses site.site_id (UUID string) not site.id (int PK).
All manager account endpoints were querying/expecting the integer PK — none matched.

- GET /by-site/{site_id}: param type str, filter by Site.site_id
- POST /register: site_ids type list[str], query Site.site_id.in_()
- DELETE /site-access: site_id type str, query Site.site_id
- schemas/manager.py: ManagerRegisterRequest.site_ids list[int] → list[str]
- SiteDetailPage.jsx: remove Number() casts on siteId in add/remove calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:45:10 +03:00
70c7b9564b feat(manager-app): Part 5 — show snapshot_taken_at freshness in DashboardPage
Adds a "Site data: X min ago" line derived from snapshot.snapshot_taken_at
so managers can see how fresh the stats snapshot is.

All other Part 5 items (menu-app api.js, OrderConfirm polling, manager-app
api.js, IncomingOrdersPage 15s poll + OrderCard actions) were already fully
implemented in the existing scaffolding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:10:08 +03:00
82b853369b feat(sysadmin): Part 4 — QR code section in SiteDetailPage
- Install qrcode.react in sysadmin_panel
- Add QR Codes section above Remote Managers with two codes side by side:
  Menu QR (https://yourdomain.com/menu/{site_id}) and
  Order QR (https://yourdomain.com/menu/{site_id}/order)
- Each card shows the URL as copyable text and a Download PNG button
- Uses site.site_id as the slug (no slug field exists on the Site model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:07:28 +03:00
ac58d150ea feat(sysadmin): Part 3 — Remote Managers section in SiteDetailPage
Backend (manager_auth.py):
- register: upsert instead of 409 — if email exists, just add site access
- GET /api/manager/by-site/{site_id}: list managers linked to a site (admin auth)
- DELETE /api/manager/site-access: remove a manager's access to a site (admin auth)

Sysadmin API client:
- getManagersBySite, addManagerToSite, removeManagerSiteAccess

SiteDetailPage.jsx:
- Remote Managers section: lists all linked managers with email, name, active badge
- Add Manager modal: email + full name + password form (password ignored if account exists)
- Remove access button with optimistic removal from list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:04:28 +03:00
20 changed files with 4598 additions and 554 deletions

View File

@@ -1,13 +1,15 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount
from models.manager_account import ManagerAccount, manager_site_access
from models.site import Site
from auth_utils import get_current_admin
from schemas.manager import (
@@ -56,10 +58,18 @@ def register_manager(
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
sites = db.query(Site).filter(Site.site_id.in_(body.site_ids)).all()
if existing:
# Email already exists — just add the new site access links, don't recreate the account
for site in sites:
if site not in existing.sites:
existing.sites.append(site)
db.commit()
db.refresh(existing)
return existing
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
manager = ManagerAccount(
email=body.email,
password_hash=_pwd.hash(body.password),
@@ -89,3 +99,52 @@ def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
@router.post("/refresh", response_model=ManagerTokenOut)
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Admin-only: list managers for a site ─────────────────────────────────────
class ManagerBySiteOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
model_config = {"from_attributes": True}
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
def get_managers_by_site(
site_id: str,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site.manager_accounts
# ── Admin-only: remove a manager's access to a site ──────────────────────────
class SiteAccessRemoveRequest(BaseModel):
manager_id: int
site_id: str # site_id UUID string, not integer PK
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
def remove_manager_site_access(
body: SiteAccessRemoveRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
site = db.query(Site).filter(Site.site_id == body.site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site in manager.sites:
manager.sites.remove(site)
db.commit()

View File

@@ -8,7 +8,7 @@ class ManagerRegisterRequest(BaseModel):
email: str
password: str
full_name: Optional[str] = None
site_ids: list[int] = []
site_ids: list[str] = [] # site_id UUID strings, not integer PKs
class ManagerLoginRequest(BaseModel):

View File

@@ -64,10 +64,20 @@ export default function DashboardPage() {
{lastUpdated && (
<p className="text-xs text-slate-400 text-right">
Updated {lastUpdated.toLocaleTimeString()}
Refreshed {lastUpdated.toLocaleTimeString()}
</p>
)}
{stats.snapshot_taken_at && (() => {
const diffMin = Math.round((Date.now() - new Date(stats.snapshot_taken_at).getTime()) / 60_000)
const label = diffMin < 1 ? 'just now' : `${diffMin} min ago`
return (
<p className="text-xs text-slate-400 text-right -mt-3">
Site data: {label}
</p>
)
})()}
{/* Pending orders */}
<div className="space-y-3">
<div className="flex items-center justify-between">

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&family=Noto+Sans: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-[13px] text-[#b3aa97] line-through">{eur(base)}</span>
)}
<span
className={`font-display ${large ? 'text-[18px]' : 'text-[16px]'} 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,922 @@
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 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>
)}
<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-3 mt-3">
<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-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 }) {
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}
/>
))}
</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 ? (
<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">
{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 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}
/>
)
})}
</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 [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 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}
/>
</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', 'Noto Sans', 'system-ui', 'sans-serif'],
sans: ['Hanken Grotesk', 'Noto Sans', '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: [],

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
@@ -2472,6 +2473,15 @@
"node": ">=10"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",

View File

@@ -22,3 +22,16 @@ client.interceptors.response.use(
)
export default client
// ── Remote Manager accounts ───────────────────────────────────────────────────
export const getManagersBySite = (siteId) =>
client.get(`/api/manager/by-site/${siteId}`)
export const addManagerToSite = (email, fullName, password, siteId) =>
client.post('/api/manager/register', {
email, full_name: fullName, password, site_ids: [siteId],
})
export const removeManagerSiteAccess = (managerId, siteId) =>
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import { QRCodeCanvas } from 'qrcode.react'
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
import LicenseStatus from '../components/LicenseStatus'
import ConfirmModal from '../components/ConfirmModal'
@@ -26,12 +27,21 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
const [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false)
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
const [newMgrEmail, setNewMgrEmail] = useState('')
const [newMgrName, setNewMgrName] = useState('')
const [newMgrPass, setNewMgrPass] = useState('')
const [addingMgr, setAddingMgr] = useState(false)
const [removingMgrId, setRemovingMgrId] = useState(null)
useEffect(() => {
async function load() {
try {
@@ -47,6 +57,49 @@ export default function SiteDetailPage() {
load()
}, [siteId])
const fetchManagers = useCallback(async () => {
setManagersLoading(true)
try {
const { data } = await getManagersBySite(siteId)
setManagers(data)
} catch {
toast.error('Failed to load managers')
} finally {
setManagersLoading(false)
}
}, [siteId])
useEffect(() => { fetchManagers() }, [fetchManagers])
async function doAddManager() {
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
setAddingMgr(true)
try {
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), siteId)
toast.success('Manager added')
setModal(null)
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
fetchManagers()
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to add manager')
} finally {
setAddingMgr(false)
}
}
async function doRemoveManager(managerId) {
setRemovingMgrId(managerId)
try {
await removeManagerSiteAccess(managerId, siteId)
toast.success('Access removed')
setManagers(prev => prev.filter(m => m.id !== managerId))
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to remove access')
} finally {
setRemovingMgrId(null)
}
}
async function doLock() {
if (!lockReason.trim()) return
setActing(true)
@@ -270,6 +323,99 @@ export default function SiteDetailPage() {
</div>
</div>
{/* QR Codes */}
{(() => {
const slug = site.site_id
const menuUrl = `http://72.61.191.197:3100/menu/${slug}`
const orderUrl = `http://72.61.191.197:3100/menu/${slug}/order`
function handleDownload(canvasId, filename) {
const canvas = document.getElementById(canvasId)
if (!canvas) return
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
}
return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">QR Codes</h2>
<div className="flex flex-wrap gap-8">
{[
{ id: `qr-menu-${site.id}`, url: menuUrl, label: 'Menu QR Code', file: `menu-qr-${slug}.png` },
{ id: `qr-order-${site.id}`, url: orderUrl, label: 'Order QR Code', file: `order-qr-${slug}.png` },
].map(({ id, url, label, file }) => (
<div key={id} className="flex flex-col items-center gap-3">
<p className="text-xs font-medium text-gray-400">{label}</p>
<div className="bg-white p-3 rounded-xl">
<QRCodeCanvas id={id} value={url} size={180} includeMargin={false} />
</div>
<p className="text-xs text-gray-600 font-mono break-all max-w-[220px] text-center">{url}</p>
<button
onClick={() => handleDownload(id, file)}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
Download PNG
</button>
</div>
))}
</div>
</div>
)
})()}
{/* Remote Managers */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
<button
onClick={() => setModal('add_manager')}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
+ Add Manager
</button>
</div>
{managersLoading && (
<p className="text-xs text-gray-500">Loading</p>
)}
{!managersLoading && managers.length === 0 && (
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
)}
{!managersLoading && managers.length > 0 && (
<div className="space-y-2">
{managers.map(mgr => (
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
{mgr.full_name && (
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
}`}>
{mgr.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => doRemoveManager(mgr.id)}
disabled={removingMgrId === mgr.id}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{removingMgrId === mgr.id ? '…' : 'Remove'}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
{modal === 'lock' && (
<ConfirmModal
@@ -349,6 +495,48 @@ export default function SiteDetailPage() {
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
onConfirm={doAddManager}
>
<div className="space-y-3 mb-2">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
<input
type="email"
value={newMgrEmail}
onChange={e => setNewMgrEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
<input
type="text"
value={newMgrName}
onChange={e => setNewMgrName(e.target.value)}
placeholder="Jane Smith"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
<input
type="password"
value={newMgrPass}
onChange={e => setNewMgrPass(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
</ConfirmModal>
)}
</div>
)
}