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:
136
connect_frontend/menu-app/src/pages/ProductModal.jsx
Normal file
136
connect_frontend/menu-app/src/pages/ProductModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user