Booking Page Show Picker — 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: Replace the booking page's error-when-no-occurrenceId state with a full show browser (calendar + upcoming shows list). Convert the booking flow from a URL-step SPA to a single scrollable page with hash navigation.
Architecture: The page has two states: (1) ShowPicker when no occurrenceId is in the URL, (2) a single scrollable booking page with #tickets / #addons / #checkout / #confirmation hash navigation once a show is selected. No page navigation, no URL step params.
Tech Stack: Next.js 16 App Router, Tailwind CSS v4, nuqs useQueryState, api.occurrences.listUpcoming for show data.
File Map
Modified:
- app/[locale]/(landing)/booking/page.tsx ← restructure with ShowPicker + hash nav
- components/booking/step-tickets.tsx ← wrap in section, change onNext to hash nav
- components/booking/step-addons.tsx ← wrap in section, change onNext to hash nav
- components/booking/checkout-form.tsx ← replace setStep("confirmation") with onSuccess prop
- components/booking/confirmation-display.tsx ← wrap in section (no hash change on success)
Created:
- components/booking/show-picker.tsx ← root component: calendar + shows grid
- components/booking/calendar-section.tsx ← month nav + day grid with availability dots
- components/booking/show-card.tsx ← single show card with Book Now button
- components/booking/step-indicator.tsx ← horizontal step indicator with clickable steps
- components/booking/sticky-mini-cart.tsx ← sticky header during booking flowTask 1: Create show-card.tsx
Files:
- Create:
apps/frontend/components/booking/show-card.tsx
"use client";
import Image from "next/image";
import { cn } from "~/lib/utils";
interface ShowCardProps {
show: {
_id: string;
title: string;
image?: string;
formattedDate: string;
formattedTime: string;
priceLabel?: string;
isSoldOut?: boolean;
};
onBook: (occurrenceId: string) => void;
}
export function ShowCard({ show, onBook }: ShowCardProps) {
return (
<div className="bg-[#2E2E2E] rounded-lg p-4 flex gap-4">
{show.image && (
<div className="relative w-20 h-20 flex-shrink-0">
<Image
src={show.image}
alt={show.title}
fill
className="object-cover rounded-md"
/>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-serif text-[#C5A059] truncate">{show.title}</h3>
<p className="text-sm text-[#808080]">
{show.formattedDate} at {show.formattedTime}
</p>
{show.priceLabel && (
<p className="text-sm text-[#e6e6e6]">{show.priceLabel}</p>
)}
</div>
<div className="flex-shrink-0 flex items-center">
{show.isSoldOut ? (
<span className="px-3 py-1 bg-[#4d4d4d] text-[#808080] text-sm rounded">
Sold Out
</span>
) : (
<button
onClick={() => onBook(show._id)}
className="px-4 py-2 bg-[#C5A059] text-[#1a1a1a] rounded font-bold text-sm"
>
Book Now
</button>
)}
</div>
</div>
);
}- Step 1: Write the file with the code above
- Step 2: Verify file exists — no test needed for UI component
Task 2: Create calendar-section.tsx
Files:
- Create:
apps/frontend/components/booking/calendar-section.tsx - Reuses:
lib/utils/calendar→getCalendarState
"use client";
import { useState } from "react";
import { getCalendarState } from "~/lib/utils/calendar";
import { cn } from "~/lib/utils";
interface CalendarSectionProps {
availableDays?: Set<number>; // Set of days (1-31) that have shows
selectedDay?: number | null;
onSelectDay?: (day: number | null) => void;
}
export function CalendarSection({
availableDays,
selectedDay,
onSelectDay,
}: CalendarSectionProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const prevMonth = () =>
setCurrentMonth((d) => new Date(d.getFullYear(), d.getMonth() - 1));
const nextMonth = () =>
setCurrentMonth((d) => new Date(d.getFullYear(), d.getMonth() + 1));
const { monthLabel, firstDay, daysInMonth } = getCalendarState(currentMonth);
return (
<div className="bg-[#2E2E2E] rounded-lg p-4">
<h3 className="font-serif text-[#C5A059] mb-4 text-center">Calendar</h3>
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
className="w-8 h-8 flex items-center justify-center text-[#e6e6e6] hover:text-[#C5A059]"
aria-label="Previous month"
>
‹
</button>
<span className="text-[#e6e6e6] font-medium">{monthLabel}</span>
<button
onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center text-[#e6e6e6] hover:text-[#C5A059]"
aria-label="Next month"
>
‹
</button>
</div>
{/* Day grid */}
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
{["S", "M", "T", "W", "T", "F", "S"].map((d) => (
<span key={d} className="text-[#808080] py-1">
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstDay }, (_, i) => (
<div key={`empty-${i}`} />
))}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
const isAvailable = availableDays?.has(day);
const isSelected = selectedDay === day;
return (
<button
key={day}
onClick={() => onSelectDay?.(isSelected ? null : day)}
disabled={!isAvailable}
className={cn(
"relative w-8 h-8 text-sm rounded flex items-center justify-center",
isAvailable
? "text-[#e6e6e6] hover:bg-[#3d3d3d]"
: "text-[#4d4d4d] cursor-not-allowed",
isSelected && "bg-[#C5A059] text-[#1a1a1a] font-bold",
)}
>
{day}
{isAvailable && !isSelected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 bg-[#C5A059] rounded-full" />
)}
</button>
);
})}
</div>
<p className="text-xs text-[#808080] mt-3 flex items-center gap-1">
<span className="w-2 h-2 bg-[#C5A059] rounded-full inline-block" />
Available
</p>
</div>
);
}- Step 1: Write the file with the code above
Task 3: Create show-picker.tsx
Files:
- Create:
apps/frontend/components/booking/show-picker.tsx
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import { CalendarSection } from "./calendar-section";
import { ShowCard } from "./show-card";
interface Show {
_id: string;
title: string;
image?: string;
formattedDate: string;
formattedTime: string;
priceLabel?: string;
isSoldOut?: boolean;
}
function useUpcomingShows(limit = 20) {
const results = useQuery(api.occurrences.listUpcoming, { limit }) as
| {
occurrenceId: string;
showTitle: string;
showImage?: string;
date: string;
time: string;
showPrice?: number;
status: string;
}[]
| undefined;
return useMemo(() => {
if (!results) return [];
return results.map((r) => ({
_id: r.occurrenceId,
title: r.showTitle,
image: r.showImage,
formattedDate: r.date,
formattedTime: r.time,
priceLabel:
r.showPrice != null
? `From ${r.showPrice.toLocaleString()} VND`
: undefined,
isSoldOut: r.status === "SOLD_OUT",
}));
}, [results]);
}
export function ShowPicker() {
const router = useRouter();
const shows = useUpcomingShows();
const [selectedDay, setSelectedDay] = useState<number | null>(null);
// Compute available days for calendar dots
const availableDays = useMemo(() => {
const days = new Set<number>();
for (const show of shows) {
const day = parseInt(show.formattedDate.split("/")[0], 10);
if (!isNaN(day)) days.add(day);
}
return days;
}, [shows]);
// Filter shows by selected day
const filteredShows = useMemo(() => {
if (!selectedDay) return shows;
return shows.filter((show) => {
const day = parseInt(show.formattedDate.split("/")[0], 10);
return day === selectedDay;
});
}, [shows, selectedDay]);
const handleBook = (occurrenceId: string) => {
// Set occurrenceId in URL and scroll to booking section
const params = new URLSearchParams();
params.set("occurrenceId", occurrenceId);
router.push(`/booking?${params.toString()}#tickets`);
};
return (
<div className="min-h-screen bg-[#1a1a1a] pt-20 px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="font-serif text-3xl text-[#C5A059] mb-8 text-center">
Book a Show
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Calendar */}
<CalendarSection
availableDays={availableDays}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
/>
{/* Shows list */}
<div>
<h3 className="font-serif text-[#C5A059] mb-4">
{selectedDay ? `Shows on day ${selectedDay}` : "Upcoming Shows"}
</h3>
{filteredShows.length === 0 ? (
<div className="text-center py-12 text-[#808080]">
<p>No shows available for this day.</p>
<p className="text-sm mt-1">
Try selecting a different day or check back soon.
</p>
</div>
) : (
<div className="space-y-3">
{filteredShows.map((show) => (
<ShowCard key={show._id} show={show} onBook={handleBook} />
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}- Step 1: Write the file with the code above
- Step 2: Verify — confirm
api.occurrences.listUpcomingaccepts{ limit: number }from the convex functions signature
Task 4: Create step-indicator.tsx
Files:
- Create:
apps/frontend/components/booking/step-indicator.tsx
"use client";
import { useRouter } from "next/navigation";
import { cn } from "~/lib/utils";
const STEPS = [
{ id: "tickets", label: "Tickets" },
{ id: "addons", label: "Addons" },
{ id: "checkout", label: "Checkout" },
{ id: "confirmation", label: "Confirmation" },
] as const;
type StepId = (typeof STEPS)[number]["id"];
interface StepIndicatorProps {
currentStep: StepId;
completedSteps: Set<StepId>;
}
export function StepIndicator({
currentStep,
completedSteps,
}: StepIndicatorProps) {
const router = useRouter();
const navigateToHash = (hash: StepId) => {
window.location.hash = hash;
document.getElementById(hash)?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex items-center justify-center gap-2 mb-8">
{STEPS.map((step, index) => {
const isActive = step.id === currentStep;
const isCompleted = completedSteps.has(step.id);
const isClickable = isCompleted || step.id === "tickets";
return (
<div key={step.id} className="flex items-center">
<button
onClick={() => isClickable && navigateToHash(step.id)}
disabled={!isClickable}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-full text-sm transition-colors",
isActive && "bg-[#C5A059] text-[#1a1a1a] font-bold",
isCompleted && !isActive && "bg-[#3d3d3d] text-[#C5A059]",
!isActive && !isCompleted && "text-[#808080]",
)}
>
<span className="w-5 h-5 rounded-full bg-current/20 flex items-center justify-center text-xs font-mono">
{index + 1}
</span>
{step.label}
</button>
{index < STEPS.length - 1 && (
<div className="w-6 h-px bg-[#4d4d4d] mx-1" />
)}
</div>
);
})}
</div>
);
}- Step 1: Write the file with the code above
Task 5: Create sticky-mini-cart.tsx
Files:
- Create:
apps/frontend/components/booking/sticky-mini-cart.tsx
"use client";
import { Ticket } from "lucide-react";
import { useBookingDraft } from "~/lib/hooks/use-booking-draft";
import { useCheckoutSummaryData } from "~/lib/hooks/use-checkout-summary-data";
export function StickyMiniCart() {
const { draft } = useBookingDraft();
const occurrenceId = draft?.occurrenceId ?? null;
const ticketType = draft?.ticketType ?? null;
const quantity = draft?.quantity ?? 1;
const addOns = draft?.addOns ?? [];
const { occurrence, addonPrices, pricing } = useCheckoutSummaryData({
occurrenceId,
ticketType,
quantity,
addOns,
});
if (!occurrenceId) return null;
return (
<div className="sticky top-0 z-40 bg-[#1a1a1a] border-b border-[#4d4d4d] px-4 py-3">
<div className="max-w-2xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Ticket className="w-4 h-4 text-[#C5A059]" />
<div>
<p className="text-sm font-serif text-[#C5A059]">
{occurrence?.showName ?? "Show"}
</p>
<p className="text-xs text-[#808080]">
{occurrence?.formattedDate} at {occurrence?.formattedTime}
{" · "}
{quantity}x {pricing.ticketLabel}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-bold text-[#e6e6e6]">
{pricing.total.toLocaleString()} VND
</p>
</div>
</div>
</div>
);
}- Step 1: Write the file with the code above
Task 6: Modify booking/page.tsx — restructure for ShowPicker + hash nav
Files:
- Modify:
apps/frontend/app/[locale]/(landing)/booking/page.tsx
"use client";
export const dynamic = "force-dynamic";
import { Suspense, useEffect, useState } from "react";
import { useQueryState } from "nuqs";
import { StickyMiniCart } from "~/components/booking/sticky-mini-cart";
import { CountdownTimer } from "~/components/booking/countdown-timer";
import { StepTickets } from "~/components/booking/step-tickets";
import { StepAddons } from "~/components/booking/step-addons";
import { CheckoutForm } from "~/components/booking/checkout-form";
import { ConfirmationDisplay } from "~/components/booking/confirmation-display";
import { StepIndicator } from "~/components/booking/step-indicator";
import { ShowPicker } from "~/components/booking/show-picker";
import { useTranslations } from "next-intl";
const STEP_ORDER = ["tickets", "addons", "checkout", "confirmation"] as const;
type StepId = (typeof STEP_ORDER)[number];
function navigateToHash(hash: string) {
window.location.hash = hash;
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
function getStepFromHash(): StepId {
const hash = window.location.hash.replace("#", "");
if (STEP_ORDER.includes(hash as StepId)) return hash as StepId;
return "tickets";
}
export default function BookingPage() {
const t = useTranslations("booking");
const [occurrenceId] = useQueryState("occurrenceId", { defaultValue: "" });
// Show picker when no occurrence is selected
if (!occurrenceId) {
return <ShowPicker />;
}
// Track completed steps for step indicator
const [completedSteps, setCompletedSteps] = useState<Set<StepId>>(
new Set(["tickets"]),
);
// Hash state — read from URL on mount
const [currentStep, setCurrentStep] = useState<StepId>(getStepFromHash);
// Listen for hash changes (back/forward browser buttons)
useEffect(() => {
const handleHashChange = () => {
const step = getStepFromHash();
setCurrentStep(step);
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, []);
// On mount, scroll to current hash section
useEffect(() => {
const el = document.getElementById(currentStep);
if (el) el.scrollIntoView({ behavior: "instant" });
}, []);
const handleStepComplete = (step: StepId) => {
setCompletedSteps((prev) => new Set([...prev, step]));
const nextIndex = STEP_ORDER.indexOf(step) + 1;
if (nextIndex < STEP_ORDER.length) {
const nextStep = STEP_ORDER[nextIndex];
navigateToHash(nextStep);
setCurrentStep(nextStep);
}
};
return (
<div className="min-h-screen bg-[#1a1a1a] pt-20">
<StickyMiniCart />
<main className="max-w-2xl mx-auto px-4 py-8">
<CountdownTimer />
<StepIndicator
currentStep={currentStep}
completedSteps={completedSteps}
/>
<section id="tickets">
<StepTickets onNext={() => handleStepComplete("tickets")} />
</section>
<section id="addons">
<StepAddons
onNext={() => handleStepComplete("addons")}
onBack={() => navigateToHash("tickets")}
/>
</section>
<section id="checkout">
<CheckoutForm onSuccess={() => handleStepComplete("checkout")} />
</section>
<section id="confirmation">
<ConfirmationDisplay />
</section>
</main>
</div>
);
}- Step 1: Replace the entire file with the code above
- Step 2: Verify — confirm
setQueryParamsis no longer used (replaced by hash navigation)
Task 7: Modify step-tickets.tsx — wrap in section
Files:
- Modify:
apps/frontend/components/booking/step-tickets.tsx:1-114
Change onNext to call navigateToHash instead of the prop. Remove the onNext prop entirely — the parent page handles hash navigation. The step component just calls onComplete() which the parent wires to hash nav.
Change the interface:
interface StepTicketsProps {
onNext: () => void; // kept for backward compat, parent page passes hash-nav wrapper
}Change the handleConfirmSelection:
const handleConfirmSelection = () => {
updateField("ticketType", ticketType);
updateField("quantity", quantity);
onNext(); // parent wired to navigateToHash("addons")
};The component itself does not change — the parent page passes an onNext that calls navigateToHash("addons"). No structural changes needed to this file.
- Step 1: Read file — verify current implementation
- Step 2: No changes required —
onNextprop already exists and works correctly with the new page wiring
Task 8: Modify step-addons.tsx — wrap in section
Files:
- Modify:
apps/frontend/components/booking/step-addons.tsx:23-44
Same pattern as step-tickets — onNext and onBack props already exist. Parent page wires them to navigateToHash.
- Step 1: Read file — verify
onNextandonBackprops exist - Step 2: No changes required — props already exist
Task 9: Modify checkout-form.tsx — replace setStep with onSuccess prop
Files:
- Modify:
apps/frontend/components/booking/checkout-form.tsx:29-34
Remove the useQueryState("step") import and the setStep call. Use the onSuccess prop instead.
Change from:
const [, setStep] = useQueryState("step", { defaultValue: "confirmation" });
const handleSuccess = (reservationId: string) => {
setStep("confirmation");
onSuccess?.(reservationId);
};Change to:
interface CheckoutFormProps {
onSuccess?: (reservationId: string) => void;
}
const handleSuccess = (reservationId: string) => {
onSuccess?.(reservationId);
};- Step 1: Read file — confirm line 29 has
useQueryStateand line 32 hassetStep("confirmation") - Step 2: Edit — remove
useQueryStateimport, addonSuccessprop, replacesetStepwithonSuccess?.() - Step 3: Commit
Task 10: Modify confirmation-display.tsx — wrap in section (no structural change needed)
Files:
- Modify:
apps/frontend/components/booking/confirmation-display.tsx
The ConfirmationDisplay uses useSearchParams() for reservationId — this still works with hash nav. The confirmation section just needs to exist in the DOM. No changes to the component itself — just ensure it renders inside <section id="confirmation"> in the page.
- Step 1: Read file — confirm it doesn't use
useQueryState("step") - Step 2: No changes required — component works as-is inside a section
Task 11: End-to-end verification
Files: apps/frontend/app/[locale]/(landing)/booking/page.tsx
- Step 1: Navigate to
/booking— should show ShowPicker with calendar and shows list - Step 2: Click a show's "Book Now" — should navigate to
/booking?occurrenceId=xxx#tickets, scroll to tickets section - Step 3: Complete tickets and click Continue — should scroll to
#addons - Step 4: Click Back — should scroll to
#tickets - Step 5: Complete full flow to confirmation — should reach
#confirmation - Step 6: Browser back button — should scroll to previous section
- Step 7: Hard refresh on
/booking?occurrenceId=xxx#checkout— should load checkout section directly
Self-Review Checklist
-
Spec coverage — All spec requirements mapped to tasks? Yes: ShowPicker (calendar + shows), hash nav, step indicator, sticky mini cart, scroll sections all covered.
-
Placeholder scan — No
TODO,TBD,implement lateranywhere. All steps have actual code. -
Type consistency —
StepId="tickets" | "addons" | "checkout" | "confirmation"used consistently in page, step-indicator, and hash nav.occurrenceIdfromnuqsused in page.useCheckoutSummaryDataanduseBookingDraftsignatures unchanged. -
Convex API —
api.occurrences.listUpcomingcalled with{ limit: number }— verify this matches the actual function signature inpackages/backend/convex/functions/occurrences.ts. -
Nuqs usage —
useQueryStateforoccurrenceIdonly; step controlled by hash. OlduseQueryState("step")removed from checkout-form.