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
| File | Reason |
|---|---|
apps/frontend/hooks/booking/use-bundle-selection.ts | BUNDLE step never existed |
apps/frontend/lib/data/booking.ts (bundleOptions only) | Only used by dead hook |
apps/frontend/lib/utils/booking-bundle.ts | Only used by dead hook |
Files to Modify
| File | Change |
|---|---|
apps/frontend/lib/constants/booking-steps.ts | Remove dead step variants |
apps/frontend/components/booking/step-tickets.tsx | Use draft directly, remove useTicketSelection |
apps/frontend/components/booking/step-addons.tsx | Use draft directly, remove useAddonSelection |
apps/frontend/components/booking/sticky-cart.tsx | Remove useCheckoutSummary import if redundant |
apps/frontend/components/booking/confirmation-display.tsx | Remove useCountdown import - check usage |
apps/frontend/app/[locale]/(landing)/schedule/page.tsx | Verify single modal approach |
Files to Verify
| File | Purpose |
|---|---|
apps/frontend/components/ui/booking-modal.tsx | Ensure modal uses correct step strings |
apps/frontend/components/booking/step-tickets-view.tsx | Different from step-tickets.tsx? |
apps/frontend/components/booking/sticky-cart-items.tsx | Used by sticky-cart |
apps/frontend/components/booking/sticky-cart-footer.tsx | Used 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(removebundleOptionsandBundleOptiontype) -
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_modulesExpected: 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_modulesExpected: 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 -50Expected: 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_modulesExpected: No matches.
- Step 3: Verify bundle code removed
grep -r "bundleOptions\|booking-bundle" apps/frontend --include="*.tsx" --include="*.ts" | grep -v node_modulesExpected: No matches (except raw WordPress files which are read-only).
- Step 4: Build test
cd apps/frontend && npm run build 2>&1 | tail -30Expected: 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) -
BookingSteptype only has 4 active steps -
useTicketSelectionremoved — components read from draft directly -
useAddonSelectionremoved — components read from draft directly -
StickyCartreads local state from draft, derived data fromuseCheckoutSummary - 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.reservationIdFiles Changed Summary
| File | Action |
|---|---|
hooks/booking/use-bundle-selection.ts | DELETE |
hooks/booking/use-ticket-selection.ts | DELETE |
hooks/booking/use-addon-selection.ts | DELETE |
lib/utils/booking-bundle.ts | DELETE |
lib/data/booking.ts | MODIFY (remove bundleOptions) |
lib/constants/booking-steps.ts | MODIFY (clean type) |
components/booking/step-tickets.tsx | MODIFY |
components/booking/step-addons.tsx | MODIFY |
components/booking/sticky-cart.tsx | MODIFY |
app/[locale]/(landing)/schedule/page.tsx | VERIFY |