• English
  • 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:

    <BookingProfileProvider slug={slug}>   ← fetches salon profile
      <PowProvider>                         ← manages PoW challenge/solution cache
        <BookingWizard slug preselectServiceId> ← orchestrates all steps

    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: Step
      services: ServiceItem[]     // chosen services (preserved across back-navigation)
      start: string | null        // selected UTC ISO slot
      holdId: string | null
      holdExpiresAt: string | null
      consumerToken: string | null
      result: CreateBookingResponse | null
    }

    Step values

    pickServicepickTimeholdingverifyContactenterCodeplacingdone

    holding and placing are transient steps (no UI rendered; side effects fire immediately).

    State Transitions

    ActionFrom → ToState changes
    pickServices(services[])pickServicepickTimeSets services
    pickTime(start)pickTimeholdingSets start
    holdCreated(id, expiresAt)holdingverifyContactSets holdId, holdExpiresAt
    toEnterCode()verifyContactenterCode
    setConsumerToken(token)any → sameSets consumerToken
    startPlacing()enterCodeplacing
    placed(result)placingdoneSets result
    backToPickTime()any → pickTimeClears start, holdId, holdExpiresAt
    backToVerify()enterCodeverifyContactClears consumerToken
    backToService()pickTimepickServiceClears start, holdId, holdExpiresAt; keeps services

    BookingWizard Orchestration

    Source: src/features/booking/BookingWizard.tsx

    BookingWizard owns the state machine and all async side effects via four useEffects:

    EffectTriggerWhat it does
    PoW pre-warmstep === 'pickTime'Calls getFreshPow() in the background so holding doesn't block
    Hold creationstep === 'holding'createHold(slug, { serviceIds, start, ...pow }). Success → holdCreated(); slot_unavailable / too_many_holds / out_of_horizon → sets a notice + backToPickTime()
    Hold expiry timerholdExpiresAt changes (active on verifyContact or enterCode)setTimeout fires backToPickTime() + notice when the hold expires
    Page-hide cleanupholdId or step changesFires releaseHoldBeacon(slug, holdId) via navigator.sendBeacon if user navigates away before done

    Back navigation logic:

    • enterCodebackToVerify()
    • verifyContact → releases the hold via releaseHold() then backToPickTime()
    • pickTimebackToService()
    • Back button is hidden on pickService, holding, placing, and done

    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 to pickTime.

    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-XXXX mask).
    • Validation errors are shown only after field blur (touched state).
    • Submit triggers: getFreshPow()requestOtp(slug, { channel, contact, ...pow }).
    • On invalid_proof_of_work or bad_request: retries once with a forced fresh PoW.
    • On success → calls onCodeSent({ name, channel, contact }) → wizard calls toEnterCode().

    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 calls setConsumerToken, startPlacing, then runs place().
    • 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 calls requestOtp again 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 until result.holdExpiresAt.
    • A link to /manage/:manageToken for 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:

    {
      load: (date: string) => Promise<AvailabilityResponse>  // injected by parent
      onPick: (utcStart: string) => void
      timezone?: string
      closedWeekdays?: number[]  // ISO: 1=Mon … 7=Sun
    }

    Date selection:

    1. Initial date: first non-closed day within the upcoming 14 days (computed on mount).
    2. 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.
    3. Calendar drawer: opens on "Show calendar" button. Displays a Monday-first 6-week grid via monthGrid(). Past dates and closed weekdays are disabled.
    4. "Next available" probe: day-by-day forward search up to MAX_PROBE_DAYS = 60, skipping closed weekdays, stopping at the first date with slots. Sets noUpcoming = true if nothing is found. Stops immediately on out_of_horizon error.

    Slot display:

    Slots from AvailabilityResponse.slots[] are bucketed into three DayPart sections:

    PartLocal hour range
    Morning< 12
    Afternoon12–16
    Evening≥ 17

    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

    StateRendered UI
    Loading<LoadingState> spinner
    Invalid tokenError message, no actions
    Normal viewBooking card with status badge, formatted time, service list, optional total
    status === 'declined'Extra red notice card
    Rescheduling<SlotPicker> replaces action buttons (uses getManageAvailability)

    Actions

    ActionConditionAPI callOutcome
    RefreshAlways visiblegetManage(token)Re-fetches full ManageView
    Cancelview.canCancel === true<ConfirmDialog>cancelBooking(token)Updates view.status → 'cancelled'; hides cancel/reschedule buttons
    Rescheduleview.canReschedule === trueEnters reschedule UI → slot pick → <ConfirmDialog> showing new time → rescheduleBooking(token, newStart)Updates view.start, view.end, view.status

    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 holding transient step and released either explicitly (back navigation from verifyContact) or via beacon on page hide. If neither fires (e.g. tab crash), the backend releases the hold automatically at holdExpiresAt.
    • PoW retries: Every call to requestOtp and createHold has a single automatic retry on invalid_proof_of_work or bad_request with getFreshPow(force: true).
    • Services preserved on back-navigation: backToService() clears time-related state but keeps services, so the user's selection is restored when they return to PickService.
    • saveBooking happens in place(): The booking is saved to localStorage before transitioning to done, so the manage link is available even if the user closes the tab before the Done screen fully renders.
    • Reschedule SlotPicker limitations: When opened from ManagePage, closedWeekdays defaults to [] (empty) and timezone defaults to undefined. The picker will not grey out closed days in this context — this is a known simplification.