plans/customer-booking-stepper-v2.md

Customer Booking — V2 Stepper Refactor (industry-standard)

Status: ✅ Shipped 2026-05-29 (S1–S10). Replaces V2 1-page. See Tracking. Mục tiêu: Refactor BookingPageV2 (1-page, ~610 LOC) sang stepper 4-step industry-standard để tối ưu mobile UX + conversion. Throw away 1-page, V1 (legacy) vẫn giữ nguyên. Effort: ~1.5-2 ngày dev. Predecessor: docs/plans/customer-booking-chain-refactor.md — đã ship P0 (PlatformSetting flag) + P1 (backend chain endpoint) + P2 (FE chain client) + P3 (V2 1-page). Stepper sẽ thay thế P3.


Motivation

V2 1-page hiện tại:

  • Khách thấy hết: services, date, time, customer details cùng lúc trên 1 màn.
  • Trên mobile (~85% traffic salon VN), summary sidebar bị đẩy xuống cuối → khách không thấy giá lúc pick service.
  • Eager fetch: mỗi lần đổi service/staff → refetch chain availability → backend chịu N+1 calls trong cùng session.
  • Bug "no available times" hiển thị bare, không có path-to-next-action.

Stepper (Treatwell/Booksy/Vagaro):

  • Mỗi screen = 1 quyết định focused → conversion rate cao hơn.
  • Mobile-first: full-screen mỗi step, summary sticky bottom.
  • Lazy fetch: chỉ gọi chain availability sau khi customer đã pick services + staff (step 3).
  • "No available" → CTA "Jump to next available date" inline (industry pattern).

Quyết định: ship Stepper là default V2. 1-page BookingPageV2 thay thế hoàn toàn. V1 (BookingPage) giữ nguyên cho backward compat.


Decisions đã chốt (từ predecessor + session 2026-05-29)

Topic Decision
Stepper architecture 4 steps: Services → Staff per service → Time → Confirm + Details
Service-first vs Date-first Service-first (industry pattern)
Per-service staff Giữ (default "Any"); step 2 dedicated cho staff selection
Date picker Trong step 3 (Time), kèm slot grid chain
Skill rule Strict — resource phải có skill cho service mới hiện ở dropdown (đã fix 2026-05-29)
allowDoubleBooking Backend skip overlap check; schedule + business hours vẫn enforce
"No available" UX Banner inline + button "Jump to next available date"
Mobile vs Desktop layout Same component, responsive — sticky bottom bar mobile, sticky right sidebar desktop
Deep-link ?serviceId= Skip step 1, land trực tiếp step 2
Re-book ?from= Pre-fill steps 1-2, land step 3
V1 fate Giữ nguyên — bookingUiVersion='v1' vẫn dùng BookingPage
V2 1-page (BookingPageV2) fate Xóa sau khi stepper ship — không double-maintain

Kiến trúc Stepper

Component tree mới

booking-web/src/app/(customer)/b/[slug]/book/
├── page.tsx                            ← router: V1 (legacy) | V2 (stepper)
├── components/                         ← V1 giữ nguyên, KHÔNG đụng
│   ├── BookingPage.tsx
│   ├── ServiceItem.tsx
│   ├── ServicePicker.tsx
│   ├── DateStrip.tsx
│   └── StickyBookingHeader.tsx
└── components-v2/                      ← REWRITE — chỉ giữ vài file reuse được
    ├── BookingFlowV2.tsx               ← orchestrator: state + step routing
    ├── BookingFlowState.ts             ← state machine (currentStep, items[], date, time, customer)
    ├── steps/
    │   ├── Step1ServicesPicker.tsx     ← multi-add services, summary live
    │   ├── Step2StaffSelector.tsx      ← per-service staff (Any | specific), avatars
    │   ├── Step3DateTimePicker.tsx     ← DateStrip + ChainTimePicker + EmptyState w/ jump-CTA
    │   └── Step4ConfirmDetails.tsx     ← customer form + voucher + summary + Submit
    ├── shared/
    │   ├── StepperHeader.tsx           ← progress bar + back button + step label
    │   ├── StepperFooter.tsx           ← Continue/Back, mobile-sticky bottom
    │   ├── BookingSummarySidebar.tsx   ← desktop right sidebar (sticky)
    │   ├── BookingSummaryBottom.tsx    ← mobile bottom card (collapsed)
    │   ├── ChainTimePicker.tsx         ← copy/reuse từ V2 1-page hiện tại
    │   ├── ChainPreview.tsx            ← copy/reuse từ V2 1-page hiện tại
    │   ├── EmptyDayBanner.tsx          ← "Đã kín lịch" + "Jump to next available date" CTA
    │   ├── ServiceCard.tsx             ← reuse cho step 1 (selectable) + step 2 (with staff dropdown)
    │   └── StaffOption.tsx             ← avatar + name + selected indicator
    └── (DELETE)
        ├── BookingPageV2.tsx           ← thay thế bởi BookingFlowV2
        └── ServiceItemV2.tsx           ← split thành ServiceCard + StaffOption

Flow state machine

type StepperStep = 'services' | 'staff' | 'time' | 'confirm';

interface BookingFlowState {
  currentStep: StepperStep;
  items: Array<{ service: PublicService; resourceId: string | null }>;
  date: string;                      // YYYY-MM-DD
  selectedStartTime: string | null;  // ISO UTC
  // customer fields owned by RHF in Step4
}

Transition rules:

  • services → staff: cần ≥1 item
  • staff → time: nếu bookingMode='assigned_only' → mọi item phải có resourceId. Nếu allow_unassigned → step có thể skip nếu khách muốn "Any" hết.
  • time → confirm: cần selectedStartTime != null
  • Mọi step có Back button → giữ state, không reset.

URL state: ?step=services|staff|time|confirm (shallow router push) — F5 không mất tiến độ, deep-link cho từng step.

Step 1 — Services picker

┌───────────────────────────────────────┐
│ ← Choose services             [1/4]   │  ← StepperHeader
├───────────────────────────────────────┤
│ Search: [_________________]            │
│                                        │
│ ▼ Hair (3 services)                    │
│   ┌────────────────────────────────┐  │
│   │ Cắt tóc nữ          45'  599kr │  │  ← ServiceCard (selectable)
│   │ ✓ Selected                      │  │
│   └────────────────────────────────┘  │
│   ┌────────────────────────────────┐  │
│   │ Nhuộm                90'  1200 │  │
│   │                          [Add] │  │
│   └────────────────────────────────┘  │
│                                        │
│ ▼ Nails (2 services)                   │
│   ...                                  │
├───────────────────────────────────────┤
│ Selected: 1 service · 45' · 599kr     │  ← BookingSummaryBottom (sticky mobile)
│            [Continue →]                │  ← StepperFooter
└───────────────────────────────────────┘

Behaviour:

  • Click ServiceCard → toggle add/remove from items[]
  • Empty state: "Pick at least one service to continue"
  • Search filter ở client (categories đã pre-fetched ở server component)
  • Continue button disabled khi items.length === 0

Step 2 — Staff per service

┌───────────────────────────────────────┐
│ ← Choose professionals     [2/4]      │
├───────────────────────────────────────┤
│ Service 1: Cắt tóc nữ (45')           │
│   ┌──────────────────────────────┐   │
│   │ ⌒ Any available     [✓]      │   │
│   │ M  Mai      [ ]               │   │
│   │ L  Lan      [ ]               │   │
│   └──────────────────────────────┘   │
│                                       │
│ Service 2: Nail (30')                 │
│   ⌒ Any available [✓]                │
│   ...                                 │
├───────────────────────────────────────┤
│ Total: 75' · 1199kr                   │
│ [← Back]              [Continue →]    │
└───────────────────────────────────────┘

Behaviour:

  • Fetch staff list per service (qua getResources(slug, serviceId)) — strict skill match, 0-skill hidden
  • Mặc định "Any" (resourceId = null) cho mỗi item
  • Click 1 staff → set resourceId, click lại → bỏ về "Any"
  • Validation: nếu settings.bookingMode === 'assigned_only' → mọi item phải có staff cụ thể trước khi Continue

Step 3 — Date + Time

┌───────────────────────────────────────┐
│ ← Choose date & time      [3/4]       │
├───────────────────────────────────────┤
│ [DateStrip — 28 days]                 │
│   ← FRI 29 SAT 30 SUN 31 ... →        │
│                                        │
│ ┌──────────────────────────────────┐  │
│ │ ⚠ No available times today        │  │  ← EmptyDayBanner
│ │ The chain (105') doesn't fit any  │  │
│ │ staff's open hours.               │  │
│ │ [Jump to next available date →]   │  │
│ └──────────────────────────────────┘  │
│                                        │
│ [If has slots:]                       │
│ ┌──────────────────────────────────┐  │
│ │ Morning                           │  │
│ │ [09:00] [09:15] [09:30] [09:45]  │  │
│ │ Afternoon                         │  │
│ │ [13:00] [13:15] ...               │  │
│ └──────────────────────────────────┘  │
│                                        │
│ [Selected: 09:00 → 10:45]             │  ← ChainPreview
│   09:00 Cắt tóc · Mai · 45'           │
│   09:45 Nail · Lan · 30'              │
│   10:15 Massage · Any · 30'           │
├───────────────────────────────────────┤
│ [← Back]              [Continue →]    │
└───────────────────────────────────────┘

Behaviour:

  • DateStrip pinned top
  • Chain availability fetched on (date, items) change — debounced 200ms để tránh thrash
  • Empty state: prominent banner + "Jump to next available date" CTA
  • Group slots by Morning (<12) / Afternoon (12-17) / Evening (>17)
  • ChainPreview hiển thị ngay khi pick slot
  • Continue disabled khi !selectedStartTime

New backend endpoint cần ship trong sprint này:

POST /public/tenants/:slug/availability/next-available-date
Body: { items: [{ serviceId, resourceId? }], fromDate, maxDaysAhead?: 30 }
Response: { date: '2026-06-02' | null }
  • Scan từ fromDate + 1 đến fromDate + maxDaysAhead
  • Reuse getChainedSlots() cho từng candidate date, return date đầu tiên có ≥1 slot
  • Optimize: stop scan ngay khi tìm thấy (không quét full range)

Step 4 — Confirm + Details

┌───────────────────────────────────────┐
│ ← Confirm your booking      [4/4]     │
├───────────────────────────────────────┤
│ 📅 Friday, May 29 · 09:00            │
│                                        │
│ • Cắt tóc nữ · Mai · 45'  599 kr      │
│ • Nail · Lan · 30'        300 kr      │
│ • Massage · Any · 30'     400 kr      │
│ ─────────────────────────────────     │
│ Total                    1299 kr      │
│                                        │
│ [Voucher input + apply]               │
│                                        │
│ Your details                          │
│ [Name______]                          │
│ [Phone____] [Email____]               │
│ [Notes____________]                   │
│                                        │
│ ⚠ Pending salon approval              │
│ Free cancellation up to 24h           │
│                                        │
│ [Deposit: 50 kr held on card]         │
├───────────────────────────────────────┤
│ [← Back]      [Pay with Bambora →]    │
└───────────────────────────────────────┘

Behaviour:

  • Summary read-only (click Back để đổi)
  • Customer form (RHF + Zod), auto-fill khi logged in
  • Voucher application giống V1/V2 hiện tại
  • Submit → backend createBooking → redirect hoặc /bookings/:id
  • Multi-PSP: 1 button per provider

Phase breakdown

Phase Scope Files Effort
S1 Backend next-available-date endpoint + DTO + tests booking-api/src/core/booking/availability.service.ts (+method), availability.dto.ts (+DTO), public-booking.controller.ts (+endpoint), availability.service.spec.ts (+3 tests) 0.5d
S2 FE state machine + flow scaffold components-v2/BookingFlowV2.tsx, BookingFlowState.ts, shared/StepperHeader.tsx, StepperFooter.tsx 0.3d
S3 Step 1 (Services) + ServiceCard steps/Step1ServicesPicker.tsx, shared/ServiceCard.tsx, BookingSummaryBottom.tsx 0.2d
S4 Step 2 (Staff) + StaffOption steps/Step2StaffSelector.tsx, shared/StaffOption.tsx 0.3d
S5 Step 3 (Date+Time) + EmptyDayBanner + ChainTimePicker reuse steps/Step3DateTimePicker.tsx, shared/EmptyDayBanner.tsx, copy ChainTimePicker.tsx + ChainPreview.tsx từ V2 1-page 0.5d
S6 Step 4 (Confirm + Details) steps/Step4ConfirmDetails.tsx (copy form + voucher logic từ BookingPageV2.tsx), BookingSummarySidebar.tsx 0.3d
S7 Router switch + URL state (?step=) + delete old V2 1-page page.tsx update, BookingPageV2.tsx + ServiceItemV2.tsx DELETE 0.2d
S8 i18n + responsive polish + back/forward nav messages/{en,nb}.json, responsive media queries 0.3d
S9 E2E tests e2e/public-booking.spec.ts thêm test stepper happy path + jump-to-next-date + back nav 0.3d
S10 Docs sync booking-flow.md, features.md, changelog.md 1h

Total ~2d (≈ 17h dev).


Backend new endpoint detail

POST /public/tenants/:slug/availability/next-available-date

Request:

{
  items: Array<{ serviceId: string; resourceId?: string }>;
  fromDate: string;       // YYYY-MM-DD, search exclusive (starts fromDate+1)
  maxDaysAhead?: number;  // default 30, max 90
}

Response:

{
  date: string | null;  // YYYY-MM-DD or null when no slot in window
  // Future: include the first 3 slot times as preview?
}

Logic:

async findNextAvailableDate(query): Promise<{ date: string | null }> {
  const max = Math.min(query.maxDaysAhead ?? 30, 90);
  const start = new Date(query.fromDate);
  for (let offset = 1; offset <= max; offset++) {
    const candidate = addDays(start, offset);
    const candidateStr = formatDate(candidate);
    const result = await this.getChainedSlots({
      tenantId, date: candidateStr, items: query.items,
    });
    if (result.slots.length > 0) return { date: candidateStr };
  }
  return { date: null };
}

Performance: worst case 30 calls to getChainedSlots → each ~10ms (cached tenant settings) = 300ms. Acceptable. Có thể batch optimize later (1 query lấy hết tháng) nếu cần.

Tests (S1):

  • Happy: tomorrow has slots → return tomorrow
  • Today fits, tomorrow fits → return tomorrow (vì fromDate exclusive)
  • 7 days all empty → return null khi maxDays=5; return day 6 khi maxDays=30
  • allowDoubleBooking=true → always returns tomorrow (vì grid full)
  • Closed business hours every day in range → null

State machine + URL sync

// BookingFlowState.ts
export type StepperStep = 'services' | 'staff' | 'time' | 'confirm';

const STEP_ORDER: StepperStep[] = ['services', 'staff', 'time', 'confirm'];

export function canAdvanceTo(target: StepperStep, state: BookingFlowState, settings: TenantSettings): boolean {
  switch (target) {
    case 'services': return true;
    case 'staff': return state.items.length > 0;
    case 'time':
      if (state.items.length === 0) return false;
      if (settings.bookingMode === 'assigned_only') {
        return state.items.every((it) => it.resourceId !== null);
      }
      return true;
    case 'confirm':
      return canAdvanceTo('time', state, settings) && state.selectedStartTime !== null;
  }
}

URL hook:

const router = useRouter();
const params = useSearchParams();
const currentStep = (params.get('step') as StepperStep) ?? 'services';
const setStep = (next: StepperStep) => {
  router.push(`?step=${next}`, { scroll: false });
};

Back button — browser native works (URL change). Stepper "Back" button = setStep(STEP_ORDER[currentIdx - 1]).


URL Behaviour
/b/<slug>/book Step 1 (Services)
/b/<slug>/book?serviceId=X Pre-add service, jump to Step 2
/b/<slug>/book?from=<bookingId> Pre-fill items + staff, jump to Step 3
/b/<slug>/book?step=time Direct jump (only if state allows — fallback to Step 1)
/b/<slug>/book?voucher=CODE Pre-fill voucher input (Step 4)

Reuse strategy — minimize throw-away

Components reuse từ V2 1-page (copy/move):

  • ChainTimePicker.tsx — drop into components-v2/shared/
  • ChainPreview.tsx — drop into components-v2/shared/
  • booking-api.ts (fetchChainAvailability) — không đổi, dùng tiếp

Components reuse từ V1 (components/):

  • DateStrip.tsx — import trực tiếp vào Step 3
  • ServicePicker.tsx — KHÔNG dùng (Step 1 thay bằng ServiceCard grid riêng)
  • StickyBookingHeader.tsx — render ngoài flow, vẫn ở page.tsx

Logic reuse từ V2 1-page (copy):

  • Voucher application (apply + preview + clear handlers) → Step 4
  • Submit handler + payment redirect → Step 4
  • Deposit calculation → Step 4 summary
  • Customer form schema (Zod + RHF) → Step 4

Throw away:

  • BookingPageV2.tsx (610 LOC) — replace bởi BookingFlowV2 + 4 step components
  • ServiceItemV2.tsx — split thành ServiceCard (selectable) + StaffOption (radio)

Risks & mitigation

Risk Mitigation
User mất state khi F5 URL state (?step=...) + sessionStorage backup cho draft items
Mobile back button confusion Stepper Back button khác browser Back; làm rõ trong UX (Continue/Back đặt mobile-sticky bottom)
Re-book deep-link skip nhiều step Test thoroughly với ?from=<id> — fallback an toàn về Step 1 nếu state invalid
Voucher recompute khi đổi service Step 1/2 Wipe voucher state khi items change — match V2 1-page behaviour
next-available-date chậm khi maxDays cao Cap maxDays=90, stop scan khi tìm thấy. Có thể optimize sau bằng 1-query/month nếu metric cho thấy slow
Throw away V2 1-page = mất 610 LOC Acceptable: V2 chưa launch khách thực, cost thấp nhất lúc này
User test V2 đã chuyển flag = v2 → đột nhiên thấy stepper khác hẳn Cảnh báo trong release notes; super-admin có thể flip về v1 nếu cần

Out of scope (defer)

  • Hybrid 1-page desktop + stepper mobile — 2x maintenance, không justify
  • Auto-suggest staff khi customer pick "Any" — backend nói staff nào sẽ làm sau
  • Multi-day chain booking — chain trong 1 ngày only
  • Real-time slot updates (websocket) — slot grid không live update khi staff khác book trùng giờ; rely on submit-time conflict check
  • A/B test V1 vs V2 stepper — không có infra, làm sau khi có analytics

Tracking

Shipped 2026-05-29 — see changelog.

  • S1 — Backend next-available-date endpoint + tests (8 tests)
  • S2 — FE state machine + flow scaffold (BookingFlowState + BookingFlowContext + BookingFlowV2 + StepperHeader/Footer)
  • S3 — Step 1 Services + ServiceCard
  • S4 — Step 2 Staff + StaffOption
  • S5 — Step 3 Date+Time + EmptyDayBanner (chain fetch extracted to useChainAvailability)
  • S6 — Step 4 Confirm + Details + BookingSummary
  • S7 — Router switch + deleted BookingPageV2/ServiceItemV2/ChainTimePicker
  • S8 — i18n (publicBooking.stepper 15 keys × en/nb)
  • S9 — E2E tests (public-booking-stepper.spec.ts, 4 pass) + BookingFlowState.test.ts (13 vitest)
  • S10 — Docs sync

Follow-up shipped:

  • Booking draft / shareable cart (?sessionId=, 2026-05-29) — server-side BookingDraft (TTL 7d) thay thế kỳ vọng "sessionStorage backup": F5 + share-link giữ nguyên service+staff+date+time+voucher. No PII. Xem changelog + docs/testing/v2-booking-conflict-scenarios.md. (Cron dọn draft + abandoned-cart remarketing vẫn defer.)

Deferred / follow-up:

  • Morning/Afternoon/Evening slot grouping (flat TimeSlotGrid shipped — sufficient for MVP).
  • jump-to-next-date e2e (seed non-deterministic; covered by backend unit tests).
  • Browser back-button between steps (shipped with history.replaceState + in-app Back chevron; pushState/popstate not wired).
  • Migrate legacy e2e/public-booking.spec.ts (V1 selectors) to stepper nav, or pin to bookingUiVersion='v1'.

Cleanup roadmap (sau khi stepper stable)

Sau ~2-4 tuần V2 stepper stable + ≥80% tenant chuyển bookingUiVersion='v2':

  1. Flip default 'v1' → 'v2' trong PlatformSetting.bookingUiVersion
  2. Sau 1 tháng + 0 issue → xóa V1 (components/BookingPage.tsx, ServiceItem.tsx, etc.)
  3. Xóa flag bookingUiVersion + migration drop column
  4. Remove plan docs (chain-refactor + stepper) → consolidate vào docs/flows/booking-flow.md

Không để code V1+V2 song song quá 3 tháng.