feat(connect): Phase 6 — menu-app and manager-app frontend SPAs

Two Vite + React 18 + Tailwind apps under connect_frontend/.
Both use React Router v6, Axios, react-hot-toast, and Lucide icons.
Neither uses localStorage — tokens live in sessionStorage (manager-app)
or component state (menu-app cart).

── menu-app (public menu + ordering) ──────────────────────────────

Routing (basename /menu):
  /:siteSlug              → MenuPage
  /:siteSlug/order        → CartPage
  /:siteSlug/confirm/:ref → OrderConfirm

Pages:
  MenuPage      — fetches menu snapshot, category nav tabs, product
                  cards; floating CartButton; opens ProductModal
  ProductModal  — quantity picker, quick-option toggles, price/discount
                  display, "Add to order" with line total
  CartPage      — order type selector (dine_in/delivery), customer
                  details form, cart summary, submits to cloud API
  OrderConfirm  — polls GET /api/orders/status/:ref every 10s;
                  renders status icon + label; stops polling on
                  terminal states (delivered/rejected)

Components:
  CategoryNav   — horizontal scrollable pill tabs
  ProductCard   — image, name, description, price with discount badge;
                  greyed + "Out of stock" badge when unavailable
  CartButton    — fixed bottom bar with item count + total

── manager-app (remote dashboard) ─────────────────────────────────

Routing (basename /manage):
  /login              → LoginPage
  /                   → SiteSelectorPage (auto-navigates if one site)
  /:siteId            → DashboardPage
  /:siteId/orders     → IncomingOrdersPage
  /:siteId/orders/history → OrderHistoryPage
  /:siteId/orders/:id → OrderDetailPage
  RequireAuth wrapper redirects to /login if no sessionStorage token

Pages:
  LoginPage          — email/password → POST /api/manager/login;
                       stores JWT in sessionStorage
  SiteSelectorPage   — lists accessible venues; auto-selects if one
  DashboardPage      — polls snapshot + pending orders every 15s;
                       2×2 stat grid + pending order list
  IncomingOrdersPage — polls pending orders every 15s; order cards
  OrderDetailPage    — full order detail; Accept/Reject with optional
                       rejection reason; lifecycle progression buttons
                       (Preparing → Ready → Delivered etc.)
  OrderHistoryPage   — all orders with status filter tabs

Components:
  RequireAuth   — route guard using sessionStorage token
  StatCard      — labelled metric tile
  StatusBadge   — colour-coded status pill
  OrderCard     — summary card linking to OrderDetailPage
  (no OrderCard uses useParams to get siteId for nav — wired correctly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 00:25:28 +03:00
parent abe7f4ff0d
commit 12bd589bb7
35 changed files with 1544 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="el">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia Connect — Manager</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">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "manager-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^1.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@@ -0,0 +1,24 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import SiteSelectorPage from './pages/SiteSelectorPage'
import DashboardPage from './pages/DashboardPage'
import IncomingOrdersPage from './pages/IncomingOrdersPage'
import OrderDetailPage from './pages/OrderDetailPage'
import OrderHistoryPage from './pages/OrderHistoryPage'
import RequireAuth from './components/RequireAuth'
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<SiteSelectorPage />} />
<Route path="/:siteId" element={<DashboardPage />} />
<Route path="/:siteId/orders" element={<IncomingOrdersPage />} />
<Route path="/:siteId/orders/history" element={<OrderHistoryPage />} />
<Route path="/:siteId/orders/:orderId" element={<OrderDetailPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,58 @@
import axios from 'axios'
const BASE = import.meta.env.VITE_CLOUD_URL || ''
const api = axios.create({ baseURL: BASE })
// Attach manager JWT from sessionStorage on every request
api.interceptors.request.use(config => {
const token = sessionStorage.getItem('manager_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Force logout on 401
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
sessionStorage.removeItem('manager_token')
window.location.href = '/manage/login'
}
return Promise.reject(err)
}
)
export default api
export async function managerLogin(email, password) {
const { data } = await api.post('/api/manager/login', { email, password })
return data // { access_token, token_type }
}
export async function fetchSites() {
const { data } = await api.get('/api/remote/sites')
return data
}
export async function fetchSnapshot(siteId) {
const { data } = await api.get(`/api/remote/sites/${siteId}/snapshot`)
return data
}
export async function fetchPendingOrders(siteId) {
const { data } = await api.get(`/api/remote/sites/${siteId}/orders/pending`)
return data
}
export async function fetchOrders(siteId, params = {}) {
const { data } = await api.get(`/api/remote/sites/${siteId}/orders`, { params })
return data
}
export async function updateOrderStatus(siteId, orderId, status, rejectionReason = null) {
await api.patch(`/api/remote/sites/${siteId}/orders/${orderId}/status`, {
status,
rejection_reason: rejectionReason,
})
}

View File

@@ -0,0 +1,41 @@
import { useNavigate, useParams } from 'react-router-dom'
import { Truck, UtensilsCrossed, Clock } from 'lucide-react'
import StatusBadge from './StatusBadge'
function timeAgo(iso) {
const diff = Math.floor((Date.now() - new Date(iso)) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
return `${Math.floor(diff / 3600)}h ago`
}
export default function OrderCard({ order }) {
const { siteId } = useParams()
const navigate = useNavigate()
return (
<button
onClick={() => navigate(`/${siteId}/orders/${order.id}`)}
className="w-full bg-white rounded-2xl shadow-sm p-4 text-left hover:shadow-md transition-shadow space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{order.order_type === 'delivery'
? <Truck size={16} className="text-indigo-500" />
: <UtensilsCrossed size={16} className="text-emerald-500" />}
<span className="font-bold text-slate-800 font-mono">{order.public_ref}</span>
</div>
<StatusBadge status={order.status} />
</div>
<p className="text-sm text-slate-600">{order.customer_name}</p>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-slate-800">{order.total?.toFixed(2)}</span>
<span className="text-xs text-slate-400 flex items-center gap-1">
<Clock size={12} /> {timeAgo(order.created_at)}
</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,6 @@
import { Navigate, Outlet } from 'react-router-dom'
export default function RequireAuth() {
const token = sessionStorage.getItem('manager_token')
return token ? <Outlet /> : <Navigate to="/login" replace />
}

View File

@@ -0,0 +1,9 @@
export default function StatCard({ label, value, sub, color = 'text-slate-800' }) {
return (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-1">
<p className="text-xs text-slate-400 font-medium uppercase tracking-wide">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value ?? '—'}</p>
{sub && <p className="text-xs text-slate-400">{sub}</p>}
</div>
)
}

View File

@@ -0,0 +1,27 @@
const STYLES = {
pending_acceptance: 'bg-amber-100 text-amber-700',
accepted: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-600',
preparing: 'bg-blue-100 text-blue-700',
ready: 'bg-teal-100 text-teal-700',
out_for_delivery: 'bg-indigo-100 text-indigo-700',
delivered: 'bg-slate-100 text-slate-500',
}
const LABELS = {
pending_acceptance: 'Pending',
accepted: 'Accepted',
rejected: 'Rejected',
preparing: 'Preparing',
ready: 'Ready',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
}
export default function StatusBadge({ status }) {
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${STYLES[status] || 'bg-slate-100 text-slate-500'}`}>
{LABELS[status] || status}
</span>
)
}

View File

@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }

View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/manage">
<App />
<Toaster position="top-center" />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Bell } from 'lucide-react'
import { fetchSnapshot, fetchPendingOrders } from '../api'
import StatCard from '../components/StatCard'
import OrderCard from '../components/OrderCard'
const POLL_MS = 15_000
export default function DashboardPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [snapshot, setSnapshot] = useState(null)
const [pending, setPending] = useState([])
const [lastUpdated, setLastUpdated] = useState(null)
const [loading, setLoading] = useState(true)
async function load() {
try {
const [snap, orders] = await Promise.all([
fetchSnapshot(siteId).catch(() => null),
fetchPendingOrders(siteId).catch(() => []),
])
setSnapshot(snap ? JSON.parse(snap.snapshot_json) : null)
setPending(orders)
setLastUpdated(new Date())
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
const timer = setInterval(load, POLL_MS)
return () => clearInterval(timer)
}, [siteId])
const stats = snapshot || {}
return (
<div className="min-h-screen bg-slate-50 pb-10">
{/* Header */}
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<button onClick={() => navigate('/')} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Dashboard</h1>
<button onClick={load} className="text-slate-400 hover:text-slate-700">
<RefreshCw size={18} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-5">
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard label="Open tables" value={stats.open_tables} />
<StatCard label="Revenue today" value={stats.today_revenue != null ? `${stats.today_revenue.toFixed(2)}` : null} color="text-emerald-600" />
<StatCard label="Orders today" value={stats.today_orders} />
<StatCard label="Online pending" value={stats.online_orders_pending} color={stats.online_orders_pending > 0 ? 'text-amber-600' : 'text-slate-800'} />
</div>
{lastUpdated && (
<p className="text-xs text-slate-400 text-right">
Updated {lastUpdated.toLocaleTimeString()}
</p>
)}
{/* Pending orders */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-bold text-slate-700 flex items-center gap-2">
<Bell size={16} className={pending.length ? 'text-amber-500' : 'text-slate-300'} />
Pending orders
{pending.length > 0 && (
<span className="bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5 rounded-full">
{pending.length}
</span>
)}
</h2>
<button
onClick={() => navigate(`/${siteId}/orders`)}
className="text-sky-500 text-sm font-medium hover:underline"
>
View all
</button>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : pending.length === 0 ? (
<div className="bg-white rounded-2xl p-8 text-center text-slate-400 text-sm shadow-sm">
No pending orders
</div>
) : (
pending.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { fetchPendingOrders } from '../api'
import OrderCard from '../components/OrderCard'
const POLL_MS = 15_000
export default function IncomingOrdersPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
async function load() {
try {
const data = await fetchPendingOrders(siteId)
setOrders(data)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
const timer = setInterval(load, POLL_MS)
return () => clearInterval(timer)
}, [siteId])
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Incoming Orders</h1>
<button onClick={load} className="text-slate-400 hover:text-slate-700">
<RefreshCw size={18} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : orders.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
<p className="font-medium">No pending orders</p>
<p className="text-sm mt-1">New orders will appear here automatically.</p>
</div>
) : (
orders.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { KeyRound } from 'lucide-react'
import { managerLogin } from '../api'
import toast from 'react-hot-toast'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
if (!email || !password) return
setLoading(true)
try {
const data = await managerLogin(email, password)
sessionStorage.setItem('manager_token', data.access_token)
navigate('/', { replace: true })
} catch (err) {
toast.error(err.response?.data?.detail || 'Invalid credentials')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-2xl shadow-xl border border-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center space-y-2">
<div className="flex justify-center mb-3">
<div className="w-12 h-12 bg-sky-500 rounded-xl flex items-center justify-center">
<KeyRound className="text-white" size={22} />
</div>
</div>
<h1 className="text-xl font-bold text-slate-800">Xenia Connect</h1>
<p className="text-sm text-slate-500">Manager Dashboard</p>
</div>
<div className="space-y-3">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
autoComplete="email"
required
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
required
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold transition-colors"
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
<p className="text-center text-xs text-slate-400 mt-4">Xenia POS · Remote Manager</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
import { fetchOrders, updateOrderStatus } from '../api'
import StatusBadge from '../components/StatusBadge'
import toast from 'react-hot-toast'
const NEXT_STATUSES = {
accepted: [{ value: 'preparing', label: 'Mark Preparing' }],
preparing: [
{ value: 'ready', label: 'Mark Ready' },
{ value: 'out_for_delivery', label: 'Out for Delivery' },
],
ready: [{ value: 'delivered', label: 'Mark Delivered' }],
out_for_delivery: [{ value: 'delivered', label: 'Mark Delivered' }],
}
export default function OrderDetailPage() {
const { siteId, orderId } = useParams()
const navigate = useNavigate()
const [order, setOrder] = useState(null)
const [loading, setLoading] = useState(true)
const [acting, setActing] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [showReject, setShowReject] = useState(false)
useEffect(() => {
fetchOrders(siteId)
.then(list => setOrder(list.find(o => String(o.id) === orderId) || null))
.finally(() => setLoading(false))
}, [siteId, orderId])
async function act(status, reason = null) {
setActing(true)
try {
await updateOrderStatus(siteId, orderId, status, reason)
toast.success(`Order ${status.replace('_', ' ')}`)
navigate(-1)
} catch (err) {
toast.error(err.response?.data?.detail || 'Action failed')
} finally {
setActing(false)
}
}
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
)
if (!order) return (
<div className="min-h-screen flex items-center justify-center text-slate-400">
Order not found.
</div>
)
const items = (() => { try { return JSON.parse(order.items_json || '[]') } catch { return [] } })()
const nextActions = NEXT_STATUSES[order.status] || []
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800 font-mono">{order.public_ref}</h1>
<div className="ml-auto"><StatusBadge status={order.status} /></div>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-5 space-y-4">
{/* Customer info */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
{order.order_type === 'delivery'
? <Truck size={15} className="text-indigo-500" />
: <UtensilsCrossed size={15} className="text-emerald-500" />}
{order.order_type === 'delivery' ? 'Delivery' : 'Dine In'}
</div>
<p className="font-bold text-slate-800">{order.customer_name}</p>
{order.customer_phone && <p className="text-sm text-slate-500">{order.customer_phone}</p>}
{order.customer_address && (
<p className="text-sm text-slate-500">{order.customer_address}</p>
)}
{order.customer_notes && (
<p className="text-sm text-slate-500 italic">"{order.customer_notes}"</p>
)}
</div>
{/* Items */}
<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>
{items.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>{order.subtotal?.toFixed(2)}</span>
</div>
{order.delivery_fee > 0 && (
<div className="flex justify-between text-sm text-slate-500">
<span>Delivery fee</span><span>{order.delivery_fee?.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-800">
<span>Total</span><span>{order.total?.toFixed(2)}</span>
</div>
</div>
</div>
{/* Actions */}
{order.status === 'pending_acceptance' && !showReject && (
<div className="flex gap-3">
<button
onClick={() => act('accepted')}
disabled={acting}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
>
Accept
</button>
<button
onClick={() => setShowReject(true)}
disabled={acting}
className="flex-1 bg-red-50 hover:bg-red-100 text-red-600 py-3.5 rounded-xl font-semibold transition-colors"
>
Reject
</button>
</div>
)}
{showReject && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-sm font-semibold text-slate-700">Rejection reason (optional)</p>
<input
type="text"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
placeholder="e.g. Kitchen closed, item unavailable…"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-red-300"
/>
<div className="flex gap-3">
<button
onClick={() => act('rejected', rejectReason || null)}
disabled={acting}
className="flex-1 bg-red-500 hover:bg-red-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold"
>
Confirm Rejection
</button>
<button onClick={() => setShowReject(false)} className="px-4 py-3 rounded-xl text-slate-500 hover:bg-slate-100">
Cancel
</button>
</div>
</div>
)}
{nextActions.map(({ value, label }) => (
<button
key={value}
onClick={() => act(value)}
disabled={acting}
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
>
{label}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { fetchOrders } from '../api'
import OrderCard from '../components/OrderCard'
const STATUSES = [
{ value: '', label: 'All' },
{ value: 'pending_acceptance', label: 'Pending' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'preparing', label: 'Preparing' },
{ value: 'ready', label: 'Ready' },
{ value: 'out_for_delivery', label: 'Delivery' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'rejected', label: 'Rejected' },
]
export default function OrderHistoryPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [orders, setOrders] = useState([])
const [filter, setFilter] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetchOrders(siteId, filter ? { order_status: filter } : {})
.then(setOrders)
.finally(() => setLoading(false))
}, [siteId, filter])
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Order History</h1>
</div>
{/* Status filter tabs */}
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1">
{STATUSES.map(s => (
<button
key={s.value}
onClick={() => setFilter(s.value)}
className={`flex-shrink-0 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
filter === s.value
? 'bg-sky-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : orders.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
No orders found.
</div>
) : (
orders.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Store, LogOut } from 'lucide-react'
import { fetchSites } from '../api'
export default function SiteSelectorPage() {
const [sites, setSites] = useState([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
fetchSites()
.then(data => {
setSites(data)
// Auto-navigate if only one site
if (data.length === 1) navigate(`/${data[0].id}`, { replace: true })
})
.finally(() => setLoading(false))
}, [])
function logout() {
sessionStorage.removeItem('manager_token')
navigate('/login', { replace: true })
}
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="max-w-md mx-auto space-y-5">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-800">Select Venue</h1>
<button onClick={logout} className="text-slate-400 hover:text-slate-600 flex items-center gap-1 text-sm">
<LogOut size={16} /> Log out
</button>
</div>
{sites.length === 0 ? (
<p className="text-slate-500 text-center py-12">No venues assigned to your account.</p>
) : (
<div className="space-y-3">
{sites.map(site => (
<button
key={site.id}
onClick={() => navigate(`/${site.id}`)}
className="w-full bg-white rounded-2xl p-5 shadow-sm hover:shadow-md transition-shadow text-left flex items-center gap-4"
>
<div className="w-12 h-12 bg-sky-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Store className="text-sky-600" size={22} />
</div>
<div>
<p className="font-bold text-slate-800">{site.name}</p>
<p className="text-xs text-slate-400 font-mono">{site.site_id}</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
},
},
plugins: [],
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/manage/',
server: { host: '0.0.0.0', port: 5201 },
build: { outDir: 'dist' },
})

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="el">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia 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">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "menu-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^1.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,20 @@
import axios from 'axios'
const BASE = import.meta.env.VITE_CLOUD_URL || ''
const api = axios.create({ baseURL: BASE })
export async function fetchMenu(siteSlug) {
const { data } = await api.get(`/api/menu/${siteSlug}`)
return data
}
export async function submitOrder(siteSlug, body) {
const { data } = await api.post(`/api/orders/${siteSlug}`, body)
return data // { order_id, public_ref, status }
}
export async function fetchOrderStatus(publicRef) {
const { data } = await api.get(`/api/orders/status/${publicRef}`)
return data // { public_ref, status, rejection_reason }
}

View File

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,55 @@
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,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }

View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/menu">
<App />
<Toaster position="top-center" />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,178 @@
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

@@ -0,0 +1,119 @@
import { useState, useEffect } 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'
export default function MenuPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const [menu, setMenu] = 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
useEffect(() => {
fetchMenu(siteSlug)
.then(data => {
setMenu(data)
if (data.categories?.length) setActiveCat(data.categories[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)
}
return [...prev, {
product_id: product.id,
name: product.digital_name || product.name,
quantity,
unit_price: discounted,
options,
}]
})
setSelected(null)
}
const activeCategory = menu?.categories?.find(c => c.id === activeCat)
const cartCount = cart.reduce((s, i) => s + i.quantity, 0)
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>
)
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>
)
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>
{/* 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)}
/>
))}
{!activeCategory?.products.length && (
<p className="text-center text-slate-400 py-12">No items in this category.</p>
)}
</div>
<CartButton cart={cart} siteSlug={siteSlug} />
{selected && (
<ProductModal
product={selected}
onClose={() => setSelected(null)}
onAdd={addToCart}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } 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' },
}
const TERMINAL = new Set(['rejected', 'delivered'])
const POLL_MS = 10_000
export default function OrderConfirm() {
const { ref } = useParams()
const [orderStatus, setOrderStatus] = useState(null)
const [rejectionReason, setRejectionReason] = useState(null)
const [error, setError] = useState(false)
useEffect(() => {
let timer
async function poll() {
try {
const data = await fetchOrderStatus(ref)
setOrderStatus(data.status)
setRejectionReason(data.rejection_reason)
if (!TERMINAL.has(data.status)) {
timer = setTimeout(poll, POLL_MS)
}
} catch {
setError(true)
}
}
poll()
return () => clearTimeout(timer)
}, [ref])
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={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
<Icon className={color} size={40} />
</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">
{error ? 'Could not load status' : label}
</h1>
{rejectionReason && (
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
)}
</div>
{!TERMINAL.has(orderStatus) && !error && (
<p className="text-xs text-slate-400">
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>
)}
{!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>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,136 @@
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

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
},
},
plugins: [],
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/menu/',
server: { host: '0.0.0.0', port: 5200 },
build: { outDir: 'dist' },
})