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:
- Tickets —
/booking?step=tickets— Calendar view (left) + pricing/experience selector (right). Guest selects date, time, ticket type, quantity - Add-ons —
/booking?step=addons— Tap-to-select add-ons UI. Skippable. - Checkout —
/booking?step=checkout— Name, phone number form + OnePay redirect - 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
addOnstable - 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 checkAll 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— addcreatePendingmutation -
Step 1: Read
convex/functions/occurrences.tsforgetAvailability
Confirm the query returns: dinnerPrice, showOnlyPrice, showOnlyEnabled, remaining, badge
- Step 2: Add
createPendingmutation 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.mdfor full seat map implementation -
Step 1: Create seat selection page (refer to seat-selection plan)
-
Step 2: Ensure
seats.holdSeatsmutation 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.listEnabledquery 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(addcreateOnePayOrdermutation 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];| Mutation | Error Code | Error Message |
|---|---|---|
reservations.createPending | OCCURRENCE_NOT_FOUND | "Show date not found" |
reservations.createPending | INVALID_TICKET_TYPE | "Invalid ticket type" |
reservations.createPending | QUANTITY_EXCEEDS_AVAILABILITY | "Not enough seats available" |
reservations.update | RESERVATION_NOT_FOUND | "Booking session expired" |
reservations.createOnePayOrderForReservation | RESERVATION_NOT_FOUND | "Booking session expired" |
reservations.createOnePayOrderForReservation | NOT_PENDING | "Booking is no longer pending" |
reservations.confirmPayment | ALREADY_PAID | "Booking already confirmed" |
reservations.confirmPayment | AMOUNT_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:
- Show "Connection lost" overlay with retry button
- Attempt to reconnect and retry payment creation
- If session expired, redirect to show page with message
- Countdown timer continues using local time even when offline
- 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
| Variable | Description | Required | Location |
|---|---|---|---|
NEXT_PUBLIC_BASE_URL | Public URL for OnePay return URLs | Yes | Client + Server |
NEXT_PUBLIC_CONVEX_URL | Convex deployment URL | Yes (auto-set) | Client |
ONEPAY_BASE_URL | OnePay API endpoint | Yes | Server only |
ONEPAY_API_KEY | OnePay API key | Yes | Server only |
Server-only variables (never exposed to client):
ONEPAY_API_KEY— OnePay API key for server-side payment processingONEPAY_BANK_ACCOUNT_XID— OnePay bank account XIDONEPAY_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
| Dependency | Plan | Shared Schema |
|---|---|---|
| Seat selection | 2026-05-03-seat-selection.md | seats table, reservations.seatIds |
| OnePay payment | 2026-05-03-payment-onepay.md | reservations.paymentStatus, createOnePayOrderForReservation |
| Pricing engine | 16-package-bundle-pricing.md | calculateDayOfWeekSurcharge, calculateSmallPartySurcharge |
| Guest profiles | 2026-05-03-guest-profiles.md | Link booking confirmation to guest profile |
| Occurrences | occurrence-system.md | occurrences.getAvailability query |
| Add-ons | addons-system.md | addOns table, api.addons.listEnabled |
10. Performance Considerations
- Convex subscriptions: Active only during booking flow; unmounted components auto-unsubscribe.
- Sticky cart: Uses
useQuerywith"skip"when nooccurrenceId— 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
useTransitionfor router push to prevent UI blocking.
Acceptance Criteria
- nuqs navigation — URL state updates between steps 1→2→3
- 10-minute countdown — timer starts on step 1, expires and shows modal with redirect
- Sticky cart — visible on all steps 1-3, shows real-time total
- Ticket selection — DINNER_THEATRE always shown, SHOW_ONLY only if
showOnlyEnabled - Add-ons step — skippable, max 3-5 add-on cards
- Checkout form — validates required fields with Zod, accepts terms
- OnePay redirect — payment URL built server-side, customer redirected to OnePay
- Confirmation page — reads OnePay return params, shows QR code on success
- Surcharges — day-of-week and small-party surcharges applied and displayed
- Real-time availability — bookedCount updates reflect across all clients via Convex subscriptions
- No language selector — locale selector hidden during booking flow
Consistency Audit: booking-flow
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Spec (09-confirmation-exp.md) vs plan routing | Spec 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. |
| 2 | CheckoutForm | Navigation using router.push() with query params | Now uses useQueryState + setStep/setReservationId for SPA-compatible navigation |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | CountdownTimer, CheckoutForm | Missing useTransition | Added useTransition for router.push/navigation |
| 2 | Throughout components | console.log usage | Replaced with consola from consola library |
| 3 | BookingPage | Missing Suspense | Wrapped step content in Suspense with loading skeleton |
| 4 | Throughout components | Hardcoded strings | All user-facing strings use useTranslations/getTranslations |
| 5 | Throughout components | Missing error handling | Added typed error codes via BOOKING_ERROR_CODES const object |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.ts | Foundation 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. |
| 2 | reservations.update mutation not defined | Must 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).