455 lines
20 KiB
TypeScript
455 lines
20 KiB
TypeScript
'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>
|
||
);
|
||
}
|