Skip to content

Scheduler + On-Page Booking Surface — Spec

Status: canonical design intent as of 2026-07-11. Execution phases live in plan.md. Scope decisions locked with Aaron: from-scratch UI (no visual reuse of the accordion stack), restructure both flow containers, booking moves out of modals onto a multi-purpose on-page surface. Locked 2026-07-11: guest hourly checkout API ships in this branch (not deferred); batch/multi-session booking is removed outright (one use in 5 months); comp/guest public endpoints get abuse hardening (below).

/book — the on-page booking surface

One multi-purpose booking page (route name /book, trivially renameable), in the (marketing) route group, cal.com-style: the page is the booker. It handles tours and hourly, with or without location/resource context.

Context comes from query params, steps fill in whatever's missing:

/book                                  → type → location → (resource) → slot → …
/book?type=tour&location=<slug>        → slot → details → success
/book?type=hourly&location=<slug>      → studio picker → slot → payment
/book?type=hourly&resource=<id>        → slot → payment (location context derived from the resource)

?minimal=1 puts the flow in simplified LP mode: forwarded to the checkout page (/checkout/reservation?…&minimal=1), same contract the old simplified modal prop had. LP links set it.

  • Step engine: plain state machine; picker steps (booking type, location, studio) render only for missing context. The location/studio pickers are lifted from HourlyBookingWithPicker (which then dissolves).
  • Picker pattern: grouped compact grid, never a single-column card wall (cal.com's equivalent surfaces are dense rows, not hero cards). Location picker groups by city with count badges; tiles are small (44px thumb, name + neighborhood, chevron), whole tile clickable, 2-3 columns by container width — a dozen options fit one screenful. The Phase 3 studio picker follows the same pattern grouped by gear group.
  • URL state: month, date, slot sync to query params on the page (cal.com does exactly this) — back button works, links are shareable, and post-auth resume becomes a plain returnTo URL. This replaces the retryCheckoutRef re-entry hack and TourModalContext's sessionStorage replay. Both branches — hourly slot/step state is as URL-native as tour's. History semantics: step transitions (type, location, resource, slot) are push (back = previous step); browsing state (month, date) is replace (shareable/resumable without polluting history with every calendar click).
  • The flow is a component, the page is a host. organisms/booking/BookingFlow.tsx owns steps + branches; /book/page.tsx wraps it with URL sync and SEO metadata. LP conversion resolved as navigation, not inline embeds: the LPs never had inline booking sections (all three hosted the flow in modals launched from CTAs), so their CTAs link to /book with context prefilled — comp offers via ?offer=<code> — and the LP booking modals are deleted. BookingFlow remains embeddable with props-based context if a genuinely inline surface ever wants it.
  • Entry points become links: openTourModal(...) call sites and the Book CTAs navigate to /book?… with context prefilled. TourModalContext keeps its API shape but navigates instead of opening a modal (or is deleted outright if call sites are converted to plain Links — decide during Phase 2).
  • Auth stays a dialog (ModalWrapper/AuthModal) where it's needed mid-flow (hourly checkout); with flow state in the URL, the auth redirect path also becomes safe.
  • Since the booker is on-page, the narrow-viewport details/confirm step is a compact dialog over the picker (cal.com's mobile pattern); the picker stays mounted behind it, so closing restores the selection. The dialog header shows the selected date/duration as summary chips — both branches, no exceptions.
  • BookingShell owns the card. Because the meta pane must outlive the Scheduler during the picker→form swap, the card cannot live inside Scheduler. One shared organism (BookingShell) owns the meta pane, the wide card, the transition, and the narrow dialog; tour and hourly branches supply only meta content, scheduler config, and form content. The shell is itself the @container (layout follows container width, not viewport); the only viewport media query is the dialog-vs-inline choice on narrow, which needs a JS signal. Inside the shell, Scheduler renders in panes layout (calendar | slots, no meta); standalone consumers use its default card layout, which includes the meta pane via container queries.
  • Slot → form transition (wide): one persistent card with the meta pane always visible. The picker disappears instantly and the form fades in (~200ms); the only size animation is a CSS max-width transition on the card (1060px → 760px) — never transform/FLIP-based layout animation, which scales and visually distorts the content (tried, rejected). Back restores the picker on the same date (the date URL param persists) with the time deselected (Aaron 2026-07-11; supersedes the earlier keep-selection behavior).

Scheduler organism (the picker)

New organisms/scheduling/Scheduler.tsx. Pure slot selection — knows nothing about users, carts, payment, or confirmation.

Contract:

interface SchedulerProps {
  calendarId: string
  appointmentTypeId: number
  durationMinutes: number | null // drives end-time display
  timezone: string
  onSlotSelect: (slot: { date: string; time: string }) => void
  selectedSlot?: { date: string; time: string } | null
  meta?: React.ReactNode // optional left pane: resource info, duration switcher, pricing
  layout?: 'card' | 'panes' // 'panes' = calendar|slots only, for hosts (BookingShell) that own the card
  // display decorations
  peakHoursStart?: string | null
  peakHoursEnd?: string | null
  peakPriceCents?: number | null
  offPeakPriceCents?: number | null
  dateWindow?: { start: string | null; end: string | null } | null // comp offer clamp
}

Layout (container queries, not viewport breakpoints): the component is @container; Tailwind 4 supports this natively. Wide container → grid of [meta?] | calendar | slots; the slot column is the only scroll region and the calendar is always fully visible. Narrow container → stacked: meta header, calendar, slot list rendered inline beneath (first available date auto-selected, so slots are visible immediately) — no interior scroll regions at all on narrow; the page or modal body scrolls naturally. Standalone, the same component works on the /book page, in an LP section, and in a card; the one mode prop is layout: 'panes' for hosts like BookingShell that own the card and meta pane themselves (see BookingShell above).

Pane widths on wide containers: side panes fixed (meta 280px, slots 300px); the calendar pane is fluid within its track with a 480px content max-width — a literally fixed 480px middle column would overflow containers between the wide breakpoint (896px) and ~1060px, and the calendar (aspect-square cells, no text-wrap risk) is the only pane that degrades gracefully when narrowed.

selectedSlot contract: selectedSlot.time is fully controlled (drives the highlighted slot every render). selectedSlot.date is adopted when the prop changes to a new date (and seeds the initial month on mount), but clearing the slot to null deselects the time, not the date — after "add another" clears the slot, the calendar stays on the same day for the next pick. Internal date clicks are never overridden by an unchanged prop.

Height and layout-shift policy: wide = fixed card height derived from a 6-row calendar reservation; slot-count changes can never resize the container (short lists leave whitespace, long lists scroll). Narrow = natural flow with a min-height floor on the slot area (never shrinks) + auto-selected first date (initial paint is full-size); growth on date change is user-initiated and exempt from CLS. If growth feels bad in mid-page LP embeds, the fallback is a max-height + internal scroll only for mid-page narrow embeds — full-page narrow keeps natural flow.

Internals — from-scratch UI, no cannibalizing. All markup and styling is new; nothing is lifted visually from the accordion stack. molecules/scheduling/MonthCalendar.tsx is a custom month grid built on @internationalized/date (not a HeroUI Calendar restyle) — full control over the cal.com cell treatment (filled available days, high-contrast selected square, today dot), with react-aria-pattern keyboard nav and roles. molecules/scheduling/SlotList.tsx is new markup; only the decoration rules (peak/price chips, availability counts, cart-conflict disabling) are ported from TimeSelector's logic. Base design-system atoms (Button, Input, Chip) are used as normal. The deliberate reuse boundary is business logic only: checkout paths, API payloads, pricing math, conflict guards, tracking — moved verbatim into hooks, because that's correctness, not UX.

Duration selection moves out of the step machine into the meta pane as a compact pill switcher (DurationSwitcher) — cal.com pattern. Its fetch logic is AppointmentTypeSelector's, ported to TanStack Query (useAppointmentTypes: tour-type filter, max-duration filter, empty-filter Sentry warning); the old raw-fetch Listbox atom is deleted in Phase 4. The switcher auto-selects the shortest duration on load (the scheduler can't query availability without one). Changing duration re-queries availability in place. If the types query settles empty or errored, the branch renders a terminal "not bookable online" state — never an indefinite skeleton.

cal.com Booker field notes (verified live 2026-07-09, i.cal.com/peer/meet)

Measured with Playwright at 1440px and 375px.

  • Desktop grid: CSS grid with grid-template-areas: "meta main timeslots", fixed widths via CSS vars — meta 280px, calendar 480px, slots 280px (240/240 below lg), ≈1040px total. Bordered rounded-md card, centered. Not fluid thirds — fixed pane widths keep the calendar grid and slot buttons at ideal proportions regardless of page width.
  • Slot column: day header ("Fri 10") + 12h/24h toggle pinned at top; below it an overflow-y-auto scroller with hidden scrollbar — the only scroll region. Slot buttons full-width, 36px min-height, rounded-lg, bordered, brand-color border on hover, ~4px gaps.
  • Calendar cells: ~59px square. Available = filled subtle background; unavailable = plain faint text (no strikethrough); selected = high-contrast filled square; today = dot under the number. Month nav arrows in the calendar pane header.
  • First available date is auto-selected on load — the slot pane is never empty.
  • Height stability (verified): booker height constant at 489.78px across day changes, month changes, and 5-row → 6-row calendar months — the calendar pane reserves its height and the slot scroller fills it (h-full). On mobile there is no height stability: the inline slot list grows/shrinks the document (fine on a hosted page with nothing below the booker; does NOT transfer to our LPs, hence the height policy above).
  • Slot → form transition (desktop): calendar and slot panes collapse, container animates narrower (transition-[width] duration-300), grid becomes meta | form. The selected date/time joins the meta list — the meta pane doubles as the running summary, and the form is the confirmation step (Back link + Confirm button; no separate review screen).
  • Mobile (375px): single stacked column — meta header, compact calendar, day header + slot list inline. Natural document scroll, zero nested scrolling. Tapping a slot opens a "Confirm your details" modal dialog (date + duration chips, form, Back/Confirm) rather than an inline step.
  • State in URL (?date=, &slot=, &layout=), layout auto-synced to viewport on resize. We adopt URL state on the /book page; LP-embedded instances use component state so they don't fight the host page's URL.
  • Not adopting: overlay-my-calendar, week/column alternate layouts, 12h/24h toggle (we always show location-local 12h + tz abbreviation).

Data layer

hooks/scheduling/useAvailability.ts — TanStack Query, key ['acuity-availability', calendarId, appointmentTypeId, month], hitting the existing /acuity/availability/dates endpoint (backed by the read-through cache, #862). Month-driven: fetch the visible month, prefetch the next; further months load on calendar navigation instead of 4-up-front. useTimeSlots becomes a query keyed [..., date] (keeps the full-day-merge option for hourly). staleTime ~60s so step-back/forward doesn't refetch. Mock-data fallback (mockDataUtils) preserved for local dev. No API changes.

Flow branches

Both flows are branches of BookingFlow sharing the same shape: a small step state machine in plain React state (URL-synced on /book), one responsive tree instead of duplicated mobile/desktop trees, and the new Scheduler for slot picking.

Tour branch (replaces TourSchedulerModal, which is deleted):

  • Steps after slot: details form (skipped/prefilled when profile or handoff info is complete) → submit → success (AddToCalendar). On narrow, details+confirm can be a dialog.
  • Host/location meta renders in the Scheduler meta pane on wide, header card on narrow.
  • Preserve: tourRequestRequired redirect to /contact (checked at page level from location data), POST /tours payload (attribution, smsMarketingConsent, desiredStudioSize, leadEventId), trackTourScheduled, postTourBooking sessionStorage handoff.

Hourly branch (restructures HourlyBookingContent):

  • Data extracted into query hooks: useHourlyResource(resourceId) (transform + timezone conversion), useCreditPackages().
  • Checkout extracted into useHourlyCheckout() — the comp / MONEY (checkout session) / BALANCE (direct reservation) / dedicated-room-FREE paths, profile-completion redirects, conflict guard, auth retry, tracking calls. Pure logic move, no behavior change; on /book, auth retry can additionally lean on URL state instead of retryCheckoutRef.
  • One responsive tree: resource meta pane | Scheduler | details/payment step — all new presentation (pricing-summary logic ported from PaymentMethodSelector). Single-session booking, no cart (multi-booking UX rejected 2026-07-09): picking a slot advances immediately — signed-in users to payment, guests and comp to a contact-details step (ContactDetailsForm, shared with tours). The meta pane shows the studio identity, an "Hourly booking" heading (parallel to tour's "Tour " heading), and — while a slot is active — the selected session with its price on its own line; Back clears the selection and the summary.
  • Guest bookings for all types (Aaron 2026-07-09; API pulled into this branch 2026-07-11): tours and comp are guest-native already; hourly guests get the details step and a true no-login checkout — auth-optional checkout-session initialize with guest identity in the payload, stub-user-by-email behind the resolveStubUserFromStaff seam (comp/waitlist/ADR-020 patterns). No auth modal anywhere in the guest path. Identity stays email-keyed and collects phone; when ADR-022 (#843) merges, phone-first dedup changes one resolution function, not the flow.
  • Preserve: comp mode (userInfo collection, booking window clamp, AlreadyRedeemedModal, success panel), simplified LP mode (via ?minimal=1, above), dedicated-room free booking, AddCreditsModal, insufficient-balance gating, all tracking + attribution.
  • Comp identity is locked to the signed-in account (revision 2026-07-14): a signed-in member on the comp details step sees a read-only "Booking as" summary (account name + email), not editable inputs — editable fields let a member redeem under a second identity since the endpoint keys the booking off the submitted email. Members with a verified phone skip the client OTP (the endpoint passes them via the mirrored phone, ADR-024); members without one keep the OTP but skip the account-exists interstitial. /users/me exposes phoneVerifiedAt to drive this. Server-side, the gate rejects a phone verified on a different live account with 409 PHONE_ACCOUNT_MISMATCH (same-account phones pass; the owner lookup excludes the booking user). Guests lose the interstitial's "Continue as guest" escape — sign in or use a different number.
  • Studio picker cards (revision 2026-07-14): image-led cards (16:9 photo, size-placeholder art when the studio has no image) with name, size + from-price line, and a two-line description. Comp mode also suppresses the "Off-hours" slot chips — peak/off-peak is pricing information and comp bookings are free.
  • Batch/multi-session booking is removed (Aaron 2026-07-11: one use in 5 months). Single-item payloads only; isBatchMode branches, multi-item cart plumbing, and reservation_ids batch success handling are gone from the customer flow. The API-side items[] path has no consumers left (staff surfaces checked, none found) and stays in place indefinitely as a working money path per ADR-019 (Aaron 2026-07-11: leave it, revisit only if a reason appears).
  • Checkout handoff is a seam, not a hardcode. A checkout-screen refactor is planned (embeddable/modal checkout). useHourlyCheckout ends by producing an outcome ({ kind: 'redirect', url } today for MONEY/confirmation paths); the host decides what to do with it. When embedded checkout lands, the same outcome renders an inline checkout step instead — no flow restructure.

Public-endpoint abuse hardening (locked 2026-07-11)

The comp cap is per-user, but users are stubbed by unverified email, so "one per account" is really "one per free email." Guest checkout shares the pattern; payment self-limits it there. Hardening, all in this branch:

  • Claimed-account guard: a request whose email belongs to a claimed (non-stub) account gets a 409 the frontend turns into a sign-in prompt (email prefilled) — no unauthenticated reservations, offer consumption, or profile writes (artistName) against claimed accounts. Unclaimed stubs keep working as today.
  • Per-IP rate limit on the public comp + guest-checkout endpoints.
  • Per-offer global redemption cap, enforced in the redemption transaction, bounding worst-case abuse of a public code.
  • requiresVerifiedPhone offer flag (default false): when set, the comp endpoint rejects redemptions without a verified phone. The verify-UX (inline phone-OTP for guests, shouldCreateUser: true wrapper over #843's PhoneOtpSteps) lands post-ADR-022; the flag is the switch we flip per offer if abuse shows up. Not gating by default: comp is top-of-funnel, verification-on-value beats a signup wall (Aaron 2026-07-11).

Behavior-preservation contract

Slot decorations: peak/off-peak chips, per-slot pricing, slotsAvailable chip. Calendar: unavailable dates, today-min, comp date-window clamp. Flows: profile prefill on the details step; comp requires info; auth-retry resume; profile-completion redirect with checkout session handoff; timezone display abbreviation. All existing data-testids that tests rely on stay or their tests are updated in the same commit. (Cart decorations — cart-date dots, cart-conflict disable, multi-item payloads — dropped with batch removal.)