From 12bd589bb7894c48fbec6a8e58747afb398bca0f Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 1 Jun 2026 00:25:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(connect):=20Phase=206=20=E2=80=94=20menu-a?= =?UTF-8?q?pp=20and=20manager-app=20frontend=20SPAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- connect_frontend/manager-app/index.html | 15 ++ connect_frontend/manager-app/package.json | 26 +++ .../manager-app/postcss.config.js | 1 + connect_frontend/manager-app/src/App.jsx | 24 +++ connect_frontend/manager-app/src/api.js | 58 ++++++ .../manager-app/src/components/OrderCard.jsx | 41 ++++ .../src/components/RequireAuth.jsx | 6 + .../manager-app/src/components/StatCard.jsx | 9 + .../src/components/StatusBadge.jsx | 27 +++ connect_frontend/manager-app/src/index.css | 6 + connect_frontend/manager-app/src/main.jsx | 15 ++ .../manager-app/src/pages/DashboardPage.jsx | 106 +++++++++++ .../src/pages/IncomingOrdersPage.jsx | 60 ++++++ .../manager-app/src/pages/LoginPage.jsx | 77 ++++++++ .../manager-app/src/pages/OrderDetailPage.jsx | 175 +++++++++++++++++ .../src/pages/OrderHistoryPage.jsx | 74 ++++++++ .../src/pages/SiteSelectorPage.jsx | 66 +++++++ .../manager-app/tailwind.config.js | 10 + connect_frontend/manager-app/vite.config.js | 9 + connect_frontend/menu-app/index.html | 15 ++ connect_frontend/menu-app/package.json | 26 +++ connect_frontend/menu-app/postcss.config.js | 1 + connect_frontend/menu-app/src/App.jsx | 15 ++ connect_frontend/menu-app/src/api.js | 20 ++ .../menu-app/src/components/CartButton.jsx | 25 +++ .../menu-app/src/components/CategoryNav.jsx | 19 ++ .../menu-app/src/components/ProductCard.jsx | 55 ++++++ connect_frontend/menu-app/src/index.css | 6 + connect_frontend/menu-app/src/main.jsx | 15 ++ .../menu-app/src/pages/CartPage.jsx | 178 ++++++++++++++++++ .../menu-app/src/pages/MenuPage.jsx | 119 ++++++++++++ .../menu-app/src/pages/OrderConfirm.jsx | 90 +++++++++ .../menu-app/src/pages/ProductModal.jsx | 136 +++++++++++++ connect_frontend/menu-app/tailwind.config.js | 10 + connect_frontend/menu-app/vite.config.js | 9 + 35 files changed, 1544 insertions(+) create mode 100644 connect_frontend/manager-app/index.html create mode 100644 connect_frontend/manager-app/package.json create mode 100644 connect_frontend/manager-app/postcss.config.js create mode 100644 connect_frontend/manager-app/src/App.jsx create mode 100644 connect_frontend/manager-app/src/api.js create mode 100644 connect_frontend/manager-app/src/components/OrderCard.jsx create mode 100644 connect_frontend/manager-app/src/components/RequireAuth.jsx create mode 100644 connect_frontend/manager-app/src/components/StatCard.jsx create mode 100644 connect_frontend/manager-app/src/components/StatusBadge.jsx create mode 100644 connect_frontend/manager-app/src/index.css create mode 100644 connect_frontend/manager-app/src/main.jsx create mode 100644 connect_frontend/manager-app/src/pages/DashboardPage.jsx create mode 100644 connect_frontend/manager-app/src/pages/IncomingOrdersPage.jsx create mode 100644 connect_frontend/manager-app/src/pages/LoginPage.jsx create mode 100644 connect_frontend/manager-app/src/pages/OrderDetailPage.jsx create mode 100644 connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx create mode 100644 connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx create mode 100644 connect_frontend/manager-app/tailwind.config.js create mode 100644 connect_frontend/manager-app/vite.config.js create mode 100644 connect_frontend/menu-app/index.html create mode 100644 connect_frontend/menu-app/package.json create mode 100644 connect_frontend/menu-app/postcss.config.js create mode 100644 connect_frontend/menu-app/src/App.jsx create mode 100644 connect_frontend/menu-app/src/api.js create mode 100644 connect_frontend/menu-app/src/components/CartButton.jsx create mode 100644 connect_frontend/menu-app/src/components/CategoryNav.jsx create mode 100644 connect_frontend/menu-app/src/components/ProductCard.jsx create mode 100644 connect_frontend/menu-app/src/index.css create mode 100644 connect_frontend/menu-app/src/main.jsx create mode 100644 connect_frontend/menu-app/src/pages/CartPage.jsx create mode 100644 connect_frontend/menu-app/src/pages/MenuPage.jsx create mode 100644 connect_frontend/menu-app/src/pages/OrderConfirm.jsx create mode 100644 connect_frontend/menu-app/src/pages/ProductModal.jsx create mode 100644 connect_frontend/menu-app/tailwind.config.js create mode 100644 connect_frontend/menu-app/vite.config.js diff --git a/connect_frontend/manager-app/index.html b/connect_frontend/manager-app/index.html new file mode 100644 index 0000000..028e13b --- /dev/null +++ b/connect_frontend/manager-app/index.html @@ -0,0 +1,15 @@ + + + + + + Xenia Connect — Manager + + + + + +
+ + + diff --git a/connect_frontend/manager-app/package.json b/connect_frontend/manager-app/package.json new file mode 100644 index 0000000..530b244 --- /dev/null +++ b/connect_frontend/manager-app/package.json @@ -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" + } +} diff --git a/connect_frontend/manager-app/postcss.config.js b/connect_frontend/manager-app/postcss.config.js new file mode 100644 index 0000000..82731cb --- /dev/null +++ b/connect_frontend/manager-app/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } } diff --git a/connect_frontend/manager-app/src/App.jsx b/connect_frontend/manager-app/src/App.jsx new file mode 100644 index 0000000..ae6a181 --- /dev/null +++ b/connect_frontend/manager-app/src/App.jsx @@ -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 ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/connect_frontend/manager-app/src/api.js b/connect_frontend/manager-app/src/api.js new file mode 100644 index 0000000..5d2f37c --- /dev/null +++ b/connect_frontend/manager-app/src/api.js @@ -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, + }) +} diff --git a/connect_frontend/manager-app/src/components/OrderCard.jsx b/connect_frontend/manager-app/src/components/OrderCard.jsx new file mode 100644 index 0000000..f4b7c14 --- /dev/null +++ b/connect_frontend/manager-app/src/components/OrderCard.jsx @@ -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 ( + + ) +} diff --git a/connect_frontend/manager-app/src/components/RequireAuth.jsx b/connect_frontend/manager-app/src/components/RequireAuth.jsx new file mode 100644 index 0000000..e050074 --- /dev/null +++ b/connect_frontend/manager-app/src/components/RequireAuth.jsx @@ -0,0 +1,6 @@ +import { Navigate, Outlet } from 'react-router-dom' + +export default function RequireAuth() { + const token = sessionStorage.getItem('manager_token') + return token ? : +} diff --git a/connect_frontend/manager-app/src/components/StatCard.jsx b/connect_frontend/manager-app/src/components/StatCard.jsx new file mode 100644 index 0000000..7ee30ea --- /dev/null +++ b/connect_frontend/manager-app/src/components/StatCard.jsx @@ -0,0 +1,9 @@ +export default function StatCard({ label, value, sub, color = 'text-slate-800' }) { + return ( +
+

{label}

+

{value ?? '—'}

+ {sub &&

{sub}

} +
+ ) +} diff --git a/connect_frontend/manager-app/src/components/StatusBadge.jsx b/connect_frontend/manager-app/src/components/StatusBadge.jsx new file mode 100644 index 0000000..f3750ce --- /dev/null +++ b/connect_frontend/manager-app/src/components/StatusBadge.jsx @@ -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 ( + + {LABELS[status] || status} + + ) +} diff --git a/connect_frontend/manager-app/src/index.css b/connect_frontend/manager-app/src/index.css new file mode 100644 index 0000000..076072e --- /dev/null +++ b/connect_frontend/manager-app/src/index.css @@ -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; } diff --git a/connect_frontend/manager-app/src/main.jsx b/connect_frontend/manager-app/src/main.jsx new file mode 100644 index 0000000..81ddaf9 --- /dev/null +++ b/connect_frontend/manager-app/src/main.jsx @@ -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( + + + + + + +) diff --git a/connect_frontend/manager-app/src/pages/DashboardPage.jsx b/connect_frontend/manager-app/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..5e31b12 --- /dev/null +++ b/connect_frontend/manager-app/src/pages/DashboardPage.jsx @@ -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 ( +
+ {/* Header */} +
+
+ +

Dashboard

+ +
+
+ +
+ + {/* Stats grid */} +
+ + + + 0 ? 'text-amber-600' : 'text-slate-800'} /> +
+ + {lastUpdated && ( +

+ Updated {lastUpdated.toLocaleTimeString()} +

+ )} + + {/* Pending orders */} +
+
+

+ + Pending orders + {pending.length > 0 && ( + + {pending.length} + + )} +

+ +
+ + {loading ? ( +
+
+
+ ) : pending.length === 0 ? ( +
+ No pending orders +
+ ) : ( + pending.map(o => ) + )} +
+
+
+ ) +} diff --git a/connect_frontend/manager-app/src/pages/IncomingOrdersPage.jsx b/connect_frontend/manager-app/src/pages/IncomingOrdersPage.jsx new file mode 100644 index 0000000..6f853d7 --- /dev/null +++ b/connect_frontend/manager-app/src/pages/IncomingOrdersPage.jsx @@ -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 ( +
+
+
+ +

Incoming Orders

+ +
+
+ +
+ {loading ? ( +
+
+
+ ) : orders.length === 0 ? ( +
+

No pending orders

+

New orders will appear here automatically.

+
+ ) : ( + orders.map(o => ) + )} +
+
+ ) +} diff --git a/connect_frontend/manager-app/src/pages/LoginPage.jsx b/connect_frontend/manager-app/src/pages/LoginPage.jsx new file mode 100644 index 0000000..be250b1 --- /dev/null +++ b/connect_frontend/manager-app/src/pages/LoginPage.jsx @@ -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 ( +
+
+
+
+
+
+
+ +
+
+

Xenia Connect

+

Manager Dashboard

+
+ +
+ 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" + /> + 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" + /> +
+ + +
+
+

Xenia POS · Remote Manager

+
+
+ ) +} diff --git a/connect_frontend/manager-app/src/pages/OrderDetailPage.jsx b/connect_frontend/manager-app/src/pages/OrderDetailPage.jsx new file mode 100644 index 0000000..48f84ca --- /dev/null +++ b/connect_frontend/manager-app/src/pages/OrderDetailPage.jsx @@ -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 ( +
+
+
+ ) + + if (!order) return ( +
+ Order not found. +
+ ) + + const items = (() => { try { return JSON.parse(order.items_json || '[]') } catch { return [] } })() + const nextActions = NEXT_STATUSES[order.status] || [] + + return ( +
+
+
+ +

{order.public_ref}

+
+
+
+ +
+ + {/* Customer info */} +
+
+ {order.order_type === 'delivery' + ? + : } + {order.order_type === 'delivery' ? 'Delivery' : 'Dine In'} +
+

{order.customer_name}

+ {order.customer_phone &&

{order.customer_phone}

} + {order.customer_address && ( +

{order.customer_address}

+ )} + {order.customer_notes && ( +

"{order.customer_notes}"

+ )} +
+ + {/* Items */} +
+

Items

+ {items.map((item, idx) => ( +
+ {item.quantity}× {item.name} + €{(item.unit_price * item.quantity).toFixed(2)} +
+ ))} +
+
+ Subtotal€{order.subtotal?.toFixed(2)} +
+ {order.delivery_fee > 0 && ( +
+ Delivery fee€{order.delivery_fee?.toFixed(2)} +
+ )} +
+ Total€{order.total?.toFixed(2)} +
+
+
+ + {/* Actions */} + {order.status === 'pending_acceptance' && !showReject && ( +
+ + +
+ )} + + {showReject && ( +
+

Rejection reason (optional)

+ 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" + /> +
+ + +
+
+ )} + + {nextActions.map(({ value, label }) => ( + + ))} +
+
+ ) +} diff --git a/connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx b/connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx new file mode 100644 index 0000000..f33f0d7 --- /dev/null +++ b/connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx @@ -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 ( +
+
+
+ +

Order History

+
+ {/* Status filter tabs */} +
+ {STATUSES.map(s => ( + + ))} +
+
+ +
+ {loading ? ( +
+
+
+ ) : orders.length === 0 ? ( +
+ No orders found. +
+ ) : ( + orders.map(o => ) + )} +
+
+ ) +} diff --git a/connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx b/connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx new file mode 100644 index 0000000..2b9b012 --- /dev/null +++ b/connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx @@ -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 ( +
+
+
+ ) + + return ( +
+
+
+

Select Venue

+ +
+ + {sites.length === 0 ? ( +

No venues assigned to your account.

+ ) : ( +
+ {sites.map(site => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/connect_frontend/manager-app/tailwind.config.js b/connect_frontend/manager-app/tailwind.config.js new file mode 100644 index 0000000..43efcdf --- /dev/null +++ b/connect_frontend/manager-app/tailwind.config.js @@ -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: [], +} diff --git a/connect_frontend/manager-app/vite.config.js b/connect_frontend/manager-app/vite.config.js new file mode 100644 index 0000000..b9109eb --- /dev/null +++ b/connect_frontend/manager-app/vite.config.js @@ -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' }, +}) diff --git a/connect_frontend/menu-app/index.html b/connect_frontend/menu-app/index.html new file mode 100644 index 0000000..fcff503 --- /dev/null +++ b/connect_frontend/menu-app/index.html @@ -0,0 +1,15 @@ + + + + + + Xenia Menu + + + + + +
+ + + diff --git a/connect_frontend/menu-app/package.json b/connect_frontend/menu-app/package.json new file mode 100644 index 0000000..5678ab6 --- /dev/null +++ b/connect_frontend/menu-app/package.json @@ -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" + } +} diff --git a/connect_frontend/menu-app/postcss.config.js b/connect_frontend/menu-app/postcss.config.js new file mode 100644 index 0000000..82731cb --- /dev/null +++ b/connect_frontend/menu-app/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } } diff --git a/connect_frontend/menu-app/src/App.jsx b/connect_frontend/menu-app/src/App.jsx new file mode 100644 index 0000000..f8b9565 --- /dev/null +++ b/connect_frontend/menu-app/src/App.jsx @@ -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 ( + + } /> + } /> + } /> + } /> + + ) +} diff --git a/connect_frontend/menu-app/src/api.js b/connect_frontend/menu-app/src/api.js new file mode 100644 index 0000000..bbb96b2 --- /dev/null +++ b/connect_frontend/menu-app/src/api.js @@ -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 } +} diff --git a/connect_frontend/menu-app/src/components/CartButton.jsx b/connect_frontend/menu-app/src/components/CartButton.jsx new file mode 100644 index 0000000..f4a6477 --- /dev/null +++ b/connect_frontend/menu-app/src/components/CartButton.jsx @@ -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 ( +
+ +
+ ) +} diff --git a/connect_frontend/menu-app/src/components/CategoryNav.jsx b/connect_frontend/menu-app/src/components/CategoryNav.jsx new file mode 100644 index 0000000..089f53a --- /dev/null +++ b/connect_frontend/menu-app/src/components/CategoryNav.jsx @@ -0,0 +1,19 @@ +export default function CategoryNav({ categories, active, onSelect }) { + return ( +
+ {categories.map(cat => ( + + ))} +
+ ) +} diff --git a/connect_frontend/menu-app/src/components/ProductCard.jsx b/connect_frontend/menu-app/src/components/ProductCard.jsx new file mode 100644 index 0000000..c85bcdd --- /dev/null +++ b/connect_frontend/menu-app/src/components/ProductCard.jsx @@ -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 ( + + ) +} diff --git a/connect_frontend/menu-app/src/index.css b/connect_frontend/menu-app/src/index.css new file mode 100644 index 0000000..076072e --- /dev/null +++ b/connect_frontend/menu-app/src/index.css @@ -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; } diff --git a/connect_frontend/menu-app/src/main.jsx b/connect_frontend/menu-app/src/main.jsx new file mode 100644 index 0000000..1cb2b6b --- /dev/null +++ b/connect_frontend/menu-app/src/main.jsx @@ -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( + + + + + + +) diff --git a/connect_frontend/menu-app/src/pages/CartPage.jsx b/connect_frontend/menu-app/src/pages/CartPage.jsx new file mode 100644 index 0000000..55e22a7 --- /dev/null +++ b/connect_frontend/menu-app/src/pages/CartPage.jsx @@ -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 ( +
+
+
+ +

Your Order

+
+
+ +
+ + {/* Order type */} +
+

Order type

+
+ {[ + { id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed }, + { id: 'delivery', label: 'Delivery', Icon: Truck }, + ].map(({ id, label, Icon }) => ( + + ))} +
+
+ + {/* Cart summary */} +
+

Items

+ {cart.map((item, idx) => ( +
+ {item.quantity}× {item.name} + €{(item.unit_price * item.quantity).toFixed(2)} +
+ ))} +
+
+ Subtotal€{subtotal.toFixed(2)} +
+ {deliveryFee > 0 && ( +
+ Delivery fee€{deliveryFee.toFixed(2)} +
+ )} +
+ Total€{total.toFixed(2)} +
+
+
+ + {/* Customer details */} +
+

Your details

+ {[ + { 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 }) => ( +
+ + 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" + /> +
+ ))} + {orderType === 'delivery' && ( +
+ +