Skip to content

05 — Customer Digital Menu & Allergy Intelligence

Route root: /[locale]/tenants/[tenant]/table/* · Access: public, anonymous, QR-entry · Status: ✅ UI fully built on mock/localStorage data

🟢 বাংলা ভূমিকা: এই ডকুমেন্টে কাস্টমারের ডিজিটাল মেনু (/table) — QR এন্ট্রি, মেনু ব্রাউজিং, অ্যালার্জি ফিল্টার, কার্ট, চেকআউট, স্প্লিট-বিল, এবং গ্যামিফিকেশন — বিস্তারিতভাবে বর্ণনা করা হয়েছে।


1. Entry Flow

Components: qr-resolve-screen.tsx → qr-expired-screen.tsx → menu-experience.tsx

A diner scans the table's QR code, landing on /{locale}/tenants/{tenant}/table?table={tableId}&token={qrToken}. qr-resolve-screen.tsx briefly shows a branded loading state while useTableTokens validates the token param against the table's current active token:

  • Valid token (or no token present — legacy/bookmarked link) → resolves straight into menu-experience.tsx.
  • Stale token (table's QR was regenerated from the dashboard since this code was printed/screenshotted) → routes to qr-expired-screen.tsx, a dead-end screen instructing the diner to ask staff for a fresh QR — this is the anti-fraud/anti-leak mechanism described below.

1.1 When there is no phone to scan with

Component: table-picker.tsx · Route: /tables

The scan is the intended path and everything else is built around it, but it assumes a phone with a camera. A guest sitting at the table with a laptop — or with a flat battery, or a phone whose camera will not focus — has no way to tell the restaurant which table they are at, and therefore cannot order at all. /tables is that missing step: the restaurant's tables grouped by zone, one tap to pick one, and then the ordinary /table?table={tableId} menu.

This needed no new ordering code. /table already binds a session to a table, and the token check above already accepts a missing token — so what was missing was never the ordering, only a way to name the table.

Two decisions worth keeping:

  • Every table stays selectable, including the ones marked in use. "In use" means someone is seated there, and the guest reading this screen is usually that someone. Refusing the selection would lock out exactly the person the page exists for. Status is shown as information, not as a gate.
  • No token is minted for this path. The token proves a scan of a current print; a guest who chose from a list never had one, and generating one here would forge that proof. What the QR gives and this cannot is evidence the guest is physically at the table — anyone can pick any table here. For dine-in the risk is small (the food is carried to a table in the same room), but it is real, so the choice is confirmed once rather than acted on from a single stray tap. The header's table label links back here, so a wrong pick is one tap to correct.

🟢 বাংলা সারাংশ: কাস্টমার QR স্ক্যান করলে qr-resolve-screen.tsx প্রথমে টোকেন যাচাই করে — টোকেন ঠিক থাকলে সরাসরি মেনুতে চলে যায়, আর পুরনো/বাতিল টোকেন হলে qr-expired-screen.tsx দেখায় যেখানে স্টাফের কাছ থেকে নতুন QR চাইতে বলা হয়। যার ফোন নেই (ল্যাপটপ আছে, বা ক্যামেরা কাজ করছে না) তার জন্য /tables পেজ — রেস্টুরেন্টের সব টেবিলের তালিকা, নিজের টেবিলে ক্লিক করলেই একই মেনুতে পৌঁছে যায়। এখানে কোনো টোকেন তৈরি করা হয় না, কারণ টোকেনের কাজ হলো "সত্যিই স্ক্যান হয়েছে" প্রমাণ করা — যে স্ক্যানই করেনি তার জন্য টোকেন বানানো মানে সেই প্রমাণ জাল করা। তাই বেছে নেওয়া টেবিলটা একবার নিশ্চিত করতে বলা হয়।


2. Dynamic Per-Table QR Architecture

Hook: use-table-tokens.ts

Each RestaurantTable carries a deterministic mock qrToken (the one "printed" on its physical QR). The dashboard's Tables module (see 04 §5) can regenerate this token at any time — e.g. a QR sticker was stolen/photographed, or the table is being reused for a new type of campaign. Regeneration is simulated client-side via a tenant-scoped localStorage override map (ros-table-tokens:{tenantSlug}):

function getActiveToken(table): string      // overrides[table.id] ?? table.qrToken
function regenerateToken(table): string     // writes a new random token to the override map
function isTokenValid(table, token): boolean // no token param = always valid (legacy link); else must match active token

Target production behavior: the same external contract (getActiveToken/regenerateToken/isTokenValid) will be backed by a table_qr_tokens table with a rotated_at timestamp instead of localStorage — no UI changes required, only the hook's internals swap.

🟢 বাংলা সারাংশ: প্রতিটি টেবিলের নিজস্ব QR টোকেন থাকে যা ড্যাশবোর্ড থেকে যেকোনো সময় regenerate করা যায় (যেমন QR চুরি/ফাঁস হলে)। এখন এটা localStorage দিয়ে সিমুলেট করা হচ্ছে, ভবিষ্যতে ডাটাবেজ টেবিল দিয়ে প্রতিস্থাপিত হবে — বাইরের API একই থাকবে।


3. Menu Browsing Experience

Components: restaurant-hero.tsx, menu-header.tsx, category-rail.tsx, menu-grid.tsx, item-detail-modal.tsx

  • Hero — restaurant cover image, name, rating, open/closed status, table label.
  • Sticky header — search input (center-aligned, per latest design pass), inline category filter chips, cart summary shortcut.
  • Category rail — horizontally swipeable category chips (icon + name), scroll-spy synced with the grid below.
  • Menu grid — responsive card grid (MenuItem), each card shows image, name, price, diet-tag badges, spice-level indicator, rating, and a quick-add button; popular/new/chef's-special items get a ribbon badge.
  • Item detail modal — full description, variant picker (size), addon checklist, quantity stepper, allergen list, "frequently paired with" upsell rail (lib/upsell.ts), Add-to-cart CTA with live price recompute (base + variantDelta + Σaddons) × quantity.

All of the above is fully responsive down to 375px width; touch targets, swipe gestures on the category rail, and the sticky header's collapse behavior were the focus of the latest mobile/tablet responsiveness pass across restaurant-hero.tsx, menu-header.tsx, and the grid layout.

In Bengali, all of it. A guest reading the menu in Bengali used to meet Bengali buttons around English category tabs, dish descriptions, package names and tagline, because only a dish's name had a Bengali field. Every piece of owner text now has an optional Bengali twin — MenuCategory.nameBn, MenuItem.descriptionBn, MealPackage.nameBn/descriptionBn, Tenant.cuisineBn, branding.taglineBn — resolved once by src/lib/localize.ts at the top of MenuExperience. Where the owner has not written one, the English stays rather than a blank. The demo restaurant (Swadh Ghar) ships with its categories, descriptions, packages and tagline translated; dish names are left to the owner, which is also how a real Bangladeshi menu tends to read.

🟢 বাংলা সারাংশ: মেনু ব্রাউজিং এক্সপেরিয়েন্সে হিরো সেকশন (রেস্টুরেন্টের ছবি, নাম, রেটিং), স্টিকি হেডার (মাঝে সার্চ বক্স, ইনলাইন ক্যাটাগরি ফিল্টার), swipeable ক্যাটাগরি রেইল, এবং রেসপনসিভ মেনু গ্রিড আছে। আইটেম ডিটেইল মোডালে ভ্যারিয়েন্ট/অ্যাডঅন বাছাই, পরিমাণ, এবং লাইভ প্রাইস ক্যালকুলেশন দেখা যায়। সব কিছু ৩৭৫px পর্যন্ত পুরোপুরি রেসপনসিভ।


4. Allergy & Dietary Intelligence Engine

Hook: use-allergy-filter.ts · UI: allergy-filter-sheet.tsx

interface AllergyFilterState {
  activeDiets: DietTag[];          // veg | vegan | halal | gluten-free | dairy-free | nut-free
  excludedAllergens: string[];     // free-text allergens sourced from each MenuItem.allergens[]
  maxSpiceLevel: number;           // 0-3, default 3 (no cap)
  matches(item): boolean;          // pure predicate — AND across diet tags, NOT-any across allergens, <= spice cap
  isActive: boolean;               // true if any filter deviates from the default/neutral state
}

Matching logic (matches, pure & memoized):

  1. If any diet tags are active, the item must satisfy all of them (item.dietTags superset of activeDiets) — e.g. selecting both "vegan" and "gluten-free" only shows items tagged with both.
  2. If any allergens are excluded, the item is hidden if it contains any of them (item.allergens ∩ excludedAllergens ≠ ∅).
  3. If item.spiceLevel > maxSpiceLevel, the item is hidden.

The filter sheet applies matches client-side over the full mock menu array, so results update instantly with no network round-trip — this same predicate function is the one piece of business logic that must be ported byte-for-byte to a server-side (or edge) query once menu data lives in Postgres, to guarantee zero behavior drift.

🟢 বাংলা সারাংশ: অ্যালার্জি ফিল্টার ইঞ্জিন তিনটি শর্ত মিলিয়ে মেনু আইটেম ফিল্টার করে — (১) নির্বাচিত সবগুলো ডায়েট ট্যাগ থাকতে হবে, (২) বাদ দেওয়া অ্যালার্জেনের একটাও থাকা যাবে না, (৩) ঝাল লেভেল নির্ধারিত সীমার মধ্যে থাকতে হবে। এই পুরো লজিকটা ক্লায়েন্ট-সাইডে চলে বলে সাথে সাথে রেজাল্ট দেখায় — ভবিষ্যতে ডাটাবেজ কোয়েরিতে রূপান্তরের সময় এই একই লজিক হুবহু বজায় রাখতে হবে।


5. Cart, Checkout & Split-Bill

Tipping. None / 5 / 10 / 15% or a typed amount, above the itemised totals. Three rules are in calculateOrderTotals rather than the component, because they are the kind of thing a server has to agree with later: a tip is a percentage of the food, not of the tax and service fee; a typed amount beats a preset; and a discount that empties the bill does not touch the tip — it is the guest's money for the staff, not the restaurant's to give away. No tip is selected by default; nobody is opted in.

🟢 বাংলা সারাংশ: চেকআউটে টিপস — নেই / ৫ / ১০ / ১৫% বা নিজের অঙ্ক। তিনটা নিয়ম কম্পোনেন্টে না রেখে calculateOrderTotals-এ রাখা হয়েছে, কারণ পরে সার্ভারকেও ঠিক এই হিসাবই মানতে হবে: টিপস খাবারের দামের উপর (ট্যাক্স বা সার্ভিস ফি-র উপর না), নিজের লেখা অঙ্ক প্রিসেটের উপরে, আর ডিসকাউন্টে পুরো বিল শূন্য হলেও টিপস কাটা যায় না — ওটা স্টাফের টাকা। ডিফল্টে কোনো টিপস ধরা থাকে না।

Provider: providers/cart-provider.tsx · Component: cart-sheet.tsx, floating-cart-button.tsx, split-bill-sheet.tsx

  • Cart provider — React context holding CartLineItem[] (menu item + chosen variant + chosen addons + quantity + notes), with a lineTotal(item) helper ((unitPrice + variantDelta + Σaddons) × quantity) shared by the cart sheet, split-bill sheet, and checkout summary so totals never drift between screens.
  • Floating cart button — persistent bottom-anchored pill showing item count + running total, opens the cart sheet.
  • Cart sheet — line-item list with quantity steppers, promo code entry (validated against usePromoStore), tip selector, and checkout CTA.
  • Split-bill sheet — post-order (or pre-payment) bill splitting: equal split (bill ÷ N guests) or itemized split (each guest claims specific line items via a tab UI), with a running "who owes what" summary and a checkmark per settled guest.
  • Guest name prompt (guest-name-prompt.tsx) — lightweight first-visit modal capturing a display name for the table session (used to label items/split-bill claims, no auth).

🟢 বাংলা সারাংশ: কার্ট প্রোভাইডার একটা React context যা সব কার্ট আইটেম (ভ্যারিয়েন্ট, অ্যাডঅনসহ) ধরে রাখে এবং একটা কমন lineTotal হিসাব ফাংশন শেয়ার করে যাতে সব স্ক্রিনে টোটাল মিলে যায়। স্প্লিট-বিল শিটে সমান ভাগ বা আইটেম-ভিত্তিক ভাগ — দুই পদ্ধতিতেই বিল ভাগ করা যায়, প্রতি গেস্টের জন্য কতটুকু বাকি তা ট্র্যাক করে।


6. Table-Side Utility Panels

  • table-orders-panel.tsx — shows the table's own order history for the current session (all orders placed from this table, live status via use-live-orders.ts), plus a "Call waiter" / "Request bill" action wired to use-table-calls.ts (surfaces instantly in the dashboard/waiter app).
  • reservation-widget.tsx — lets a walk-in browsing the menu also book a future reservation without leaving the page.

🟢 বাংলা সারাংশ: টেবিল অর্ডার প্যানেলে সেই টেবিল থেকে করা সব অর্ডারের লাইভ স্ট্যাটাস দেখা যায়, এবং "ওয়েটার ডাকো"/"বিল চাই" বাটন আছে যা সাথে সাথে ড্যাশবোর্ড ও ওয়েটার অ্যাপে দেখা যায়।


7. Gamification & Retention

The rating now reaches the restaurant. When an order is served or paid for, the orders panel asks for five stars, a few tags and an optional line. Submitting writes a Review to ros-reviews:{tenant} keyed by order id, so a guest is asked once per meal and a reload does not ask again; the owner's CRM board merges those with the seeded ones. Tags are stored in the language the guest read them in — a review is recorded history, like an order's timeline (CLAUDE.md rule 3). Ratings are deliberately not published as structured data while they are demo seeds (docs/07 §1).

🟢 বাংলা সারাংশ: খাবার সার্ভ হওয়ার বা বিল দেওয়ার পরে অতিথিকে রেটিং জিজ্ঞাসা করা হয়, আর সেটা এখন সত্যিই জমা হয় — অর্ডার অনুযায়ী একবারই, রিলোড করলেও আবার জিজ্ঞাসা করে না, আর মালিক সেটা CRM বোর্ডে দেখতে পান। ট্যাগ যে ভাষায় অতিথি পড়েছেন সেই ভাষাতেই জমা থাকে, কারণ রিভিউ একটা ঘটে যাওয়া রেকর্ড।

  • spin-wheel-reward.tsx — one-time-per-session spin wheel offering a randomized small reward (free item, % discount, loyalty points) to first-time table visitors, driving engagement and repeat-order incentive.
  • use-abandoned-cart.ts — a pure client-side idle timer: if the cart has items but no checkout activity for a threshold period, a gentle "still there? complete your order" nudge fires; resets automatically once the cart empties (checkout or clear), so the same device can be re-prompted on a future visit. (Threshold is intentionally shortened in mock/demo builds vs. a real 5-10 minute production value.)
  • Promo code redemption at checkout ties into the dashboard's Promos module — the same code that appears on a /promo/[promoId] marketing page (see 04 §12) can be typed directly into the cart sheet, so the promo loop works whether the diner arrives via the promo landing page's "Order now" button or types the code manually while already browsing /table.

🟢 বাংলা সারাংশ: স্পিন-হুইল প্রথমবার আসা কাস্টমারকে একটা র‍্যান্ডম রিওয়ার্ড দেয় (ফ্রি আইটেম/ছাড়/লয়্যালটি পয়েন্ট)। অ্যাবানডন্ড-কার্ট টাইমার কার্টে আইটেম রেখে চেকআউট না করলে একটা নরম রিমাইন্ডার দেখায়। প্রোমো কোড /promo/[promoId] ল্যান্ডিং পেজ থেকে আসুক বা সরাসরি টাইপ করা হোক — দুই ক্ষেত্রেই একইভাবে কাজ করে।


8. Responsiveness Contract

Per the project's explicit design mandate, /table (and /promo) must feel like a native mobile app:

  • Bottom-anchored floating cart button and sheet-style modals (slide-up from bottom) rather than desktop-style centered dialogs on small viewports.
  • Swipeable category rail and image carousels use momentum/touch-friendly gesture handling (Framer Motion drag constraints), not just CSS overflow-x-auto.
  • All interactive targets meet a minimum 44×44px touch target size.
  • Verified breakpoints: 375px (small phone), 768px (tablet portrait), 1024px (tablet landscape) — zero horizontal scroll, zero clipped text, sticky header collapses gracefully at each.

🟢 বাংলা সারাংশ: /table এবং /promo পেজ যেন নেটিভ মোবাইল অ্যাপের মতো লাগে তার জন্য বটম-শিট মোডাল, সোয়াইপেবল ক্যারোজেল, এবং কমপক্ষে ৪৪×৪৪px টাচ টার্গেট নিশ্চিত করা হয়েছে। ৩৭৫px, ৭৬৮px, এবং ১০২৪px — এই তিনটি ব্রেকপয়েন্টে কোনো হরাইজন্টাল স্ক্রল বা কাটা টেক্সট নেই তা যাচাই করা হয়েছে।