plans
2026-05-05
2026 05 05 Booking Page Show Picker

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 flow

Task 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/calendargetCalendarState
"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"
        >
          &#x2039;
        </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"
        >
          &#x2039;
        </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.listUpcoming accepts { 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 setQueryParams is 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 requiredonNext prop 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 onNext and onBack props 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 useQueryState and line 32 has setStep("confirmation")
  • Step 2: Edit — remove useQueryState import, add onSuccess prop, replace setStep with onSuccess?.()
  • 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

  1. Spec coverage — All spec requirements mapped to tasks? Yes: ShowPicker (calendar + shows), hash nav, step indicator, sticky mini cart, scroll sections all covered.

  2. Placeholder scan — No TODO, TBD, implement later anywhere. All steps have actual code.

  3. Type consistencyStepId = "tickets" | "addons" | "checkout" | "confirmation" used consistently in page, step-indicator, and hash nav. occurrenceId from nuqs used in page. useCheckoutSummaryData and useBookingDraft signatures unchanged.

  4. Convex APIapi.occurrences.listUpcoming called with { limit: number } — verify this matches the actual function signature in packages/backend/convex/functions/occurrences.ts.

  5. Nuqs usageuseQueryState for occurrenceId only; step controlled by hash. Old useQueryState("step") removed from checkout-form.