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