Files
shahikitchen-prod/app/orderfromtable/OrderFromTableClient.tsx
T

497 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { 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 {
getMenuItemDescription,
getMenuItemName,
localizeOrderLineName,
} from '@/application/i18n/menu-localization';
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(() => {
const query = search.toLowerCase().trim();
return allMenuItems.filter((item) => {
if (vegOnly && !item.isVegetarian) return false;
if (!query) return true;
const localizedName = getMenuItemName(language, item).toLowerCase();
const localizedDescription = getMenuItemDescription(language, t, item).toLowerCase();
return (
localizedName.includes(query) ||
localizedDescription.includes(query) ||
item.name.toLowerCase().includes(query)
);
});
}, [allMenuItems, search, vegOnly, language, t]);
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 ${selected.length > 0 ? 'pb-36 lg:pb-24' : '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 — below summary on mobile for faster checkout */}
<div className="order-2 lg:order-1 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 start-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 ps-11 pe-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');
const dishName = getMenuItemName(language, dish);
const dishDescription = getMenuItemDescription(language, t, dish);
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={dishName}
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">
{dishName}
</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>
{dishDescription && (
<div className="mt-1 text-xs text-[#6B665F] line-clamp-2">
{dishDescription}
</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 (first on mobile, sticky on desktop) */}
<div className="order-1 lg:order-2 lg:col-span-5">
<div className="lg:sticky lg:top-[calc(var(--header-height)+5.5rem)]">
<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]">{localizeOrderLineName(language, item)}</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-11 w-11 flex items-center justify-center rounded-full border border-[#EDE6D9] bg-white active:bg-white text-lg leading-none active:scale-95 touch-manipulation"
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-11 w-11 flex items-center justify-center rounded-full border border-[#EDE6D9] bg-white active:bg-white text-lg leading-none active:scale-95 touch-manipulation"
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 — desktop only; mobile uses sticky bottom bar */}
<div className="hidden lg:block 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>
{/* Mobile: show total inline in summary card */}
<div className="lg:hidden mt-5 border-t border-[#EDE6D9] pt-4">
<div className="flex items-baseline justify-between text-lg">
<span className="text-[#6B665F]">{to.total}</span>
<span className="font-semibold tabular-nums">{selectedTotal} kr</span>
</div>
</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>
{/* Mobile sticky order bar */}
{selected.length > 0 && (
<div className="lg:hidden fixed inset-x-0 bottom-0 z-50 border-t border-[#EDE6D9] bg-white/95 backdrop-blur-xl shadow-[0_-4px_24px_-4px_rgba(16,23,36,0.12)] px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<div className="mx-auto flex max-w-6xl items-center gap-4">
<div className="min-w-0 flex-1">
<div className="text-xs text-[#6B665F]">
{selected.length} {selected.length === 1 ? 'item' : 'items'}
</div>
<div className="font-semibold tabular-nums text-[#101724]">{selectedTotal} kr</div>
</div>
<button
onClick={handleSendOrder}
disabled={isSending}
className="shrink-0 rounded-2xl bg-gradient-to-r from-[#B38B4D] via-[#c99a2e] to-[#B38B4D] px-6 py-3.5 text-sm font-semibold tracking-[0.5px] text-white shadow-lg active:brightness-95 disabled:opacity-60 touch-manipulation"
>
{isSending ? 'Sending...' : to.sendOrder}
</button>
</div>
</div>
)}
<Footer />
</div>
);
}