plans
2026-05-11
2026 05 11 Booking Flow Consolidation

Booking Flow Consolidation 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: Consolidate fragmented booking flow by removing dead code, eliminating duplicate local state, and cleaning up type definitions. Make ReservationDraftContext the single source of truth.

Architecture: Remove useTicketSelection and useAddonSelection hooks — their state lives in ReservationDraft already. Components read/write draft directly. Remove BUNDLE step entirely (never existed in PRD).

Tech Stack: Next.js 16 (App Router), Convex, React Context


File Map

Dead Code to Delete

FileReason
apps/frontend/hooks/booking/use-bundle-selection.tsBUNDLE step never existed
apps/frontend/lib/data/booking.ts (bundleOptions only)Only used by dead hook
apps/frontend/lib/utils/booking-bundle.tsOnly used by dead hook

Files to Modify

FileChange
apps/frontend/lib/constants/booking-steps.tsRemove dead step variants
apps/frontend/components/booking/step-tickets.tsxUse draft directly, remove useTicketSelection
apps/frontend/components/booking/step-addons.tsxUse draft directly, remove useAddonSelection
apps/frontend/components/booking/sticky-cart.tsxRemove useCheckoutSummary import if redundant
apps/frontend/components/booking/confirmation-display.tsxRemove useCountdown import - check usage
apps/frontend/app/[locale]/(landing)/schedule/page.tsxVerify single modal approach

Files to Verify

FilePurpose
apps/frontend/components/ui/booking-modal.tsxEnsure modal uses correct step strings
apps/frontend/components/booking/step-tickets-view.tsxDifferent from step-tickets.tsx?
apps/frontend/components/booking/sticky-cart-items.tsxUsed by sticky-cart
apps/frontend/components/booking/sticky-cart-footer.tsxUsed by sticky-cart

Task 1: Remove Dead Bundle Code

Files:

  • Delete: apps/frontend/hooks/booking/use-bundle-selection.ts

  • Delete: apps/frontend/lib/utils/booking-bundle.ts

  • Modify: apps/frontend/lib/data/booking.ts (remove bundleOptions and BundleOption type)

  • Step 1: Delete use-bundle-selection.ts

rm apps/frontend/hooks/booking/use-bundle-selection.ts
  • Step 2: Delete booking-bundle.ts
rm apps/frontend/lib/utils/booking-bundle.ts
  • Step 3: Edit booking.ts to remove bundle-related code

Remove lines 12-54 (BundleOption type and bundleOptions array). Keep only BookingOption and BookingExperienceOption types and data (lines 59-120).

  • Step 4: Commit
git add apps/frontend/lib/data/booking.ts
git rm apps/frontend/hooks/booking/use-bundle-selection.ts apps/frontend/lib/utils/booking-bundle.ts
git commit -m "chore: remove dead bundle selection code (BUNDLE step never existed)"

Task 2: Clean Up BookingStep Type

File:

  • Modify: apps/frontend/lib/constants/booking-steps.ts

  • Step 1: Rewrite to only active steps

/**
 * booking-steps.ts — Booking step constants
 *
 * @description Active booking steps for the 4-step modal flow.
 *   Only steps that actually exist in BookingModal are listed.
 *
 * @changes
 * | Date       | Author   | Change                                |
 * | ---------- | -------- | ------------------------------------- |
 * | 2026-05-11 | Curly Ng | Removed dead step variants |
 */
 
export type BookingStep = "tickets" | "addons" | "checkout" | "confirmation";
 
export const BOOKING_STEP_ORDER: BookingStep[] = [
  "tickets",
  "addons",
  "checkout",
  "confirmation",
];
 
export function isBookingStep(value: string): value is BookingStep {
  return (BOOKING_STEP_ORDER as readonly string[]).includes(value);
}
  • Step 2: Commit
git add apps/frontend/lib/constants/booking-steps.ts
git commit -m "refactor: clean BookingStep type to only active steps"

Task 3: Consolidate StepTickets State

File:

  • Modify: apps/frontend/components/booking/step-tickets.tsx

Problem: useTicketSelection holds ticketType and quantity in local state, but these also live in ReservationDraft. This creates sync issues.

Solution: Read/write directly from draft. Remove useTicketSelection dependency.

  • Step 1: Read current file to understand exact changes needed
cat apps/frontend/components/booking/step-tickets.tsx
  • Step 2: Rewrite to use draft directly

Replace the entire file:

/**
 * StepTickets — Ticket selection step
 *
 * @description Pure UI step component. Reads/writes ticketType and quantity
 *   directly from ReservationDraft (single source of truth).
 *
 * @changes
 * | Date       | Author   | Change                                |
 * | ---------- | -------- | ------------------------------------- |
 * | 2026-05-11 | Curly Ng | Use draft directly, remove useTicketSelection |
 */
 
"use client";
 
import * as m from "~/src/paraglide/messages";
import { useQuery, useMutation } from "convex/react";
import { useContextSelector } from "use-context-selector";
import { ReservationDraftContext } from "~/contexts/reservation-draft-context";
import { TicketTypeOption } from "~/components/booking/ticket-type-option";
import { GuestCountSelector } from "~/components/booking/guest-count-selector";
import { api } from "@packages/backend/convex/_generated/api";
import type { Id } from "@packages/backend/convex/_generated/dataModel";
import { consola } from "consola";
 
interface StepTicketsProps {
  onNext: () => void;
}
 
export function StepTickets({ onNext }: StepTicketsProps) {
  const draft = useContextSelector(ReservationDraftContext, (s) => s.draft);
  const updateField = useContextSelector(
    ReservationDraftContext,
    (s) => s.updateField,
  );
  const isDraftLoading = useContextSelector(
    ReservationDraftContext,
    (s) => s.isLoading,
  );
 
  // Create reservation mutation
  const createPending = useMutation(api.domains.reservations.createPending);
 
  // Local state from draft (single source of truth)
  const ticketType = draft?.ticketType ?? "DINNER_THEATRE";
  const quantity = draft?.quantity ?? 1;
  const eventId = draft?.eventId ?? null;
 
  // Fetch availability directly in component
  const availability = useQuery(
    api.domains.events.getAvailability,
    eventId ? { eventId: eventId as Id<"experienceEvents"> } : "skip",
  );
 
  const handleTicketTypeChange = (type: "DINNER_THEATRE" | "SHOW_ONLY") => {
    updateField("ticketType", type);
  };
 
  const handleQuantityChange = (delta: number) => {
    const current = draft?.quantity ?? 1;
    const max = availability?.remaining ?? 32;
    const newQty = Math.max(1, Math.min(max, current + delta));
    updateField("quantity", newQty);
  };
 
  const handleConfirmSelection = async () => {
    const currentEventId = draft?.eventId;
    if (!currentEventId) {
      consola.error("StepTickets: no eventId in draft");
      return;
    }
 
    const reservationId = await createPending({
      eventId: currentEventId as Id<"experienceEvents">,
      ticketType,
      quantity,
    });
 
    updateField("reservationId", reservationId);
    consola.success("Reservation created", { reservationId });
    onNext();
  };
 
  if (!availability)
    return <div className="animate-pulse space-y-4">{m.common_loading()}</div>;
 
  return (
    <div className="space-y-6">
      {/* Show reminder */}
      <div className="bg-surface p-4 rounded-lg">
        <p className="text-gold font-serif">{availability.experienceTitle}</p>
        <p className="text-muted-foreground">
          {availability.date} at {availability.time}
        </p>
      </div>
 
      {/* Ticket type */}
      <div>
        <label className="block text-sm font-medium mb-2 text-foreground">
          {m.booking_tickets_ticketType()}
        </label>
        <div className="space-y-2">
          <TicketTypeOption
            label={m.dinnerTheatre()}
            price={availability.dinnerPrice}
            isSelected={ticketType === "DINNER_THEATRE"}
            onClick={() => handleTicketTypeChange("DINNER_THEATRE")}
          />
          {availability.experienceOnlyEnabled && (
            <TicketTypeOption
              label={m.showOnly()}
              price={availability.experienceOnlyPrice}
              isSelected={ticketType === "SHOW_ONLY"}
              onClick={() => handleTicketTypeChange("SHOW_ONLY")}
            />
          )}
        </div>
      </div>
 
      {/* Quantity */}
      <div>
        <label className="block text-sm font-medium mb-2 text-foreground">
          {m.quantity()}
        </label>
        <div className="flex items-center gap-4">
          <GuestCountSelector
            count={quantity}
            onIncrement={() => handleQuantityChange(1)}
            onDecrement={() => handleQuantityChange(-1)}
            max={availability.remaining}
          />
          <span className="text-sm text-muted-foreground">
            {m.maxAvailable({ count: availability.remaining })}
          </span>
        </div>
      </div>
 
      <button
        onClick={handleConfirmSelection}
        disabled={isDraftLoading}
        className="w-full bg-gold text-background py-3 rounded-lg font-bold disabled:opacity-50"
      >
        {isDraftLoading ? m.processing() : m.continue()}
      </button>
    </div>
  );
}
  • Step 3: Commit
git add apps/frontend/components/booking/step-tickets.tsx
git commit -m "refactor: StepTickets uses draft directly, removes useTicketSelection"

Task 4: Consolidate StepAddons State

File:

  • Modify: apps/frontend/components/booking/step-addons.tsx

Problem: useAddonSelection holds selectedAddOns in local state, but these also live in ReservationDraft.

Solution: Read/write addOns directly from draft. Remove useAddonSelection dependency.

  • Step 1: Read current file to understand exact changes needed
cat apps/frontend/components/booking/step-addons.tsx
  • Step 2: Rewrite to use draft directly
/**
 * StepAddons — Add-ons selection step
 *
 * @description Pure UI step component. Reads/writes addOns directly
 *   from ReservationDraft (single source of truth).
 *
 * @changes
 * | Date       | Author   | Change                                |
 * | ---------- | -------- | ------------------------------------- |
 * | 2026-05-11 | Curly Ng | Use draft directly, remove useAddonSelection |
 */
 
"use client";
 
import * as m from "~/src/paraglide/messages";
import { useContextSelector } from "use-context-selector";
import { ReservationDraftContext } from "~/contexts/reservation-draft-context";
import { AddonCard } from "~/components/booking/addon-card";
import { AddonSkeleton } from "~/components/booking/addon-skeleton";
import { useQuery, useMutation } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import type { Doc, Id } from "@packages/backend/convex/_generated/dataModel";
import { consola } from "consola";
 
export function StepAddons({
  onNext,
  onBack,
}: {
  onNext: () => void;
  onBack?: () => void;
}) {
  const draft = useContextSelector(ReservationDraftContext, (s) => s.draft);
  const updateField = useContextSelector(
    ReservationDraftContext,
    (s) => s.updateField,
  );
  const isDraftLoading = useContextSelector(
    ReservationDraftContext,
    (s) => s.isLoading,
  );
 
  // Local state from draft (single source of truth)
  const selectedAddOns = draft?.addOns ?? [];
  const reservationId = draft?.reservationId;
 
  // Update reservation with addons mutation
  const updateWithAddOns = useMutation(
    api.domains.reservations.updateWithAddOns,
  );
 
  // Convex query - called directly in component
  const addOns = useQuery(api.domains.payments.listEnabled) as
    | Doc<"addOns">[]
    | undefined;
 
  // Toggle addon quantity in draft
  const handleToggleAddon = (addOnId: string, quantity: number) => {
    const current = draft?.addOns ?? [];
    const existing = current.find((a) => a.addOnId === addOnId);
    let updated: Array<{ addOnId: string; quantity: number }>;
 
    if (quantity === 0) {
      updated = current.filter((a) => a.addOnId !== addOnId);
    } else if (existing) {
      updated = current.map((a) =>
        a.addOnId === addOnId ? { ...a, quantity } : a,
      );
    } else {
      updated = [...current, { addOnId, quantity }];
    }
 
    updateField("addOns", updated);
  };
 
  const getAddonQuantity = (addOnId: string): number => {
    return selectedAddOns.find((a) => a.addOnId === addOnId)?.quantity ?? 0;
  };
 
  const handleConfirmSelection = async () => {
    if (!reservationId) {
      consola.error("StepAddons: no reservationId in draft");
      return;
    }
 
    const addOnsList = selectedAddOns.map((a) => ({
      addOnId: a.addOnId as Id<"addOns">,
      quantity: a.quantity,
    }));
 
    await updateWithAddOns({
      reservationId: reservationId as Id<"reservations">,
      addOns: addOnsList,
    });
 
    consola.success("Addons added to reservation", { reservationId });
    onNext();
  };
 
  const handleSkipSelection = () => {
    updateField("addOns", []);
    onNext();
  };
 
  if (addOns === undefined) {
    return (
      <div className="space-y-6">
        <div className="text-center">
          <h2 className="text-2xl font-serif text-gold mb-2">
            {m.booking_addons_title()}
          </h2>
          <p className="text-muted-foreground">{m.booking_addons_subtitle()}</p>
        </div>
        <div className="grid gap-4">
          {[1, 2].map((i) => (
            <AddonSkeleton key={i} />
          ))}
        </div>
      </div>
    );
  }
 
  return (
    <div className="space-y-6">
      <div className="text-center">
        <h2 className="text-2xl font-serif text-gold mb-2">
          {m.booking_addons_title()}
        </h2>
        <p className="text-muted-foreground">{m.booking_addons_subtitle()}</p>
      </div>
 
      <div className="grid gap-4">
        {addOns.map((addon: Doc<"addOns">) => {
          const quantity = getAddonQuantity(addon._id);
          return (
            <AddonCard
              key={addon._id}
              addon={addon}
              quantity={quantity}
              onDecrement={() => handleToggleAddon(addon._id, 0)}
              onIncrement={() => handleToggleAddon(addon._id, quantity + 1)}
              perPersonLabel={m.perPerson()}
            />
          );
        })}
      </div>
 
      <div className="flex gap-4">
        {onBack && (
          <button
            onClick={onBack}
            disabled={isDraftLoading}
            className="flex-1 py-3 border border-border rounded-lg text-foreground disabled:opacity-50"
          >
            {m.common_buttons_back()}
          </button>
        )}
        <button
          onClick={handleSkipSelection}
          disabled={isDraftLoading}
          className="flex-1 py-3 border border-border rounded-lg text-foreground disabled:opacity-50"
        >
          {m.booking_addons_skip()}
        </button>
        <button
          onClick={handleConfirmSelection}
          disabled={isDraftLoading}
          className="flex-1 bg-gold text-background py-3 rounded-lg font-bold disabled:opacity-50"
        >
          {m.booking_addons_continueWithSelection()}
        </button>
      </div>
    </div>
  );
}
  • Step 3: Commit
git add apps/frontend/components/booking/step-addons.tsx
git commit -m "refactor: StepAddons uses draft directly, removes useAddonSelection"

Task 5: Verify StickyCart State

File:

  • Modify: apps/frontend/components/booking/sticky-cart.tsx

Check: StickyCart uses useCheckoutSummary. Verify this hook is still needed or if it should also read from draft directly.

  • Step 1: Read current file
cat apps/frontend/components/booking/sticky-cart.tsx
  • Step 2: Analyze if useCheckoutSummary is redundant

The hook fetches event details and addon prices, then transforms them for display. This is not duplicate state — it's derived/computed data from Convex that doesn't live in draft. This is acceptable.

However, the hook also reads from draft for ticketType, quantity, addOns. These should come directly from draft context, not via a separate hook.

  • Step 3: Refactor to use draft directly for local state
// In sticky-cart.tsx, change:
const { event, addonPrices, pricing, addOns, eventId } = useCheckoutSummary();
 
// To: read eventId, addOns directly from draft, useCheckoutSummary only for event/prices
const draft = useContextSelector(ReservationDraftContext, (s) => s.draft);
const eventId = draft?.eventId ?? null;
const ticketType = draft?.ticketType ?? "DINNER_THEATRE";
const quantity = draft?.quantity ?? 1;
const addOns = draft?.addOns ?? [];
 
const { event, addonPrices, pricing } = useCheckoutSummary();
  • Step 4: Commit
git add apps/frontend/components/booking/sticky-cart.tsx
git commit -m "refactor: StickyCart reads draft directly for local state"

Task 6: Remove useTicketSelection Hook

File:

  • Delete: apps/frontend/hooks/booking/use-ticket-selection.ts

  • Step 1: Verify no other consumers

grep -r "useTicketSelection" apps/frontend --include="*.tsx" --include="*.ts" | grep -v node_modules

Expected: No matches (only step-tickets used it, which was refactored).

  • Step 2: Delete the file
rm apps/frontend/hooks/booking/use-ticket-selection.ts
  • Step 3: Commit
git rm apps/frontend/hooks/booking/use-ticket-selection.ts
git commit -m "chore: remove useTicketSelection hook (state now in draft)"

Task 7: Remove useAddonSelection Hook

File:

  • Delete: apps/frontend/hooks/booking/use-addon-selection.ts

  • Step 1: Verify no other consumers

grep -r "useAddonSelection" apps/frontend --include="*.tsx" --include="*.ts" | grep -v node_modules

Expected: No matches (only step-addons used it, which was refactored).

  • Step 2: Delete the file
rm apps/frontend/hooks/booking/use-addon-selection.ts
  • Step 3: Commit
git rm apps/frontend/hooks/booking/use-addon-selection.ts
git commit -m "chore: remove useAddonSelection hook (state now in draft)"

Task 8: Verify Schedule Page Modal Usage

File:

  • Modify: apps/frontend/app/[locale]/(landing)/schedule/page.tsx

  • Step 1: Read current file

cat apps/frontend/app/\[locale\]/\(landing\)/schedule/page.tsx
  • Step 2: Verify it uses single modal state approach

The schedule page should manage ONE modal state (BookingModal) triggered directly from show row "Book Now" buttons. The ExperiencePicker is for the top CTA when user hasn't selected a specific show.

If it has two separate modal states (ExperiencePicker + BookingModal), consolidate to use BookingModal directly for show rows.

  • Step 3: Commit (if changes needed)
git add apps/frontend/app/\[locale\]/\(landing\)/schedule/page.tsx
git commit -m "refactor: schedule page uses single BookingModal state"

Task 9: Verify BookingModal Step Order

File:

  • Modify: apps/frontend/components/ui/booking-modal.tsx

  • Step 1: Read current file

cat apps/frontend/components/ui/booking-modal.tsx
  • Step 2: Verify STEP_ORDER matches cleaned up BookingStep type

The modal currently has:

const STEP_ORDER = ["tickets", "addons", "checkout", "confirmation"] as const;

This should already match the cleaned up BookingStep type. If it does, no changes needed.

  • Step 3: Commit (if changes needed)

Task 10: Final Verification

  • Step 1: Run TypeScript check
cd apps/frontend && npx tsc --noEmit 2>&1 | head -50

Expected: No errors (or only pre-existing errors).

  • Step 2: Verify no orphaned hooks remain
grep -r "useTicketSelection\|useAddonSelection\|useBundleSelection" apps/frontend --include="*.tsx" --include="*.ts" | grep -v node_modules

Expected: No matches.

  • Step 3: Verify bundle code removed
grep -r "bundleOptions\|booking-bundle" apps/frontend --include="*.tsx" --include="*.ts" | grep -v node_modules

Expected: No matches (except raw WordPress files which are read-only).

  • Step 4: Build test
cd apps/frontend && npm run build 2>&1 | tail -30

Expected: Build succeeds.

  • Step 5: Commit final cleanup
git add -A
git commit -m "chore: booking flow consolidation complete"

Self-Review Checklist

  • All dead bundle code removed (useBundleSelection, booking-bundle.ts, bundleOptions)
  • BookingStep type only has 4 active steps
  • useTicketSelection removed — components read from draft directly
  • useAddonSelection removed — components read from draft directly
  • StickyCart reads local state from draft, derived data from useCheckoutSummary
  • Schedule page uses single modal approach
  • TypeScript compiles without new errors
  • Build succeeds

Architecture After Consolidation

ReservationDraftContext (Convex-persisted)
├── eventId
├── ticketType ("DINNER_THEATRE" | "SHOW_ONLY")
├── quantity
├── addOns[]
├── reservationId
└── currentStep

Components read/write draft directly:
├── StepTickets → draft.ticketType, draft.quantity
├── StepAddons → draft.addOns
├── StickyCart → draft.* (local) + useCheckoutSummary (derived)
└── CheckoutForm → draft.reservationId

Files Changed Summary

FileAction
hooks/booking/use-bundle-selection.tsDELETE
hooks/booking/use-ticket-selection.tsDELETE
hooks/booking/use-addon-selection.tsDELETE
lib/utils/booking-bundle.tsDELETE
lib/data/booking.tsMODIFY (remove bundleOptions)
lib/constants/booking-steps.tsMODIFY (clean type)
components/booking/step-tickets.tsxMODIFY
components/booking/step-addons.tsxMODIFY
components/booking/sticky-cart.tsxMODIFY
app/[locale]/(landing)/schedule/page.tsxVERIFY