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,slotsync to query params on the page (cal.com does exactly this) — back button works, links are shareable, and post-auth resume becomes a plainreturnToURL. This replaces theretryCheckoutRefre-entry hack andTourModalContext's sessionStorage replay. Both branches — hourly slot/step state is as URL-native as tour's. History semantics: step transitions (type,location,resource,slot) arepush(back = previous step); browsing state (month,date) isreplace(shareable/resumable without polluting history with every calendar click). - The flow is a component, the page is a host.
organisms/booking/BookingFlow.tsxowns steps + branches;/book/page.tsxwraps 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/bookwith context prefilled — comp offers via?offer=<code>— and the LP booking modals are deleted.BookingFlowremains 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.TourModalContextkeeps its API shape but navigates instead of opening a modal (or is deleted outright if call sites are converted to plainLinks — 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.
BookingShellowns the card. Because the meta pane must outlive theSchedulerduring the picker→form swap, the card cannot live insideScheduler. 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,Schedulerrenders inpaneslayout (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-widthtransition 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 (thedateURL 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 belowlg), ≈1040px total. Borderedrounded-mdcard, 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-autoscroller 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/bookpage; 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
Schedulermeta pane on wide, header card on narrow. - Preserve:
tourRequestRequiredredirect to/contact(checked at page level from location data),POST /tourspayload (attribution,smsMarketingConsent,desiredStudioSize,leadEventId),trackTourScheduled,postTourBookingsessionStorage 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 ofretryCheckoutRef. - One responsive tree: resource meta pane |
Scheduler| details/payment step — all new presentation (pricing-summary logic ported fromPaymentMethodSelector). 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
resolveStubUserFromStaffseam (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),simplifiedLP 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/meexposesphoneVerifiedAtto drive this. Server-side, the gate rejects a phone verified on a different live account with 409PHONE_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;
isBatchModebranches, multi-item cart plumbing, andreservation_idsbatch success handling are gone from the customer flow. The API-sideitems[]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).
useHourlyCheckoutends 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.
requiresVerifiedPhoneoffer flag (default false): when set, the comp endpoint rejects redemptions without a verified phone. The verify-UX (inline phone-OTP for guests,shouldCreateUser: truewrapper over #843'sPhoneOtpSteps) 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.)