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,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>
)
}