Booking Flow
This page documents the full booking wizard flow, its underlying state machine, each step component, the slot picker, and the manage page.
Overview
The booking flow lives in src/features/booking/. The entry point is BookingPage (src/pages/BookingPage.tsx), which wraps:
BookingWizard renders one step at a time based on the current step value from the state machine.
State Machine
Source: src/features/booking/useBookingMachine.ts
The wizard state is a plain useState-based finite state machine — no external library. The full state shape:
Step values
pickService → pickTime → holding → verifyContact → enterCode → placing → done
holding and placing are transient steps (no UI rendered; side effects fire immediately).
State Transitions
BookingWizard Orchestration
Source: src/features/booking/BookingWizard.tsx
BookingWizard owns the state machine and all async side effects via four useEffects:
Back navigation logic:
enterCode→backToVerify()verifyContact→ releases the hold viareleaseHold()thenbackToPickTime()pickTime→backToService()- Back button is hidden on
pickService,holding,placing, anddone
Hold countdown bar (visible on verifyContact and enterCode): a <Countdown> + "Change time" button appears while holdExpiresAt is set.
place() function: Called after OTP verification. Calls setConsumerToken, startPlacing, then createBooking(slug, { consumerToken, holdId, serviceIds, start, consumer: { name, phone? }, preferredChannel }). On success → saveBooking() to localStorage → placed(result).
Steps
Step 1: PickService
Source: src/features/booking/steps/PickService.tsx
Loads the service catalog (getServices(slug)) on mount. Groups services by category using groupServices() and renders a <ChipBar> for category filtering. Each service is a <SelectableCard>.
- Maximum 8 services can be selected (
MAX_SERVICES = 8); cards beyond the cap render dimmed. - A sticky bottom bar shows selected count, total duration, and total price once at least one service is chosen.
- Pre-selection via
?serviceId=query param is honoured if the user hasn't already made a selection (safe for back-navigation). - Calls
onContinue(services)→pickServices(services)→ advances topickTime.
Step 2: SlotPicker (rendered by BookingWizard)
Source: src/features/booking/SlotPicker.tsx
See Slot Picker below.
Step 3: VerifyContact
Source: src/features/booking/steps/VerifyContact.tsx
Collects the consumer's name and contact information. Supports email and/or sms channels as dictated by otpMethods from BookingProfileProvider. If both are available, a toggle link allows switching.
- Phone input uses NANP formatting (
formatNanpInput— progressive(NXX) NXX-XXXXmask). - Validation errors are shown only after field blur (
touchedstate). - Submit triggers:
getFreshPow()→requestOtp(slug, { channel, contact, ...pow }). - On
invalid_proof_of_workorbad_request: retries once with a forced fresh PoW. - On success → calls
onCodeSent({ name, channel, contact })→ wizard callstoEnterCode().
Step 4: EnterCode
Source: src/features/booking/steps/EnterCode.tsx
Renders a 6-slot <InputOTP> (numeric only). Submit is disabled until all 6 digits are entered.
- Calls
verifyOtp(slug, { channel, contact, code })→ returns{ consumerToken }. - On success → calls
onVerified(consumerToken)→ wizard callssetConsumerToken,startPlacing, then runsplace(). - Resend: 30-second cooldown (
RESEND_SECONDS = 30). The resend link is hidden during cooldown. Resending clears the code input and restarts the timer. The actual resend callsrequestOtpagain with a fresh PoW.
Step 5: placing (transient)
No UI. BookingWizard shows a spinner while createBooking runs. On success, transitions to done.
Step 6: Done
Source: src/features/booking/steps/Done.tsx
Terminal step — no further transitions. Displays:
- A confirmation icon.
- A pending-confirmation title (appointments require merchant approval).
- A
<Countdown>showing time untilresult.holdExpiresAt. - A link to
/manage/:manageTokenfor self-service management.
The manage token has already been saved to localStorage by place() before this screen renders.
Slot Picker
Source: src/features/booking/SlotPicker.tsx
The SlotPicker is a reusable component used in both the booking wizard and the manage page (for reschedule). It accepts:
Date selection:
- Initial date: first non-closed day within the upcoming 14 days (computed on mount).
- Day chips strip: a scrollable bar of upcoming days; dynamically extends up to 70 days when a far date is selected. Active chip scrolls into view automatically.
- Calendar drawer: opens on "Show calendar" button. Displays a Monday-first 6-week grid via
monthGrid(). Past dates and closed weekdays are disabled. - "Next available" probe: day-by-day forward search up to
MAX_PROBE_DAYS = 60, skipping closed weekdays, stopping at the first date with slots. SetsnoUpcoming = trueif nothing is found. Stops immediately onout_of_horizonerror.
Slot display:
Slots from AvailabilityResponse.slots[] are bucketed into three DayPart sections:
Empty sections are hidden. Each slot button shows the time formatted by formatSlotTime(s, tz) and calls onPick(utcIsoString) on click.
Loading state: 9 skeleton placeholders (3×3 grid).
Fully booked: "No slots" card with "Go to next available" button.
Manage Page
Source: src/features/manage/ManagePage.tsx
Accessible at /manage/:token. The token comes from the emailed magic link {ManageBaseUrl}/{token}.
Data loaded: getManage(token) → ManageView — includes status, start, end, services[], currency, timezone, canCancel, canReschedule.
UI States
Actions
Cancel errors: not_actionable shows a specific message; others show a generic retry prompt.
Reschedule errors: slot_unavailable, out_of_horizon, not_actionable each have specific messages.
StoreHeader
Source: src/features/booking/StoreHeader.tsx
Displays the salon's name, logo, address (linked to Google Maps), phone (linked as tel:), and business hours. Hours are parsed from hoursJson via parseHours() and weeklyHours(), and today's open/closed status is derived from the current local time versus the salon's timezone.
The header is collapsible — BookingWizard passes an expanded boolean (controlled by storeExpanded state) that PickService can toggle to false when the user taps a category chip.
Notes for Future Agents
- Hold lifecycle: A hold is created during the
holdingtransient step and released either explicitly (back navigation fromverifyContact) or via beacon on page hide. If neither fires (e.g. tab crash), the backend releases the hold automatically atholdExpiresAt. - PoW retries: Every call to
requestOtpandcreateHoldhas a single automatic retry oninvalid_proof_of_workorbad_requestwithgetFreshPow(force: true). - Services preserved on back-navigation:
backToService()clears time-related state but keepsservices, so the user's selection is restored when they return toPickService. saveBookinghappens inplace(): The booking is saved to localStorage before transitioning todone, so the manage link is available even if the user closes the tab before theDonescreen fully renders.- Reschedule
SlotPickerlimitations: When opened fromManagePage,closedWeekdaysdefaults to[](empty) andtimezonedefaults toundefined. The picker will not grey out closed days in this context — this is a known simplification.