Replace entire repo content with shahikitchen-v1-prodcution.zip (v1.1 production)

This commit is contained in:
root
2026-06-25 13:44:21 +00:00
parent 284dcfa120
commit 6cd5aaa048
850 changed files with 15616 additions and 26143 deletions
+454
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}