Booking Flow Convex State Migration 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 BookingContext (ephemeral local state) with Convex-persisted booking draft state. User is authenticated via Clerk, so their subject ID from Convex auth is used as the session key — no sessionStorage needed.
Architecture: All booking flow state lives in bookingDrafts table in Convex, keyed by user's subject ID from auth. useBookingDraft hook already exists — it just needs sessionStorage replaced with auth identity, and the 4 remaining BookingContext consumers migrated to use it directly.
Tech Stack: Convex (auth + database), React Hook Form, Zod
File Structure
apps/backend/convex/
├── schema.ts # MODIFY: bookingDrafts index uses userId
├── functions/
│ └── booking_drafts.ts # MODIFY: use auth.getUserIdentity() for userId
apps/frontend/
├── lib/hooks/use-booking-draft.ts # MODIFY: use auth subject instead of sessionStorage
└── lib/booking-context.tsx # DELETE: no longer neededBooking Flow Pages to Update
These pages already use useBookingDraft — just need sessionStorage → auth identity swap:
| Page | File |
|---|---|
| Experience | app/[locale]/booking/experience/page.tsx |
| Show | app/[locale]/booking/show/page.tsx |
| Bundle | app/[locale]/booking/bundle/page.tsx |
| Reservation | app/[locale]/booking/reservation/page.tsx |
| Payment | app/[locale]/booking/payment/page.tsx |
| Confirmation | app/[locale]/booking/confirmation/page.tsx |
These pages/components still use BookingContext — migrate to useBookingDraft:
| Component | File |
|---|---|
| StepTickets | components/booking/step-tickets.tsx |
| StepAddons | components/booking/step-addons.tsx |
| StickyCart | components/booking/sticky-cart.tsx |
| CheckoutForm | components/booking/checkout-form.tsx |
Task 1: Update booking_drafts.ts to Use Auth Identity
File:
-
Modify:
apps/backend/convex/functions/booking_drafts.ts -
Step 1: Update getOrCreate to use auth identity
Replace the getOrCreate mutation to get userId from auth instead of args:
// Get or create a draft for the authenticated user
export const getOrCreate = mutation({
args: {}, // No args — userId comes from auth
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
const existing = await ctx.db
.query("bookingDrafts")
.withIndex("by_session", (q) => q.eq("sessionId", userId))
.first();
if (existing) return existing._id;
const now = Date.now();
const expiresAt = now + 30 * 60 * 1000; // 30 minutes
return await ctx.db.insert("bookingDrafts", {
sessionId: userId,
expiresAt,
createdAt: now,
updatedAt: now,
});
},
});- Step 2: Update updateDraft to use auth identity
Update updateDraft to get userId from auth and find draft by userId:
// Update draft fields (partial update)
export const updateDraft = mutation({
args: {
data: v.object({
// Same schema as before...
occurrenceId: v.optional(v.id("showOccurrences")),
experience: v.optional(v.string()),
ticketType: v.optional(
v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
),
quantity: v.optional(v.number()),
addOns: v.optional(
v.array(
v.object({
addOnId: v.id("addOns"),
quantity: v.number(),
}),
),
),
bundle: v.optional(v.string()),
guests: v.optional(v.number()),
customerInfo: v.optional(
v.object({
firstName: v.string(),
lastName: v.string(),
email: v.string(),
phone: v.optional(v.string()),
}),
),
currentStep: v.optional(
v.union(
v.literal("EXPERIENCE"),
v.literal("SHOW"),
v.literal("TICKETS"),
v.literal("BUNDLE"),
v.literal("ADDONS"),
v.literal("CUSTOMER_INFO"),
v.literal("PAYMENT"),
v.literal("CONFIRMATION"),
),
),
}),
},
handler: async (ctx, { data }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
const draft = await ctx.db
.query("bookingDrafts")
.withIndex("by_session", (q) => q.eq("sessionId", userId))
.first();
if (!draft) throw new Error("No booking draft found");
await ctx.db.patch(draft._id, {
...data,
updatedAt: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
});
},
});- Step 3: Update deleteDraft to use auth identity
// Delete draft (on booking completion or cancellation)
export const deleteDraft = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
const draft = await ctx.db
.query("bookingDrafts")
.withIndex("by_session", (q) => q.eq("sessionId", userId))
.first();
if (draft) {
await ctx.db.delete(draft._id);
}
},
});- Step 4: Update getBySession to use auth identity
// Get draft by session (for page load recovery)
export const getBySession = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const userId = identity.subject;
return await ctx.db
.query("bookingDrafts")
.withIndex("by_session", (q) => q.eq("sessionId", userId))
.first();
},
});- Step 5: Commit
git add apps/backend/convex/functions/booking_drafts.ts
git commit -m "feat(booking): use Convex auth identity for booking draft session"Task 2: Update useBookingDraft Hook to Remove sessionStorage
File:
-
Modify:
apps/frontend/lib/hooks/use-booking-draft.ts -
Step 1: Simplify to use auth (no sessionStorage)
"use client";
import { useEffect, useCallback, useRef } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "convex/_generated/api";
const DEBOUNCE_MS = 500;
export function useBookingDraft() {
const draft = useQuery(api.bookingDrafts.getBySession);
const createDraft = useMutation(api.bookingDrafts.getOrCreate);
const updateDraft = useMutation(api.bookingDrafts.updateDraft);
const deleteDraft = useMutation(api.bookingDrafts.deleteDraft);
// Stable ref for draft ID
const draftIdRef = useRef<string | null>(null);
useEffect(() => {
if (draft?._id) {
draftIdRef.current = draft._id;
}
}, [draft]);
// Debounced update ref
const pendingUpdate = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ensure draft exists on mount
useEffect(() => {
createDraft({});
}, [createDraft]);
// Debounced field update
const updateField = useCallback(
(field: string, value: unknown) => {
const id = draftIdRef.current;
if (!id) return;
if (pendingUpdate.current) clearTimeout(pendingUpdate.current);
pendingUpdate.current = setTimeout(() => {
updateDraft({ data: { [field]: value } });
}, DEBOUNCE_MS);
},
[updateDraft],
);
// Immediate update for step changes (no debounce)
const setStep = useCallback(
(
step:
| "EXPERIENCE"
| "SHOW"
| "TICKETS"
| "BUNDLE"
| "ADDONS"
| "CUSTOMER_INFO"
| "PAYMENT"
| "CONFIRMATION",
) => {
updateDraft({ data: { currentStep: step } });
},
[updateDraft],
);
// Cleanup on completion
const complete = useCallback(() => {
deleteDraft({});
}, [deleteDraft]);
return {
draft,
updateField,
setStep,
complete,
isLoading: draft === undefined,
};
}- Step 2: Commit
git add apps/frontend/lib/hooks/use-booking-draft.ts
git commit -m "refactor(booking): use auth identity instead of sessionStorage"Task 3: Migrate StepTickets to useBookingDraft
File:
- Modify:
apps/frontend/components/booking/step-tickets.tsx
Context: This component uses useContextSelector(BookingContext, ...) for state and actions. Need to replace with useBookingDraft.
- Step 1: Read the file to understand current usage
cat apps/frontend/components/booking/step-tickets.tsx- Step 2: Replace imports and hook usage
Replace:
import { BookingContext } from "~/lib/booking-context";
// ...
const { occurrenceId, ticketType, quantity, selectTicket } = useContextSelector(
BookingContext,
(s) => ({
occurrenceId: s.occurrenceId,
ticketType: s.ticketType,
quantity: s.quantity,
selectTicket: s.selectTicket,
}),
);With:
import { useBookingDraft } from "~/lib/hooks/use-booking-draft";
// ...
const { draft, updateField } = useBookingDraft();
// Update field calls:
updateField("occurrenceId", occurrenceId);
updateField("ticketType", ticketType);
updateField("quantity", quantity);- Step 3: Commit
git add apps/frontend/components/booking/step-tickets.tsx
git commit -m "refactor(booking): migrate step-tickets to useBookingDraft"Task 4: Migrate StepAddons to useBookingDraft
File:
-
Modify:
apps/frontend/components/booking/step-addons.tsx -
Step 1: Read and update
Replace BookingContext with useBookingDraft:
-
setAddOns→updateField("addOns", addOns) -
Step 2: Commit
git add apps/frontend/components/booking/step-addons.tsx
git commit -m "refactor(booking): migrate step-addons to useBookingDraft"Task 5: Migrate StickyCart to useBookingDraft
File:
-
Modify:
apps/frontend/components/booking/sticky-cart.tsx -
Step 1: Read and update
Replace useContextSelector(BookingContext, ...) with useBookingDraft:
-
ticketType,quantity,addOns→ fromdraft -
selectTicket→updateField("ticketType", ...)+updateField("quantity", ...) -
Step 2: Commit
git add apps/frontend/components/booking/sticky-cart.tsx
git commit -m "refactor(booking): migrate sticky-cart to useBookingDraft"Task 6: Migrate CheckoutForm to useBookingDraft
File:
-
Modify:
apps/frontend/components/booking/checkout-form.tsx -
Step 1: Read and update
Replace BookingContext with useBookingDraft:
-
customerInfo,setCustomerInfo→updateField("customerInfo", ...) -
Already uses
useMutation(api.reservations.createOnePayOrderForReservation)— keep that -
Step 2: Commit
git add apps/frontend/components/booking/checkout-form.tsx
git commit -m "refactor(booking): migrate checkout-form to useBookingDraft"Task 7: Delete BookingContext
File:
-
Delete:
apps/frontend/lib/booking-context.tsx -
Step 1: Delete
git rm apps/frontend/lib/booking-context.tsx
git commit -m "chore: remove deprecated BookingContext (replaced by useBookingDraft)"Task 8: Verify No BookingContext Usage Remains
- Step 1: Check
grep -r "BookingContext\|useBookingContext" apps/frontend --include="*.tsx" --include="*.ts" | grep -v "raw/" | grep -v "booking-context.tsx"Expected: No matches.
- Step 2: Commit
git commit -m "chore: verify no BookingContext usage remains"Architecture Notes
Why auth identity instead of sessionStorage?
- User is logged in — Clerk auth means we have a reliable user ID
- No sessionStorage — cleaner, survives page refresh, no UUID generation
- Per-user drafts — if user opens multiple tabs, they share the same draft
- Auto-expiry — Convex handles cleanup via
expiresAtfield
What happens for non-authenticated users?
Booking flow should require authentication. If a user is not logged in:
- Redirect to login page
- Or: create draft with anonymous sessionId (fallback to sessionStorage)
The plan assumes authenticated users only. If unauthenticated access is needed, add sessionStorage fallback in getBySession and getOrCreate.
Debouncing
Field updates are debounced (500ms) to avoid excessive Convex writes. Step changes (setStep) are immediate — they need to be reflected immediately for navigation state.