plans
2026-05-11
2026 05 11 Booking Page to Modal

Booking Page to Modal 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: Remove /booking page route and replace all /booking links with BookingModal overlay triggered from ExperienceCard onBookNow callbacks.

Architecture: Replace page-navigated booking with modal-overlay booking. The BookingModal component already exists and wraps the 4-step flow. Parent components manage modal open state and eventId, passing onBookNow to ExperienceCard which renders a <button> that triggers the modal instead of navigating to /booking.

Tech Stack: Next.js 16 (App Router), paraglide-js i18n, BookingModal, ExperienceCard, BookingModalContext


File Map

Components that need modal state management (these render ExperienceCard with onBookNow)

FileResponsibility
components/home/upcoming-experiences-section.tsxHome page experience list — needs to manage BookingModal state
components/ui/upcoming-shows-section.tsxDinner theater page — needs to manage BookingModal state
components/shows/experience-event-list.tsxEvent row with "Book" button — needs BookingModal state
components/home/experience-schedule-preview.tsxSchedule calendar — needs BookingModal state

CTA-only components (no specific event, use ExperiencePicker flow)

FileResponsibility
components/home/cta-section.tsxHero CTA — opens ExperiencePicker modal on click
components/home/hero-bottom.tsxHero bottom CTA — opens ExperiencePicker modal
components/home/journey-section.tsxJourney CTA — opens ExperiencePicker modal
components/marketing/visit-cta-section.tsxVisit CTA — opens ExperiencePicker modal
app/[locale]/(landing)/schedule/page.tsxSchedule page hero CTA — opens ExperiencePicker modal

Booking flow components (already exist, no changes needed)

FileResponsibility
components/ui/booking-modal.tsxAlready exists — wraps 4-step booking in modal
components/booking/experience-picker.tsxAllows event selection when no eventId provided
components/ui/experience-card.tsxAlready supports onBookNow prop

Pages to remove/redirect

FileAction
app/[locale]/(landing)/booking/page.tsxDelete — page route no longer needed
app/[locale]/(landing)/booking/layout.tsxDelete — only existed for booking page

Other pages needing updates

FileResponsibility
app/[locale]/audience/page.tsxUses /booking/${id}/tickets href — needs BookingModal

Task 1: Create BookingModalContext

Why: Many parent components need to open the same BookingModal from different child interactions. Instead of prop-drilling, a context lets any child open the booking modal.

Files:

  • Create: apps/frontend/contexts/booking-modal-context.tsx

  • Step 1: Write the context file

// ipsoc checked: 2026-05-11
// SoC: Global booking modal state — avoids prop drilling for onBookNow callbacks
 
import {
  createContext,
  useContext,
  useState,
  useCallback,
  type ReactNode,
} from "react";
 
type BookingModalState = {
  isOpen: boolean;
  eventId: string | null;
  open: (eventId: string) => void;
  close: () => void;
};
 
const BookingModalContext = createContext<BookingModalState | null>(null);
 
export function BookingModalProvider({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const [eventId, setEventId] = useState<string | null>(null);
 
  const open = useCallback((id: string) => {
    setEventId(id);
    setIsOpen(true);
  }, []);
 
  const close = useCallback(() => {
    setIsOpen(false);
    setEventId(null);
  }, []);
 
  return (
    <BookingModalContext.Provider value={{ isOpen, eventId, open, close }}>
      {children}
    </BookingModalContext.Provider>
  );
}
 
export function useBookingModal() {
  const ctx = useContext(BookingModalContext);
  if (!ctx) {
    throw new Error("useBookingModal must be used inside BookingModalProvider");
  }
  return ctx;
}
  • Step 2: Add BookingModalProvider to locale layout

Files:

  • Modify: apps/frontend/app/[locale]/layout.tsx

Read the file first to find where to add the provider (after ConvexClientProvider).

import { BookingModalProvider } from "~/contexts/booking-modal-context";
import { BookingModal } from "~/components/ui/booking-modal";

Add <BookingModalProvider> wrapping children, and <BookingModal> inside the providers:

{/* Before: children */}
{/* After: */}
<BookingModalProvider>
  {children}
</BookingModalProvider>
<BookingModalByContext />

Where BookingModalByContext reads from context:

"use client";
import { useBookingModal } from "~/contexts/booking-modal-context";
import { BookingModal } from "~/components/ui/booking-modal";
 
function BookingModalByContext() {
  const { isOpen, eventId, close } = useBookingModal();
  if (!eventId) return null;
  return <BookingModal isOpen={isOpen} onClose={close} eventId={eventId} />;
}
  • Step 3: Commit
git add apps/frontend/contexts/booking-modal-context.tsx apps/frontend/app/\[locale\]/layout.tsx
git commit -m "feat: add BookingModalContext for global booking modal state"

Task 2: Update UpcomingExperiencesSection to use modal

Files:

  • Modify: apps/frontend/components/home/upcoming-experiences-section.tsx

Changes:

  1. Import useBookingModal from context
  2. Remove ExperienceSchedulePreview fallback (it uses /booking links) — replace with a simple "no events" message OR import and use BookingModal directly
  3. Pass onBookNow={open} to each ExperienceCard
  • Step 1: Update the component
// ipsoc checked: 2026-05-06
// SoC: Pure UI component - data from useUpcomingShowsFrom hook
// FIXED: extracted useQuery to useUpcomingShowsFromShows hook (2026-05-06)
// FIXED: 2026-05-06 - uses unified ShowCard component
// IMPROVED: Use SectionWrapper for consistent spacing
 
"use client";
 
import { Heading } from "~/components/ui/typography";
import { SectionWrapper } from "~/components/ui/section-wrapper";
import { SectionContainer } from "~/components/ui/section-container";
import { ExperienceCard } from "~/components/ui/experience-card";
import { useBookingModal } from "~/contexts/booking-modal-context";
import * as m from "~/src/paraglide/messages";
 
export type UpcomingShowData = {
  event: {
    _id: string;
    date: string;
    time: string;
    status: "SCHEDULED" | "CANCELLED" | "SOLD_OUT";
    bookedCount: number;
    actualCapacity: number;
  };
  show: {
    _id: string;
    title: string;
    slug: string;
    gallery: string[];
    defaultDinnerPrice: number;
    supportedTicketTypes: ("DINNER_THEATRE" | "SHOW_ONLY")[];
  };
};
 
type UpcomingExperiencesSectionProps = {
  shows?: UpcomingShowData[];
};
 
export function UpcomingExperiencesSection({
  shows: propShows,
}: UpcomingExperiencesSectionProps) {
  const { open } = useBookingModal();
  const shows = propShows ?? [];
 
  return (
    <SectionWrapper id="upcoming">
      <SectionContainer className="flex flex-col gap-12">
        <Heading level="h2" align="center">
          {m.home_upcomingExperiences_title()}
        </Heading>
        {shows.length > 0 ? (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
            {shows.map((showData: UpcomingShowData) => (
              <ExperienceCard
                key={showData.event._id}
                experience={showData}
                onBookNow={open}
              />
            ))}
          </div>
        ) : (
          <p className="text-center text-muted-foreground py-12">
            {m.noDates()}
          </p>
        )}
      </SectionContainer>
    </SectionWrapper>
  );
}
  • Step 2: Commit
git add apps/frontend/components/home/upcoming-experiences-section.tsx
git commit -m "feat: wire ExperienceCard onBookNow to BookingModal context"

Task 3: Update UpcomingShowsSection to use modal

Files:

  • Modify: apps/frontend/components/ui/upcoming-shows-section.tsx

Changes: Add internal modal state (since this component doesn't receive onBookNow from parent) and pass to ExperienceCard.

  • Step 1: Update the component
// ipsoc checked: 2026-05-11
// FIXED: 2026-05-11 - Use SectionContainer for consistent layout
// FIXED: 2026-05-11 - Internal modal state management (self-contained)

Add useState for selectedEventId, pass to BookingModal inside the component:

// Inside component:
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const handleBookNow = useCallback((eventId: string) => {
  setSelectedEventId(eventId);
}, []);

Pass onBookNow={handleBookNow} to ExperienceCard.

Render BookingModal at bottom of component:

{
  selectedEventId && (
    <BookingModal
      isOpen={true}
      onClose={() => setSelectedEventId(null)}
      eventId={selectedEventId}
    />
  );
}
  • Step 2: Commit
git add apps/frontend/components/ui/upcoming-shows-section.tsx
git commit -m "feat: add internal BookingModal state to UpcomingShowsSection"

Task 4: Update ExperienceEventList to use modal

Files:

  • Modify: apps/frontend/components/shows/experience-event-list.tsx

Changes: Replace router.push with BookingModal state. Add modal state and render BookingModal inline.

  • Step 1: Update EventRow component
// Inside the component file:
import { useState, useCallback } from "react";
import { BookingModal } from "~/components/ui/booking-modal";
 
function EventRow({ event }: { event: Doc<"experienceEvents"> }) {
  const [modalOpen, setModalOpen] = useState(false);
  // ... existing code ...
 
  const handleBook = useCallback(() => {
    setModalOpen(true);
  }, []);
 
  // ... replace router.push with setModalOpen(true)
 
  return (
    <>
      {/* existing JSX */}
      <button onClick={handleBook} ... />
      <BookingModal
        isOpen={modalOpen}
        onClose={() => setModalOpen(false)}
        eventId={event._id}
      />
    </>
  );
}
  • Step 2: Commit
git add apps/frontend/components/shows/experience-event-list.tsx
git commit -m "feat: replace router.push with BookingModal in ExperienceEventList"

Task 5: Update ExperienceSchedulePreview to use modal

Files:

  • Modify: apps/frontend/components/home/experience-schedule-preview.tsx (around line 404)

Changes: Replace the LocaleLink to /booking with BookingModal state for each experience.

The component renders a list of ExperienceScheduleItem with a "Book Now" button per item. Add a selectedEventId state and render a BookingModal for the selected event.

  • Step 1: Add modal state and replace LocaleLink
// Inside ExperienceSchedulePreview component function:
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);

Replace the LocaleLink around line 404:

<button
  type="button"
  onClick={() => setSelectedEventId(experience._id)}
  className="inline-flex items-center justify-center px-5 py-2.5 bg-gold/20 hover:bg-gold text-gold hover:text-background text-sm font-semibold rounded-full border border-gold/30 hover:border-transparent transition-all duration-200 whitespace-nowrap"
>
  {m.common_buttons_bookNow()}
</button>

Add BookingModal at bottom of the return:

{
  selectedEventId && (
    <BookingModal
      isOpen={true}
      onClose={() => setSelectedEventId(null)}
      eventId={selectedEventId}
    />
  );
}
  • Step 2: Commit
git add apps/frontend/components/home/experience-schedule-preview.tsx
git commit -m "feat: replace /booking links with BookingModal in ExperienceSchedulePreview"

Task 6: Update CTA sections to use ExperiencePicker

Files:

  • Modify: apps/frontend/components/home/cta-section.tsx
  • Modify: apps/frontend/components/home/hero-bottom.tsx
  • Modify: apps/frontend/components/home/journey-section.tsx
  • Modify: apps/frontend/components/marketing/visit-cta-section.tsx

Why: These CTA sections don't have a specific event — clicking them should open the ExperiencePicker which lets users browse and select an event before booking.

Changes: Replace LocaleLink href="/booking" with a <button> that opens ExperiencePicker. The ExperiencePicker already exists in components/booking/experience-picker.tsx and handles event selection, then navigates.

Actually — looking at experience-picker.tsx line 73, it does router.push('/booking?...'). Since we're removing /booking, we need to modify ExperiencePicker to instead use BookingModal after event selection.

Task 6a: Modify ExperiencePicker to use modal

Files:

  • Modify: apps/frontend/components/booking/experience-picker.tsx

  • Step 1: Update ExperiencePicker to use modal instead of router

Replace router.push with BookingModal state:

import { useState } from "react";
import { BookingModal } from "~/components/ui/booking-modal";
 
// Inside component:
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);

Replace router.push(\/booking?${params.toString()}#tickets`)withsetSelectedEventId(eventId)`.

Add BookingModal render:

{
  selectedEventId && (
    <BookingModal
      isOpen={true}
      onClose={() => setSelectedEventId(null)}
      eventId={selectedEventId}
    />
  );
}
  • Step 2: Commit
git add apps/frontend/components/booking/experience-picker.tsx
git commit -m "feat: ExperiencePicker opens BookingModal instead of /booking page"

Task 6b: Update CTA sections

Files:

  • Modify: apps/frontend/components/home/cta-section.tsx
  • Modify: apps/frontend/components/home/hero-bottom.tsx
  • Modify: apps/frontend/components/home/journey-section.tsx
  • Modify: apps/frontend/components/marketing/visit-cta-section.tsx

Changes: Replace LocaleLink href="/booking" with a <button onClick={openPicker}> that sets picker state.

Add picker state to each section. Since these are separate components on the same page (home), they should share picker state — but they can't share state directly. The cleanest approach:

  1. Wrap the home page in a BookingPickerProvider
  2. OR: Make each CTA section open ExperiencePicker as its own modal/dialog

The simplest approach that works: Each CTA section manages its own picker modal state.

// cta-section.tsx changes:
import { useState } from "react";
import { ExperiencePicker } from "~/components/booking/experience-picker";
 
// Inside component:
const [pickerOpen, setPickerOpen] = useState(false);
 
<button onClick={() => setPickerOpen(true)} ...>
 
{pickerOpen && (
  <ExperiencePicker onClose={() => setPickerOpen(false)} />
)}

Repeat for hero-bottom.tsx, journey-section.tsx, visit-cta-section.tsx.

  • Step 3: Update each CTA section

Apply the same pattern to each file: add useState for picker, replace LocaleLink with <button>, render ExperiencePicker.

  • Step 4: Commit
git add apps/frontend/components/home/cta-section.tsx
git add apps/frontend/components/home/hero-bottom.tsx
git add apps/frontend/components/home/journey-section.tsx
git add apps/frontend/components/marketing/visit-cta-section.tsx
git commit -m "feat: replace /booking links with ExperiencePicker in CTA sections"

Task 7: Update Schedule page CTA

Files:

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

Changes:

  1. Replace ctaHref="/booking" with a button that opens ExperiencePicker
  2. Replace each href="/booking" in the show rows with picker state
  • Step 1: Add picker state

Add useState and ExperiencePicker to the schedule page:

const [pickerOpen, setPickerOpen] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);

Replace the PageHero CTA and show row buttons:

<button onClick={() => setPickerOpen(true)} className="inline-flex ...">
  {m.schedule_bookTickets()}
</button>

Each show row button:

<button
  onClick={() => setSelectedEventId(show.eventId ?? "")}
  className="inline-flex items-center gap-2 ..."
>
  {m.schedule_bookNow()}
</button>

Render both modals:

{
  pickerOpen && <ExperiencePicker onClose={() => setPickerOpen(false)} />;
}
{
  selectedEventId && (
    <BookingModal
      isOpen={true}
      onClose={() => setSelectedEventId(null)}
      eventId={selectedEventId}
    />
  );
}
  • Step 2: Commit
git add "apps/frontend/app/[locale]/(landing)/schedule/page.tsx"
git commit -m "feat: replace /booking links with BookingModal on schedule page"

Task 8: Update Audience page booking link

Files:

  • Modify: app/[locale]/audience/page.tsx (line 141)

Changes: Replace the /booking/${nextShow._id}/tickets href with modal state.

  • Step 1: Add modal state and replace href
const [bookingEventId, setBookingEventId] = useState<string | null>(null);

Replace:

<a href={localizeHref(`/booking/${nextShow._id}/tickets`)} ...>

With:

<button onClick={() => setBookingEventId(nextShow._id)} ...>

Add modal render at end of component's return:

{
  bookingEventId && (
    <BookingModal
      isOpen={true}
      onClose={() => setBookingEventId(null)}
      eventId={bookingEventId}
    />
  );
}
  • Step 2: Commit
git add "apps/frontend/app/[locale]/audience/page.tsx"
git commit -m "feat: replace /booking link with BookingModal on audience page"

Task 9: Remove /booking page route

Files:

  • Delete: apps/frontend/app/[locale]/(landing)/booking/page.tsx
  • Delete: apps/frontend/app/[locale]/(landing)/booking/layout.tsx

Why: The booking page no longer exists — all booking flows are now handled by BookingModal.

  • Step 1: Delete the booking page files
rm apps/frontend/app/\[locale\]/\(landing\)/booking/page.tsx
rm apps/frontend/app/\[locale\]/\(landing\)/booking/layout.tsx
rmdir apps/frontend/app/\[locale\]/\(landing\)/booking
  • Step 2: Commit
git add -A
git commit -m "feat: remove /booking page route — booking now via modal only"

Verification

After all tasks:

  1. grep -rn "/booking" apps/frontend --include="*.tsx" | grep -v node_modules | grep -v ".next" — should return zero results
  2. grep -rn "router.push.*booking" apps/frontend --include="*.tsx" — should return zero results
  3. Build succeeds: npm run build in apps/frontend
  4. npm run deploy to deploy to Cloudflare Pages

Self-Review Checklist

  • All /booking links replaced with either BookingModal (for specific event) or ExperiencePicker (for generic CTA)
  • BookingModal is rendered in the locale layout (global singleton)
  • ExperiencePicker no longer uses router.push('/booking')
  • Deleted /booking page and layout files
  • No new use client directives added to server components
  • All BookingModal renders pass isOpen, onClose, and eventId props
  • Build passes with npm run build