Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · zod

Restaurant Ordering and Kitchen Display System Demo

Scan a table code, build an order with sizes, extras and kitchen notes, and place it. Then switch to the kitchen display and move the ticket from New to Served while your order tracker follows along. Open the dev panel to see the API calls and the SQL each step would run.

Read the case study →

Interactive demo

samiullah.tech/lab/restaurant-ordering
Loading menu…

## How it works

The menu, option groups and pricing rules live in db.ts. One function, unitPrice, prices a line from the item and its chosen options. The cart calls it for display; the API calls it again to build the real total and ignores any price the browser sent. Tick “send tampered prices” in the dev panel to see that.

POST /api/orders validates the body (dine-in needs a table, pickup needs a name, option groups respect their min and max), re-prices every line, and writes the order, its lines and its first status event in one transaction. An Idempotency-Key header means a double-tapped “Place order” returns the same order instead of creating two.

The kitchen moves an order one step at a time with PATCH /api/orders/:id/status. The update includes the version the screen last saw, so two screens cannot silently overwrite each other: the slower one gets 409. After each commit a notification goes to the kitchen screens and to the guest's tracker.

## View source

The code running in the window above, exactly as deployed.

app/lab/restaurant-ordering/api.ts265 lines
/**
 * The ordering API, written the way the NestJS service would be: a validation pipe (zod here,
 * class-validator DTOs in Nest), a roles guard, a service method per endpoint and one transaction
 * per write. Every handler is pure: it takes the current Db and returns the response, the next Db
 * and the SQL the PostgreSQL-backed service would run, which the demo shows in its dev panel.
 */
import { z } from "zod";
import {
  MENU,
  CATEGORIES,
  STATUSES,
  menuItem,
  totals,
  unitPrice,
  validateOptions,
  type Db,
  type Order,
  type OrderLine,
  type OrderStatus,
} from "./db";

export type Role = "guest" | "kitchen";

export type ApiResponse<T = unknown> = {
  method: "GET" | "POST" | "PATCH";
  path: string;
  status: number;
  body: T;
  sql: string[];
  db: Db;
};

type NestError = { statusCode: number; error: string; message: string | string[] };

const ERROR_NAMES: Record<number, string> = {
  400: "Bad Request",
  403: "Forbidden",
  404: "Not Found",
  409: "Conflict",
  422: "Unprocessable Entity",
};

const fail = (status: number, message: string | string[]): NestError => ({
  statusCode: status,
  error: ERROR_NAMES[status] ?? "Error",
  message,
});

/** SQL literal for the log only. The real service uses parameters ($1, $2), never string building. */
const lit = (v: unknown): string =>
  v === null ? "NULL" : typeof v === "number" ? String(v) : `'${String(v).replace(/'/g, "''")}'`;

/* ------------------------------------------------------------------ */
/* DTOs (the ValidationPipe)                                           */
/* ------------------------------------------------------------------ */

export const PlaceOrderDto = z
  .object({
    mode: z.enum(["dine-in", "pickup"]),
    table: z.string().regex(/^T[1-8]$/, "table must be T1–T8").nullable(),
    customer: z.string().trim().max(40, "customer must be at most 40 characters"),
    lines: z
      .array(
        z.object({
          itemId: z.string().min(1),
          qty: z.number().int().min(1, "qty must be at least 1").max(20, "qty must be at most 20"),
          optionIds: z.array(z.string()).max(10),
          note: z.string().max(140, "note must be at most 140 characters").default(""),
          /** Sent by the browser for display only. The service never reads it. */
          unitPrice: z.number().optional(),
        }),
      )
      .min(1, "the cart is empty")
      .max(30),
  })
  .superRefine((d, ctx) => {
    if (d.mode === "dine-in" && !d.table) ctx.addIssue({ code: "custom", path: ["table"], message: "dine-in orders need a table (scan the QR code on your table)" });
    if (d.mode === "pickup" && d.customer.length < 2) ctx.addIssue({ code: "custom", path: ["customer"], message: "pickup orders need a name for the counter" });
  });

export type PlaceOrderInput = z.input<typeof PlaceOrderDto>;

const zodMessages = (e: z.ZodError) => e.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`);

/* ------------------------------------------------------------------ */
/* GET /api/menu                                                       */
/* ------------------------------------------------------------------ */

export function getMenu(db: Db): ApiResponse {
  return {
    method: "GET",
    path: "/api/menu",
    status: 200,
    db,
    body: {
      categories: CATEGORIES.length,
      items: MENU.length,
      soldOut: db.soldOut,
    },
    sql: [
      "SELECT c.id, c.name, i.id, i.name, i.price, i.diet, i.sold_out FROM categories c JOIN menu_items i ON i.category_id = c.id ORDER BY c.position, i.position",
      "SELECT g.item_id, g.id, g.name, g.min_select, g.max_select, o.id, o.name, o.price FROM option_groups g JOIN options o ON o.group_id = g.id",
      "-- Cache-Control: s-maxage=60, stale-while-revalidate (menu changes rarely)",
    ],
  };
}

/* ------------------------------------------------------------------ */
/* POST /api/orders                                                    */
/* ------------------------------------------------------------------ */

export function placeOrder(db: Db, raw: unknown, idempotencyKey: string, now: number): ApiResponse<Order | NestError> {
  const base = { method: "POST" as const, path: "/api/orders", db };

  // 1. ValidationPipe: reject malformed bodies before any SQL runs.
  const parsed = PlaceOrderDto.safeParse(raw);
  if (!parsed.success) return { ...base, status: 400, body: fail(400, zodMessages(parsed.error)), sql: ["-- rejected by ValidationPipe, no query sent"] };
  const dto = parsed.data;

  const sql = ["BEGIN", `SELECT order_id FROM idempotency_keys WHERE key = ${lit(idempotencyKey)}`];

  // 2. Idempotency: the same key returns the original order instead of creating a second one.
  const existing = db.idempotency[idempotencyKey];
  if (existing !== undefined) {
    const order = db.orders.find((o) => o.id === existing);
    if (order) return { ...base, status: 200, body: order, sql: [...sql, `-- key already used for order ${existing}: replay the stored response`, "COMMIT"] };
  }

  const ids = [...new Set(dto.lines.map((l) => l.itemId))];
  sql.push(`SELECT id, name, price, sold_out FROM menu_items WHERE id = ANY(ARRAY[${ids.map(lit).join(", ")}]) FOR SHARE`);
  sql.push(`SELECT o.id, o.price, g.id AS group_id, g.min_select, g.max_select FROM options o JOIN option_groups g ON g.id = o.group_id WHERE g.item_id = ANY(ARRAY[${ids.map(lit).join(", ")}])`);

  // 3. Service: re-price every line from the database. Prices from the browser are ignored.
  const lines: OrderLine[] = [];
  const errors: string[] = [];
  for (const [i, l] of dto.lines.entries()) {
    const item = menuItem(l.itemId);
    if (!item) {
      errors.push(`lines.${i}: unknown item "${l.itemId}"`);
      continue;
    }
    if (db.soldOut.includes(item.id)) {
      return { ...base, status: 409, body: fail(409, `${item.name} sold out a moment ago. Remove it and try again.`), sql: [...sql, "ROLLBACK"] };
    }
    const optErr = validateOptions(item, l.optionIds);
    if (optErr) {
      errors.push(`lines.${i}: ${optErr}`);
      continue;
    }
    const unit = unitPrice(item, l.optionIds);
    lines.push({
      itemId: item.id,
      name: item.name,
      qty: l.qty,
      unitPrice: unit,
      options: item.groups.flatMap((g) =>
        g.options.filter((o) => l.optionIds.includes(o.id)).map((o) => ({ groupName: g.name, name: o.name, price: o.price })),
      ),
      note: l.note.trim(),
      lineTotal: unit * l.qty,
    });
  }
  if (errors.length) return { ...base, status: 422, body: fail(422, errors), sql: [...sql, "ROLLBACK"] };

  const tampered = dto.lines.some((l, i) => l.unitPrice !== undefined && l.unitPrice !== lines[i]?.unitPrice);
  const t = totals(lines.reduce((s, l) => s + l.lineTotal, 0), dto.mode);
  const id = db.nextOrderId;
  const order: Order = {
    id,
    number: `#${id}`,
    mode: dto.mode,
    table: dto.mode === "dine-in" ? dto.table : null,
    customer: dto.mode === "dine-in" ? `Table ${dto.table}` : dto.customer,
    lines,
    ...t,
    status: "new",
    version: 1,
    placedAt: now,
    events: [{ status: "new", at: now }],
  };

  if (tampered) sql.push("-- client-sent unitPrice differs from the database price: ignored, total computed server-side");
  sql.push(
    `INSERT INTO orders (mode, table_code, customer, subtotal, service, tax, total, status) VALUES (${[order.mode, order.table, order.customer, t.subtotal, t.service, t.tax, t.total, "new"].map(lit).join(", ")}) RETURNING id, version  -- ${id}`,
    `INSERT INTO order_lines (order_id, item_id, qty, unit_price, note) VALUES ${lines.map((l) => `(${[id, l.itemId, l.qty, l.unitPrice, l.note].map(lit).join(", ")})`).join(", ")}`,
    `INSERT INTO order_events (order_id, status) VALUES (${id}, 'new')`,
    `INSERT INTO idempotency_keys (key, order_id) VALUES (${lit(idempotencyKey)}, ${id})`,
    "COMMIT",
    `SELECT pg_notify('kitchen', '{"event":"order.created","id":${id}}')  -- gateway pushes it to the kitchen screen over WebSocket`,
  );

  return {
    ...base,
    status: 201,
    body: order,
    sql,
    db: {
      ...db,
      orders: [...db.orders, order],
      nextOrderId: id + 1,
      idempotency: { ...db.idempotency, [idempotencyKey]: id },
    },
  };
}

/* ------------------------------------------------------------------ */
/* PATCH /api/orders/:id/status   (@Roles('kitchen'))                  */
/* ------------------------------------------------------------------ */

/**
 * Moves an order one step along new -> preparing -> ready -> served (or one step back to undo).
 * Optimistic concurrency: the client sends the version it rendered; if another screen moved the
 * order first, the UPDATE matches no row and the API answers 409 instead of silently overwriting.
 */
export function updateStatus(db: Db, id: number, to: OrderStatus, expectedVersion: number, role: Role, now: number): ApiResponse<Order | NestError> {
  const base = { method: "PATCH" as const, path: `/api/orders/${id}/status`, db };
  if (role !== "kitchen") return { ...base, status: 403, body: fail(403, "Only kitchen staff can change an order's status."), sql: ["-- blocked by RolesGuard, no query sent"] };

  const order = db.orders.find((o) => o.id === id);
  if (!order) return { ...base, status: 404, body: fail(404, `Order ${id} not found.`), sql: [`SELECT id FROM orders WHERE id = ${id}  -- 0 rows`] };

  const from = STATUSES.indexOf(order.status);
  const target = STATUSES.indexOf(to);
  if (Math.abs(target - from) !== 1) {
    return { ...base, status: 422, body: fail(422, `Cannot move order ${order.number} from ${order.status} to ${to}.`), sql: ["-- rejected by OrderStateMachine, no query sent"] };
  }

  const sql = [
    "BEGIN",
    `UPDATE orders SET status = ${lit(to)}, version = version + 1, updated_at = now() WHERE id = ${id} AND version = ${expectedVersion} RETURNING version`,
  ];
  if (order.version !== expectedVersion) {
    return { ...base, status: 409, body: fail(409, `Order ${order.number} was updated on another screen. Refresh and try again.`), sql: [...sql, "-- 0 rows updated (stale version)", "ROLLBACK"] };
  }

  const next: Order = { ...order, status: to, version: order.version + 1, events: [...order.events, { status: to, at: now }] };
  sql.push(
    `INSERT INTO order_events (order_id, status) VALUES (${id}, ${lit(to)})`,
    "COMMIT",
    `SELECT pg_notify('order_${id}', '{"event":"order.updated","status":"${to}"}')  -- customer tracker and other kitchen screens update`,
  );
  return { ...base, status: 200, body: next, sql, db: { ...db, orders: db.orders.map((o) => (o.id === id ? next : o)) } };
}

/* ------------------------------------------------------------------ */
/* PATCH /api/menu-items/:id   (@Roles('kitchen'))                     */
/* ------------------------------------------------------------------ */

export function setSoldOut(db: Db, itemId: string, soldOut: boolean, role: Role): ApiResponse<{ id: string; soldOut: boolean } | NestError> {
  const base = { method: "PATCH" as const, path: `/api/menu-items/${itemId}`, db };
  if (role !== "kitchen") return { ...base, status: 403, body: fail(403, "Only kitchen staff can change availability."), sql: ["-- blocked by RolesGuard, no query sent"] };
  if (!menuItem(itemId)) return { ...base, status: 404, body: fail(404, `Menu item ${itemId} not found.`), sql: [] };
  const list = db.soldOut.filter((x) => x !== itemId);
  return {
    ...base,
    status: 200,
    body: { id: itemId, soldOut },
    sql: [
      `UPDATE menu_items SET sold_out = ${soldOut} WHERE id = ${lit(itemId)}`,
      "-- revalidateTag('menu') so the cached menu page drops the item",
    ],
    db: { ...db, soldOut: soldOut ? [...list, itemId] : list },
  };
}
app/lab/restaurant-ordering/db.ts268 lines
/**
 * Data layer of the restaurant ordering demo: table types, the seeded menu and the pricing rules.
 * In the production design these are PostgreSQL tables (menu_items, option_groups, options, orders,
 * order_lines, order_events) owned by a NestJS service. Here they are plain objects in the browser.
 *
 * Money is always integer cents. Pricing lives in one function that both the cart (for display)
 * and the API (for the real total) call, so the two can never disagree about the rules.
 */

export type Diet = "veg" | "spicy" | "nuts" | "gf";

export type Option = { id: string; name: string; price: number };

/** min 1 = required. max 1 renders as radio buttons, max > 1 as checkboxes. */
export type OptionGroup = { id: string; name: string; min: number; max: number; options: Option[] };

export type Category = { id: string; name: string };

export type MenuItem = {
  id: string;
  categoryId: string;
  name: string;
  description: string;
  price: number;
  diet: Diet[];
  groups: OptionGroup[];
};

export type OrderStatus = "new" | "preparing" | "ready" | "served";
export const STATUSES: OrderStatus[] = ["new", "preparing", "ready", "served"];
export const STATUS_LABEL: Record<OrderStatus, string> = {
  new: "New",
  preparing: "Preparing",
  ready: "Ready",
  served: "Served",
};

export type OrderMode = "dine-in" | "pickup";

export type OrderLine = {
  itemId: string;
  name: string;
  qty: number;
  unitPrice: number;
  options: { groupName: string; name: string; price: number }[];
  note: string;
  lineTotal: number;
};

export type Order = {
  id: number;
  number: string;
  mode: OrderMode;
  table: string | null;
  customer: string;
  lines: OrderLine[];
  subtotal: number;
  service: number;
  tax: number;
  total: number;
  status: OrderStatus;
  version: number;
  placedAt: number;
  events: { status: OrderStatus; at: number }[];
};

/** The mutable part of the database. The menu itself is static fixture data below. */
export type Db = {
  orders: Order[];
  nextOrderId: number;
  /** Item ids the kitchen has marked sold out today ("86'd"). */
  soldOut: string[];
  /** Idempotency-Key header -> order id, so a double-tapped "Place order" creates one order. */
  idempotency: Record<string, number>;
};

export const RESTAURANT = { name: "Saffron Lane Kitchen", currency: "USD", taxRate: 0.08, serviceRate: 0.1 };
export const TABLES = ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8"];

/* ------------------------------------------------------------------ */
/* Menu fixtures                                                       */
/* ------------------------------------------------------------------ */

const SIZE: OptionGroup = {
  id: "size",
  name: "Size",
  min: 1,
  max: 1,
  options: [
    { id: "size-reg", name: "Regular", price: 0 },
    { id: "size-lg", name: "Large", price: 250 },
  ],
};
const SPICE: OptionGroup = {
  id: "spice",
  name: "Spice level",
  min: 1,
  max: 1,
  options: [
    { id: "spice-mild", name: "Mild", price: 0 },
    { id: "spice-med", name: "Medium", price: 0 },
    { id: "spice-hot", name: "Hot", price: 0 },
  ],
};
const EXTRAS: OptionGroup = {
  id: "extras",
  name: "Extras",
  min: 0,
  max: 3,
  options: [
    { id: "x-cheese", name: "Extra cheese", price: 150 },
    { id: "x-egg", name: "Fried egg", price: 120 },
    { id: "x-jalapeno", name: "Jalapeños", price: 80 },
    { id: "x-avocado", name: "Avocado", price: 200 },
  ],
};
const DIPS: OptionGroup = {
  id: "dips",
  name: "Dips",
  min: 0,
  max: 2,
  options: [
    { id: "dip-mint", name: "Mint yoghurt", price: 60 },
    { id: "dip-garlic", name: "Garlic mayo", price: 60 },
    { id: "dip-chilli", name: "Chilli sauce", price: 60 },
  ],
};
const BREAD: OptionGroup = {
  id: "bread",
  name: "Choose a bread",
  min: 1,
  max: 1,
  options: [
    { id: "bread-plain", name: "Plain naan", price: 0 },
    { id: "bread-garlic", name: "Garlic naan", price: 90 },
    { id: "bread-roti", name: "Whole-wheat roti", price: 0 },
  ],
};
const MILK: OptionGroup = {
  id: "milk",
  name: "Milk",
  min: 1,
  max: 1,
  options: [
    { id: "milk-whole", name: "Whole milk", price: 0 },
    { id: "milk-oat", name: "Oat milk", price: 70 },
  ],
};

export const CATEGORIES: Category[] = [
  { id: "starters", name: "Starters" },
  { id: "grill", name: "Grill" },
  { id: "bowls", name: "Bowls & curries" },
  { id: "burgers", name: "Burgers" },
  { id: "sides", name: "Sides" },
  { id: "drinks", name: "Drinks" },
  { id: "desserts", name: "Desserts" },
];

export const MENU: MenuItem[] = [
  { id: "samosa", categoryId: "starters", name: "Vegetable samosas (3)", description: "Crisp pastry, spiced potato and peas.", price: 550, diet: ["veg"], groups: [DIPS] },
  { id: "wings", categoryId: "starters", name: "Chilli garlic wings", description: "Six wings, sticky glaze, spring onion.", price: 890, diet: ["spicy", "gf"], groups: [SPICE, DIPS] },
  { id: "hummus", categoryId: "starters", name: "Hummus & warm flatbread", description: "Chickpea purée, olive oil, sumac.", price: 650, diet: ["veg"], groups: [] },
  { id: "tikka", categoryId: "grill", name: "Chicken tikka platter", description: "Charcoal-grilled thigh, salad, choice of bread.", price: 1490, diet: ["gf"], groups: [SPICE, BREAD, DIPS] },
  { id: "seekh", categoryId: "grill", name: "Lamb seekh kebab", description: "Two skewers, onion salad, lemon.", price: 1590, diet: ["spicy"], groups: [SPICE, BREAD] },
  { id: "paneer", categoryId: "grill", name: "Paneer shashlik", description: "Paneer, peppers and onion off the grill.", price: 1290, diet: ["veg", "gf"], groups: [SPICE, BREAD] },
  { id: "karahi", categoryId: "bowls", name: "Chicken karahi", description: "Tomato, ginger and green chilli, cooked to order.", price: 1650, diet: ["spicy", "gf"], groups: [SIZE, SPICE, BREAD] },
  { id: "daal", categoryId: "bowls", name: "Slow-cooked daal", description: "Black lentils, butter, cream.", price: 1090, diet: ["veg", "gf"], groups: [SIZE, BREAD] },
  { id: "rice-bowl", categoryId: "bowls", name: "Korma rice bowl", description: "Mild almond korma over saffron rice.", price: 1350, diet: ["nuts"], groups: [SIZE, EXTRAS] },
  { id: "smash", categoryId: "burgers", name: "Double smash burger", description: "Two beef patties, cheese, pickles, house sauce.", price: 1390, diet: [], groups: [EXTRAS] },
  { id: "zinger", categoryId: "burgers", name: "Crispy chicken burger", description: "Buttermilk chicken, slaw, chilli mayo.", price: 1250, diet: ["spicy"], groups: [SPICE, EXTRAS] },
  { id: "fries", categoryId: "sides", name: "Masala fries", description: "Skin-on fries, chaat masala.", price: 450, diet: ["veg", "gf"], groups: [SIZE, DIPS] },
  { id: "salad", categoryId: "sides", name: "Kachumber salad", description: "Cucumber, tomato, onion, lime.", price: 390, diet: ["veg", "gf"], groups: [] },
  { id: "lassi", categoryId: "drinks", name: "Mango lassi", description: "Yoghurt, mango, cardamom.", price: 490, diet: ["veg", "gf"], groups: [SIZE] },
  { id: "chai", categoryId: "drinks", name: "Karak chai", description: "Strong spiced tea.", price: 320, diet: ["veg", "gf"], groups: [MILK] },
  { id: "lemonade", categoryId: "drinks", name: "Mint lemonade", description: "Fresh lime, mint, soda.", price: 380, diet: ["veg", "gf"], groups: [SIZE] },
  { id: "kheer", categoryId: "desserts", name: "Pistachio kheer", description: "Rice pudding, pistachio, rose.", price: 550, diet: ["veg", "nuts", "gf"], groups: [] },
  { id: "brownie", categoryId: "desserts", name: "Warm brownie", description: "With vanilla ice cream.", price: 620, diet: ["veg"], groups: [] },
];

export const menuItem = (id: string) => MENU.find((m) => m.id === id);

/* ------------------------------------------------------------------ */
/* Pricing (shared by the cart preview and the API)                    */
/* ------------------------------------------------------------------ */

export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;

/** Unit price of an item with the chosen options. Unknown option ids are ignored here; the API rejects them. */
export function unitPrice(item: MenuItem, optionIds: string[]): number {
  let p = item.price;
  for (const g of item.groups) for (const o of g.options) if (optionIds.includes(o.id)) p += o.price;
  return p;
}

/** Service charge applies to dine-in only. Tax is charged on subtotal + service. Rounded per step, half up. */
export function totals(subtotal: number, mode: OrderMode) {
  const service = mode === "dine-in" ? Math.round(subtotal * RESTAURANT.serviceRate) : 0;
  const tax = Math.round((subtotal + service) * RESTAURANT.taxRate);
  return { subtotal, service, tax, total: subtotal + service + tax };
}

/** Group-level validation, the same rule a class-validator custom constraint would run in NestJS. */
export function validateOptions(item: MenuItem, optionIds: string[]): string | null {
  const known = new Set(item.groups.flatMap((g) => g.options.map((o) => o.id)));
  const stray = optionIds.find((id) => !known.has(id));
  if (stray) return `Option "${stray}" does not belong to ${item.name}.`;
  for (const g of item.groups) {
    const n = g.options.filter((o) => optionIds.includes(o.id)).length;
    if (n < g.min) return g.min === 1 ? `${g.name}: choose one.` : `${g.name}: choose at least ${g.min}.`;
    if (n > g.max) return `${g.name}: choose at most ${g.max}.`;
  }
  return null;
}

/* ------------------------------------------------------------------ */
/* Seed                                                                */
/* ------------------------------------------------------------------ */

function seedOrder(id: number, at: number, status: OrderStatus, mode: OrderMode, table: string | null, customer: string,
  lines: { itemId: string; qty: number; optionIds: string[]; note?: string }[]): Order {
  const built: OrderLine[] = lines.map((l) => {
    const item = menuItem(l.itemId)!;
    const unit = unitPrice(item, l.optionIds);
    return {
      itemId: item.id,
      name: item.name,
      qty: l.qty,
      unitPrice: unit,
      options: item.groups.flatMap((g) =>
        g.options.filter((o) => l.optionIds.includes(o.id)).map((o) => ({ groupName: g.name, name: o.name, price: o.price })),
      ),
      note: l.note ?? "",
      lineTotal: unit * l.qty,
    };
  });
  const t = totals(built.reduce((s, l) => s + l.lineTotal, 0), mode);
  const events = STATUSES.slice(0, STATUSES.indexOf(status) + 1).map((s, i) => ({ status: s, at: at + i * 4 * 60_000 }));
  return { id, number: `#${id}`, mode, table, customer, lines: built, ...t, status, version: events.length, placedAt: at, events };
}

export function seedDb(now: number): Db {
  const min = 60_000;
  return {
    nextOrderId: 1045,
    soldOut: ["brownie"],
    idempotency: {},
    orders: [
      seedOrder(1041, now - 26 * min, "ready", "dine-in", "T2", "Table T2", [
        { itemId: "tikka", qty: 1, optionIds: ["spice-med", "bread-garlic"] },
        { itemId: "lassi", qty: 2, optionIds: ["size-reg"] },
      ]),
      seedOrder(1042, now - 14 * min, "preparing", "pickup", null, "Ayesha K.", [
        { itemId: "smash", qty: 2, optionIds: ["x-cheese"], note: "No pickles on one please" },
        { itemId: "fries", qty: 1, optionIds: ["size-lg", "dip-garlic"] },
      ]),
      seedOrder(1043, now - 6 * min, "new", "dine-in", "T5", "Table T5", [
        { itemId: "karahi", qty: 1, optionIds: ["size-lg", "spice-hot", "bread-plain"] },
        { itemId: "samosa", qty: 1, optionIds: ["dip-mint"] },
        { itemId: "chai", qty: 2, optionIds: ["milk-oat"] },
      ]),
      seedOrder(1044, now - 2 * min, "new", "pickup", null, "Daniel R.", [
        { itemId: "zinger", qty: 1, optionIds: ["spice-mild"], note: "Allergy: sesame" },
      ]),
    ],
  };
}
app/lab/restaurant-ordering/Demo.tsx666 lines
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getMenu, placeOrder, setSoldOut, updateStatus, type ApiResponse, type PlaceOrderInput } from "./api";
import {
  CATEGORIES,
  MENU,
  RESTAURANT,
  STATUSES,
  STATUS_LABEL,
  TABLES,
  menuItem,
  money,
  seedDb,
  totals,
  unitPrice,
  type Db,
  type Diet,
  type MenuItem,
  type Order,
  type OrderMode,
  type OrderStatus,
} from "./db";
import { DevPanel, type LogEntry } from "./DevPanel";
import { ItemDialog, type Selection } from "./ItemDialog";
import { KitchenBoard } from "./KitchenBoard";

const KEY = "lab:restaurant-ordering:v1";

type CartLine = { key: string; itemId: string; qty: number; optionIds: string[]; note: string };
type View = "order" | "kitchen";
type Saved = { db: Db; cart: CartLine[]; mode: OrderMode; table: string | null; customer: string; myOrders: number[] };

const DIET: Record<Diet, { label: string; cls: string }> = {
  veg: { label: "Veg", cls: "text-accent" },
  spicy: { label: "Spicy", cls: "text-danger" },
  nuts: { label: "Contains nuts", cls: "text-warn-fg" },
  gf: { label: "Gluten-free", cls: "text-accent-2" },
};

const NAMES = ["Sara M.", "Omar F.", "Lena P.", "Hamza T.", "Priya S.", "Jon B."];
const NOTES = ["", "", "No onions", "Extra napkins please", "Allergy: peanuts", ""];

const newKey = () => `k_${Math.random().toString(36).slice(2, 10)}`;
const pick = <T,>(xs: T[]) => xs[Math.floor(Math.random() * xs.length)]!;

/** A deterministic, QR-looking 21×21 pattern for a table URL. Decorative: it is not a scannable QR code. */
function PseudoQr({ text, size = 84 }: { text: string; size?: number }) {
  const cells = useMemo(() => {
    let h = 2166136261;
    for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
    const out: [number, number][] = [];
    const finder = (x: number, y: number) => {
      for (const [ox, oy] of [[0, 0], [14, 0], [0, 14]] as const) {
        const dx = x - ox;
        const dy = y - oy;
        if (dx >= 0 && dx < 7 && dy >= 0 && dy < 7) {
          const ring = Math.min(dx, dy, 6 - dx, 6 - dy);
          return ring === 0 || ring >= 2 ? 1 : 0;
        }
        if (dx >= -1 && dx <= 7 && dy >= -1 && dy <= 7) return 0;
      }
      return -1;
    };
    for (let y = 0; y < 21; y++)
      for (let x = 0; x < 21; x++) {
        const f = finder(x, y);
        h ^= h << 13;
        h ^= h >>> 17;
        h ^= h << 5;
        if (f === 1 || (f === -1 && (h & 3) < 2)) out.push([x, y]);
      }
    return out;
  }, [text]);
  return (
    <svg viewBox="-2 -2 25 25" width={size} height={size} className="rounded bg-white" aria-hidden="true">
      {cells.map(([x, y]) => (
        <rect key={`${x}-${y}`} x={x} y={y} width={1.02} height={1.02} fill="#111" />
      ))}
    </svg>
  );
}

function Tracker({ order }: { order: Order }) {
  const idx = STATUSES.indexOf(order.status);
  const text: Record<OrderStatus, string> = {
    new: "The kitchen has your order.",
    preparing: "Your food is being prepared.",
    ready: order.mode === "dine-in" ? "Ready, on its way to your table." : "Ready for pickup at the counter.",
    served: order.mode === "dine-in" ? "Served. Enjoy your meal." : "Collected. Enjoy your meal.",
  };
  return (
    <li className="rounded-lg border border-border bg-bg p-3">
      <div className="flex items-center justify-between text-sm">
        <span className="font-mono font-semibold">{order.number}</span>
        <span className="font-mono text-xs text-fg-muted">{money(order.total)}</span>
      </div>
      <ol className="mt-2 grid grid-cols-4 gap-1" aria-label={`Order ${order.number} progress`}>
        {STATUSES.map((s, i) => (
          <li key={s} className="text-center">
            <span className={`block h-1.5 rounded-full ${i <= idx ? "bg-accent" : "bg-bg-muted"}`} />
            <span className={`mt-1 block text-[11px] ${i === idx ? "font-semibold text-fg" : "text-fg-muted"}`}>
              {STATUS_LABEL[s]}
              {i === idx && <span className="sr-only"> (current)</span>}
            </span>
          </li>
        ))}
      </ol>
      <p className="mt-1 text-xs text-fg-muted">{text[order.status]}</p>
    </li>
  );
}

export default function Demo() {
  const [db, setDb] = useState<Db | null>(null);
  const dbRef = useRef<Db | null>(null);
  const [cart, setCart] = useState<CartLine[]>([]);
  const [mode, setMode] = useState<OrderMode>("dine-in");
  const [table, setTable] = useState<string | null>(null);
  const [customer, setCustomer] = useState("");
  const [myOrders, setMyOrders] = useState<number[]>([]);
  const [view, setView] = useState<View>("order");
  const [devOpen, setDevOpen] = useState(false);
  const [log, setLog] = useState<LogEntry[]>([]);
  const logId = useRef(0);
  const [tamper, setTamper] = useState(false);
  const [category, setCategory] = useState<string>("all");
  const [vegOnly, setVegOnly] = useState(false);
  const [query, setQuery] = useState("");
  const [dialog, setDialog] = useState<{ item: MenuItem; edit: CartLine | null } | null>(null);
  const [placing, setPlacing] = useState(false);
  const [checkoutError, setCheckoutError] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");
  const [now, setNow] = useState(0);
  const idemKey = useRef("");

  /* ---------------- persistence (after mount, never during render) ---------------- */

  const loaded = useRef(false);
  useEffect(() => {
    let saved: Saved | null = null;
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) saved = JSON.parse(raw) as Saved;
    } catch {
      /* storage blocked or corrupted: start from the seed */
    }
    const start = saved?.db?.orders ? saved.db : seedDb(Date.now());
    dbRef.current = start;
    setDb(start);
    if (saved) {
      setCart(saved.cart ?? []);
      setMode(saved.mode ?? "dine-in");
      setTable(saved.table ?? null);
      setCustomer(saved.customer ?? "");
      setMyOrders(saved.myOrders ?? []);
    }
    // A QR code on the table opens /lab/restaurant-ordering?table=T4.
    const qt = new URLSearchParams(window.location.search).get("table");
    if (qt && TABLES.includes(qt)) {
      setTable(qt);
      setMode("dine-in");
    }
    idemKey.current = newKey();
    setNow(Date.now());
    const res = getMenu(start);
    setLog([{ id: ++logId.current, kind: "http", method: res.method, path: res.path, status: res.status, sql: res.sql, body: res.body }]);
    loaded.current = true;
    const t = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(t);
  }, []);

  useEffect(() => {
    if (!loaded.current || !db) return;
    try {
      localStorage.setItem(KEY, JSON.stringify({ db, cart, mode, table, customer, myOrders } satisfies Saved));
    } catch {
      /* ignore */
    }
  }, [db, cart, mode, table, customer, myOrders]);

  /* ---------------- API plumbing ---------------- */

  const pushWs = useCallback((text: string) => setLog((l) => [{ id: ++logId.current, kind: "ws" as const, text }, ...l].slice(0, 60)), []);

  /** Run a handler against the current DB, commit its result and write it to the dev panel. */
  const call = useCallback(<T,>(fn: (d: Db) => ApiResponse<T>, note?: string) => {
    const cur = dbRef.current!;
    const res = fn(cur);
    dbRef.current = res.db;
    setDb(res.db);
    setLog((l) =>
      [{ id: ++logId.current, kind: "http" as const, method: res.method, path: res.path, status: res.status, sql: res.sql, body: res.body, note }, ...l].slice(0, 60),
    );
    return res;
  }, []);

  /* ---------------- keyboard shortcuts ---------------- */

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const el = e.target as HTMLElement;
      if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable || e.metaKey || e.ctrlKey || e.altKey) return;
      if (document.querySelector("dialog[open]")) return;
      if (e.key === "k") setView((v) => (v === "order" ? "kitchen" : "order"));
      else if (e.key === "`") setDevOpen((v) => !v);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  /* ---------------- customer actions ---------------- */

  function scanTable(t: string) {
    setTable(t);
    setMode("dine-in");
    try {
      const url = new URL(window.location.href);
      url.searchParams.set("table", t);
      window.history.replaceState(null, "", url);
    } catch {
      /* ignore */
    }
    setAnnounce(`Table ${t} selected. Orders go to table ${t}.`);
  }

  function saveLine(sel: Selection) {
    if (!dialog) return;
    const { item, edit } = dialog;
    setCart((c) =>
      edit
        ? c.map((l) => (l.key === edit.key ? { ...l, ...sel } : l))
        : [...c, { key: `${item.id}-${Date.now()}`, itemId: item.id, ...sel }],
    );
    setCheckoutError(null);
    setAnnounce(`${edit ? "Updated" : "Added"} ${sel.qty} × ${item.name}.`);
  }

  function setQty(key: string, qty: number) {
    setCart((c) => (qty <= 0 ? c.filter((l) => l.key !== key) : c.map((l) => (l.key === key ? { ...l, qty: Math.min(20, qty) } : l))));
  }

  function checkout(e: React.FormEvent) {
    e.preventDefault();
    const body: PlaceOrderInput = {
      mode,
      table: mode === "dine-in" ? table : null,
      customer,
      lines: cart.map((l) => {
        const item = menuItem(l.itemId);
        // The browser sends its own price; the service recalculates and ignores it.
        return { itemId: l.itemId, qty: l.qty, optionIds: l.optionIds, note: l.note, unitPrice: tamper ? 1 : item ? unitPrice(item, l.optionIds) : 0 };
      }),
    };
    const key = idemKey.current;
    const wasTampered = tamper;
    setPlacing(true);
    setCheckoutError(null);
    // Simulated network latency. A second click inside this window reuses the same Idempotency-Key.
    window.setTimeout(() => {
      const res = call((d) => placeOrder(d, body, key, Date.now()), `Idempotency-Key: ${key}${wasTampered ? " · tampered prices sent" : ""}`);
      setPlacing(false);
      if (res.status === 201 || res.status === 200) {
        const order = res.body as Order;
        setMyOrders((m) => (m.includes(order.id) ? m : [order.id, ...m]));
        if (res.status === 201) {
          pushWs(`kitchen ← order.created ${order.number} (${order.lines.length} lines)`);
          setCart([]);
          setTamper(false);
          idemKey.current = newKey();
          setAnnounce(`Order ${order.number} placed. Total ${money(order.total)}.`);
        }
      } else {
        const msg = (res.body as { message: string | string[] }).message;
        setCheckoutError(Array.isArray(msg) ? msg.join(" ") : msg);
      }
    }, 450);
  }

  /* ---------------- kitchen actions ---------------- */

  function move(o: Order, to: OrderStatus) {
    const res = call((d) => updateStatus(d, o.id, to, o.version, "kitchen", Date.now()));
    if (res.status === 200) {
      pushWs(`order_${o.id} ← order.updated ${to} (customer tracker${myOrders.includes(o.id) ? ", your order" : ""})`);
      setAnnounce(`Order ${o.number} moved to ${STATUS_LABEL[to]}.`);
    } else {
      const msg = (res.body as { message: string | string[] }).message;
      setAnnounce(Array.isArray(msg) ? msg.join(" ") : msg);
    }
  }

  function toggleSoldOut(itemId: string, v: boolean) {
    call((d) => setSoldOut(d, itemId, v, "kitchen"));
    pushWs(`menu ← item.${v ? "sold_out" : "available"} ${itemId}`);
  }

  function simulate() {
    const d = dbRef.current;
    if (!d) return;
    const available = MENU.filter((m) => !d.soldOut.includes(m.id));
    const n = 1 + Math.floor(Math.random() * 3);
    const lines = Array.from({ length: n }, () => {
      const item = pick(available);
      const optionIds = item.groups.filter((g) => g.min > 0).map((g) => pick(g.options).id);
      return { itemId: item.id, qty: 1 + Math.floor(Math.random() * 2), optionIds, note: pick(NOTES) };
    });
    const dine = Math.random() < 0.6;
    const body: PlaceOrderInput = { mode: dine ? "dine-in" : "pickup", table: dine ? pick(TABLES) : null, customer: dine ? "" : pick(NAMES), lines };
    const key = newKey();
    const res = call((x) => placeOrder(x, body, key, Date.now()), `Idempotency-Key: ${key} · simulated guest`);
    if (res.status === 201) {
      const o = res.body as Order;
      pushWs(`kitchen ← order.created ${o.number} (${o.lines.length} lines)`);
      setAnnounce(`New order ${o.number} arrived.`);
    }
  }

  function reset() {
    const s = seedDb(Date.now());
    dbRef.current = s;
    setDb(s);
    setCart([]);
    setMode("dine-in");
    setTable(null);
    setCustomer("");
    setMyOrders([]);
    setLog([]);
    setTamper(false);
    setCheckoutError(null);
    idemKey.current = newKey();
    try {
      const url = new URL(window.location.href);
      url.searchParams.delete("table");
      window.history.replaceState(null, "", url);
    } catch {
      /* ignore */
    }
    setAnnounce("Demo reset.");
  }

  /* ---------------- derived ---------------- */

  const soldOut = useMemo(() => db?.soldOut ?? [], [db]);
  const items = useMemo(() => {
    const q = query.trim().toLowerCase();
    return MENU.filter(
      (m) =>
        (category === "all" || m.categoryId === category) &&
        (!vegOnly || m.diet.includes("veg")) &&
        (!q || m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)),
    );
  }, [category, vegOnly, query]);

  const cartView = cart.map((l) => {
    const item = menuItem(l.itemId)!;
    const unit = unitPrice(item, l.optionIds);
    const opts = item.groups.flatMap((g) => g.options.filter((o) => l.optionIds.includes(o.id)).map((o) => o.name));
    return { ...l, item, unit, opts, total: unit * l.qty, gone: soldOut.includes(l.itemId) };
  });
  const sums = totals(cartView.reduce((s, l) => s + l.total, 0), mode);
  const mine = (db?.orders ?? []).filter((o) => myOrders.includes(o.id)).sort((a, b) => b.placedAt - a.placedAt).slice(0, 3);
  const activeKitchen = (db?.orders ?? []).filter((o) => o.status !== "served").length;

  const tab = (v: View) =>
    `rounded-md px-3 py-1.5 text-sm font-medium ${view === v ? "bg-accent text-accent-fg" : "text-fg-muted hover:text-fg"}`;

  if (!db) {
    return (
      <div className="p-6 text-sm text-fg-muted" aria-busy="true">
        Loading menu…
      </div>
    );
  }

  return (
    <div>
      {/* Toolbar */}
      <div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
        <div role="tablist" aria-label="Screen" className="flex gap-1 rounded-lg border border-border bg-bg-muted p-1">
          <button type="button" role="tab" aria-selected={view === "order"} onClick={() => setView("order")} className={tab("order")}>
            Guest ordering
          </button>
          <button type="button" role="tab" aria-selected={view === "kitchen"} onClick={() => setView("kitchen")} className={tab("kitchen")}>
            Kitchen display <span className="font-mono text-xs opacity-80">({activeKitchen})</span>
          </button>
        </div>
        <span className="hidden font-mono text-xs text-fg-muted md:inline">
          <kbd className="rounded border border-border px-1">k</kbd> switch · <kbd className="rounded border border-border px-1">`</kbd> dev panel
        </span>
        <div className="ml-auto flex gap-2">
          <button
            type="button"
            aria-pressed={devOpen}
            onClick={() => setDevOpen((v) => !v)}
            className={`rounded-md border px-3 py-1.5 font-mono text-xs ${devOpen ? "border-accent text-accent" : "border-border hover:border-accent"}`}
          >
            Dev panel
          </button>
          <button type="button" onClick={reset} className="rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-danger hover:text-danger">
            Reset demo
          </button>
        </div>
      </div>

      <p className="sr-only" aria-live="polite">
        {announce}
      </p>

      {view === "kitchen" ? (
        <KitchenBoard orders={db.orders} now={now} soldOut={soldOut} onMove={move} onToggleSoldOut={toggleSoldOut} onSimulate={simulate} />
      ) : (
        <div className="grid lg:grid-cols-[1.5fr_1fr]">
          {/* Menu */}
          <div className="space-y-4 p-4 sm:p-6">
            <div>
              <h3 className="text-xl font-semibold">{RESTAURANT.name}</h3>
              <p className="text-sm text-fg-muted">
                Fictional restaurant · {table && mode === "dine-in" ? <>ordering for <strong className="text-fg">table {table}</strong></> : "scan a table code or choose pickup"}
              </p>
            </div>
            <div className="flex flex-wrap items-center gap-2">
              <label className="min-w-0 flex-1">
                <span className="sr-only">Search the menu</span>
                <input
                  type="search"
                  value={query}
                  onChange={(e) => setQuery(e.target.value)}
                  placeholder="Search the menu…"
                  className="w-full rounded-md border border-border bg-bg-elev px-3 py-1.5 text-sm outline-none focus:border-accent"
                />
              </label>
              <label className="flex items-center gap-2 text-sm">
                <input type="checkbox" checked={vegOnly} onChange={(e) => setVegOnly(e.target.checked)} className="accent-[var(--accent)]" />
                Vegetarian only
              </label>
            </div>
            <div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1" role="group" aria-label="Categories">
              {[{ id: "all", name: "All" }, ...CATEGORIES].map((c) => (
                <button
                  key={c.id}
                  type="button"
                  aria-pressed={category === c.id}
                  onClick={() => setCategory(c.id)}
                  className={`shrink-0 rounded-full border px-3 py-1 text-sm ${category === c.id ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:text-fg"}`}
                >
                  {c.name}
                </button>
              ))}
            </div>

            {items.length === 0 && <p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-fg-muted">Nothing matches. Clear the search or filters.</p>}

            {CATEGORIES.filter((c) => items.some((m) => m.categoryId === c.id)).map((c) => (
              <section key={c.id} aria-labelledby={`cat-${c.id}`}>
                <h4 id={`cat-${c.id}`} className="mb-2 font-mono text-xs font-semibold uppercase tracking-wide text-fg-muted">
                  {c.name}
                </h4>
                <ul className="grid gap-2 sm:grid-cols-2">
                  {items
                    .filter((m) => m.categoryId === c.id)
                    .map((m) => {
                      const out = soldOut.includes(m.id);
                      return (
                        <li key={m.id}>
                          <button
                            type="button"
                            disabled={out}
                            onClick={() => setDialog({ item: m, edit: null })}
                            className="flex h-full w-full flex-col rounded-lg border border-border bg-bg-elev p-3 text-left hover:border-accent disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:border-border"
                          >
                            <span className="flex w-full items-start justify-between gap-2">
                              <span className="font-medium">{m.name}</span>
                              <span className="font-mono text-sm">{money(m.price)}</span>
                            </span>
                            <span className="mt-0.5 text-xs text-fg-muted">{m.description}</span>
                            <span className="mt-2 flex flex-wrap gap-x-2 gap-y-1 font-mono text-[11px]">
                              {out && <span className="rounded bg-warn-bg px-1.5 text-warn-fg">Sold out today</span>}
                              {m.diet.map((d) => (
                                <span key={d} className={DIET[d].cls}>
                                  ● {DIET[d].label}
                                </span>
                              ))}
                              {m.groups.length > 0 && !out && <span className="text-fg-muted">options</span>}
                            </span>
                          </button>
                        </li>
                      );
                    })}
                </ul>
              </section>
            ))}
          </div>

          {/* Order side */}
          <div className="space-y-5 border-t border-border bg-bg-elev p-4 sm:p-6 lg:border-t-0 lg:border-l">
            <form onSubmit={checkout} noValidate className="space-y-4">
              <fieldset>
                <legend className="text-sm font-semibold">How are you eating?</legend>
                <div className="mt-2 grid grid-cols-2 gap-2">
                  {(["dine-in", "pickup"] as const).map((m) => (
                    <label key={m} className={`flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm ${mode === m ? "border-accent bg-accent-soft" : "border-border"}`}>
                      <input type="radio" name="mode" value={m} checked={mode === m} onChange={() => setMode(m)} className="accent-[var(--accent)]" />
                      {m === "dine-in" ? "Dine in" : "Pickup"}
                    </label>
                  ))}
                </div>
              </fieldset>

              {mode === "dine-in" ? (
                <div className="space-y-2">
                  <label className="flex items-center gap-2 text-sm">
                    <span className="font-medium">Table</span>
                    <select
                      value={table ?? ""}
                      onChange={(e) => (e.target.value ? scanTable(e.target.value) : setTable(null))}
                      className="rounded-md border border-border bg-bg px-2 py-1 text-sm"
                    >
                      <option value="">Not set</option>
                      {TABLES.map((t) => (
                        <option key={t} value={t}>
                          {t}
                        </option>
                      ))}
                    </select>
                  </label>
                  <details className="rounded-md border border-border p-2" open={!table}>
                    <summary className="cursor-pointer text-sm">Scan a table code</summary>
                    <p className="mt-1 text-xs text-fg-muted">Each table has a code linking to <code>?table=T4</code>. Press one to “scan” it (decorative patterns, not real QR codes).</p>
                    <div className="mt-2 grid grid-cols-4 gap-2">
                      {TABLES.map((t) => (
                        <button
                          key={t}
                          type="button"
                          onClick={() => scanTable(t)}
                          aria-label={`Scan table ${t} code`}
                          className={`flex flex-col items-center gap-1 rounded-md border p-1.5 ${table === t ? "border-accent" : "border-border hover:border-accent"}`}
                        >
                          <PseudoQr text={`https://saffron.example/t/${t}`} size={56} />
                          <span className="font-mono text-xs">{t}</span>
                        </button>
                      ))}
                    </div>
                  </details>
                </div>
              ) : (
                <label className="block">
                  <span className="text-sm font-medium">Name for the counter</span>
                  <input
                    value={customer}
                    onChange={(e) => setCustomer(e.target.value)}
                    maxLength={40}
                    autoComplete="given-name"
                    placeholder="e.g. Sam"
                    className="mt-1 w-full rounded-md border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-accent"
                  />
                </label>
              )}

              <section aria-labelledby="cart-heading">
                <h3 id="cart-heading" className="flex items-center justify-between text-sm font-semibold">
                  Your order <span className="font-mono text-xs text-fg-muted">{cart.reduce((s, l) => s + l.qty, 0)} items</span>
                </h3>
                {cartView.length === 0 ? (
                  <p className="mt-2 rounded-md border border-dashed border-border p-4 text-center text-sm text-fg-muted">Your cart is empty. Pick something from the menu.</p>
                ) : (
                  <ul className="mt-2 divide-y divide-border">
                    {cartView.map((l) => (
                      <li key={l.key} className="py-2 text-sm">
                        <div className="flex items-start justify-between gap-2">
                          <span className="font-medium">
                            {l.item.name}
                            {l.gone && <span className="ml-1 rounded bg-warn-bg px-1 font-mono text-[11px] text-warn-fg">sold out</span>}
                          </span>
                          <span className="font-mono">{money(l.total)}</span>
                        </div>
                        {l.opts.length > 0 && <p className="text-xs text-fg-muted">{l.opts.join(", ")}</p>}
                        {l.note && <p className="text-xs text-warn-fg">“{l.note}”</p>}
                        <div className="mt-1 flex items-center gap-1.5">
                          <button type="button" onClick={() => setQty(l.key, l.qty - 1)} aria-label={`Decrease ${l.item.name}`} className="size-7 rounded border border-border font-mono hover:border-accent">
                            −
                          </button>
                          <span className="w-6 text-center font-mono" aria-label={`Quantity ${l.qty}`}>
                            {l.qty}
                          </span>
                          <button type="button" onClick={() => setQty(l.key, l.qty + 1)} aria-label={`Increase ${l.item.name}`} className="size-7 rounded border border-border font-mono hover:border-accent">
                            +
                          </button>
                          <button type="button" onClick={() => setDialog({ item: l.item, edit: l })} className="ml-2 font-mono text-xs text-accent hover:underline">
                            edit
                          </button>
                          <button type="button" onClick={() => setQty(l.key, 0)} className="ml-auto font-mono text-xs text-fg-muted hover:text-danger">
                            remove
                          </button>
                        </div>
                      </li>
                    ))}
                  </ul>
                )}
              </section>

              <dl className="space-y-1 border-t border-border pt-3 font-mono text-sm">
                <div className="flex justify-between">
                  <dt className="text-fg-muted">Subtotal</dt>
                  <dd>{money(sums.subtotal)}</dd>
                </div>
                <div className="flex justify-between">
                  <dt className="text-fg-muted">Service {mode === "dine-in" ? "10%" : "(pickup: none)"}</dt>
                  <dd>{money(sums.service)}</dd>
                </div>
                <div className="flex justify-between">
                  <dt className="text-fg-muted">Tax 8%</dt>
                  <dd>{money(sums.tax)}</dd>
                </div>
                <div className="flex justify-between border-t border-border pt-1 font-semibold">
                  <dt>Total</dt>
                  <dd>{money(sums.total)}</dd>
                </div>
              </dl>

              {checkoutError && (
                <p role="alert" className="text-sm text-danger">
                  {checkoutError}
                </p>
              )}
              <button type="submit" className="w-full rounded-md bg-accent px-4 py-2.5 font-medium text-accent-fg hover:opacity-90" aria-busy={placing}>
                {placing ? "Placing order…" : `Place order · ${money(sums.total)}`}
              </button>
              <p className="text-xs text-fg-muted">No payment is taken. Pay at the table or counter. The server recalculates this total from menu prices.</p>
            </form>

            <section aria-labelledby="track-heading">
              <h3 id="track-heading" className="text-sm font-semibold">
                Order status
              </h3>
              {mine.length === 0 ? (
                <p className="mt-2 text-sm text-fg-muted">Orders you place show up here and update live as the kitchen works on them.</p>
              ) : (
                <ul className="mt-2 space-y-2" aria-live="polite">
                  {mine.map((o) => (
                    <Tracker key={o.id} order={o} />
                  ))}
                </ul>
              )}
              {mine.length > 0 && (
                <button type="button" onClick={() => setView("kitchen")} className="mt-2 font-mono text-xs text-accent hover:underline">
                  open the kitchen display to move it →
                </button>
              )}
            </section>
          </div>
        </div>
      )}

      {devOpen && <DevPanel entries={log} tamper={tamper} onTamper={setTamper} onClear={() => setLog([])} />}

      <ItemDialog
        item={dialog?.item ?? null}
        initial={dialog?.edit ?? null}
        onClose={() => setDialog(null)}
        onSave={saveLine}
      />
    </div>
  );
}
app/lab/restaurant-ordering/KitchenBoard.tsx159 lines
"use client";

import { useState } from "react";
import { CATEGORIES, MENU, STATUSES, STATUS_LABEL, type Order, type OrderStatus } from "./db";

type Props = {
  orders: Order[];
  now: number;
  soldOut: string[];
  onMove: (order: Order, to: OrderStatus) => void;
  onToggleSoldOut: (itemId: string, soldOut: boolean) => void;
  onSimulate: () => void;
};

function elapsed(from: number, now: number) {
  if (!now) return "--:--";
  const s = Math.max(0, Math.floor((now - from) / 1000));
  return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}

const NEXT_LABEL: Record<OrderStatus, string> = { new: "Start", preparing: "Ready", ready: "Served", served: "" };
const LATE_MIN = 15;

/**
 * Kitchen display: one column per status. Move a ticket with its buttons, by dragging it to a
 * column, or by focusing it and pressing ← / →.
 */
export function KitchenBoard({ orders, now, soldOut, onMove, onToggleSoldOut, onSimulate }: Props) {
  const [dragId, setDragId] = useState<number | null>(null);
  const [over, setOver] = useState<OrderStatus | null>(null);

  function step(o: Order, dir: 1 | -1) {
    const to = STATUSES[STATUSES.indexOf(o.status) + dir];
    if (to) onMove(o, to);
  }

  return (
    <div className="space-y-4 p-4 sm:p-6">
      <div className="flex flex-wrap items-center gap-3">
        <button type="button" onClick={onSimulate} className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
          Simulate incoming order
        </button>
        <p className="text-xs text-fg-muted">
          Drag tickets between columns, use the buttons, or focus a ticket and press <kbd className="rounded border border-border px-1 font-mono">←</kbd>{" "}
          <kbd className="rounded border border-border px-1 font-mono">→</kbd>.
        </p>
      </div>

      <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
        {STATUSES.map((status) => {
          let col = orders.filter((o) => o.status === status).sort((a, b) => a.placedAt - b.placedAt);
          if (status === "served") col = col.slice(-5).reverse();
          return (
            <section
              key={status}
              aria-labelledby={`col-${status}`}
              onDragOver={(e) => {
                e.preventDefault();
                setOver(status);
              }}
              onDragLeave={() => setOver((s) => (s === status ? null : s))}
              onDrop={(e) => {
                e.preventDefault();
                setOver(null);
                const o = orders.find((x) => x.id === dragId);
                setDragId(null);
                if (o && o.status !== status) onMove(o, status);
              }}
              className={`min-h-40 rounded-lg border bg-bg-muted p-2 transition-colors ${over === status ? "border-accent" : "border-border"}`}
            >
              <h3 id={`col-${status}`} className="mb-2 flex items-center justify-between px-1 font-mono text-xs font-semibold uppercase tracking-wide">
                {STATUS_LABEL[status]}
                <span className="rounded-full bg-bg-elev px-2 py-0.5 text-fg-muted">{orders.filter((o) => o.status === status).length}</span>
              </h3>
              <ul className="space-y-2">
                {col.length === 0 && <li className="px-1 py-4 text-center text-xs text-fg-muted">No tickets</li>}
                {col.map((o) => {
                  const late = (o.status === "new" || o.status === "preparing") && now > 0 && now - o.placedAt > LATE_MIN * 60_000;
                  return (
                    <li key={o.id}>
                      <article
                        tabIndex={0}
                        draggable
                        onDragStart={(e) => {
                          setDragId(o.id);
                          e.dataTransfer.effectAllowed = "move";
                        }}
                        onDragEnd={() => setDragId(null)}
                        onKeyDown={(e) => {
                          if (e.key === "ArrowRight") {
                            e.preventDefault();
                            step(o, 1);
                          } else if (e.key === "ArrowLeft") {
                            e.preventDefault();
                            step(o, -1);
                          }
                        }}
                        aria-label={`Order ${o.number}, ${o.mode === "dine-in" ? `table ${o.table}` : `pickup for ${o.customer}`}, ${STATUS_LABEL[o.status]}`}
                        className={`cursor-grab rounded-md border bg-bg-elev p-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent active:cursor-grabbing ${
                          late ? "border-danger" : "border-border"
                        } ${dragId === o.id ? "opacity-50" : ""}`}
                      >
                        <header className="flex items-center justify-between gap-2">
                          <span className="font-mono font-semibold">{o.number}</span>
                          <span className={`rounded px-1.5 py-0.5 font-mono text-[11px] ${o.mode === "dine-in" ? "bg-accent-soft text-accent" : "bg-bg-muted text-accent-2"}`}>
                            {o.mode === "dine-in" ? o.table : "PICKUP"}
                          </span>
                          <span className={`ml-auto font-mono text-xs ${late ? "text-danger" : "text-fg-muted"}`} title="Time since the order was placed">
                            {elapsed(o.placedAt, now)}
                          </span>
                        </header>
                        {o.mode === "pickup" && <p className="text-xs text-fg-muted">{o.customer}</p>}
                        <ul className="mt-1.5 space-y-1">
                          {o.lines.map((l, i) => (
                            <li key={i}>
                              <span className="font-mono font-semibold">{l.qty}×</span> {l.name}
                              {l.options.length > 0 && <span className="block pl-5 text-xs text-fg-muted">{l.options.map((x) => x.name).join(", ")}</span>}
                              {l.note && <span className="block pl-5 text-xs text-warn-fg">“{l.note}”</span>}
                            </li>
                          ))}
                        </ul>
                        <div className="mt-2 flex gap-1.5">
                          {o.status !== "new" && (
                            <button type="button" onClick={() => step(o, -1)} aria-label={`Move ${o.number} back`} className="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-accent">
                              ←
                            </button>
                          )}
                          {o.status !== "served" && (
                            <button type="button" onClick={() => step(o, 1)} className="flex-1 rounded border border-accent/50 px-2 py-0.5 font-mono text-xs text-accent hover:bg-accent-soft">
                              {NEXT_LABEL[o.status]} →
                            </button>
                          )}
                        </div>
                      </article>
                    </li>
                  );
                })}
              </ul>
            </section>
          );
        })}
      </div>

      <details className="rounded-lg border border-border p-3">
        <summary className="cursor-pointer text-sm font-medium">Availability (86 board)</summary>
        <p className="mt-1 text-xs text-fg-muted">Mark an item sold out and it disappears from ordering at once; a cart that still holds it gets a 409 on checkout.</p>
        <div className="mt-2 grid gap-x-4 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
          {CATEGORIES.flatMap((c) => MENU.filter((m) => m.categoryId === c.id)).map((m) => (
            <label key={m.id} className="flex items-center gap-2 text-sm">
              <input type="checkbox" checked={soldOut.includes(m.id)} onChange={(e) => onToggleSoldOut(m.id, e.target.checked)} className="accent-[var(--accent)]" />
              <span className={soldOut.includes(m.id) ? "text-fg-muted line-through" : ""}>{m.name}</span>
            </label>
          ))}
        </div>
      </details>
    </div>
  );
}
app/lab/restaurant-ordering/ItemDialog.tsx178 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { money, unitPrice, validateOptions, type MenuItem } from "./db";

export type Selection = { optionIds: string[]; qty: number; note: string };

type Props = {
  item: MenuItem | null;
  /** Present when editing an existing cart line. */
  initial: Selection | null;
  onClose: () => void;
  onSave: (sel: Selection) => void;
};

/** Required single-choice groups start on their first option, except spice, which the guest must pick. */
function defaults(item: MenuItem): string[] {
  return item.groups.filter((g) => g.min === 1 && g.max === 1 && g.id !== "spice").map((g) => g.options[0]!.id);
}

/**
 * Item options dialog. Native <dialog> gives a focus trap and Escape; focus goes back to the
 * button that opened it when it closes.
 */
export function ItemDialog({ item, initial, onClose, onSave }: Props) {
  const ref = useRef<HTMLDialogElement>(null);
  const opener = useRef<HTMLElement | null>(null);
  const [optionIds, setOptionIds] = useState<string[]>([]);
  const [qty, setQty] = useState(1);
  const [note, setNote] = useState("");
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const d = ref.current;
    if (!d) return;
    if (item && !d.open) {
      opener.current = document.activeElement as HTMLElement | null;
      setOptionIds(initial?.optionIds ?? defaults(item));
      setQty(initial?.qty ?? 1);
      setNote(initial?.note ?? "");
      setError(null);
      d.showModal();
    } else if (!item && d.open) {
      d.close();
    }
  }, [item, initial]);

  function handleClose() {
    onClose();
    // Return focus to whatever opened the dialog (the item card or the cart line's Edit button).
    const el = opener.current;
    requestAnimationFrame(() => el?.focus());
  }

  function toggle(groupId: string, optionId: string) {
    if (!item) return;
    const g = item.groups.find((x) => x.id === groupId)!;
    const inGroup = new Set(g.options.map((o) => o.id));
    setError(null);
    setOptionIds((cur) => {
      if (g.max === 1) return [...cur.filter((id) => !inGroup.has(id)), optionId];
      if (cur.includes(optionId)) return cur.filter((id) => id !== optionId);
      if (cur.filter((id) => inGroup.has(id)).length >= g.max) return cur;
      return [...cur, optionId];
    });
  }

  function submit(e: React.FormEvent) {
    e.preventDefault();
    if (!item) return;
    const err = validateOptions(item, optionIds);
    if (err) {
      setError(err);
      return;
    }
    onSave({ optionIds, qty, note: note.trim() });
    ref.current?.close();
  }

  const price = item ? unitPrice(item, optionIds) * qty : 0;

  return (
    <dialog
      ref={ref}
      onClose={handleClose}
      aria-labelledby="item-dialog-title"
      onClick={(e) => e.target === ref.current && ref.current?.close()}
      className="m-auto max-h-[90vh] w-[min(30rem,calc(100vw-1.5rem))] overflow-y-auto rounded-xl border border-border bg-bg-elev p-0 text-fg shadow-2xl backdrop:bg-black/60"
    >
      {item && (
        <form onSubmit={submit} noValidate className="space-y-4 p-5">
          <div className="flex items-start justify-between gap-3">
            <div>
              <h3 id="item-dialog-title" className="text-lg font-semibold">
                {item.name}
              </h3>
              <p className="text-sm text-fg-muted">{item.description}</p>
            </div>
            <button type="button" onClick={() => ref.current?.close()} aria-label="Close" className="rounded border border-border px-2 py-0.5 font-mono text-sm hover:border-accent">
              ×
            </button>
          </div>

          {item.groups.map((g) => {
            const picked = g.options.filter((o) => optionIds.includes(o.id)).length;
            const full = g.max > 1 && picked >= g.max;
            return (
              <fieldset key={g.id} className="rounded-lg border border-border p-3">
                <legend className="px-1 text-sm font-medium">
                  {g.name}{" "}
                  <span className="font-mono text-xs text-fg-muted">
                    {g.min === 1 && g.max === 1 ? "required" : g.min === 0 ? `optional · up to ${g.max}` : `${g.min}–${g.max}`}
                  </span>
                </legend>
                <div className="mt-1 space-y-1">
                  {g.options.map((o) => {
                    const checked = optionIds.includes(o.id);
                    return (
                      <label key={o.id} className={`flex items-center gap-2 rounded px-1 py-1 text-sm ${full && !checked ? "opacity-50" : ""}`}>
                        <input
                          type={g.max === 1 ? "radio" : "checkbox"}
                          name={`${item.id}-${g.id}`}
                          checked={checked}
                          disabled={full && !checked}
                          onChange={() => toggle(g.id, o.id)}
                          className="accent-[var(--accent)]"
                        />
                        <span className="flex-1">{o.name}</span>
                        {o.price > 0 && <span className="font-mono text-xs text-fg-muted">+{money(o.price)}</span>}
                      </label>
                    );
                  })}
                </div>
              </fieldset>
            );
          })}

          <label className="block">
            <span className="text-sm font-medium">Note for the kitchen</span>
            <textarea
              value={note}
              onChange={(e) => setNote(e.target.value)}
              maxLength={140}
              rows={2}
              placeholder="e.g. no onions, allergy: sesame"
              className="mt-1 w-full rounded-md border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-accent"
            />
            <span className="font-mono text-xs text-fg-muted">{note.length}/140</span>
          </label>

          {error && (
            <p role="alert" className="text-sm text-danger">
              {error}
            </p>
          )}

          <div className="flex items-center gap-3">
            <div className="flex items-center rounded-md border border-border" role="group" aria-label="Quantity">
              <button type="button" onClick={() => setQty((q) => Math.max(1, q - 1))} aria-label="Decrease quantity" className="px-3 py-2 font-mono hover:text-accent">
                −
              </button>
              <span className="w-8 text-center font-mono" aria-live="polite">
                {qty}
              </span>
              <button type="button" onClick={() => setQty((q) => Math.min(20, q + 1))} aria-label="Increase quantity" className="px-3 py-2 font-mono hover:text-accent">
                +
              </button>
            </div>
            <button type="submit" className="flex-1 rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">
              {initial ? "Update" : "Add to order"} · {money(price)}
            </button>
          </div>
        </form>
      )}
    </dialog>
  );
}
app/lab/restaurant-ordering/DevPanel.tsx74 lines
"use client";

/** One line in the dev panel: an HTTP call with the SQL it ran, or a realtime event pushed to screens. */
export type LogEntry =
  | { id: number; kind: "http"; method: string; path: string; status: number; sql: string[]; body: unknown; note?: string }
  | { id: number; kind: "ws"; text: string };

type Props = {
  entries: LogEntry[];
  tamper: boolean;
  onTamper: (v: boolean) => void;
  onClear: () => void;
};

const statusTone = (s: number) => (s >= 500 ? "text-[#f87171]" : s >= 400 ? "text-[#fbbf24]" : "text-[#4ade80]");

const sqlTone = (line: string) =>
  line.startsWith("--")
    ? "text-[#71717a]"
    : /^(BEGIN|COMMIT)/.test(line)
      ? "text-[#c084fc]"
      : line.startsWith("ROLLBACK")
        ? "text-[#f87171]"
        : "text-[#93c5fd]";

/**
 * Always-dark terminal, so it uses fixed colours rather than theme tokens.
 * Shows what the NestJS + PostgreSQL service would do for each action in the demo.
 */
export function DevPanel({ entries, tamper, onTamper, onClear }: Props) {
  return (
    <section aria-label="Dev panel: API requests and SQL" className="border-t border-border bg-[#0b0b0d] p-3 font-mono text-[12px] text-[#a1a1aa] sm:p-4">
      <div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-2">
        <h3 className="text-[13px] font-semibold text-[#e4e4e7]">dev panel · requests &amp; SQL</h3>
        <label className="flex items-center gap-2">
          <input type="checkbox" checked={tamper} onChange={(e) => onTamper(e.target.checked)} className="accent-[#fbbf24]" />
          <span>send tampered prices ($0.01) with the next order</span>
        </label>
        <button type="button" onClick={onClear} className="ml-auto rounded border border-[#3f3f46] px-2 py-0.5 hover:border-[#a1a1aa] hover:text-[#e4e4e7]">
          clear
        </button>
      </div>
      <ol role="log" aria-live="polite" className="max-h-80 space-y-3 overflow-y-auto">
        {entries.length === 0 && <li className="text-[#71717a]">$ waiting for requests… place an order or move a ticket.</li>}
        {entries.map((e) =>
          e.kind === "ws" ? (
            <li key={e.id} className="text-[#22d3ee]">
              ⇢ ws {e.text}
            </li>
          ) : (
            <li key={e.id}>
              <div>
                <span className="text-[#e4e4e7]">{e.method}</span> {e.path} <span className={statusTone(e.status)}>→ {e.status}</span>
                {e.note && <span className="text-[#fbbf24]"> · {e.note}</span>}
              </div>
              <ul className="mt-1 border-l border-[#27272a] pl-3">
                {e.sql.map((l, i) => (
                  <li key={i} className={`break-all ${sqlTone(l)}`}>
                    {l}
                  </li>
                ))}
              </ul>
              <details className="mt-1">
                <summary className="cursor-pointer text-[#71717a] hover:text-[#a1a1aa]">response body</summary>
                <pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-all text-[#d4d4d8]">{JSON.stringify(e.body, null, 2)}</pre>
              </details>
            </li>
          ),
        )}
      </ol>
    </section>
  );
}