plans
2026-05-03
2026 05 03 Table Pos System

Table Ordering & POS System Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement a table-based ordering and POS system for House of Legends dinner theater. Guests pre-book tables (via existing booking flow), then order food/beverages via a QR-code PWA at their table or through a staff POS. Kitchen sees real-time orders via KDS. All tabs are added to the reservation and paid together.

Architecture: Single Next.js app with role-based routes. Convex backend handles all real-time subscriptions. Staff login via existing Clerk auth. Guest PWA accessed via QR code token (no login). Orders are per-table, fire immediately to kitchen/bar.

Tech Stack: Next.js 16 (App Router, SSG), Convex (real-time DB + functions), Clerk (staff auth), Tailwind CSS v4, PWA (service worker + manifest), nuqs for URL state management (category, filters, tab state in URL), qrcode library for QR generation.


Business Summary

What this does: Implements a table-based ordering and POS system where guests scan a QR code at their pre-booked table to browse the menu and order food/beverages via PWA, with orders firing real-time to kitchen (KDS) and bar displays. Staff can also take orders on behalf of guests via a POS interface.

Why it matters: Increases per-table revenue by making ordering frictionless and available throughout the experience. Reduces wait times and staff burden by routing orders directly to the correct station (kitchen vs bar). All charges attach to the reservation tab, simplifying checkout.

Time to implement: 10-15 days | Complexity: High

Dependencies: Requires booking-flow (for QR token generation on reservation confirmation), staff-operations (for staff auth helpers staffMutation/adminMutation). QR token validation is standalone and can be implemented in parallel with auth helpers.


Context & Key Constraints

[P0 GAP] staffMutation / adminMutation not yet implemented. The existing convex/auth.ts only provides getCurrentUser, upsertUser, and isAdmin helpers. Any plan referencing staffMutation or adminMutation must be blocked on the foundation plan implementing these wrappers. Use plain mutation with inline role checks via ctx.auth.getUserIdentity() instead.

[P0 RULE] No dynamic URL segments. All routing uses query params via nuqs — never /table/[tableId] or /booking/[occurrenceId]. Guest PWA URL: /table?tableId=xxx&token=xxx. Staff POS: /pos/kitchen?station=KITCHEN.

[P1 RULE] No console.log. Use consola for all structured logging.

[P1 RULE] All mutation error throws must use POS_ERRORS constants. Do not use inline strings like throw new Error("INVALID_CAPACITY") — use throw new Error(POS_ERRORS.INVALID_CAPACITY) with the named constant from the enrichment section.


File Map

convex/
├── schema.ts                     # MODIFY — add tables, menuItems, orders, orderItems
└── functions/
    ├── tables.ts                  # CREATE — table CRUD
    ├── menu.ts                    # CREATE — menu item CRUD
    └── orders.ts                  # CREATE — order lifecycle

apps/frontend/
├── components/
│   ├── menu/
│   │   ├── menu-card.tsx        # CREATE — individual menu item card
│   │   ├── menu-category.tsx     # CREATE — category section
│   │   └── cart-button.tsx       # CREATE — floating cart button
│   ├── pos/
│   │   ├── kitchen-board.tsx     # CREATE — KDS kanban board
│   │   ├── order-item-card.tsx   # CREATE — order item with status actions
│   │   └── table-status.tsx      # CREATE — table status chip
│   └── ui/
├── app/[locale]/
│   ├── table/
│   │   └── page.tsx              # CREATE — guest PWA: menu + cart (nuqs URL: ?tableId=&token=)
│   └── pos/
│       ├── page.tsx               # CREATE — role-based POS home (redirects)

Phase 1: Schema Extensions

Task 1: Add POS Tables to Schema

Files:

  • Modify: convex/schema.ts

  • Step 1: Read existing schema

cat convex/schema.ts
  • Step 2: Add POS tables (same as staff-operations-plan.md Phase 1)

Tables: tables, menuItems, orders, orderItems All indexes: by_status, by_category, by_station, by_available, by_table, by_reservation, by_order, by_station_status

  • Step 3: Commit
git add convex/schema.ts
git commit -m "feat(table-pos): add POS tables to schema"

Phase 2: Convex Functions

Task 2: Create POS Functions

Files:

  • Create: convex/functions/tables.ts
  • Create: convex/functions/menu.ts
  • Create: convex/functions/orders.ts

[P0 GAP] Auth wrappers. staffMutation and adminMutation do not exist in convex/auth.ts. Use plain mutation with inline role checks via ctx.auth.getUserIdentity().

  • Step 1: Create functions — inline role check pattern
// convex/functions/tables.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
 
// Inline role check — staffMutation/adminMutation not available yet
// Convex QueryBuilder types are inferred automatically
async function requireStaffOrAdmin(
  ctx: HandlerContext,
): Promise<{ _id: Id<"users">; role: string; email: string }> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("UNAUTHORIZED");
  const user = await ctx.db
    .query("users")
    .withIndex("by_email", (q) => q.eq("email", identity.email!))
    .first();
  if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
    throw new Error("UNAUTHORIZED");
  }
  return user;
}
 
type HandlerContext = {
  auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
  db: {
    query: (table: string) => {
      withIndex: (
        name: string,
        fn: (q: QueryBuilder) => { first: () => Promise<unknown> },
      ) => { first: () => Promise<unknown> };
    };
  };
};
 
type QueryBuilder = {
  eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
 
// Public mutations (guest PWA) — no auth required
export const getOrCreateForTable = mutation({
  args: {
    tableId: v.id("tables"),
    reservationId: v.optional(v.id("reservations")),
  },
  handler: async (ctx, { tableId, reservationId }) => {
    // No auth — guests use QR token (validated elsewhere in Next.js)
    const existing = await ctx.db
      .query("orders")
      .withIndex("by_table", (q) => q.eq("tableId", tableId))
      .collect()
      .then((orders) =>
        orders.find((o) => o.status === "OPEN" || o.status === "SUBMITTED"),
      );
    if (existing) return existing;
    const id = await ctx.db.insert("orders", {
      tableId,
      reservationId: reservationId ?? undefined,
      status: "OPEN",
      totalAmount: 0,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
    return await ctx.db.get(id);
  },
});
  • Step 2: Commit
git add convex/functions/tables.ts convex/functions/menu.ts convex/functions/orders.ts
git commit -m "feat(table-pos): add POS Convex functions"

Phase 3: QR Token Security

Task 3: Implement Token Generation and Validation

Files:

  • Create: apps/frontend/lib/qr-token.ts

  • Step 1: Create HMAC token utilities

// apps/frontend/lib/qr-token.ts
import { createHmac } from "crypto";
 
type TokenPayload = {
  tableId: string;
  reservationId: string;
  expiresAt: number; // Unix timestamp
};
 
const SECRET = process.env.QR_TOKEN_SECRET!;
 
/**
 * Generate a signed QR token for table access.
 * Token = base64url(payload) + "." + hmac_sha256
 */
export function generateTableToken(payload: TokenPayload): string {
  const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const sig = createHmac("sha256", SECRET)
    .update(payloadB64)
    .digest("base64url");
  return `${payloadB64}.${sig}`;
}
 
/**
 * Validate and decode a QR token.
 * Returns null if invalid, expired, or tampered.
 */
export function verifyTableToken(token: string): TokenPayload | null {
  try {
    const [payloadB64, sig] = token.split(".");
    const expectedSig = createHmac("sha256", SECRET)
      .update(payloadB64)
      .digest("base64url");
    if (sig !== expectedSig) return null;
    const payload: TokenPayload = JSON.parse(
      Buffer.from(payloadB64, "base64url").toString(),
    );
    if (payload.expiresAt < Date.now()) return null;
    return payload;
  } catch {
    return null;
  }
}
  • Step 2: Token generation in booking flow

When a reservation is confirmed, generate the table token:

// In the reservation confirmation flow
import { generateTableToken } from "~/lib/qr-token";
 
const token = generateTableToken({
  tableId: reservation.tableId,
  reservationId: reservation._id,
  expiresAt: showDateTimestamp + 24 * 60 * 60 * 1000, // 24h after show
});
const tableUrl = `${NEXT_PUBLIC_APP_URL}/${locale}/table?tableId=${tableId}&token=${token}`;
  • Step 3: Token validation in PWA page
// apps/frontend/app/table/page.tsx
"use client";
 
import { Suspense, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { verifyTableToken } from "~/lib/qr-token";
import { MenuCategory } from "~/components/menu/menu-category";
import { CartButton } from "~/components/menu/cart-button";
 
function TablePWALoading() {
  return (
    <div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
      <div className="animate-pulse space-y-4">
        <div className="h-8 bg-[#2E2E2E] rounded w-48" />
        <div className="h-4 bg-[#2E2E2E] rounded w-32" />
      </div>
    </div>
  );
}
 
export default function TablePage() {
  const t = useTranslations();
 
  // CORRECT: nuqs URL state — no dynamic route segments
  const [tableId] = useQueryState("tableId", { defaultValue: "" });
  const [token] = useQueryState("token", { defaultValue: "" });
 
  // Validate token on load
  const validPayload = useMemo(() => {
    if (!token) return null;
    return verifyTableToken(token);
  }, [token]);
 
  if (!validPayload || validPayload.tableId !== tableId) {
    return (
      <div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
        <div className="text-center">
          <h1 className="font-serif text-[#C5A059] text-xl mb-2">
            {t("pos.table.invalidTokenTitle")}
          </h1>
          <p className="text-[#808080]">{t("pos.table.invalidTokenMessage")}</p>
        </div>
      </div>
    );
  }
 
  // Get or create order for this table
  const openOrder = useQuery(
    api.orders.getOpenOrderByTable,
    tableId ? { tableId } : "skip"
  );
 
  return (
    <div className="min-h-screen bg-[#1a1a1a] text-[#e6e6e6]">
      <header className="sticky top-0 z-10 bg-[#2E2E2E] border-b border-[#4d4d4d] px-4 py-3">
        <div className="max-w-lg mx-auto flex justify-between items-center">
          <div>
            <h1 className="font-serif text-[#C5A059] text-lg">
              {t("pos.table.title", { tableId })}
            </h1>
            <p className="text-xs text-[#808080]">{t("pos.table.venue")}</p>
          </div>
          {openOrder && openOrder.status !== "OPEN" && (
            <span className="text-xs bg-[#C5A059]/20 text-[#C5A059] px-2 py-1 rounded">
              {t("pos.table.orderSent")}
            </span>
          )}
        </div>
      </header>
 
      <Suspense fallback={<TablePWALoading />}>
        <div className="max-w-lg mx-auto">
          <MenuCategory />
          <CartButton tableId={tableId} openOrder={openOrder} />
        </div>
      </Suspense>
    </div>
  );
}
  • Step 4: Commit
git add apps/frontend/lib/qr-token.ts
git commit -m "feat(table-pos): add QR token generation and validation"

Phase 4: Guest PWA Components

Task 4: Create Menu Components

Files:

  • Create: apps/frontend/components/menu/menu-card.tsx

  • Create: apps/frontend/components/menu/menu-category.tsx

  • Create: apps/frontend/components/menu/cart-button.tsx

  • Step 1: Create menu card component

// apps/frontend/components/menu/menu-card.tsx
"use client";
 
import { useCallback, useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
 
type MenuItem = {
  _id: string;
  name: string;
  description: string;
  price: number;
  category: string;
};
 
type OpenOrder = {
  _id: string;
  status: string;
} | null;
 
export function MenuCard({
  item,
  openOrder,
  onCartChange,
}: {
  item: MenuItem;
  openOrder: OpenOrder;
  onCartChange: () => void;
}) {
  const t = useTranslations();
  const [isPending, startTransition] = useTransition();
  const addItem = useMutation(api.orders.addItem);
 
  const handleAdd = useCallback(() => {
    if (!openOrder) return;
    startTransition(() => {
      addItem({ orderId: openOrder._id, menuItemId: item._id, quantity: 1 });
      onCartChange();
    });
  }, [openOrder, addItem, item._id, onCartChange]);
 
  return (
    <div
      className="bg-[#2E2E2E] border border-[#4d4d4d] p-4 rounded-lg"
      data-testid="menu-item-card"
      data-category={item.category}
    >
      <div className="flex justify-between items-start">
        <div className="flex-1">
          <h3 className="font-medium text-[#e6e6e6]">{item.name}</h3>
          <p className="text-sm text-[#808080] mt-1">{item.description}</p>
        </div>
        <span className="text-[#C5A059] font-medium ml-4">
          {item.price.toLocaleString()}{t("common.currencyShort")}
        </span>
      </div>
      <div className="mt-3">
        <button
          onClick={handleAdd}
          disabled={isPending || !openOrder}
          data-testid="menu-item-add-btn"
          className="text-sm bg-[#C5A059] text-[#1a1a1a] px-3 py-1.5 rounded font-medium hover:bg-[#DEC89E] transition-colors disabled:opacity-50"
        >
          {t("common.add")}
        </button>
      </div>
    </div>
  );
}
  • Step 2: Create menu category component
// apps/frontend/components/menu/menu-category.tsx
"use client";
 
import { useQuery } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { MenuCard } from "./menu-card";
 
const CATEGORIES = [
  { key: "APPETIZER", labelKey: "menu.category.appetizer" },
  { key: "MAIN", labelKey: "menu.category.main" },
  { key: "DESSERT", labelKey: "menu.category.dessert" },
  { key: "DRINK", labelKey: "menu.category.drink" },
  { key: "COCKTAIL", labelKey: "menu.category.cocktail" },
];
 
export function MenuCategory() {
  const t = useTranslations();
  const [category, setCategory] = useQueryState("cat", {
    defaultValue: "APPETIZER",
    serialize: (v) => v,
    parse: (v) => v,
  });
 
  // CORRECT: useQuery(api.fn, args) — pass function reference
  const items = useQuery(api.menu.listByCategory, { category });
 
  return (
    <div data-testid="menu-grid">
      <div className="flex gap-1 px-4 py-3 overflow-x-auto">
        {CATEGORIES.map((cat) => (
          <button
            key={cat.key}
            onClick={() => setCategory(cat.key)}
            data-testid={`category-tab-${cat.key}`}
            className={`px-3 py-1.5 text-sm rounded whitespace-nowrap ${
              category === cat.key
                ? "bg-[#C5A059] text-[#1a1a1a] font-medium"
                : "bg-[#2E2E2E] text-[#808080] border border-[#4d4d4d]"
            }`}
          >
            {t(cat.labelKey)}
          </button>
        ))}
      </div>
      <div className="px-4 pb-24 space-y-3">
        {items?.map((item) => (
          <MenuCard key={item._id} item={item} openOrder={null} onCartChange={() => {}} />
        ))}
      </div>
    </div>
  );
}
  • Step 3: Commit
git add apps/frontend/components/menu/
git commit -m "feat(table-pos): add menu components"

Phase 5: PWA Manifest & Offline

Task 5: PWA Setup

Files:

  • Create: apps/frontend/public/manifest.webmanifest

  • Step 1: Create manifest

{
  "name": "House of Legends - Table Ordering",
  "short_name": "HOL Table",
  "description": "Order food and drinks at your table",
  "start_url": "/table",
  "display": "standalone",
  "background_color": "#1a1a1a",
  "theme_color": "#C5A059",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}
  • Step 2: Add iOS meta tags to locale layout
// In apps/frontend/app/[locale]/layout.tsx, add to <head>:
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
  • Step 3: Commit
git add apps/frontend/public/manifest.webmanifest
git commit -m "feat(table-pos): add PWA manifest"

Enrichment Sections

1. Zod Schemas

// Shared POS schemas
import { z } from "zod";
 
export const CreateTableSchema = z.object({
  name: z.string().min(1, "Table number is required"), // e.g., "T01"
  capacity: z.number().int().positive("Capacity must be at least 1"),
});
 
export const CreateMenuItemSchema = z.object({
  name: z.string().min(1, "Menu item name is required"),
  description: z.string(),
  price: z.number().nonnegative("Price must be non-negative"),
  category: z.enum([
    "APPETIZER",
    "MAIN",
    "DESSERT",
    "DRINK",
    "COCKTAIL",
    "WINE",
    "BEER",
    "SOFT_DRINK",
    "OTHER",
  ]),
  station: z.enum(["KITCHEN", "BAR"]),
  imageUrl: z.string().url().optional(),
});
 
export const CreateOrderSchema = z.object({
  tableId: z.string().min(1, "Table ID is required"),
  reservationId: z.string().optional(),
});
 
export const AddItemSchema = z.object({
  orderId: z.string().min(1, "Order ID is required"),
  menuItemId: z.string().min(1, "Menu item ID is required"),
  quantity: z.number().int().positive("Quantity must be at least 1"),
  notes: z.string().optional(),
});
 
export const TokenPayloadSchema = z.object({
  tableId: z.string().min(1),
  reservationId: z.string().min(1),
  expiresAt: z.number().positive(),
});

2. Error Handling

// convex/functions/orders.ts
export const POS_ERROR_CODES = {
  MENU_ITEM_NOT_FOUND: "MENU_ITEM_NOT_FOUND",
  ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
  ORDER_NOT_OPEN: "ORDER_NOT_OPEN",
  INVALID_QUANTITY: "INVALID_QUANTITY",
  ORDER_EMPTY: "ORDER_EMPTY",
  UNAUTHORIZED: "UNAUTHORIZED",
  INVALID_TOKEN: "INVALID_TOKEN",
  TOKEN_EXPIRED: "TOKEN_EXPIRED",
} as const;
 
type POSErrorCode = keyof typeof POS_ERROR_CODES;
FunctionError CodeMessage KeyCondition
verifyTableTokenINVALID_TOKENerrors.pos.invalidTokenToken signature mismatch
verifyTableTokenTOKEN_EXPIREDerrors.pos.tokenExpiredToken past expiry
addItemMENU_ITEM_NOT_FOUNDerrors.menu.notFoundMenu item does not exist
addItemORDER_NOT_OPENerrors.order.notOpenOrder already submitted
submitOrderORDER_EMPTYerrors.order.emptyNo items in order

3. Convex Real-time Subscription Pattern

// Guest PWA — open order for table
const openOrder = useQuery(
  api.orders.getOpenOrderByTable,
  tableId ? { tableId } : "skip",
);
 
// Kitchen KDS — items by station and status
const kitchenItems = useQuery(api.orders.getByStationAndStatus, {
  station: "KITCHEN",
});
 
const barItems = useQuery(api.orders.getByStationAndStatus, {
  station: "BAR",
});
 
// Menu items by category
const menuItems = useQuery(api.menu.listByCategory, { category });

4. Mobile/Responsive Considerations

ComponentMobile Behavior
Guest PWASingle-column menu; floating cart button; bottom sheet cart
Kitchen KDSHorizontal scroll kanban; sticky header
Floor POSTable list left; menu grid right (tablet+)
ReceptionTable list view with status indicators
Offline bannerPersistent top banner when navigator.onLine === false

5. PWA / Offline Behavior

Service Worker Strategy:
- Menu page shell: Cache-First with background revalidation
- Menu item data: Network-First with 5s timeout, fallback to cache
- Convex subscriptions: Always online (real-time required)

Offline Capabilities:
- Menu browsing: WORKS (cached)
- Add to cart: BLOCKED (requires Convex write)
- Order submission: BLOCKED (requires Convex write)
- Order status tracking: BLOCKED (real-time required)

Offline UI:
- Banner: "You are offline. Menu browsing available. Ordering requires connection."
- Add buttons: disabled with tooltip

6. i18n / next-intl Requirements

{
  "pos": {
    "table": {
      "title": "Table {tableId}",
      "venue": "House of Legends",
      "orderSent": "Order Sent",
      "sendOrder": "Send Order",
      "callStaff": "Call Staff",
      "invalidTokenTitle": "Invalid Link",
      "invalidTokenMessage": "This link is invalid or has expired. Please contact staff.",
      "offline": "You are offline. Menu browsing available. Ordering requires connection."
    },
    "orderStatus": {
      "pending": "Pending",
      "submitted": "Submitted",
      "preparing": "Preparing",
      "ready": "Ready",
      "served": "Served"
    },
    "cart": {
      "yourOrder": "Your Order",
      "empty": "Your cart is empty",
      "sendOrder": "Send Order",
      "total": "Total"
    }
  },
  "menu": {
    "category": {
      "appetizer": "Appetizers",
      "main": "Main Courses",
      "dessert": "Desserts",
      "drink": "Drinks",
      "cocktail": "Cocktails",
      "wine": "Wine",
      "softDrink": "Soft Drinks"
    }
  },
  "common": {
    "add": "Add",
    "currencyShort": " VND",
    "loading": "Loading..."
  },
  "errors": {
    "pos": {
      "invalidToken": "This link is invalid. Please contact staff.",
      "tokenExpired": "This link has expired. Please contact staff."
    },
    "menu": {
      "notFound": "Menu item not found"
    },
    "order": {
      "notOpen": "Order is not open for modifications",
      "empty": "Cannot submit an empty order"
    }
  }
}

7. Environment-Specific Configuration

# Server-only:
CLERK_SECRET_KEY=
QR_TOKEN_SECRET=           # HMAC secret for table QR tokens (32+ bytes)
 
# Client-safe:
NEXT_PUBLIC_CONVEX_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_APP_URL=       # Frontend URL for token generation

8. TDD Test Cases

E2E Tests (Playwright):

// e2e/table-pos.spec.ts
 
test("POS-E2E-1.1: Valid token renders PWA menu", async ({ page }) => {
  // Given: Guest scans valid QR code
  // When: Guest navigates to /table?tableId=table1&token=validToken
  // Then: Menu grid renders with categories
  await page.goto("/en/table?tableId=table1&token=validToken");
  await expect(page.locator('[data-testid="menu-grid"]')).toBeVisible();
});
 
test("POS-E2E-1.2: Invalid token shows error state", async ({ page }) => {
  // Given: Guest uses tampered or expired token
  // When: Guest navigates to /table?tableId=table1&token=invalidToken
  // Then: Error message shown, menu not accessible
  await page.goto("/en/table?tableId=table1&token=invalidToken");
  await expect(page.getByText("Invalid Link")).toBeVisible();
});
 
test("POS-E2E-1.3: Add item updates cart count", async ({ page }) => {
  // Given: Guest PWA is open with valid token and open order
  // When: Guest taps "Add" on a menu item
  // Then: Cart count increments
  await page.goto("/en/table?tableId=table1&token=validToken");
  await page.getByTestId("menu-item-add-btn").first().click();
  await expect(page.getByTestId("cart-count")).toContainText("1");
});
 
test("POS-E2E-1.4: Submit order changes status to SUBMITTED", async ({
  page,
}) => {
  // Given: Guest has items in cart
  // When: Guest taps "Send Order"
  // Then: Order status changes to SUBMITTED, badge appears
  await page.goto("/en/table?tableId=table1&token=validToken");
  await page.getByTestId("menu-item-add-btn").first().click();
  await page.getByTestId("send-order-btn").click();
  await expect(page.locator('[data-testid="order-sent-badge"]')).toBeVisible();
});
 
test("POS-E2E-1.5: Offline banner shows when disconnected", async ({
  page,
}) => {
  // Given: Guest PWA is open
  // When: Browser goes offline
  // Then: Offline banner appears, Add buttons disabled
  await page.goto("/en/table?tableId=table1&token=validToken");
  await page.context().setOffline(true);
  await expect(page.getByText("You are offline")).toBeVisible();
});

Component Tests (Vitest + RTL):

// __tests__/lib/qr-token.test.ts
 
it("POS-CT-1.1: generateTableToken creates valid signed token", () => {
  const payload = {
    tableId: "table1",
    reservationId: "res1",
    expiresAt: Date.now() + 86400000,
  };
  const token = generateTableToken(payload);
  expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
});
 
it("POS-CT-1.2: verifyTableToken returns payload for valid token", () => {
  const payload = {
    tableId: "table1",
    reservationId: "res1",
    expiresAt: Date.now() + 86400000,
  };
  const token = generateTableToken(payload);
  const result = verifyTableToken(token);
  expect(result).toEqual(payload);
});
 
it("POS-CT-1.3: verifyTableToken returns null for tampered token", () => {
  const payload = {
    tableId: "table1",
    reservationId: "res1",
    expiresAt: Date.now() + 86400000,
  };
  const token = generateTableToken(payload);
  const tampered = token.slice(0, -5) + "XXXXX";
  expect(verifyTableToken(tampered)).toBeNull();
});
 
it("POS-CT-1.4: verifyTableToken returns null for expired token", () => {
  const payload = {
    tableId: "table1",
    reservationId: "res1",
    expiresAt: Date.now() - 1000,
  };
  const token = generateTableToken(payload);
  expect(verifyTableToken(token)).toBeNull();
});

Backend Tests (Vitest):

// __tests__/convex/orders.test.ts
 
it("POS-BE-1.1: getOrCreateForTable creates new order when none exists", async () => {
  const ctx = createMockContext({ orders: [] });
  const result = await ctx.runMutation(api.orders.getOrCreateForTable, {
    tableId: "table1",
  });
  expect(result.status).toBe("OPEN");
  expect(result.totalAmount).toBe(0);
});
 
it("POS-BE-1.2: getOrCreateForTable returns existing OPEN order", async () => {
  const ctx = createMockContext({
    orders: [{ tableId: "table1", status: "OPEN", totalAmount: 50000 }],
  });
  const result = await ctx.runMutation(api.orders.getOrCreateForTable, {
    tableId: "table1",
  });
  expect(result.totalAmount).toBe(50000);
});
 
it("POS-BE-1.3: addItem throws for nonexistent menu item", async () => {
  const ctx = createMockContext({ menuItems: {} });
  await expect(
    ctx.runMutation(api.orders.addItem, {
      orderId: "order1",
      menuItemId: "nonexistent",
      quantity: 1,
    }),
  ).rejects.toThrow("MENU_ITEM_NOT_FOUND");
});

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Required bypackage-bundle-pricingorders.totalAmount accumulated at checkout
Required bynotifications-crmreservation.tableId links to table
Depends onstaff-operationsShared tables, menuItems, orders, orderItems schema
QR token generationbooking-flowToken generated on reservation confirmation
Token validationguest-profiles (Spec 05)Token validation shared with guest onboarding

10. Performance Considerations

ScenarioAt Scale (100 tables, 500 concurrent guests)
Token validationHMAC-SHA256 is O(1); negligible latency
Menu browsingCached per category; Network-First with 5s timeout prevents stale data
Order submissionConvex mutation < 100ms; real-time push to KDS < 500ms end-to-end
Offline browsingCache-First for shell; serve from Service Worker while revalidating in background
Concurrent ordersConvex handles 50+ concurrent writes; compound index by_station_status prevents scans

Acceptance Criteria

  1. QR token is validated server-side before PWA renders — invalid/expired tokens show error state
  2. Guest can browse menu by category without logging in
  3. Adding item to cart updates order total in real-time
  4. Submitting order fires items to correct KDS (KITCHEN vs BAR)
  5. Kitchen KDS kanban moves items through SUBMITTED -> PREPARING -> READY -> SERVED
  6. Guest PWA reflects order status changes in real-time
  7. Multiple orders accumulate under same reservation
  8. Staff POS can create and submit orders on behalf of guests
  9. Service worker caches menu page for offline browsing
  10. Offline state shows clear banner, cannot submit orders

User Stories

IDAs a...I want to...So that...Priority
POS-US01GuestScan QR code and browse menu at my tableI can order food and drinks without waiting for staffMust
POS-US02GuestSee order status update in real-timeI know when my drinks are being preparedMust
POS-US03GuestHave my orders be routed to correct stationKitchen sees food, bar sees drinksMust
POS-US04StaffTake orders on behalf of guestsI can help guests who prefer not to use the PWAMust
POS-US05GuestAccess the PWA offline for menu browsingI can browse the menu even without internetShould
POS-US06SystemValidate QR tokens server-sidePrevent unauthorized access to other tablesMust

Test Scenarios

IDScenarioGivenWhenThen
POS-TS01Valid token renders PWAValid HMAC-signed tokenGuest loads /table pageMenu renders, no error
POS-TS02Invalid token shows errorTampered tokenGuest loads /table pageError state shown
POS-TS03Expired token shows errorExpired timestamp in tokenGuest loads /table pageError state shown
POS-TS04Add to cartOpen order existsGuest taps "Add" on menu itemCart count increments, total updates
POS-TS05Submit order fires to KDSItems in cartGuest taps "Send Order"Order status changes to SUBMITTED, KDS sees items
POS-TS06Station filterFood + bar items in same orderGuest submitsKitchen sees food, bar sees drinks
POS-TS07Offline menu browsingService worker cachedGo offline, reloadMenu categories + items visible
POS-TS08Offline submit blockedOffline stateGuest taps "Send Order"Button disabled, offline banner shown
POS-TS09Real-time updateGuest PWA + KDS openStatus changed in KDSGuest PWA item status updates without refresh
POS-TS10Token validation failureTable ID mismatch in token vs URLGuest loads /table pageError state shown

Consistency Audit: table-pos-system

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Phase 4, TablePageUses nuqs URL state (/table?tableId=xxx&token=xxx) — CORRECTVerified compliance with P0 rule
2Phase 2Auth helper usage patternAdded explicit requireStaffOrAdmin inline pattern with clean QueryBuilder type
3Phase 3Token validation shows error state (not just 404)Proper error UI with translation keys
4File MapReferences staffMutation/adminMutation from convex/auth.ts which don't existChanged to plain mutation with inline role checks

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1QR token libNo logging needed for security-sensitive codeClean implementation with no console usage
2PWA pageMissing Suspense boundaryAdded Suspense with TablePWALoading skeleton

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1staffMutation/adminMutation do not exist in convex/auth.tsFoundation plan must implement these auth wrappers
2PWA manifest icons need to be created as actual PNG filesDesign team must provide 192x192 and 512x512 icons

Schema Consistency Check

  • orders table links to tables via tableId (correct)
  • orderItems links to orders via orderId and to menuItems via menuItemId (correct)
  • All indexes (by_table, by_station_status, by_category) match query patterns
  • Token payload includes tableId, reservationId, expiresAt — no PII stored in token