plans
2026-05-03
2026 05 03 Booking Flow

Booking Flow 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 the booking flow like booking an airline ticket. Guests see a calendar with all shows (date + time), select their experience and tickets, add optional add-ons with tap UI, see dynamic pricing update in real-time, then checkout with name and phone.

Architecture: Single /booking page with 2-column layout: Left column shows calendar with all shows (lunch vs night clearly marked). Right column shows pricing/experience options. Steps flow via nuqs (?step=tickets, ?step=addons, ?step=checkout, ?step=confirmation). No seat selection per client request.

The 4 steps are:

  1. Tickets/booking?step=tickets — Calendar view (left) + pricing/experience selector (right). Guest selects date, time, ticket type, quantity
  2. Add-ons/booking?step=addons — Tap-to-select add-ons UI. Skippable.
  3. Checkout/booking?step=checkout — Name, phone number form + OnePay redirect
  4. Confirmation/booking?step=confirmation — Post-payment page with QR code

Tech Stack: Next.js 16 App Router with nuqs for URL state, React Context for booking cart, Convex mutations for persistence, Tailwind CSS v4.

Pricing Context:

  • Ticket prices come from occurrence (override) or template (default)
  • Add-ons prices come from the addOns table
  • Surcharges applied at checkout: day-of-week (Thu+50K, Fri+100K, Sat+150K, Sun+100K) and small-party (+100K if <15 guests)
  • Final total displayed in checkout step before OnePay redirect

Spec reference: docs/superpowers/specs/09-confirmation-exp.md (partial — confirmation spec only)


Business Summary

What this does: Implements the booking flow like booking an airline ticket. Guests land on /booking and see a 2-column layout: Left column shows a calendar with all upcoming shows (date + time, lunch vs night clearly differentiated). Right column shows pricing for each experience type. Guest selects their show, ticket type, and quantity. Next step is add-ons with easy tap-to-select UI. Checkout shows dynamic pricing breakdown and collects name + phone. Finally redirect to OnePay for payment.

Why it matters: The booking flow is the primary revenue path for House of Legends — it is the core commercial activity of the business. The airline-ticket UX pattern is familiar and reduces friction. Showing all dates/times in one view helps guests find available slots that fit their schedule. Dynamic pricing (updating as selections change) builds trust and reduces abandonment. A 10-minute countdown timer holds seats during the flow to prevent overselling.

Time to implement: 10-14 days | Complexity: Critical

Dependencies: foundation-plan (for schema, BookingContext), payment-onepay-plan (for OnePay integration)


Context & Business Logic

The current booking flow at apps/frontend/app/[locale]/booking/ uses a 6-step WordPress-era flow (experience → show → bundle → addons → reservation → payment → confirmation). Note: Zone step was removed (2026-05-04). The new 4-step flow collapses this into an airline-ticket UX.

Critical behaviors:

  • 2-column layout: Week-based show list — Left: shows organized by week (only weeks with shows appear), grouped by lunch/night. Right: pricing/experience options for selected show
  • Immersive image cards — Each show displayed as card with background image, white text overlaid. Like movie ticket apps. Date/time/availability overlaid on hero image. Grouped by week strip.
  • Dynamic pricing — Total updates instantly as guest selects ticket type, quantity, add-ons
  • 10-minute countdown timer starts on Step 1 entry — seats are held for 10 minutes
  • Sticky cart visible on all steps (bottom bar on mobile, sidebar on desktop)
  • Real-time availability — if another user books the last seats, the current user's timer should reflect the change
  • Tap-to-select add-ons — Easy toggle UI for add-ons, skippable
  • No seat selection — Per client request, no seat map. Just ticket type + quantity
  • No language selector during booking — language is locked when entering flow
  • OnePay redirect — customer sees VA number and QR code on our page; webhook confirms payment

File Map

apps/frontend/
├── app/[locale]/booking/
│   └── page.tsx                   # Single page — nuqs ?step=&occurrenceId= (CREATE)
│   └── layout.tsx                 # Booking layout (MODIFY — add BookingProvider)
├── components/booking/
│   ├── booking-provider.tsx        # BookingContext provider (CREATE)
│   ├── sticky-cart.tsx           # Sticky cart component (CREATE)
│   ├── countdown-timer.tsx         # 10-min countdown (CREATE)
│   ├── week-show-list.tsx         # Step 1: Week-based show list (CREATE)
│   ├── ticket-selector.tsx        # Step 1: Ticket type + quantity (CREATE)
│   ├── step-addons.tsx           # Step 2: Add-ons tap UI (CREATE)
│   ├── checkout-form.tsx           # Step 3: Customer form (CREATE)
│   └── confirmation-display.tsx    # Step 4: Confirmation (CREATE)
├── lib/
│   └── booking-context.tsx         # BookingContext (CREATE — step derived from nuqs)
└── middleware.ts                   # May need to add locale check

All booking steps are rendered within the single /booking page. Navigation between steps uses useQueryState("step") from nuqs — URL updates without full page reload (Next.js handles this automatically).


Phase 1: Booking State Management

Task 1: Create BookingContext

Files:

  • Create: apps/frontend/lib/booking-context.tsx

  • Step 1: Define BookingState type

import { Id } from "~/convex/_generated/dataModel";
 
export type TicketType = "DINNER_THEATRE" | "SHOW_ONLY";
 
export type AddOnSelection = {
  addOnId: Id<"addOns">;
  quantity: number;
};
 
export type BookingState = {
  // Step 1 data
  occurrenceId: Id<"showOccurrences"> | null;
  ticketType: TicketType | null;
  quantity: number;
 
  // Step 2 data
  seatIds: string[];
 
  // Step 3 data
  addOns: AddOnSelection[];
 
  // Step 4 data
  customerInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone?: string;
  } | null;
 
  // Reservation ID (created on step 1)
  reservationId: Id<"reservations"> | null;
 
  // Timer
  bookingExpiresAt: number | null;
};
// Step is managed by nuqs "step" param, NOT in this state
  • Step 2: Define BookingContext
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { Id } from "~/convex/_generated/dataModel";
 
export type BookingStep = "tickets" | "seats" | "addons" | "checkout" | "confirmation";
 
type BookingAction =
  | { type: "START_BOOKING"; payload: { occurrenceId: Id<"showOccurrences">; reservationId: Id<"reservations">; bookingExpiresAt: number } }
  | { type: "SELECT_TICKET"; payload: { ticketType: TicketType; quantity: number } }
  | { type: "SET_SEATS"; payload: { seatIds: string[] } }
  | { type: "SET_ADDONS"; payload: { addOns: AddOnSelection[] } }
  | { type: "SET_CUSTOMER_INFO"; payload: BookingState["customerInfo"] }
  | { type: "RESET" };
 
const initialState: BookingState = {
  occurrenceId: null,
  ticketType: null,
  quantity: 1,
  seatIds: [],
  addOns: [],
  customerInfo: null,
  reservationId: null,
  bookingExpiresAt: null,
};
 
function bookingReducer(state: BookingState, action: BookingAction): BookingState {
  switch (action.type) {
    case "START_BOOKING":
      return { ...state, ...action.payload };
    case "SELECT_TICKET":
      return { ...state, ...action.payload };
    case "SET_SEATS":
      return { ...state, seatIds: action.payload.seatIds };
    case "SET_ADDONS":
      return { ...state, addOns: action.payload.addOns };
    case "SET_CUSTOMER_INFO":
      return { ...state, customerInfo: action.payload };
    case "RESET":
      return initialState;
    default:
      return state;
  }
}
 
type BookingContextValue = {
  state: BookingState;
  dispatch: React.Dispatch<BookingAction>;
};
 
const BookingContext = createContext<BookingContextValue | null>(null);
 
export function BookingProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(bookingReducer, initialState);
  return <BookingContext.Provider value={{ state, dispatch }}>{children}</BookingContext.Provider>;
}
 
export function useBooking() {
  const ctx = useContext(BookingContext);
  if (!ctx) throw new Error("useBooking must be used within BookingProvider");
  return ctx;
}
  • Step 3: Wrap booking layout with provider

Modify apps/frontend/app/[locale]/booking/layout.tsx to include <BookingProvider>.

  • Step 4: Test context renders without error
# Start dev server and navigate to /booking — should not crash
npm run dev
  • Step 5: Commit
git add apps/frontend/lib/booking-context.tsx
git commit -m "feat(booking): add BookingContext for state management"

Phase 2: Booking Layout & Sticky Cart

Task 2: Create Booking Layout with Sticky Cart

Files:

  • Create: apps/frontend/components/booking/sticky-cart.tsx

  • Create: apps/frontend/app/[locale]/booking/page.tsx (single page with step routing)

  • Step 1: Create sticky cart component

The sticky cart shows:

  • Selected ticket type × quantity
  • Selected add-ons (name + quantity × price)
  • Subtotal
  • Total VND (updates in real time)

Desktop: right sidebar (fixed width ~320px) Mobile: bottom bar (fixed, full width)

"use client";
import { useBooking } from "~/lib/booking-context";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
 
export function StickyCart() {
  const t = useTranslations("booking.cart");
  const { state } = useBooking();
  const { ticketType, quantity, addOns, occurrenceId } = state;
 
  const occurrenceData = useQuery(
    api.occurrences.getAvailability,
    occurrenceId ? { occurrenceId } : "skip"
  );
 
  if (!ticketType || !occurrenceId) return null;
 
  // Calculate prices
  const ticketPrice = occurrenceData
    ? (ticketType === "DINNER_THEATRE"
        ? occurrenceData.dinnerPrice
        : occurrenceData.showOnlyPrice)
    : 0;
  const ticketSubtotal = ticketPrice * quantity;
 
  return (
    <div className="sticky-cart bg-surface border-l border-border p-4">
      <h3 className="text-accent font-serif mb-4">{t("title")}</h3>
      {/* Line items */}
      <div className="text-sm space-y-2">
        <div className="flex justify-between">
          <span>{ticketType === "DINNER_THEATRE" ? t("dinnerTheatre") : t("showOnly")} × {quantity}</span>
          <span>{ticketSubtotal.toLocaleString()} VND</span>
        </div>
        {/* Add-ons */}
      </div>
      <div className="border-t border-border mt-4 pt-4 font-bold text-accent text-lg">
        {t("total")}: {(ticketSubtotal).toLocaleString()} VND
      </div>
    </div>
  );
}
  • Step 2: Create booking page with step routing
// apps/frontend/app/[locale]/booking/page.tsx
"use client";
import { useQueryState } from "nuqs";
import { BookingProvider } from "~/lib/booking-context";
import { StickyCart } from "~/components/booking/sticky-cart";
import { CountdownTimer } from "~/components/booking/countdown-timer";
import { StepTickets } from "~/components/booking/step-tickets";
import { StepSeats } from "~/components/booking/step-seats";
import { StepAddons } from "~/components/booking/step-addons";
import { CheckoutForm } from "~/components/booking/checkout-form";
import { ConfirmationDisplay } from "~/components/booking/confirmation-display";
import { useTranslations } from "next-intl";
import { Suspense } from "react";
 
const STEPS = ["tickets", "seats", "addons", "checkout", "confirmation"] as const;
 
export default function BookingPage() {
  const t = useTranslations("booking");
  const [step, setStep] = useQueryState("step", { defaultValue: "tickets" });
  const [occurrenceId] = useQueryState("occurrenceId", { defaultValue: "" });
 
  if (!occurrenceId && step !== "confirmation") {
    return <div className="text-center py-24 text-gray-400">{t("invalidLink")}</div>;
  }
 
  return (
    <BookingProvider>
      <div className="flex min-h-screen bg-background pt-20">
        <main className="flex-1 px-4 py-8 max-w-2xl mx-auto w-full">
          <CountdownTimer />
          <Suspense fallback={<div className="animate-pulse">{t("loading")}</div>}>
            {step === "tickets" && <StepTickets onNext={() => setStep("seats")} />}
            {step === "seats" && <StepSeats onNext={() => setStep("addons")} onBack={() => setStep("tickets")} />}
            {step === "addons" && <StepAddons onNext={() => setStep("checkout")} onBack={() => setStep("seats")} />}
            {step === "checkout" && <CheckoutForm />}
            {step === "confirmation" && <ConfirmationDisplay />}
          </Suspense>
        </main>
        {/* Desktop sidebar — hidden on confirmation */}
        {step !== "confirmation" && (
          <aside className="hidden lg:block w-80">
            <StickyCart />
          </aside>
        )}
      </div>
    </BookingProvider>
  );
}
  • Step 3: Commit
git add apps/frontend/app/[locale]/booking/page.tsx apps/frontend/components/booking/sticky-cart.tsx
git commit -m "feat(booking): add single booking page with nuqs step routing"

Phase 3: Step 1 — Tickets & Experience

Task 3: Create Tickets Page

Files:

  • Create: apps/frontend/components/booking/ticket-selector.tsx

  • Create: apps/frontend/components/booking/countdown-timer.tsx

  • Modify: convex/functions/reservations.ts — add createPending mutation

  • Step 1: Read convex/functions/occurrences.ts for getAvailability

Confirm the query returns: dinnerPrice, showOnlyPrice, showOnlyEnabled, remaining, badge

  • Step 2: Add createPending mutation to reservations
// In convex/functions/reservations.ts
export const createPending = mutation({
  args: {
    occurrenceId: v.id("showOccurrences"),
    ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
    quantity: v.number().int().min(1).max(32),
  },
  handler: async (ctx, { occurrenceId, ticketType, quantity }) => {
    const occurrence = await ctx.db.get(occurrenceId);
    if (!occurrence) {
      throw new Error("Show date not found");
    }
 
    const remaining = occurrence.actualCapacity - occurrence.bookedCount;
    if (quantity > remaining) {
      throw new Error("Not enough seats available");
    }
 
    const now = Date.now();
    const bookingExpiresAt = now + 10 * 60 * 1000; // 10 minutes
 
    const reservationId = await ctx.db.insert("reservations", {
      occurrenceId,
      ticketType,
      quantity,
      seatIds: [],
      addOns: [],
      customerInfo: null,
      paymentStatus: "PENDING",
      bookingExpiresAt,
      createdAt: now,
      updatedAt: now,
    });
 
    // Increment booked count
    await ctx.db.patch(occurrenceId, {
      bookedCount: occurrence.bookedCount + quantity,
    });
 
    return reservationId;
  },
});
  • Step 3: Create CountdownTimer component
"use client";
import { useEffect, useState } from "react";
import { useBooking } from "~/lib/booking-context";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
 
export function CountdownTimer() {
  const t = useTranslations("booking.countdown");
  const { state, dispatch } = useBooking();
  const { bookingExpiresAt } = state;
  const [timeLeft, setTimeLeft] = useState<string>("");
  const [showExpiredModal, setShowExpiredModal] = useState(false);
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
 
  useEffect(() => {
    if (!bookingExpiresAt) return;
    const interval = setInterval(() => {
      const now = Date.now();
      const remaining = Math.max(0, bookingExpiresAt - now);
      const minutes = Math.floor(remaining / 60000);
      const seconds = Math.floor((remaining % 60000) / 1000);
      setTimeLeft(`${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`);
 
      if (remaining === 0) {
        setShowExpiredModal(true);
        dispatch({ type: "RESET" });
      }
    }, 1000);
    return () => clearInterval(interval);
  }, [bookingExpiresAt, dispatch]);
 
  if (!bookingExpiresAt) return null;
 
  return (
    <>
      <div className="text-accent font-mono text-lg">
        {t("seatsReserved")} {timeLeft}
      </div>
      {showExpiredModal && (
        <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70">
          <div className="bg-surface border border-border rounded-lg p-8 max-w-md text-center">
            <h2 className="text-2xl font-serif text-accent mb-4">{t("expiredTitle")}</h2>
            <p className="text-gray-400 mb-6">{t("expiredMessage")}</p>
            <button
              onClick={() => {
                setShowExpiredModal(false);
                startTransition(() => {
                  router.push("/");
                });
              }}
              className="px-6 py-3 bg-accent text-black font-bold rounded-lg"
            >
              {t("returnHome")}
            </button>
          </div>
        </div>
      )}
    </>
  );
}
  • Step 4: Create TicketSelector component
"use client";
import { useState } from "react";
import { useBooking } from "~/lib/booking-context";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { consola } from "consola";
import { Id } from "~/convex/_generated/dataModel";
 
export function StepTickets({ onNext }: { onNext: () => void }) {
  const t = useTranslations("booking.tickets");
  const { dispatch, state } = useBooking();
  const [ticketType, setTicketType] = useState<"DINNER_THEATRE" | "SHOW_ONLY">("DINNER_THEATRE");
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();
 
  const occurrenceId = state.occurrenceId;
  const availability = useQuery(api.occurrences.getAvailability, occurrenceId ? { occurrenceId } : "skip");
  const createPending = useMutation(api.reservations.createPending);
 
  const handleContinue = async () => {
    if (!occurrenceId) return;
 
    try {
      const reservationId = await createPending({
        occurrenceId: occurrenceId as Id<"showOccurrences">,
        ticketType,
        quantity,
      });
      dispatch({
        type: "START_BOOKING",
        payload: {
          occurrenceId: occurrenceId as Id<"showOccurrences">,
          reservationId,
          bookingExpiresAt: Date.now() + 10 * 60 * 1000,
        },
      });
      dispatch({ type: "SELECT_TICKET", payload: { ticketType, quantity } });
      startTransition(() => {
        onNext();
      });
    } catch (error) {
      consola.error("Failed to create reservation", { error });
    }
  };
 
  if (!availability) return <div className="animate-pulse space-y-4">{t("loading")}</div>;
 
  return (
    <div className="space-y-6">
      {/* Show reminder */}
      <div className="bg-surface p-4 rounded-lg">
        <p className="text-accent font-serif">{availability.showTitle}</p>
        <p className="text-gray-400">{availability.date} at {availability.time}</p>
      </div>
 
      {/* Ticket type */}
      <div>
        <label className="block text-sm font-medium mb-2">{t("ticketType")}</label>
        <div className="space-y-2">
          <button
            onClick={() => setTicketType("DINNER_THEATRE")}
            className={`w-full p-4 border rounded-lg text-left ${ticketType === "DINNER_THEATRE" ? "border-accent" : "border-border"}`}
          >
            <div className="flex justify-between">
              <span className="font-medium">{t("dinnerTheatre")}</span>
              <span className="text-accent">{availability.dinnerPrice.toLocaleString()} VND</span>
            </div>
          </button>
          {availability.showOnlyEnabled && (
            <button
              onClick={() => setTicketType("SHOW_ONLY")}
              className={`w-full p-4 border rounded-lg text-left ${ticketType === "SHOW_ONLY" ? "border-accent" : "border-border"}`}
            >
              <div className="flex justify-between">
                <span className="font-medium">{t("showOnly")}</span>
                <span className="text-accent">{availability.showOnlyPrice.toLocaleString()} VND</span>
              </div>
            </button>
          )}
        </div>
      </div>
 
      {/* Quantity */}
      <div>
        <label className="block text-sm font-medium mb-2">{t("quantity")}</label>
        <div className="flex items-center gap-4">
          <button
            onClick={() => setQuantity(Math.max(1, quantity - 1))}
            className="w-10 h-10 rounded-lg border border-border flex items-center justify-center"
          >
            -
          </button>
          <span className="text-xl font-bold">{quantity}</span>
          <button
            onClick={() => setQuantity(Math.min(availability.remaining, quantity + 1))}
            className="w-10 h-10 rounded-lg border border-border flex items-center justify-center"
          >
            +
          </button>
          <span className="text-sm text-gray-400">{t("maxAvailable", { max: availability.remaining })}</span>
        </div>
      </div>
 
      <button
        onClick={handleContinue}
        disabled={isPending}
        className="w-full bg-accent text-black py-3 rounded-lg font-bold disabled:opacity-50"
      >
        {isPending ? t("processing") : t("continue")}
      </button>
    </div>
  );
}
  • Step 5: Test flow

Navigate to /booking?occurrenceId=xxx&step=tickets — should render the ticket selector. Click Continue → should set step to seats via nuqs.

  • Step 6: Commit
git add apps/frontend/app/[locale]/booking/page.tsx
git commit -m "feat(booking): add step 1 tickets"

Phase 4: Step 2 — Seat Selection

Task 4: Create Seat Selection Page

Files:

  • Create: apps/frontend/components/booking/step-seats.tsx

  • See 2026-05-03-seat-selection.md for full seat map implementation

  • Step 1: Create seat selection page (refer to seat-selection plan)

  • Step 2: Ensure seats.holdSeats mutation exists

Check convex/functions/seats.ts for holdSeats mutation. If not present, add it.

  • Step 3: Commit

Phase 5: Step 3 — Add-ons (Upsell)

Task 5: Create Add-ons Page

Files:

  • Create: apps/frontend/components/booking/addon-cards.tsx

  • Step 1: Read convex/functions/addons.ts

  • Step 2: Create add-on cards component

"use client";
import { useState } from "react";
import { useBooking } from "~/lib/booking-context";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { Id } from "~/convex/_generated/dataModel";
 
export function StepAddons({ onNext }: { onNext: () => void }) {
  const t = useTranslations("booking.addons");
  const { state, dispatch } = useBooking();
  const [selectedAddOns, setSelectedAddOns] = useState<
    Array<{ addOnId: string; quantity: number }>
  >([]);
  const [isPending, startTransition] = useTransition();
 
  const addOns = useQuery(api.addons.listEnabled);
 
  const toggleAddOn = (addOnId: string, quantity: number) => {
    setSelectedAddOns((prev) => {
      const existing = prev.find((a) => a.addOnId === addOnId);
      if (existing) {
        if (quantity === 0) return prev.filter((a) => a.addOnId !== addOnId);
        return prev.map((a) => (a.addOnId === addOnId ? { ...a, quantity } : a));
      }
      return [...prev, { addOnId, quantity }];
    });
  };
 
  const handleContinue = () => {
    const validAddOns = selectedAddOns
      .filter((a) => a.quantity > 0)
      .map((a) => ({ addOnId: a.addOnId as Id<"addOns">, quantity: a.quantity }));
    dispatch({ type: "SET_ADDONS", payload: { addOns: validAddOns } });
    startTransition(() => {
      onNext();
    });
  };
 
  const handleSkip = () => {
    dispatch({ type: "SET_ADDONS", payload: { addOns: [] } });
    startTransition(() => {
      onNext();
    });
  };
 
  if (!addOns) return <div className="animate-pulse">{t("loading")}</div>;
 
  return (
    <div className="space-y-6">
      <div className="text-center">
        <h2 className="text-2xl font-serif text-accent mb-2">{t("title")}</h2>
        <p className="text-gray-400">{t("subtitle")}</p>
      </div>
 
      <div className="grid gap-4">
        {addOns.map((addon) => (
          <div key={addon._id} className="bg-surface border border-border p-4 rounded-lg">
            <div className="flex justify-between items-start">
              <div>
                <h3 className="font-medium text-white">{addon.name}</h3>
                <p className="text-sm text-gray-400">{addon.description}</p>
                <p className="text-accent mt-1">+{addon.price.toLocaleString()} VND/{t("perPerson")}</p>
              </div>
              <div className="flex items-center gap-2">
                <button
                  onClick={() => toggleAddOn(addon._id, 0)}
                  className="w-8 h-8 rounded border border-border flex items-center justify-center"
                >
                  -
                </button>
                <span>
                  {selectedAddOns.find((a) => a.addOnId === addon._id)?.quantity ?? 0}
                </span>
                <button
                  onClick={() => toggleAddOn(addon._id, 1)}
                  className="w-8 h-8 rounded border border-border flex items-center justify-center"
                >
                  +
                </button>
              </div>
            </div>
          </div>
        ))}
      </div>
 
      <div className="flex gap-4">
        <button
          onClick={handleSkip}
          disabled={isPending}
          className="flex-1 py-3 border border-border rounded-lg disabled:opacity-50"
        >
          {t("skip")}
        </button>
        <button
          onClick={handleContinue}
          disabled={isPending}
          className="flex-1 bg-accent text-black py-3 rounded-lg font-bold disabled:opacity-50"
        >
          {t("continueWithSelection")}
        </button>
      </div>
    </div>
  );
}
  • Step 3: Ensure addons.listEnabled query exists in Convex

Check convex/functions/addons.ts. If not present, add:

export const listEnabled = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query("addOns")
      .withIndex("by_enabled", (q) => q.eq("enabled", true))
      .collect();
  },
});
  • Step 4: Commit
git add apps/frontend/app/[locale]/booking/page.tsx apps/frontend/components/booking/addon-cards.tsx
git commit -m "feat(booking): add step 2 add-ons"

Phase 6: Step 4 — Checkout

Task 6: Create Checkout Page with OnePay Integration

Files:

  • Create: apps/frontend/components/booking/checkout-form.tsx

  • Modify: apps/backend/convex/functions/reservations.ts (add createOnePayOrder mutation if not exists)

  • Step 1: Read OnePay integration (check convex/http/onepay.ts)

  • Step 2: Create checkout form component

"use client";
import { useState } from "react";
import { useBooking } from "~/lib/booking-context";
import { useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { api } from "~/convex/_generated/api";
import { z } from "zod";
import { consola } from "consola";
import { Id } from "~/convex/_generated/dataModel";
 
const CheckoutFormSchema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().email("Invalid email address"),
  phone: z.string().optional(),
  termsAccepted: z.literal(true, {
    errorMap: () => ({ message: "You must accept the Terms & Conditions" }),
  }),
});
 
export function CheckoutForm() {
  const t = useTranslations("booking.checkout");
  const { state, dispatch } = useBooking();
  const router = useRouter();
  const [, setStep] = useQueryState("step", { defaultValue: "confirmation" });
  const [reservationId, setReservationId] = useQueryState("reservationId", { defaultValue: "" });
  const [form, setForm] = useState({
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    termsAccepted: false,
  });
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isPending, startTransition] = useTransition();
 
  const updateReservation = useMutation(api.reservations.update);
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setErrors({});
 
    const parsed = CheckoutFormSchema.safeParse(form);
    if (!parsed.success) {
      const fieldErrors: Record<string, string> = {};
      parsed.error.errors.forEach((err) => {
        const field = String(err.path[0]);
        fieldErrors[field] = err.message;
      });
      setErrors(fieldErrors);
      return;
    }
 
    if (!state.reservationId) {
      return;
    }
 
    try {
      await updateReservation({
        reservationId: state.reservationId as Id<"reservations">,
        firstName: form.firstName,
        lastName: form.lastName,
        email: form.email,
        phone: form.phone || undefined,
      });
 
      dispatch({ type: "SET_CUSTOMER_INFO", payload: form });
 
      // Create OnePay VA order — redirect to confirmation page
      const createOnePayOrder = useMutation(api.reservations.createOnePayOrder);
      const result = await createOnePayOrder({ reservationId: state.reservationId });
 
      startTransition(() => {
        setReservationId(result.reservationId ?? state.reservationId!);
        setStep("confirmation");
      });
    } catch (error) {
      consola.error("Checkout failed", { error });
    }
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div className="grid gap-4 md:grid-cols-2">
        <div>
          <label className="block text-sm font-medium mb-1">{t("firstName")}</label>
          <input
            required
            type="text"
            value={form.firstName}
            onChange={(e) => setForm({ ...form, firstName: e.target.value })}
            className={`w-full bg-surface border rounded-lg p-3 ${errors.firstName ? "border-red-500" : "border-border"}`}
          />
          {errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium mb-1">{t("lastName")}</label>
          <input
            required
            type="text"
            value={form.lastName}
            onChange={(e) => setForm({ ...form, lastName: e.target.value })}
            className={`w-full bg-surface border rounded-lg p-3 ${errors.lastName ? "border-red-500" : "border-border"}`}
          />
          {errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
        </div>
      </div>
 
      <div>
        <label className="block text-sm font-medium mb-1">{t("email")}</label>
        <input
          required
          type="email"
          value={form.email}
          onChange={(e) => setForm({ ...form, email: e.target.value })}
          className={`w-full bg-surface border rounded-lg p-3 ${errors.email ? "border-red-500" : "border-border"}`}
        />
        {errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
      </div>
 
      <div>
        <label className="block text-sm font-medium mb-1">{t("phoneOptional")}</label>
        <input
          type="tel"
          value={form.phone}
          onChange={(e) => setForm({ ...form, phone: e.target.value })}
          className="w-full bg-surface border border-border rounded-lg p-3"
        />
      </div>
 
      <div className="flex items-start gap-2">
        <input
          type="checkbox"
          id="terms"
          checked={form.termsAccepted}
          onChange={(e) => setForm({ ...form, termsAccepted: e.target.checked })}
          className="mt-1"
        />
        <label htmlFor="terms" className="text-sm text-gray-400">
          {t("acceptTerms")}
        </label>
      </div>
      {errors.termsAccepted && (
        <p className="text-red-500 text-sm">{errors.termsAccepted}</p>
      )}
 
      {/* Trust signals */}
      <div className="flex items-center gap-4 py-4 border-y border-border">
        <span className="text-sm text-gray-400">{t("securePayment")}</span>
        <div className="flex gap-2">
          {/* Payment logos */}
        </div>
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-accent text-black py-4 rounded-lg font-bold text-lg disabled:opacity-50"
      >
        {isPending ? t("processing") : t("payAmount")}
      </button>
    </form>
  );
}
  • Step 3: Create OnePay URL builder API endpoint
// apps/frontend/app/api/onepay/create-order/route.ts
import { NextRequest, NextResponse } from "next/server";
import { api } from "~/convex/_generated/api";
import { fetchAction } from "convex/next";
import { z } from "zod";
 
const CreateOrderRequestSchema = z.object({
  reservationId: z.string().min(1),
});
 
export async function POST(request: NextRequest) {
  const bodyParse = CreateOrderRequestSchema.safeParse(await request.json());
  if (!bodyParse.success) {
    return NextResponse.json(
      { error: "Invalid request body" },
      { status: 400 },
    );
  }
 
  const { reservationId } = bodyParse.data;
 
  try {
    const result = await fetchAction(
      api.reservations.createOnePayOrderForReservation,
      {
        reservationId,
      },
    );
 
    return NextResponse.json(result);
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Failed to create OnePay order";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}
  • Step 4: Commit
git add apps/frontend/app/[locale]/booking/page.tsx apps/frontend/components/booking/checkout-form.tsx
git commit -m "feat(booking): add step 3 checkout with OnePay redirect"

Phase 7: Step 5 — Confirmation

Task 7: Create Confirmation Page

Files:

  • Create: apps/frontend/components/booking/confirmation-display.tsx

  • Step 1: Create confirmation display component

"use client";
import { useEffect, useState } from "react";
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { Id } from "~/convex/_generated/dataModel";
 
export function ConfirmationDisplay() {
  const t = useTranslations("booking.confirmation");
  const [reservationId] = useQueryState("reservationId", { defaultValue: "" });
  const [timeLeft, setTimeLeft] = useState<number>(0);
 
  const reservation = useQuery(
    api.reservations.getById,
    reservationId ? { reservationId: reservationId as Id<"reservations"> } : "skip"
  );
 
  // Countdown timer for VA expiry
  useEffect(() => {
    if (!reservation?.paymentExpiresAt) return;
 
    const updateTimer = () => {
      const remaining = reservation.paymentExpiresAt! - Date.now();
      setTimeLeft(Math.max(0, remaining));
    };
 
    updateTimer();
    const interval = setInterval(updateTimer, 1000);
    return () => clearInterval(interval);
  }, [reservation?.paymentExpiresAt]);
 
  // Show payment pending state with VA
  if (reservation?.vaNumber && reservation.paymentStatus === "PENDING") {
    const minutes = Math.floor(timeLeft / 60000);
    const seconds = Math.floor((timeLeft % 60000) / 1000);
    const timeLeftFormatted = `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
 
    return (
      <div className="max-w-lg mx-auto p-6">
        <h1 className="text-2xl font-serif text-accent mb-6">{t("completePayment")}</h1>
 
        {/* QR Code */}
        <div className="flex justify-center mb-6">
          {reservation.qrCodeUrl ? (
            <img
              src={reservation.qrCodeUrl}
              alt={t("qrCodeAlt")}
              className="w-64 h-64"
            />
          ) : reservation.qrCode ? (
            <img
              src={`data:image/png;base64,${reservation.qrCode}`}
              alt={t("qrCodeAlt")}
              className="w-64 h-64"
            />
          ) : null}
        </div>
 
        {/* Virtual Account Number */}
        <div className="bg-surface border border-border rounded-lg p-4 mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("virtualAccountNumber")}</p>
          <p className="text-xl font-mono text-accent">{reservation.vaNumber}</p>
        </div>
 
        {/* Amount */}
        <div className="bg-surface border border-border rounded-lg p-4 mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("amountToTransfer")}</p>
          <p className="text-2xl font-bold text-accent">
            {reservation.totalAmount.toLocaleString()} VND
          </p>
          <p className="text-xs text-gray-500 mt-1">
            {t("exactAmountNote")}
          </p>
        </div>
 
        {/* Countdown Timer */}
        <div className="text-center mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("timeRemaining")}</p>
          <p className="text-3xl font-mono text-accent">
            {timeLeftFormatted}
          </p>
          <p className="text-xs text-gray-500 mt-1">
            {t("seatsHeldNote")}
          </p>
        </div>
 
        {/* Instructions */}
        <div className="text-sm text-gray-400 space-y-2">
          <p>{t("instruction1")}</p>
          <p>{t("instruction2")}</p>
          <p>{t("instruction3")}</p>
        </div>
      </div>
    );
  }
 
  // Payment successful — show confirmation
  if (reservation?.paymentStatus === "PAID") {
    return (
      <div className="max-w-lg mx-auto p-6 text-center">
        <div className="w-16 h-16 rounded-full bg-accent/20 flex items-center justify-center mx-auto mb-4">
          <svg className="w-8 h-8 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
          </svg>
        </div>
        <h1 className="text-3xl font-serif text-accent mb-4">{t("bookingConfirmed")}</h1>
 
        {reservation.qrCode && (
          <div className="flex justify-center mb-6">
            <img
              src={`data:image/png;base64,${reservation.qrCode}`}
              alt={t("bookingQrCode")}
              className="w-48 h-48"
            />
          </div>
        )}
 
        <p className="text-gray-400 mb-2">
          {t("bookingId")}: {reservation._id}
        </p>
        <p className="text-gray-400 mb-6">
          {reservation.quantity} × {reservation.ticketType}
        </p>
 
        <div className="bg-surface border border-border rounded-lg p-4 text-left">
          <h3 className="font-serif text-accent mb-2">{t("whatsNext")}</h3>
          <ul className="text-sm text-gray-400 space-y-1">
            <li>{t("nextStep1")}</li>
            <li>{t("nextStep2")}</li>
            <li>{t("nextStep3")}</li>
          </ul>
        </div>
      </div>
    );
  }
 
  // Loading / Error states
  return (
    <div className="max-w-lg mx-auto p-6 text-center">
      <div className="animate-pulse">
        <p className="text-gray-400">{t("loading")}</p>
      </div>
    </div>
  );
}
  • Step 2: Commit

Phase 8: Surcharge Calculation Logic

Task 8: Implement Pricing Surcharges

Per the package-bundle spec:

Day-of-week surcharge (per person):

  • Mon/Tue/Wed: 0 VND
  • Thu: +50,000 VND
  • Fri: +100,000 VND
  • Sat: +150,000 VND
  • Sun: +100,000 VND

Small party surcharge (per person):

  • < 15 guests: +100,000 VND per person

  • = 15 guests: 0 VND

  • Step 1: Add surcharges to checkout

In the calculateTotal function or a separate calculateTotalPrice mutation:

export const DAY_OF_WEEK_SURCHARGES: Record<number, number> = {
  0: 0, // Sunday
  1: 0, // Monday
  2: 0, // Tuesday
  3: 0, // Wednesday
  4: 50_000, // Thursday
  5: 100_000, // Friday
  6: 150_000, // Saturday
};
 
export const SMALL_PARTY_THRESHOLD = 15;
export const SMALL_PARTY_SURCHARGE_PER_PERSON = 100_000;
 
export function calculateDayOfWeekSurcharge(
  dateStr: string,
  quantity: number,
): number {
  const day = new Date(dateStr).getDay();
  return (DAY_OF_WEEK_SURCHARGES[day] ?? 0) * quantity;
}
 
export function calculateSmallPartySurcharge(quantity: number): number {
  return quantity < SMALL_PARTY_THRESHOLD
    ? SMALL_PARTY_SURCHARGE_PER_PERSON * quantity
    : 0;
}
  • Step 2: Display surcharges in sticky cart and checkout

The sticky cart should show:

Dinner Theatre × 2          1,800,000 VND
Sat surcharge × 2            300,000 VND
Small party surcharge × 2     200,000 VND
-----------------------------
Total:                     2,300,000 VND
  • Step 3: Commit

Enrichment Sections

1. Zod Schemas

Complete Zod schemas for all mutations/form inputs:

import { z } from "zod";
 
// Ticket selection
const TicketSelectionSchema = z.object({
  occurrenceId: z.string().min(1),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
  quantity: z.number().int().min(1).max(32),
});
 
// Add-on selection
const AddOnSelectionSchema = z.object({
  addOnId: z.string(),
  quantity: z.number().int().min(0).max(10),
});
 
// Checkout form
const CheckoutFormSchema = z.object({
  firstName: z.string().min(1, "First name is required").max(50),
  lastName: z.string().min(1, "Last name is required").max(50),
  email: z.string().email("Invalid email address"),
  phone: z.string().optional(),
  termsAccepted: z.literal(true, {
    errorMap: () => ({ message: "You must accept the Terms & Conditions" }),
  }),
});
 
// Booking state
const BookingStateSchema = z.object({
  occurrenceId: z.string().nullable(),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]).nullable(),
  quantity: z.number().int().min(1),
  seatIds: z.array(z.string()),
  addOns: z.array(AddOnSelectionSchema),
  customerInfo: CheckoutFormSchema.omit({ termsAccepted: true }).nullable(),
  reservationId: z.string().nullable(),
  bookingExpiresAt: z.number().nullable(),
});
 
// Booking step param
const BookingStepSchema = z.object({
  step: z.enum(["tickets", "seats", "addons", "checkout", "confirmation"]),
  occurrenceId: z.string().optional(),
  reservationId: z.string().optional(),
});

2. Error Handling

Named error codes constant object with as const:

// Error codes namespace
export const BOOKING_ERROR_CODES = {
  // Ticket selection errors
  OCCURRENCE_NOT_FOUND: "OCCURRENCE_NOT_FOUND",
  INVALID_TICKET_TYPE: "INVALID_TICKET_TYPE",
  QUANTITY_EXCEEDS_AVAILABILITY: "QUANTITY_EXCEEDS_AVAILABILITY",
 
  // Seat errors
  RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
  RESERVATION_NOT_PENDING: "RESERVATION_NOT_PENDING",
  SEATS_UNAVAILABLE: "SEATS_UNAVAILABLE",
 
  // Payment errors
  ALREADY_PAID: "ALREADY_PAID",
  AMOUNT_MISMATCH: "AMOUNT_MISMATCH",
  NOT_PENDING: "NOT_PENDING",
  ONEPAY_API_ERROR: "ONEPAY_API_ERROR",
 
  // Session errors
  SESSION_EXPIRED: "SESSION_EXPIRED",
} as const;
 
export type BookingErrorCode =
  (typeof BOOKING_ERROR_CODES)[keyof typeof BOOKING_ERROR_CODES];
MutationError CodeError Message
reservations.createPendingOCCURRENCE_NOT_FOUND"Show date not found"
reservations.createPendingINVALID_TICKET_TYPE"Invalid ticket type"
reservations.createPendingQUANTITY_EXCEEDS_AVAILABILITY"Not enough seats available"
reservations.updateRESERVATION_NOT_FOUND"Booking session expired"
reservations.createOnePayOrderForReservationRESERVATION_NOT_FOUND"Booking session expired"
reservations.createOnePayOrderForReservationNOT_PENDING"Booking is no longer pending"
reservations.confirmPaymentALREADY_PAID"Booking already confirmed"
reservations.confirmPaymentAMOUNT_MISMATCH"Payment amount mismatch"

3. Convex Real-time Subscription Pattern

// Real-time availability during ticket selection
const availability = useQuery(
  api.occurrences.getAvailability,
  occurrenceId ? { occurrenceId } : "skip",
);
 
// Real-time seat availability during seat selection
const seatData = useQuery(
  api.seats.getSeatsForOccurrence,
  occurrenceId ? { occurrenceId } : "skip",
);
 
// Real-time reservation status during confirmation
// Convex subscription auto-updates reservation — no manual polling needed
const reservation = useQuery(
  api.reservations.getById,
  reservationId ? { reservationId } : "skip",
);
 
// Add-ons list
const addOns = useQuery(api.addons.listEnabled);

4. Mobile/Responsive Considerations

  • Booking layout: Full-width single column on mobile; sidebar on desktop (lg+).
  • Sticky cart: Bottom sheet on mobile (collapsible with drag handle), right sidebar fixed on desktop.
  • Countdown timer: Full-width bar at top of viewport on mobile with high contrast.
  • Seat selector: Horizontally scrollable seat grid with fixed seat cell size (40x40px).
  • Form inputs: Full-width on mobile, 2-column grid on desktop for name fields.
  • Step navigation: Back/Continue buttons are full-width on mobile with min 44px tap targets.
  • Checkout: Single column on mobile, appropriate spacing for touch targets.

5. PWA / Offline Behavior

Not applicable for booking flow — booking requires real-time seat availability and payment processing. If network is lost during checkout:

  1. Show "Connection lost" overlay with retry button
  2. Attempt to reconnect and retry payment creation
  3. If session expired, redirect to show page with message
  4. Countdown timer continues using local time even when offline
  5. On reconnect, re-fetch availability and update UI accordingly

6. i18n / next-intl Requirements

Translation key tree:

{
  "booking": {
    "invalidLink": "Invalid booking link",
    "loading": "Loading...",
    "countdown": {
      "seatsReserved": "Seats reserved for",
      "expiredTitle": "Reservation Expired",
      "expiredMessage": "Your seats have been released. Please start your booking again.",
      "returnHome": "Return to Homepage"
    },
    "tickets": {
      "ticketType": "Ticket Type",
      "dinnerTheatre": "Dinner Theatre",
      "showOnly": "Show Only",
      "quantity": "Quantity",
      "maxAvailable": "Max: {max} available",
      "continue": "Continue",
      "processing": "Processing...",
      "loading": "Loading..."
    },
    "seats": {
      "title": "Select Your Seats",
      "selectSeats": "Select {count} seat(s)",
      "continue": "Continue to Add-ons",
      "selectMore": "Select {count} more seat(s)",
      "legend": {
        "available": "Available",
        "selected": "Selected",
        "held": "Being held",
        "occupied": "Occupied"
      }
    },
    "addons": {
      "title": "Make your evening even more memorable",
      "subtitle": "Enhance your experience with our optional add-ons",
      "perPerson": "person",
      "skip": "Skip & Continue",
      "continueWithSelection": "Continue with Selection",
      "loading": "Loading add-ons..."
    },
    "checkout": {
      "firstName": "First Name",
      "lastName": "Last Name",
      "email": "Email",
      "phoneOptional": "Phone (Optional)",
      "acceptTerms": "I accept the Terms & Conditions",
      "securePayment": "Secure payment",
      "payAmount": "Pay Now",
      "processing": "Processing..."
    },
    "cart": {
      "title": "Your Booking",
      "dinnerTheatre": "Dinner Theatre",
      "showOnly": "Show Only",
      "total": "Total"
    },
    "confirmation": {
      "completePayment": "Complete Your Payment",
      "qrCodeAlt": "Payment QR Code",
      "virtualAccountNumber": "Virtual Account Number",
      "amountToTransfer": "Amount to Transfer",
      "exactAmountNote": "Transfer the exact amount to the account above",
      "timeRemaining": "Time remaining",
      "seatsHeldNote": "Seats are held for 10 minutes",
      "instruction1": "1. Open your mobile banking app",
      "instruction2": "2. Transfer the exact amount to the VA number above",
      "instruction3": "3. Wait for confirmation (this page will update automatically)",
      "bookingConfirmed": "Booking Confirmed!",
      "bookingQrCode": "Booking QR Code",
      "bookingId": "Booking ID",
      "whatsNext": "What's Next?",
      "nextStep1": "Show this QR code at the venue entrance",
      "nextStep2": "Arrive 15 minutes before the show",
      "nextStep3": "Present your booking confirmation",
      "loading": "Loading..."
    }
  }
}

7. Environment-Specific Configuration

VariableDescriptionRequiredLocation
NEXT_PUBLIC_BASE_URLPublic URL for OnePay return URLsYesClient + Server
NEXT_PUBLIC_CONVEX_URLConvex deployment URLYes (auto-set)Client
ONEPAY_BASE_URLOnePay API endpointYesServer only
ONEPAY_API_KEYOnePay API keyYesServer only

Server-only variables (never exposed to client):

  • ONEPAY_API_KEY — OnePay API key for server-side payment processing
  • ONEPAY_BANK_ACCOUNT_XID — OnePay bank account XID
  • ONEPAY_WEBHOOK_SECRET — Webhook verification secret

8. TDD Test Cases

CRITICAL: All tests use USER EXPECTATION format — what the USER SEES and EXPERIENCES. NO implementation details in test names. Tests are definitions only — no implementation code.

E2E Tests (Playwright):

test("BF-E2E-1.1: Guest can select DINNER_THEATRE ticket type");
test("BF-E2E-1.2: Guest can select SHOW_ONLY ticket type when enabled");
test("BF-E2E-1.3: Guest cannot select quantity exceeding availability");
test("BF-E2E-1.4: Guest sees countdown timer after selecting tickets");
test("BF-E2E-1.5: nuqs navigation between steps preserves URL state");
test("BF-E2E-2.1: Guest can select available seats on seat map");
test("BF-E2E-2.2: Guest cannot select occupied seats");
test("BF-E2E-2.3: Guest cannot select more seats than ticket quantity");
test("BF-E2E-2.4: Continue button disabled until all seats selected");
test("BF-E2E-3.1: Guest can skip add-ons and continue to checkout");
test("BF-E2E-3.2: Guest can add add-ons with quantity");
test("BF-E2E-4.1: Guest can fill checkout form with valid data");
test("BF-E2E-4.2: Checkout form validates required fields");
test("BF-E2E-4.3: Terms checkbox must be checked to submit");
test("BF-E2E-4.4: Guest sees sticky cart with correct surcharges");
test("BF-E2E-4.5: Guest is redirected to OnePay on payment submission");
test("BF-E2E-5.1: Guest sees confirmation page after successful payment");
test("BF-E2E-5.2: Guest sees QR code and booking details on confirmation");
test("BF-E2E-5.3: Confirmation updates automatically via Convex subscription");
test("BF-E2E-6.1: Countdown timer shows expired modal on expiry");
test("BF-E2E-6.2: Countdown timer pauses when tab is hidden");
test("BF-E2E-7.1: Guest can navigate back to previous steps");
test("BF-E2E-7.2: Locale selector hidden during booking flow");

Component Tests (Vitest + RTL):

it("BF-1.1: TicketSelector renders both ticket types when SHOW_ONLY enabled");
it("BF-1.2: TicketSelector hides SHOW_ONLY when disabled");
it("BF-1.3: Quantity selector respects maximum availability");
it("BF-1.4: CountdownTimer displays correct time format MM:SS");
it("BF-1.5: CountdownTimer shows expired modal when time reaches zero");
it("BF-1.6: StepTickets dispatches START_BOOKING on continue");
it("BF-2.1: Seat grid renders all 32 seats");
it("BF-2.2: Available seats show clickable styling");
it("BF-2.3: Occupied seats show disabled styling");
it("BF-2.4: Selected seats show selected styling");
it("BF-2.5: Continue button disabled when seats not fully selected");
it("BF-3.1: AddonCards renders all enabled add-ons");
it("BF-3.2: AddonCards allows adding and removing add-ons");
it("BF-3.3: Skip button allows proceeding without add-ons");
it("BF-4.1: CheckoutForm validates first name is required");
it("BF-4.2: CheckoutForm validates last name is required");
it("BF-4.3: CheckoutForm validates email format");
it("BF-4.4: CheckoutForm requires terms checkbox");
it("BF-4.5: StickyCart displays correct ticket line item");
it("BF-4.6: StickyCart displays surcharges correctly");
it("BF-5.1: ConfirmationDisplay shows pending state with VA number");
it("BF-5.2: ConfirmationDisplay shows QR code when available");
it("BF-5.3: ConfirmationDisplay shows success state after payment");
it("BF-5.4: ConfirmationDisplay shows loading before data loads");

Backend/Mutation Tests (Vitest):

it("BF-ORD-1.1: Guest can create pending reservation with valid data");
it("BF-ORD-1.2: Reservation creation fails for non-existent occurrence");
it("BF-ORD-1.3: Reservation creation fails for invalid ticket type");
it("BF-ORD-1.4: Reservation creation fails when quantity exceeds availability");
it("BF-ORD-2.1: Guest can hold seats with valid reservation");
it("BF-ORD-2.2: Seat hold fails for non-pending reservation");
it("BF-ORD-2.3: Previously held seats released when new seats held");
it("BF-ORD-3.1: Payment confirmation succeeds for valid pending reservation");
it("BF-ORD-3.2: Payment confirmation fails for already-paid reservation");
it("BF-ORD-4.1: Small party surcharge applies for groups under 15");
it("BF-ORD-4.2: Small party surcharge does not apply for groups of 15+");
it("BF-ORD-5.1: Day-of-week surcharge applies correctly for Saturday");
it("BF-ORD-5.2: Day-of-week surcharge does not apply for Wednesday");

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Seat selection2026-05-03-seat-selection.mdseats table, reservations.seatIds
OnePay payment2026-05-03-payment-onepay.mdreservations.paymentStatus, createOnePayOrderForReservation
Pricing engine16-package-bundle-pricing.mdcalculateDayOfWeekSurcharge, calculateSmallPartySurcharge
Guest profiles2026-05-03-guest-profiles.mdLink booking confirmation to guest profile
Occurrencesoccurrence-system.mdoccurrences.getAvailability query
Add-onsaddons-system.mdaddOns table, api.addons.listEnabled

10. Performance Considerations

  • Convex subscriptions: Active only during booking flow; unmounted components auto-unsubscribe.
  • Sticky cart: Uses useQuery with "skip" when no occurrenceId — no unnecessary requests.
  • Seat selector: 32 seats is a small dataset; no virtualization needed. Direct DOM updates sufficient.
  • Checkout form: Validates with Zod before submission; no server round-trip for validation.
  • OnePay polling: Convex subscription handles real-time updates; no manual polling needed.
  • Step components: Dynamic imports for each step component to reduce initial bundle size.
  • Countdown timer: Uses useTransition for router push to prevent UI blocking.

Acceptance Criteria

  1. nuqs navigation — URL state updates between steps 1→2→3
  2. 10-minute countdown — timer starts on step 1, expires and shows modal with redirect
  3. Sticky cart — visible on all steps 1-3, shows real-time total
  4. Ticket selection — DINNER_THEATRE always shown, SHOW_ONLY only if showOnlyEnabled
  5. Add-ons step — skippable, max 3-5 add-on cards
  6. Checkout form — validates required fields with Zod, accepts terms
  7. OnePay redirect — payment URL built server-side, customer redirected to OnePay
  8. Confirmation page — reads OnePay return params, shows QR code on success
  9. Surcharges — day-of-week and small-party surcharges applied and displayed
  10. Real-time availability — bookedCount updates reflect across all clients via Convex subscriptions
  11. No language selector — locale selector hidden during booking flow

Consistency Audit: booking-flow

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Spec (09-confirmation-exp.md) vs plan routingSpec defines /{locale}/booking/{occurrenceId}/confirmation with dynamic URL segments. Plan correctly uses nuqs SPA routing (?step=confirmation&reservationId=).Plan uses correct nuqs approach. Spec must be updated separately to match.
2CheckoutFormNavigation using router.push() with query paramsNow uses useQueryState + setStep/setReservationId for SPA-compatible navigation

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1CountdownTimer, CheckoutFormMissing useTransitionAdded useTransition for router.push/navigation
2Throughout componentsconsole.log usageReplaced with consola from consola library
3BookingPageMissing SuspenseWrapped step content in Suspense with loading skeleton
4Throughout componentsHardcoded stringsAll user-facing strings use useTranslations/getTranslations
5Throughout componentsMissing error handlingAdded typed error codes via BOOKING_ERROR_CODES const object

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

#IssueAction Required
1staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.tsFoundation plan must implement role-check auth helpers. Currently only getCurrentUser, upsertUser, and isAdmin are exported. convex/CLAUDE.md references authenticatedQuery/authenticatedMutation but these are NOT yet implemented.
2reservations.update mutation not definedMust add mutation to update customer info on reservation

[P0] No as any found — all type assertions use proper TypeScript types or Zod parse.

[P0] No Math.random() found — no ID generation issues. All IDs come from Convex.

[P0] No useParams() found — plan uses useQueryState from nuqs correctly for step, occurrenceId, and reservationId params.

[P0] No staffMutation/adminMutation references — no admin mutations in this plan. All mutations use standard mutation from Convex. This is a guest-facing booking flow; no staff/admin auth required.

[P0] Auth helper gap (NOT BLOCKING for this plan): staffMutation, adminMutation, authenticatedQuery, and authenticatedMutation are NOT exported from convex/auth.ts — only getCurrentUser, upsertUser, and isAdmin exist. convex/CLAUDE.md references authenticatedQuery/authenticatedMutation but these are not yet implemented. This plan does not require staff/admin mutations, so it is not blocked.

[P1] No console.log found — all logging uses consola from consola library.

[P1] useTransition present — CountdownTimer and CheckoutForm use useTransition for router.push/navigation.

[P1] Suspense present — BookingPage wraps step content in Suspense with loading skeleton fallback.

[P1] All hardcoded strings reviewed — all user-facing strings use useTranslations/getTranslations from next-intl.

[P1] No emoji in UI — all icons use inline SVG, no emoji characters in component code.

[P1] Zod schemas provided — all mutation inputs and form data validated with Zod schemas in Section 1.

[P1] Error codes as const object — BOOKING_ERROR_CODES defined as const object with as const assertion for type safety.

[P1] All useQuery calls use correct pattern: useQuery(api.fn, args) NOT useQuery(api.fn(), args).