Replace entire repo content with shahikitchen-v1-prodcution.zip (v1.1 production)
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* /orderfromtable — Single reusable table ordering page (QR driven)
|
||||
*
|
||||
* This page is the ONLY customer-facing order page for physical tables.
|
||||
* All 80 table QR codes point here with ?table=backaplan-001 etc.
|
||||
*
|
||||
* - Strict validation of table param (branch + 001-040)
|
||||
* - Local item selection (independent of global cart)
|
||||
* - On "Send order" we only show confirmation (no backend call yet)
|
||||
* - Payload is prepared exactly in the shape the future POST /api/table-orders will expect
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { filterMenuItems } from '@/application/menu/filter-menu';
|
||||
import { getMenuPosterSrc, logoUrl, SITE_ASSETS } from '@/lib/assets';
|
||||
import { buildTableOrderPayload } from '@/application/table-order/table-order-use-cases';
|
||||
import { parseTableId } from '@/domain/table/table-id';
|
||||
import {
|
||||
addOrderLine,
|
||||
calculateLineTotal,
|
||||
calculateOrderTotal,
|
||||
formatLineQuantityLabel,
|
||||
removeOrderLine,
|
||||
updateOrderLineQuantity,
|
||||
type OrderLine,
|
||||
} from '@/domain/shared/order-line';
|
||||
import { buildCartLineFromMenuItem } from '@/application/cart/cart-line-builder';
|
||||
import { container } from '@/infrastructure/di/container';
|
||||
import type { MenuItem } from '@/domain/menu/entities';
|
||||
import { playAddSound, playSuccessSound } from '@/infrastructure/audio/web-audio-sound-adapter';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import Navbar from '@/components/Navbar';
|
||||
|
||||
import Footer from '@/components/Footer';
|
||||
import { Search, Plus, Minus, X, CheckCircle, UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
export default function OrderFromTablePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const tableParam = searchParams.get('table');
|
||||
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const to = t.tableOrder;
|
||||
|
||||
const parsed = useMemo(() => parseTableId(tableParam), [tableParam]);
|
||||
const allMenuItems = container.menuRepository.getAllItems();
|
||||
|
||||
// Local order state (NOT the global cart)
|
||||
const [selected, setSelected] = useState<OrderLine[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [vegOnly, setVegOnly] = useState(false);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const isValid = parsed.valid;
|
||||
|
||||
const branchLabel = isValid
|
||||
? (parsed.branch === 'askim' ? to.branchAskim : to.branchBackaplan)
|
||||
: '';
|
||||
const tableDisplay = isValid ? parsed.tableNumber : '';
|
||||
|
||||
// Filtered dishes for the selector grid (same data source as everywhere else)
|
||||
const filteredDishes = useMemo(
|
||||
() =>
|
||||
filterMenuItems(allMenuItems, {
|
||||
searchQuery: search,
|
||||
vegetarianOnly: vegOnly,
|
||||
}),
|
||||
[allMenuItems, search, vegOnly]
|
||||
);
|
||||
|
||||
const selectedTotal = calculateOrderTotal(selected);
|
||||
|
||||
function addDish(dish: MenuItem) {
|
||||
playAddSound();
|
||||
setSelected((prev) => addOrderLine(prev, buildCartLineFromMenuItem(dish)));
|
||||
}
|
||||
|
||||
function updateQty(id: string, newQty: number) {
|
||||
setSelected((prev) => updateOrderLineQuantity(prev, id, newQty));
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setSelected((prev) => removeOrderLine(prev, id));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelected([]);
|
||||
setNotes('');
|
||||
}
|
||||
|
||||
async function handleSendOrder() {
|
||||
if (!isValid || selected.length === 0) return;
|
||||
|
||||
setIsSending(true);
|
||||
|
||||
const orderPayload = buildTableOrderPayload(
|
||||
parsed.branch,
|
||||
parsed.tableNumber,
|
||||
parsed.tableIdentifier,
|
||||
selected,
|
||||
notes
|
||||
);
|
||||
|
||||
// For now we do NOT call any backend.
|
||||
// This log + comment makes future integration trivial.
|
||||
// TODO: replace the block below with real fetch when backend is ready.
|
||||
console.log('[TABLE ORDER] Prepared payload (will be POSTed to /api/table-orders):', orderPayload);
|
||||
|
||||
// Simulate a tiny delay for nice UX (feels like it is being sent)
|
||||
await new Promise((r) => setTimeout(r, 420));
|
||||
|
||||
playSuccessSound();
|
||||
setIsSending(false);
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
function resetForAnotherOrder() {
|
||||
setSent(false);
|
||||
setSelected([]);
|
||||
setNotes('');
|
||||
setSearch('');
|
||||
setVegOnly(false);
|
||||
}
|
||||
|
||||
// ====================== INVALID QR STATE ======================
|
||||
if (!isValid) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
<div className="flex min-h-[70vh] items-center justify-center px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-red-100/70">
|
||||
<X className="h-10 w-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-3">{to.invalidTitle}</h1>
|
||||
<p className="text-sm md:text-[15px] text-[#6B665F] mb-8">{to.invalidMessage}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[#101724] px-8 py-3 text-sm font-semibold text-white active:bg-black"
|
||||
>
|
||||
Go to homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ====================== CONFIRMATION STATE ======================
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
<div className="flex min-h-[70vh] items-center justify-center px-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="max-w-lg text-center"
|
||||
>
|
||||
<div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-[#B38B4D]/10">
|
||||
<CheckCircle className="h-14 w-14 text-[#B38B4D]" />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-sm tracking-[3px] text-[#B38B4D] font-medium">
|
||||
{branchLabel.toUpperCase()} • {to.tableLabel.toUpperCase()} {tableDisplay}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-4">{to.confirmationTitle}</h1>
|
||||
|
||||
<p className="text-sm md:text-[15px] text-[#6B665F] leading-relaxed mb-10">
|
||||
{to.confirmationMessage}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={resetForAnotherOrder}
|
||||
className="btn-primary px-10 py-4 rounded-2xl text-base font-semibold tracking-[0.5px] active:scale-[0.985]"
|
||||
>
|
||||
{to.orderAnother}
|
||||
</button>
|
||||
<a
|
||||
href="/menu"
|
||||
className="btn-outline px-10 py-4 rounded-2xl text-base font-semibold tracking-[0.5px]"
|
||||
>
|
||||
Browse full menu
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-xs text-[#8A8478]">
|
||||
{branchLabel} — {to.tableLabel} {tableDisplay}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ====================== MAIN ORDERING UI ======================
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
|
||||
{/* Table header bar */}
|
||||
<div className="sticky top-[116px] z-40 border-b border-[#EDE6D9] bg-[#F8F5F0]/95 backdrop-blur-xl">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-[#c99a2e]/30 bg-white p-1.5">
|
||||
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl tracking-[-1px] leading-none">{branchLabel}</div>
|
||||
<div className="text-[#B38B4D] text-sm font-medium tracking-[1px] mt-0.5">
|
||||
{to.tableLabel} {tableDisplay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:ml-auto flex items-center gap-2 text-xs uppercase tracking-[2px] text-[#8A8478]">
|
||||
<UtensilsCrossed className="h-3.5 w-3.5" />
|
||||
ORDER FROM TABLE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 pt-8 pb-24">
|
||||
{/* Intro */}
|
||||
<div className="mb-8">
|
||||
<div className="text-[#B38B4D] text-xs tracking-[3.5px] mb-2 font-medium">{to.tableLabel.toUpperCase()} {tableDisplay}</div>
|
||||
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-2 text-[#101724] break-words">{to.selectTitle}</h1>
|
||||
<p className="text-sm md:text-[15px] text-[#6B665F] max-w-xl leading-relaxed">{to.selectSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-12 gap-8">
|
||||
{/* DISH SELECTOR */}
|
||||
<div className="lg:col-span-7">
|
||||
{/* Filters */}
|
||||
<div className="mb-5 flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-4 top-3.5 h-4 w-4 text-[#B38B4D]" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={to.searchPlaceholder}
|
||||
className="w-full rounded-2xl border border-[#EDE6D9] bg-white py-3 pl-11 pr-4 text-sm placeholder:text-[#8A8478] focus:border-[#B38B4D] focus:ring-1 focus:ring-[#B38B4D]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setVegOnly(!vegOnly)}
|
||||
className={`px-5 py-3 rounded-2xl text-sm font-medium border transition active:scale-[0.985] whitespace-nowrap ${
|
||||
vegOnly
|
||||
? 'bg-[#0f5a4a] text-white border-[#0f5a4a]'
|
||||
: 'border-[#EDE6D9] bg-white text-[#101724] hover:border-[#c99a2e]'
|
||||
}`}
|
||||
>
|
||||
{vegOnly ? '✓ ' : ''}{to.showVegetarian}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dish grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AnimatePresence>
|
||||
{filteredDishes.length > 0 ? (
|
||||
filteredDishes.map((dish) => {
|
||||
const inOrder = selected.some((s) => s.id === dish.id);
|
||||
const poster = getMenuPosterSrc(dish, 'optimized');
|
||||
return (
|
||||
<motion.button
|
||||
key={dish.id}
|
||||
whileTap={{ scale: 0.985 }}
|
||||
onClick={() => addDish(dish)}
|
||||
className="group text-left flex gap-4 rounded-2xl border border-[#EDE6D9] bg-white p-3 active:border-[#B38B4D] hover:border-[#c99a2e]/50 transition overflow-hidden"
|
||||
>
|
||||
<div className="relative h-20 w-20 flex-shrink-0 overflow-hidden rounded-xl bg-[#F2EDE4]">
|
||||
<img
|
||||
src={poster}
|
||||
alt={dish.name}
|
||||
className="absolute inset-0 h-full w-full object-cover group-active:scale-105 transition"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.src = SITE_ASSETS.dishes.defaultPoster;
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/5 to-black/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col py-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium tracking-[-0.2px] text-[15px] leading-tight line-clamp-2 pr-1">
|
||||
{dish.name}
|
||||
</div>
|
||||
<div className="shrink-0 text-right font-semibold text-[#B38B4D] tabular-nums text-sm leading-tight">
|
||||
{dish.pricing === 'weight' ? (
|
||||
<>
|
||||
<span className="block">{dish.pricePerHalfKg} kr / ½ kg</span>
|
||||
<span className="block text-[10px] font-normal text-[#8A8478]">{dish.pricePerKg} {t.menu.perKg}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{dish.price}
|
||||
<span className="text-[10px] font-normal ml-px">kr</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dish.description && (
|
||||
<div className="mt-1 text-xs text-[#6B665F] line-clamp-2">
|
||||
{dish.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-[#B38B4D] group-active:text-[#8C6B3A]">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{inOrder ? 'Add another' : 'Add to order'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-full py-12 text-center text-[#6B665F]">
|
||||
No dishes match your search.
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ORDER SUMMARY (sticky on desktop) */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="font-semibold tracking-tight text-lg">{to.yourOrder}</div>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-[#B38B4D] hover:underline active:text-[#8C6B3A]"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="text-5xl mb-3 opacity-50">🍽️</div>
|
||||
<p className="text-[#6B665F] text-sm">{to.emptyOrder}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-5 max-h-[320px] overflow-auto pr-1">
|
||||
{selected.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3 rounded-2xl bg-[#F8F5F0] px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-[15px] tracking-[-0.2px]">{item.name}</div>
|
||||
<div className="text-xs text-[#6B665F] tabular-nums">
|
||||
{item.pricingMode === 'weight'
|
||||
? `${item.pricePerHalfKg ?? item.price} kr / ½ kg · ${formatLineQuantityLabel(item)}`
|
||||
: `${item.price} kr × ${item.quantity}`}
|
||||
{' '}= {calculateLineTotal(item)} kr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => updateQty(item.id, item.quantity - 1)}
|
||||
className="h-8 w-8 flex items-center justify-center rounded-full border border-[#EDE6D9] bg-white active:bg-white text-lg leading-none active:scale-95"
|
||||
aria-label="Decrease"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="min-w-12 text-center font-medium tabular-nums">
|
||||
{formatLineQuantityLabel(item)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateQty(item.id, item.quantity + 1)}
|
||||
className="h-8 w-8 flex items-center justify-center rounded-full border border-[#EDE6D9] bg-white active:bg-white text-lg leading-none active:scale-95"
|
||||
aria-label="Increase"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="ml-1.5 text-[#B38B4D] p-1 active:text-red-600"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs font-semibold tracking-widest text-[#6B665F] mb-1.5">
|
||||
{to.notesLabel}
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={to.notesPlaceholder}
|
||||
rows={2}
|
||||
className="w-full resize-y rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] p-4 text-sm placeholder:text-[#8A8478] focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total + Send */}
|
||||
<div className="mt-5 border-t border-[#EDE6D9] pt-5">
|
||||
<div className="flex items-baseline justify-between text-lg mb-4">
|
||||
<span className="text-[#6B665F]">{to.total}</span>
|
||||
<span className="font-semibold tabular-nums">{selectedTotal} kr</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendOrder}
|
||||
disabled={selected.length === 0 || isSending}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-[#B38B4D] via-[#c99a2e] to-[#B38B4D] py-4 text-base font-semibold tracking-[0.6px] text-white shadow-lg active:brightness-95 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSending ? 'Sending...' : to.sendOrder}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-center text-[11px] text-[#8A8478]">
|
||||
Staff will come to your table to confirm.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Small context line */}
|
||||
<div className="mt-3 text-center text-xs text-[#8A8478] tracking-wider">
|
||||
{branchLabel} • {to.tableLabel} {tableDisplay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Suspense } from 'react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import OrderFromTableClient from './OrderFromTableClient';
|
||||
|
||||
/**
|
||||
* Server wrapper for /orderfromtable
|
||||
*
|
||||
* We must wrap any component that uses useSearchParams() in a Suspense boundary
|
||||
* (Next.js 16 requirement for pages that read search params).
|
||||
*
|
||||
* This keeps the actual ordering UI fully client-side while satisfying the framework.
|
||||
*/
|
||||
export default function OrderFromTablePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
<div className="flex min-h-[50vh] items-center justify-center px-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-pulse rounded-full bg-[#B38B4D]/30" />
|
||||
<p className="text-sm tracking-widest text-[#8A8478]">Loading table...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OrderFromTableClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user