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 itemstaff → time: nếubookingMode='assigned_only'→ mọi item phải córesourceId. Nếuallow_unassigned→ step có thể skip nếu khách muốn "Any" hết.time → confirm: cầnselectedStartTime != 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đếnfromDate + 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]).
Deep-link handling
| 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 intocomponents-v2/shared/ChainPreview.tsx— drop intocomponents-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 3ServicePicker.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ởiBookingFlowV2+ 4 step componentsServiceItemV2.tsx— split thànhServiceCard(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-dateendpoint + 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.stepper15 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-sideBookingDraft(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-datee2e (seed non-deterministic; covered by backend unit tests).- Browser back-button between steps (shipped with
history.replaceState+ in-app Back chevron;pushState/popstatenot wired). - Migrate legacy
e2e/public-booking.spec.ts(V1 selectors) to stepper nav, or pin tobookingUiVersion='v1'.
Cleanup roadmap (sau khi stepper stable)
Sau ~2-4 tuần V2 stepper stable + ≥80% tenant chuyển bookingUiVersion='v2':
- Flip default
'v1' → 'v2'trongPlatformSetting.bookingUiVersion - Sau 1 tháng + 0 issue → xóa V1 (
components/BookingPage.tsx,ServiceItem.tsx, etc.) - Xóa flag
bookingUiVersion+ migration drop column - 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.