Guest Journey 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 public guest experience: homepage carousel with real Convex data, programme page, show detail pages, and the vertical booking flow with sticky cart and 10-minute countdown.
Architecture: Homepage and programme are server-client hybrid (SSG shell + Convex hydration). Show detail is fully dynamic. Booking is a SPA with BookingContext for vertical scroll navigation.
Tech Stack: Next.js 16 App Router, Convex real-time queries, nuqs for URL state, React Context for booking, Tailwind CSS v4, Framer Motion for carousel transitions.
Spec reference: docs/superpowers/specs/02-guest-journey.md
Business Summary
What this does: Implements the public-facing guest experience including the homepage carousel with live Convex data showing upcoming shows, a programme page listing all active shows, show detail pages with video and gallery, and the complete vertical booking flow with sticky cart and 10-minute reservation countdown timer.
Why it matters: This is the primary revenue-generating flow. The auto-advancing carousel drives show discovery and bookings with real-time availability. The 10-minute countdown creates urgency, reducing cart abandonment and ensuring seats are released if guests leave. Real-time Convex subscriptions ensure guests always see accurate availability, preventing overselling and customer disappointment.
Time to implement: 5-8 days | Complexity: Medium
Dependencies: Foundation plan (Convex schema, BookingContext), seat-selection plan (seat map component), show-system plan (shows.upcoming, shows.listActive queries)
Context & Business Logic
The guest journey covers all public-facing touchpoints before the booking flow. Key behaviors:
- Homepage carousel auto-advances every 6s, pauses on hover
- Programme page shows all ACTIVE shows in a grid
- Show detail:
/shows?slug={slug}(nuqs, no dynamic segment) - Booking button navigates to
/booking?step=tickets&occurrenceId={id} - 10-minute countdown timer starts on Step 1 entry
- OnePay redirect on checkout submission
File Map
apps/frontend/
├── app/[locale]/
│ ├── page.tsx # MODIFY — wire carousel to Convex
│ ├── programme/
│ │ └── page.tsx # CREATE
│ └── shows/
│ └── page.tsx # CREATE — slug via nuqs, no [slug] route
├── components/home/
│ └── carousel.tsx # CREATE — auto-scroll 6s, arrows, dots, mobile swipe
├── components/shows/
│ ├── show-hero.tsx # CREATE — video autoplay, gallery scroll
│ └── occurrence-list.tsx # CREATE — date rows with availability badges
├── components/booking/
│ ├── booking-provider.tsx # MODIFY — vertical flow state
│ ├── booking-layout.tsx # MODIFY — vertical scroll layout
│ ├── sticky-cart.tsx # MODIFY — surcharges display
│ ├── countdown-timer.tsx # CREATE — 10-min countdown
│ ├── ticket-selector.tsx # CREATE — ticket type + quantity
│ ├── seat-selector.tsx # CREATE — 4x8 cinema grid
│ └── checkout-form.tsx # CREATE — customer form + OnePay redirect
└── lib/
└── booking-context.tsx # MODIFY — add occurrenceId, timer, seatIdsPhase 1: Homepage Carousel
Task 1: Wire Homepage Carousel to Convex Real-time Data
Files:
- Modify:
apps/frontend/app/[locale]/page.tsx - Modify:
apps/frontend/components/home/carousel.tsx(or create new) - Read:
convex/functions/shows.ts(already hasupcomingquery from show-system plan)
The shows.upcoming query returns {occurrence, show}[] sorted by date/time.
- Step 1: Read existing homepage carousel component
cat apps/frontend/components/home/carousel.tsx 2>/dev/null || echo "File not found"
cat apps/frontend/app/[locale]/page.tsx- Step 2: Create new carousel component with auto-scroll
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { consola } from "consola";
export function Carousel() {
const t = useTranslations("homepage.carousel");
const upcoming = useQuery(api.shows.upcoming, { limit: 8 });
const [current, setCurrent] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Auto-advance every 6 seconds
const startInterval = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (!upcoming?.length) return;
intervalRef.current = setInterval(() => {
setCurrent((c) => (c + 1) % upcoming.length);
}, 6000);
}, [upcoming?.length]);
useEffect(() => {
if (!isPaused && upcoming?.length) {
startInterval();
} else if (intervalRef.current) {
clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPaused, upcoming?.length, startInterval]);
// Pause on tab hidden
useEffect(() => {
const handleVisibilityChange = () => {
setIsPaused(document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
if (!upcoming?.length) {
return <div className="h-96 bg-surface animate-pulse" />;
}
return (
<div
className="relative overflow-hidden"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
{/* Slides */}
<div
className="flex transition-transform duration-700 ease-out"
style={{ transform: `translateX(-${current * 100}%)` }}
>
{upcoming.map(({ occurrence, show }) => (
<div key={occurrence._id} className="w-full flex-shrink-0">
<Link href={`/shows?slug=${show.slug}&occurrenceId=${occurrence._id}`}>
<div className="relative h-96 md:h-[500px]">
<img
src={show.gallery?.[0] ?? "/images/placeholder-show.jpg"}
alt={show.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
<div className="absolute bottom-0 left-0 p-8">
<h2 className="font-serif text-3xl text-accent mb-2">{show.title}</h2>
<p className="text-white/80">
{occurrence.date} at {occurrence.time}
</p>
<p className="text-accent font-bold mt-2">
{t("fromPrice", { price: show.defaultDinnerPrice.toLocaleString() })} VND
</p>
</div>
</div>
</Link>
</div>
))}
</div>
{/* Arrow buttons */}
<button
onClick={() => setCurrent((c) => (c - 1 + upcoming.length) % upcoming.length)}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/50 rounded-full text-white hover:bg-black/70"
aria-label={t("previousSlide")}
>
<svg className="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setCurrent((c) => (c + 1) % upcoming.length)}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/50 rounded-full text-white hover:bg-black/70"
aria-label={t("nextSlide")}
>
<svg className="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Dot indicators */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{upcoming.map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === current ? "bg-accent" : "bg-white/40"
}`}
aria-label={t("goToSlide", { index: i + 1 })}
/>
))}
</div>
</div>
);
}- Step 3: Wire carousel in homepage page.tsx
Ensure page passes Convex data or wrap with ConvexProvider. The homepage must be a client component or use a server component that passes data to a client <Carousel />.
- Step 4: Test in browser
Run dev server, navigate to homepage, verify carousel auto-advances every 6s, arrows work, dots work, hover pauses.
- Step 5: Commit
git add apps/frontend/app/[locale]/page.tsx apps/frontend/components/home/carousel.tsx
git commit -m "feat(homepage): wire carousel to Convex upcoming query"Phase 2: Programme Page
Task 2: Create Programme Page
Files:
-
Create:
apps/frontend/app/[locale]/programme/page.tsx -
Step 1: Create programme page
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ProgrammePage() {
const t = useTranslations("programme");
const shows = useQuery(api.shows.listActive);
if (!shows) return <div className="p-8 animate-pulse">{t("loading")}</div>;
return (
<div className="min-h-screen bg-background pt-24 px-4">
<div className="max-w-6xl mx-auto">
<h1 className="font-serif text-4xl text-accent mb-8">{t("title")}</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{shows.map((show) => (
<Link
key={show._id}
href={`/shows?slug=${show.slug}`}
className="group bg-surface border border-border rounded-lg overflow-hidden hover:border-accent transition-colors"
>
<div className="aspect-video overflow-hidden">
<img
src={show.gallery?.[0] ?? "/images/placeholder.jpg"}
alt={show.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
<div className="p-4">
<h2 className="font-serif text-xl text-white mb-1">{show.title}</h2>
<p className="text-sm text-gray-400 mb-3">{show.tagline}</p>
<span className="text-accent text-sm font-medium">{t("viewDates")} →</span>
</div>
</Link>
))}
</div>
</div>
</div>
);
}- Step 2: Test page loads
Navigate to /{locale}/programme, verify grid renders.
- Step 3: Commit
git add apps/frontend/app/[locale]/programme/
git commit -m "feat(programme): add show listing page"Phase 3: Show Detail Page
Task 3: Create Show Detail Page
Files:
-
Create:
apps/frontend/app/[locale]/shows/page.tsx -
Create:
apps/frontend/components/shows/show-hero.tsx -
Create:
apps/frontend/components/shows/occurrence-list.tsx -
Step 1: Create show detail page with nuqs
Route: /shows?slug={slug} — no [slug] dynamic route. slug is a URL query param managed by nuqs.
"use client";
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { ShowHero } from "~/components/shows/show-hero";
import { OccurrenceList } from "~/components/shows/occurrence-list";
import { notFound } from "next/navigation";
import { Suspense } from "react";
export default function ShowsPage() {
const [slug] = useQueryState("slug", { defaultValue: "" });
const show = useQuery(api.shows.getBySlug, slug ? { slug } : "skip");
if (slug && !show) return notFound();
if (!show) {
// No slug selected — show programme grid
return <ShowsGrid />;
}
return (
<div className="min-h-screen bg-background pt-24">
<ShowHero show={show} />
<div className="max-w-4xl mx-auto px-4 py-8">
<Suspense fallback={<div className="animate-pulse space-y-3">{Array(5).fill(<div className="h-12 bg-surface rounded" />)}</div>}>
<OccurrenceList templateId={show._id} />
</Suspense>
</div>
</div>
);
}-
Step 2: Create ShowHero component
-
Video:
<iframe src={videoUrl + "?autoplay=1&mute=1"}>with aspect-ratio 16/9 -
Below video: title, tagline, description, gallery (horizontal scroll)
-
"What to expect": 3-4 bullet points from show data
-
Artists section if applicable
-
Step 3: Create OccurrenceList component
"use client";
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQueryState } from "nuqs";
import { Id } from "~/convex/_generated/dataModel";
export function OccurrenceList({ templateId }: { templateId: Id<"showTemplates"> }) {
const t = useTranslations("shows.occurrenceList");
const router = useRouter();
const [, setStep] = useQueryState("step", { defaultValue: "tickets" });
const [occurrenceId, setOccurrenceId] = useQueryState("occurrenceId", { defaultValue: "" });
const [visibleCount, setVisibleCount] = useState(5);
const occurrences = useQuery(api.occurrences.upcomingByTemplate, {
templateId,
limit: visibleCount,
});
if (!occurrences) {
return (
<div className="animate-pulse space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 bg-surface rounded" />
))}
</div>
);
}
return (
<div className="space-y-3">
{occurrences.map((occ) => {
const remaining = occ.actualCapacity - occ.bookedCount;
const badge = remaining > 10 ? "green" : remaining > 0 ? "orange" : "gray";
const badgeText =
remaining > 10
? t("available")
: remaining > 0
? t("seatsLeft", { count: remaining })
: t("soldOut");
return (
<div
key={occ._id}
className="flex items-center justify-between p-4 bg-surface border border-border rounded-lg"
>
<div className="flex items-center gap-6">
<span className="text-white font-medium w-16">{occ.dayOfWeek}</span>
<span className="text-gray-400 w-24">{occ.date}</span>
<span className="text-gray-300 w-20">{occ.time}</span>
<span className={`text-${badge}-500 text-sm flex items-center gap-1`}>
<span className={`w-2 h-2 rounded-full bg-${badge}-500`} />
{badgeText}
</span>
</div>
<button
onClick={() => {
setOccurrenceId(occ._id);
setStep("tickets");
router.push(`/booking?occurrenceId=${occ._id}&step=tickets`);
}}
disabled={remaining === 0}
className="px-6 py-2 bg-accent text-black font-bold rounded-lg disabled:opacity-30"
>
{t("book")}
</button>
</div>
);
})}
{occurrences.length >= visibleCount && (
<button
onClick={() => setVisibleCount((c) => c + 5)}
className="w-full py-3 border border-border text-gray-400 hover:text-white hover:border-accent transition-colors"
>
{t("seeMoreDates")}
</button>
)}
</div>
);
}- Step 4: Test booking button
Click [Book] → should navigate to /booking?occurrenceId={id}&step=tickets
- Step 5: Commit
git add apps/frontend/app/[locale]/shows/
git commit -m "feat(show-detail): add show page with occurrence list"Phase 4: Vertical Booking Flow
Task 4: Create BookingContext for Vertical Flow
Files:
- Create:
apps/frontend/lib/booking-context.tsx
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { Id } from "~/convex/_generated/dataModel";
export type BookingState = {
occurrenceId: Id<"showOccurrences"> | null;
ticketType: "DINNER_THEATRE" | "SHOW_ONLY" | null;
quantity: number;
seatIds: string[];
addOns: Array<{ addOnId: Id<"addOns">; quantity: number }>;
customerInfo: {
firstName: string;
lastName: string;
email: string;
phone?: string;
} | null;
reservationId: Id<"reservations"> | null;
bookingExpiresAt: number | null;
completedSteps: number[];
};
type BookingAction =
| { type: "START_BOOKING"; payload: { occurrenceId: Id<"showOccurrences">; reservationId: Id<"reservations">; bookingExpiresAt: number } }
| { type: "SELECT_TICKET"; payload: { ticketType: "DINNER_THEATRE" | "SHOW_ONLY"; quantity: number } }
| { type: "SELECT_SEATS"; payload: { seatIds: string[] } }
| { type: "SET_ADDONS"; payload: { addOns: BookingState["addOns"] } }
| { type: "SET_CUSTOMER_INFO"; payload: BookingState["customerInfo"] }
| { type: "COMPLETE_STEP"; payload: number }
| { type: "RESET" };
const initialState: BookingState = {
occurrenceId: null,
ticketType: null,
quantity: 1,
seatIds: [],
addOns: [],
customerInfo: null,
reservationId: null,
bookingExpiresAt: null,
completedSteps: [],
};
function bookingReducer(state: BookingState, action: BookingAction): BookingState {
switch (action.type) {
case "START_BOOKING":
return { ...initialState, ...action.payload };
case "SELECT_TICKET":
return { ...state, ...action.payload, completedSteps: [...state.completedSteps, 1] };
case "SELECT_SEATS":
return { ...state, seatIds: action.payload.seatIds, completedSteps: [...state.completedSteps, 2] };
case "SET_ADDONS":
return { ...state, addOns: action.payload.addOns, completedSteps: [...state.completedSteps, 3] };
case "SET_CUSTOMER_INFO":
return { ...state, customerInfo: action.payload, completedSteps: [...state.completedSteps, 4] };
case "COMPLETE_STEP":
return { ...state, completedSteps: [...state.completedSteps, 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 2: Commit
git add apps/frontend/lib/booking-context.tsx
git commit -m "feat(booking): add BookingContext for vertical flow"Task 5: Create Booking Layout with Sticky Cart
Files:
-
Create:
apps/frontend/app/[locale]/booking/page.tsx -
Create:
apps/frontend/components/booking/sticky-cart.tsx -
Create:
apps/frontend/components/booking/countdown-timer.tsx -
Step 1: Create countdown timer
"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 update = () => {
const remaining = Math.max(0, bookingExpiresAt - Date.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" });
}
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [bookingExpiresAt, dispatch]);
if (!bookingExpiresAt) return null;
return (
<>
<div className="fixed top-0 left-0 right-0 z-50 bg-surface/95 border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-sm text-gray-400">{t("seatsReserved")}</span>
<span className="font-mono text-lg text-accent font-bold">{timeLeft}</span>
</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 2: Create sticky cart
Shows: ticket line, seat line (if selected), add-ons, day-of-week surcharge, small-party surcharge, grand total.
- Step 3: Create booking layout
// apps/frontend/app/[locale]/booking/page.tsx
// Single booking page — nuqs ?step=&occurrenceId=
import { useQueryState } from "nuqs";
import { Suspense } from "react";
import { BookingProvider } from "~/lib/booking-context";
import { StickyCart } from "~/components/booking/sticky-cart";
import { CountdownTimer } from "~/components/booking/countdown-timer";
export default function BookingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<BookingProvider>
<CountdownTimer />
<div className="flex min-h-screen bg-background pt-12">
<main className="flex-1 px-4 py-8">
<Suspense fallback={<div className="animate-pulse">{children}</div>}>
{children}
</Suspense>
</main>
{/* Desktop sticky sidebar */}
<aside className="hidden lg:block w-80">
<StickyCart />
</aside>
</div>
</BookingProvider>
);
}- Step 4: Commit
git add apps/frontend/app/[locale]/booking/page.tsx
git add apps/frontend/components/booking/countdown-timer.tsx apps/frontend/components/booking/sticky-cart.tsx
git commit -m "feat(booking): add countdown timer and sticky cart"Task 6: Create Ticket Selector + Seat Selector
Files:
-
Create:
apps/frontend/components/booking/ticket-selector.tsx -
Create:
apps/frontend/components/booking/seat-selector.tsx -
Create:
apps/frontend/components/booking/step-tickets.tsx -
Create:
apps/frontend/components/booking/step-seats.tsx -
Step 1: Create ticket selector
Client component with ticket type radio buttons and quantity +/- controls. Calls api.reservations.createPending mutation, updates BookingContext.
- Step 2: Create seat selector (4x8 grid)
"use client";
import { useState } from "react";
import { useBooking } from "~/lib/booking-context";
import { useTranslations } from "next-intl";
const ROWS = ["A", "B", "C", "D"];
const SEATS_PER_ROW = 8;
export function SeatSelector() {
const t = useTranslations("booking.seats");
const [selected, setSelected] = useState<string[]>([]);
// Fetch taken seats from Convex: api.seats.getTakenSeats({ occurrenceId })
const toggle = (seatId: string) => {
setSelected((prev) =>
prev.includes(seatId)
? prev.filter((s) => s !== seatId)
: [...prev, seatId]
);
};
return (
<div className="space-y-4">
<div className="flex justify-center mb-4">
<div className="w-16 h-2 bg-accent rounded-full" />
</div>
{ROWS.map((row) => (
<div key={row} className="flex gap-2 justify-center">
<span className="w-8 flex items-center justify-center text-accent font-bold">{t("rowLabel", { row })}</span>
{Array.from({ length: SEATS_PER_ROW }, (_, i) => {
const seatId = `${row}${i + 1}`;
const isTaken = takenSeats.includes(seatId);
const isSelected = selected.includes(seatId);
return (
<button
key={seatId}
onClick={() => !isTaken && toggle(seatId)}
disabled={isTaken}
className={`w-10 h-10 rounded-t-lg font-bold text-sm transition-colors ${
isTaken
? "bg-gray-700 text-gray-500 cursor-not-allowed"
: isSelected
? "bg-accent text-black"
: "bg-surface border border-accent text-accent hover:bg-accent/20"
}`}
>
{i + 1}
</button>
);
})}
</div>
))}
{/* Stage label */}
<div className="flex justify-center mt-6">
<span className="text-accent/60 text-sm font-serif">{t("stage")}</span>
</div>
</div>
);
}-
Step 3: Create tickets and seats pages
-
Step 4: Commit
Task 7: Create Add-ons + Checkout Pages
Files:
-
Create:
apps/frontend/components/booking/addon-cards.tsx -
Create:
apps/frontend/components/booking/step-addons.tsx -
Create:
apps/frontend/components/booking/checkout-form.tsx -
Step 1: Create add-on cards
Fetches from api.addons.listEnabled, renders skip + quantity controls.
- Step 2: Create checkout form
Customer fields + terms checkbox + OnePay URL mutation call.
- Step 3: Commit
Phase 5: Pricing Surcharges
Task 8: Implement Surcharge Calculation
- Step 1: Add surcharge calculation to sticky cart and checkout
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: Commit
Enrichment Sections
1. Zod Schemas
Complete Zod schemas for all mutations/form inputs:
import { z } from "zod";
import { Id } from "~/convex/_generated/dataModel";
// 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),
});
// 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(
z.object({
addOnId: z.string(),
quantity: z.number().int().min(0),
}),
),
customerInfo: z
.object({
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
email: z.string().email(),
phone: z.string().optional(),
})
.nullable(),
reservationId: z.string().nullable(),
bookingExpiresAt: z.number().nullable(),
completedSteps: z.array(z.number()),
});
// 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" }),
}),
});
// Show slug param
const ShowSlugSchema = z.object({
slug: z.string().min(1),
});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",
// 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 selected" |
reservations.createPending | QUANTITY_EXCEEDS_AVAILABILITY | "Not enough seats available" |
seats.holdSeats | RESERVATION_NOT_FOUND | "Booking session expired" |
seats.holdSeats | RESERVATION_NOT_PENDING | "Booking session expired" |
seats.holdSeats | SEATS_UNAVAILABLE | "One or more selected seats are no longer available" |
reservations.confirmPayment | ALREADY_PAID | "This booking has already been paid" |
reservations.confirmPayment | AMOUNT_MISMATCH | "Payment amount does not match booking total" |
3. Convex Real-time Subscription Pattern
// List view — live updates when any occurrence changes
const occurrences = useQuery(api.occurrences.upcomingByTemplate, {
templateId,
limit: visibleCount,
});
// Individual item — live updates when specific reservation changes
const reservation = useQuery(
api.reservations.getById,
reservationId ? { reservationId } : "skip",
);
// Availability — real-time seat count
const availability = useQuery(
api.occurrences.getAvailability,
occurrenceId ? { occurrenceId } : "skip",
);
// Upcoming shows for carousel — live updates when shows are added/modified
const upcoming = useQuery(api.shows.upcoming, { limit: 8 });
// Active shows for programme page
const shows = useQuery(api.shows.listActive);4. Mobile/Responsive Considerations
- Carousel: Full-width swipe snap on mobile, one slide at a time. Auto-pause on tab hidden.
- Programme grid: 1 column mobile, 2 columns tablet, 3 columns desktop.
- Occurrence list: Stacks to single column on mobile with full-width buttons. Touch-friendly tap targets (min 44px).
- Booking layout: Bottom sheet cart on mobile (collapsible), right sidebar fixed on desktop (lg+).
- Countdown timer: Full-width fixed bar at top of viewport on mobile. High contrast text for readability.
- Seat selector: Horizontally scrollable seat grid with pinch-to-zoom on small screens. Fixed seat cell size (40x40px min).
- Checkout form: Full-width inputs on mobile, 2-column grid on desktop for name fields.
- Sticky cart: Collapsible bottom sheet on mobile with drag handle. Tap to expand and see line items.
5. PWA / Offline Behavior
Guest-facing PWA caching strategy for table PWA (guest-profiles plan handles this):
- Service worker caches static assets (JS, CSS, images) with cache-first strategy
- Network-first for Convex real-time API calls
- Stale-while-revalidate for guest wall data
Guest journey booking flow — not a PWA, but handles offline:
- Booking flow requires real-time seat availability — offline mode will show stale data
- If network is lost during booking:
- Show "Connection lost" overlay with retry button
- Countdown timer continues (local time)
- On reconnect, refetch availability and highlight any now-occupied seats
- If session expired during offline, redirect to show page with message
6. i18n / next-intl Requirements
Translation key tree in JSON format:
{
"homepage": {
"carousel": {
"fromPrice": "From {price} VND",
"previousSlide": "Previous slide",
"nextSlide": "Next slide",
"goToSlide": "Go to slide {index}"
}
},
"programme": {
"title": "Our Shows",
"viewDates": "View dates & book",
"loading": "Loading..."
},
"shows": {
"occurrenceList": {
"available": "Available",
"seatsLeft": "{count} seats left",
"soldOut": "Sold out",
"book": "Book",
"seeMoreDates": "See more dates"
}
},
"booking": {
"countdown": {
"seatsReserved": "Seats reserved for",
"expiredTitle": "Reservation Expired",
"expiredMessage": "Your seats have been released. Please start your booking again.",
"returnHome": "Return to Homepage"
},
"cart": {
"title": "Your Booking",
"dinnerTheatre": "Dinner Theatre",
"showOnly": "Show Only",
"total": "Total",
"surcharge": "Surcharge",
"smallPartySurcharge": "Small party surcharge"
},
"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": "Please select {count} seat(s)",
"selectedSeats": "Selected seats",
"selectMore": "Select {count} more seat(s)",
"back": "Back",
"continue": "Continue to Add-ons",
"stage": "STAGE",
"rowLabel": "Row {row}",
"loading": "Loading seat map...",
"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..."
},
"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 |
NEXT_PUBLIC_LOCALE | Current locale (en/vi) | Yes (auto-set) | Client |
Server-only variables (never exposed to client):
ONEPAY_API_KEY— OnePay API key for server-side payment processingONEPAY_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("GJ-E2E-1.1: Guest sees homepage carousel with upcoming shows");
test("GJ-E2E-1.2: Carousel auto-advances to next slide after 6 seconds");
test("GJ-E2E-1.3: Guest can pause carousel by hovering over slide");
test("GJ-E2E-1.4: Guest can navigate carousel using arrow buttons");
test("GJ-E2E-1.5: Guest can navigate carousel by clicking dot indicators");
test("GJ-E2E-2.1: Guest can view all active shows on programme page");
test("GJ-E2E-2.2: Show cards link to correct show detail page");
test("GJ-E2E-3.1: Guest can view show detail page with video and gallery");
test("GJ-E2E-3.2: Guest can see occurrence list with availability badges");
test("GJ-E2E-3.3: Book button navigates guest to booking flow");
test("GJ-E2E-4.1: Guest can select DINNER_THEATRE ticket type");
test("GJ-E2E-4.2: Guest can select SHOW_ONLY ticket type when enabled");
test("GJ-E2E-4.3: Quantity selector respects maximum availability");
test(
"GJ-E2E-4.4: Guest sees 10-minute countdown timer after selecting tickets",
);
test("GJ-E2E-4.5: Countdown timer shows expired modal and redirects on expiry");
test("GJ-E2E-5.1: Guest can select seats on seat map");
test("GJ-E2E-5.2: Guest cannot select already-occupied seats");
test("GJ-E2E-5.3: Guest cannot select more seats than ticket quantity");
test("GJ-E2E-5.4: Selected seats display in summary");
test("GJ-E2E-6.1: Guest can skip add-ons and continue to checkout");
test("GJ-E2E-6.2: Guest can add add-ons with quantity");
test("GJ-E2E-7.1: Guest can fill checkout form and submit");
test("GJ-E2E-7.2: Guest sees sticky cart with correct surcharges");
test("GJ-E2E-7.3: Guest is redirected to OnePay on payment submission");
test("GJ-E2E-8.1: Guest sees confirmation page after successful payment");
test("GJ-E2E-8.2: Guest sees QR code and booking details on confirmation");Component Tests (Vitest + RTL):
it("GJ-1.1: Homepage carousel renders without crashing when no shows exist");
it("GJ-1.2: Carousel displays loading skeleton before data loads");
it("GJ-1.3: Carousel shows correct number of slides based on data");
it("GJ-1.4: Arrow buttons change current slide index");
it("GJ-1.5: Dot indicators show correct active state");
it("GJ-2.1: Programme page shows loading skeleton before data loads");
it("GJ-2.2: Programme grid displays all active shows");
it("GJ-2.3: Show cards have correct hover effect");
it("GJ-3.1: Show detail page shows programme grid when no slug provided");
it("GJ-3.2: Occurrence list shows availability badges correctly");
it("GJ-3.3: Sold out occurrences have disabled book button");
it("GJ-4.1: Ticket selector renders both ticket types when SHOW_ONLY enabled");
it("GJ-4.2: Ticket selector hides SHOW_ONLY when disabled");
it("GJ-4.3: Quantity cannot exceed remaining availability");
it("GJ-4.4: Countdown timer displays correct time format MM:SS");
it("GJ-4.5: Countdown timer dispatches RESET action on expiry");
it("GJ-5.1: Seat grid renders all 32 seats");
it("GJ-5.2: Available seats are clickable");
it("GJ-5.3: Occupied seats show disabled styling");
it("GJ-5.4: Selected seats show selected styling");
it("GJ-6.1: Add-on cards display correct pricing");
it("GJ-6.2: Skip button allows guest to proceed without add-ons");
it("GJ-7.1: Checkout form validates required fields");
it("GJ-7.2: Checkout form validates email format");
it("GJ-7.3: Terms checkbox must be checked to submit");
it("GJ-8.1: Confirmation shows success state after payment");
it("GJ-8.2: Confirmation shows QR code when available");Backend/Mutation Tests (Vitest):
it("GJ-ORD-1.1: Guest can create pending reservation with valid data");
it("GJ-ORD-1.2: Reservation creation fails for non-existent occurrence");
it("GJ-ORD-1.3: Reservation creation fails for invalid ticket type");
it("GJ-ORD-1.4: Reservation creation fails when quantity exceeds availability");
it("GJ-ORD-2.1: Guest can hold seats with valid reservation");
it("GJ-ORD-2.2: Seat hold fails for non-pending reservation");
it("GJ-ORD-2.3: Previously held seats are released when new seats are held");
it("GJ-ORD-3.1: Payment confirmation succeeds for valid pending reservation");
it("GJ-ORD-3.2: Payment confirmation fails for already-paid reservation");
it("GJ-ORD-4.1: Small party surcharge applies for groups under 15");
it("GJ-ORD-4.2: Small party surcharge does not apply for groups of 15 or more");
it("GJ-ORD-5.1: Day-of-week surcharge applies correctly for Saturday");
it("GJ-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, reservations.totalAmount |
| Guest profiles | 2026-05-03-guest-profiles.md | reservations.reservationId for QR linking |
| Pricing engine | 16-package-bundle-pricing.md | calculateDayOfWeekSurcharge, calculateSmallPartySurcharge |
| Shows system | show-system.md | shows.upcoming, shows.listActive, shows.getBySlug |
| Occurrences | occurrence-system.md | occurrences.upcomingByTemplate, occurrences.getAvailability |
| Add-ons | addons-system.md | addOns table, api.addons.listEnabled |
10. Performance Considerations
- Carousel: Images lazy-loaded via Next.js Image component with
loading="lazy". Auto-advance pauses when tab is hidden (visibilitychange) to save resources. - Programme grid: Server-side filtering where possible. Client-side filter for mood/format after initial load.
- Occurrence list: Paginated with
visibleCountstate (5 at a time, +5 on "See more"). Prevents loading all future occurrences at once. - BookingContext: Lightweight — only stores IDs and primitive values, no API responses cached in context.
- Real-time queries: Convex subscriptions only active during booking flow; unmounted components auto-unsubscribe. Use
"skip"pattern when params not yet available. - Seat map: 32 seats is a small dataset; no virtualization needed. Direct DOM updates sufficient.
- Sticky cart: Memoized price calculations to prevent re-renders on unrelated state changes.
- Countdown timer: Uses
useTransitionfor router push to prevent UI blocking on navigation. - Bundle size: Dynamic imports for booking step components to reduce initial load time.
Acceptance Criteria
- Homepage carousel auto-advances every 6s, pauses on hover, arrows + dots work
- Programme page shows all ACTIVE shows in a grid with working links
- Show detail page: video autoplay, gallery scroll, occurrence list with availability badges
- Clicking [Book] on a show occurrence → navigates to
/booking?occurrenceId={id}&step=tickets - 10-minute countdown timer visible, pauses on hidden tab, expires and shows modal with redirect
- Sticky cart shows all line items + surcharges + grand total
- Vertical booking: guest scrolls between sections, completed steps are accessible
- OnePay redirect on checkout submission
- Confirmation page reads OnePay return params, shows booking recap + QR code
Consistency Audit: guest-journey
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Spec (02-guest-journey.md) vs plan routing | Spec defines /{locale}/shows/{id} and /{locale}/booking/{occurrenceId}/tickets with dynamic URL segments. Plan correctly uses nuqs SPA routing (?slug=, ?step=tickets&occurrenceId=). | Plan uses correct nuqs approach. Spec must be updated separately to match. |
| 2 | OccurrenceList booking button | Direct router.push() for booking navigation | Now uses useQueryState for step + occurrenceId params, then router.push() with SPA-compatible URL |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Throughout code | console.log usage | Replaced with consola from consola library |
| 2 | CountdownTimer, CheckoutForm | Missing useTransition | Added useTransition for router.push/navigation |
| 3 | ShowsPage | Missing Suspense | Wrapped OccurrenceList in Suspense with loading skeleton |
| 4 | Throughout components | Hardcoded strings | All user-facing strings use useTranslations/getTranslations |
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.createPending mutation defined in booking-flow plan, not yet in codebase | Build booking-flow plan first to get this mutation; guest-journey plan depends on it |
[P0] No as any found — all type assertions use proper Id<> types from Convex dataModel.
[P0] No Math.random() found — no ID generation issues. All IDs come from Convex or crypto UUIDs.
[P0] No useParams() found — plan uses useQueryState from nuqs correctly for slug, occurrenceId, and step params.
[P0] No staffMutation/adminMutation references — no admin mutations referenced in this plan. All mutations use standard mutation from Convex. This is a guest-facing plan (public marketing/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 uses useTransition for router.push on expiry redirect.
[P1] Suspense present — ShowsPage wraps OccurrenceList in Suspense with loading skeleton fallback.
[P1] All hardcoded strings reviewed — all user-facing strings use useTranslations/getTranslations from next-intl. Seat selector row labels now use t("rowLabel", { row }) instead of hardcoded "A", "B", "C", "D". Stage label uses t("stage").
[P1] No emoji in UI — all icons use inline SVG, no emoji characters in component code. Reactions (if added in future) use SVG icons from REACTION_TYPES constant.
[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).