plans
2026-05-03
2026 05 03 Guest Journey Plan

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, seatIds

Phase 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 has upcoming query 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];
MutationError CodeError Message
reservations.createPendingOCCURRENCE_NOT_FOUND"Show date not found"
reservations.createPendingINVALID_TICKET_TYPE"Invalid ticket type selected"
reservations.createPendingQUANTITY_EXCEEDS_AVAILABILITY"Not enough seats available"
seats.holdSeatsRESERVATION_NOT_FOUND"Booking session expired"
seats.holdSeatsRESERVATION_NOT_PENDING"Booking session expired"
seats.holdSeatsSEATS_UNAVAILABLE"One or more selected seats are no longer available"
reservations.confirmPaymentALREADY_PAID"This booking has already been paid"
reservations.confirmPaymentAMOUNT_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:
    1. Show "Connection lost" overlay with retry button
    2. Countdown timer continues (local time)
    3. On reconnect, refetch availability and highlight any now-occupied seats
    4. 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

VariableDescriptionRequiredLocation
NEXT_PUBLIC_BASE_URLPublic URL for OnePay return URLsYesClient + Server
NEXT_PUBLIC_CONVEX_URLConvex deployment URLYes (auto-set)Client
NEXT_PUBLIC_LOCALECurrent locale (en/vi)Yes (auto-set)Client

Server-only variables (never exposed to client):

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

8. TDD Test Cases

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

E2E Tests (Playwright):

test("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

DependencyPlanShared Schema
Seat selection2026-05-03-seat-selection.mdseats table, reservations.seatIds
OnePay payment2026-05-03-payment-onepay.mdreservations.paymentStatus, reservations.totalAmount
Guest profiles2026-05-03-guest-profiles.mdreservations.reservationId for QR linking
Pricing engine16-package-bundle-pricing.mdcalculateDayOfWeekSurcharge, calculateSmallPartySurcharge
Shows systemshow-system.mdshows.upcoming, shows.listActive, shows.getBySlug
Occurrencesoccurrence-system.mdoccurrences.upcomingByTemplate, occurrences.getAvailability
Add-onsaddons-system.mdaddOns 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 visibleCount state (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 useTransition for router push to prevent UI blocking on navigation.
  • Bundle size: Dynamic imports for booking step components to reduce initial load time.

Acceptance Criteria

  1. Homepage carousel auto-advances every 6s, pauses on hover, arrows + dots work
  2. Programme page shows all ACTIVE shows in a grid with working links
  3. Show detail page: video autoplay, gallery scroll, occurrence list with availability badges
  4. Clicking [Book] on a show occurrence → navigates to /booking?occurrenceId={id}&step=tickets
  5. 10-minute countdown timer visible, pauses on hidden tab, expires and shows modal with redirect
  6. Sticky cart shows all line items + surcharges + grand total
  7. Vertical booking: guest scrolls between sections, completed steps are accessible
  8. OnePay redirect on checkout submission
  9. Confirmation page reads OnePay return params, shows booking recap + QR code

Consistency Audit: guest-journey

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Spec (02-guest-journey.md) vs plan routingSpec 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.
2OccurrenceList booking buttonDirect router.push() for booking navigationNow uses useQueryState for step + occurrenceId params, then router.push() with SPA-compatible URL

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1Throughout codeconsole.log usageReplaced with consola from consola library
2CountdownTimer, CheckoutFormMissing useTransitionAdded useTransition for router.push/navigation
3ShowsPageMissing SuspenseWrapped OccurrenceList in Suspense with loading skeleton
4Throughout componentsHardcoded stringsAll user-facing strings use useTranslations/getTranslations

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

#IssueAction Required
1staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.tsFoundation plan must implement role-check auth helpers. Currently only getCurrentUser, upsertUser, and isAdmin are exported. convex/CLAUDE.md references authenticatedQuery/authenticatedMutation but these are NOT yet implemented.
2reservations.createPending mutation defined in booking-flow plan, not yet in codebaseBuild 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).