plans
2026-05-04
2026 05 04 Booking Convex State

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 needed

Booking Flow Pages to Update

These pages already use useBookingDraft — just need sessionStorage → auth identity swap:

PageFile
Experienceapp/[locale]/booking/experience/page.tsx
Showapp/[locale]/booking/show/page.tsx
Bundleapp/[locale]/booking/bundle/page.tsx
Reservationapp/[locale]/booking/reservation/page.tsx
Paymentapp/[locale]/booking/payment/page.tsx
Confirmationapp/[locale]/booking/confirmation/page.tsx

These pages/components still use BookingContext — migrate to useBookingDraft:

ComponentFile
StepTicketscomponents/booking/step-tickets.tsx
StepAddonscomponents/booking/step-addons.tsx
StickyCartcomponents/booking/sticky-cart.tsx
CheckoutFormcomponents/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:

  • setAddOnsupdateField("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 → from draft

  • selectTicketupdateField("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, setCustomerInfoupdateField("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?

  1. User is logged in — Clerk auth means we have a reliable user ID
  2. No sessionStorage — cleaner, survives page refresh, no UUID generation
  3. Per-user drafts — if user opens multiple tabs, they share the same draft
  4. Auto-expiry — Convex handles cleanup via expiresAt field

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.