Customer Booking — Chain Refactor (industry-standard)
Status: 🚧 Chưa bắt đầu — scoped 2026-05-28
Mục tiêu: Đại tu UX khách hàng đặt multi-service: bỏ per-service time picker, dùng 1 start time duy nhất, service tự nối tiếp sequential. Ship dưới dạng v2 song song với v1, switch qua tenant setting (super-admin only).
Effort: ~4 ngày dev (thêm 0.5d cho setting + migration + super-admin UI; admin drawer giữ nguyên)
Liên quan: docs/flows/booking-flow.md, backend BookingItem.startTime nullable đã sẵn sàng từ multi-service refactor.
Motivation
Vấn đề user phản ánh (2026-05-28):
"Với mỗi service, khách lại phải tự chọn thời gian. Trong khi bình thường thì khách sẽ chỉ chọn thời điểm đến (booking đầu tiên), còn lại các service khác sẽ tuần tự lần lượt."
Hiện trạng (lệch pha FE/BE):
- Backend đã sẵn sàng:
BookingItem.startTimenullable; null = sequential từBooking.startTime(hoặc end của item trước).AvailabilityServiceđã hiểu cả parent + per-item resource conflict. - Frontend lệch: Mỗi
ServiceItem(booking-web/src/app/(customer)/b/[slug]/book/components/ServiceItem.tsx) cóTimeSlotGridriêng → khách pick time N lần cho N service. - Test gap: E2E
1.2 multi-servicechỉ assert tổng tiền, không cover time flow.1.3 unassigned bookingđang.fixme().
Benchmark: Treatwell, Booksy, Vagaro — tất cả dùng pattern 1 start time + sequential.
Decisions đã chốt
| Topic | Decision | Note |
|---|---|---|
| Gap giữa service | Strict sequential, không gap | Service B bắt đầu ngay sau A. 95% salon dùng pattern này. |
| Per-service staff | Giữ per-service | Default "Bất kỳ" (resourceId = null). Khách override từng service nếu muốn. |
| Re-validate khi đổi service | Auto re-validate, giữ slot nếu vẫn fit | Refetch chain availability; nếu selectedStartTime vẫn trong list mới → keep; else reset + toast. |
| Admin drawer | Giữ nguyên (per-item time) | Out of scope sprint này. Admin cần flexibility cao. |
allowDoubleBooking |
Backend skip conflict, FE render full | Tenant setting bật → backend trả tất cả slot trong business hours (chỉ check lead time + closure). FE không decide. |
| Versioning | PlatformSetting singleton bookingUiVersion: 'v1' | 'v2' |
Global flag — apply cho toàn hệ thống, không per-tenant. Super-admin toggle qua radio group ở /admin/superadmin/settings (tab Branding). Default 'v1' cho cả existing + new install. Flip 'v2' khi UI chain ổn định. |
Kiến trúc
Customer page layout mới (/b/[slug]/book)
Step 1: Service picker (multi-add)
▸ Cắt tóc · 45' · 200k · [Staff: Bất kỳ ▾]
▸ Cạo râu · 20' · 80k · [Staff: Bất kỳ ▾]
[+ Thêm dịch vụ]
Tổng: 65 phút · 280k
Step 2: Chọn ngày (DateStrip giữ nguyên)
Step 3: Chọn giờ bắt đầu (1 TimeSlotGrid, slot cho TOÀN CHAIN)
10:00 10:15 10:30 10:45 11:00 ...
Step 4: Preview timeline
10:00 → Cắt tóc · Lan · 45'
10:45 → Cạo râu · Bất kỳ · 20'
11:05 Kết thúc
Step 5: Thông tin khách + Submit
State shape mới
interface BookingDraft {
items: Array<{
service: PublicService;
resourceId: string | null; // null = "Bất kỳ"
// KHÔNG còn selectedSlot per item
}>;
date: string;
selectedStartTime: string | null; // ← single chain start
}
Submit payload (backward-compat)
createBooking(slug, {
startTime: draft.selectedStartTime,
items: draft.items.map(it => ({
serviceId: it.service.id,
...(it.resourceId ? { resourceId: it.resourceId } : {}),
// KHÔNG gửi startTime per item → backend tự sequential
})),
// customer details...
});
File structure mới
booking-web/src/app/(customer)/b/[slug]/book/
├── page.tsx ← server: fetch tenant settings, decide v1/v2
├── components/ ← V1 (giữ nguyên, không sửa)
│ ├── BookingPage.tsx
│ ├── ServiceItem.tsx
│ ├── ServicePicker.tsx
│ ├── DateStrip.tsx
│ ├── TimeSlotGrid.tsx
│ └── StickyBookingHeader.tsx
└── components-v2/ ← V2 (mới)
├── BookingPageV2.tsx
├── ServiceItemV2.tsx (bỏ TimeSlotGrid, giữ staff dropdown)
├── ChainTimePicker.tsx (single time picker cho cả chain)
├── ChainPreview.tsx (timeline preview)
└── (reuse) DateStrip, ServicePicker, StickyBookingHeader từ components/
Switch logic (page.tsx):
const tenant = await fetchPublicTenant(slug);
const version = tenant.settings.bookingUiVersion; // 'v1' | 'v2'
return version === 'v2'
? <BookingPageV2 {...props} />
: <BookingPage {...props} />;
Public tenant API (GET /public/:slug) phải expose bookingUiVersion để FE biết render gì.
Phase breakdown
Phase 0 — PlatformSetting global flag + super-admin UI ✅ shipped 2026-05-29
File:
booking-api/prisma/schema.prisma— thêmbookingUiVersion String @default("v1")vào modelPlatformSettingbooking-api/prisma/migrations/20260528195501_add_platform_booking_ui_version/migration.sql— ALTER + CHECK constraintbooking-api/src/core/platform-settings/dto/platform-settings.dto.ts—UpdatePlatformSettingsDto+PlatformSettingsAdminDto+PlatformSettingsPublicDtobooking-api/src/core/platform-settings/platform-settings.service.ts—parseBookingUiVersion()helper, wire vàogetAdmin/getPublic/updatebooking-api/src/core/platform-settings/platform-settings.service.spec.ts+controller.spec.ts— test fixtures + flip-to-v2 testbooking-web/src/components/superadmin/settings/BrandingSection.tsx— radio group cuối form (Section "Booking page version")booking-web/messages/{en,nb}.json— i18n strings dướisuperadmin.settings.bookingUiVersion.*booking-web/src/lib/platform-settings-server.ts—DEFAULT_FALLBACKthêmbookingUiVersion: 'v1'
Migration:
ALTER TABLE "platform_settings"
ADD COLUMN "booking_ui_version" TEXT NOT NULL DEFAULT 'v1';
ALTER TABLE "platform_settings"
ADD CONSTRAINT "platform_settings_booking_ui_version_check"
CHECK ("booking_ui_version" IN ('v1', 'v2'));
Super-admin UI:
- Radio group ở
/admin/superadmin/settings(tab Branding, Section "Booking page version" — cuối form, trước Save) - 2 options: V1 (Legacy) / V2 (Chain industry-standard)
- Submit cùng PATCH
/superadmin/platform-settingsvới các field branding khác
Public API:
GET /public/platform-settingsđã có sẵn — chỉ thêm fieldbookingUiVersionvào response shape- FE booking page router đọc field này để switch component
Verification:
- Backend: 2125/2127 tests pass (2 skipped pre-existing), lint 0 errors
- Web: lint 0 errors, build pass
- GitNexus impact MEDIUM, no high-risk paths
Estimate: 0.5d (actual ~0.5d)
Phase 1 — Backend getChainedSlots
File:
booking-api/src/core/booking/availability.service.ts(+1 method)booking-api/src/core/booking/availability.controller.ts(+1 endpoint)booking-api/src/core/booking/dto/availability-chain.dto.ts(new)
Endpoint: GET /public/:slug/availability/chain
Query:
date=2026-05-30
items[0][serviceId]=svc1&items[0][resourceId]=stf1
items[1][serviceId]=svc2
Response:
{
"success": true,
"data": {
"totalDuration": 65,
"slots": [
{ "startTime": "2026-05-30T08:00:00.000Z", "endTime": "2026-05-30T09:05:00.000Z" }
]
}
}
Logic:
- Resolve services + durations →
totalDuration = sum(durations) - Generate candidate startTimes theo business hours - lead time - closure (reuse helper hiện có)
- If
shouldSkipConflict(tenant.settings)→ return all candidates (allowDoubleBookingcase) - Else: chain walker — với mỗi candidate slot:
- Walk N legs:
legStart_i = candidate + sum(durations[0..i-1]) - Cho mỗi leg, check
items[i].resourceId(specific staff hoặc "any of skilled") free trong[legStart_i, legStart_i + duration_i] - Reuse
bookingMapđã tính 1 lần (per-resource intervals)
- Walk N legs:
- Slot pass nếu mọi leg đều free
Tests:
- Unit:
availability.service.spec.tsthêm describegetChainedSlots- Happy path: 2 services, cùng staff, slot fit cả 2 leg
- Mix staff: leg 1 staff A, leg 2 staff B, slot phải free cả 2
- "Any" staff: leg 2 ko chỉ định, pick from pool of skilled
- Slot fit A nhưng không fit B (B đang busy) → loại
allowDoubleBooking = true→ trả full grid bất chấp conflict- Closure / lead time / business hours edge
Estimate: 0.5d
Phase 2 — FE API client + types
File:
booking-web/src/lib/booking-api.ts(+1 functionfetchChainAvailability)booking-web/src/types/api.generated.ts(regen sau khi BE xong)
Workflow:
# Trong booking-api:
yarn generate:openapi
# Trong booking-web:
yarn generate:types
Estimate: 1h
Phase 3 — FE tạo V2 components (song song V1)
File mới (folder components-v2/):
booking-web/src/app/(customer)/b/[slug]/book/components-v2/BookingPageV2.tsxbooking-web/src/app/(customer)/b/[slug]/book/components-v2/ServiceItemV2.tsx(bỏTimeSlotGrid, giữ staff dropdown + duration)booking-web/src/app/(customer)/b/[slug]/book/components-v2/ChainTimePicker.tsxbooking-web/src/app/(customer)/b/[slug]/book/components-v2/ChainPreview.tsx
File sửa (router):
booking-web/src/app/(customer)/b/[slug]/book/page.tsx— đọctenant.settings.bookingUiVersion, switch component
File KHÔNG đụng (V1):
booking-web/src/app/(customer)/b/[slug]/book/components/**— giữ y nguyên
Changes:
BookingPage.tsx:
- Bỏ
selectedSlotkhỏiBookingItemData, thêm top-levelselectedStartTime - Thêm
useQuerychoavailability/chainvớiqueryKey: ['avail-chain', slug, date, itemsKey] enabled: items.length > 0 && date != null- Validate trong handler: nếu
selectedStartTimekhông có trong slots mới → reset
ServiceItem.tsx:
- Giữ: service info, staff
SearchSelect, remove button - Bỏ:
TimeSlotGrid,fetchAvailabilityper item
ChainTimePicker.tsx:
- Render
TimeSlotGridcủa chain - Empty state: "Chưa có dịch vụ — vui lòng chọn ít nhất 1 dịch vụ"
- Loading / error states
ChainPreview.tsx:
- Hiển thị timeline sau khi pick time:
10:00 → Cắt tóc · Lan · 45' 10:45 → Cạo râu · Bất kỳ · 20' 11:05 Kết thúc - Reuse
formatTime,formatDurationtừlib/utils.ts+lib/formatters.ts
Estimate: 1.5d
Phase 4 — Auto re-validate + edge cases
Logic:
// Khi items / date / staff thay đổi
const chainQuery = useQuery({
queryKey: ['avail-chain', slug, date, itemsKey],
queryFn: () => fetchChainAvailability(slug, date, items),
enabled: items.length > 0,
});
useEffect(() => {
// React Compiler không thích setState trong effect → dùng derived state pattern
// Nếu chainQuery.data thay đổi và selectedStartTime không còn trong slots → reset
}, [chainQuery.data]);
→ Đúng hơn là derived state: tính isSelectedSlotValid = chainQuery.data?.slots.some(s => s.startTime === selectedStartTime). Nếu user submit mà invalid → block + toast.
Đồng thời, mỗi lần onItemsChange:
const handleItemsChange = (next) => {
setItems(next);
// Không reset selectedStartTime ngay; chờ chainQuery refetch → derived check.
// Nếu invalid → toast trong onSuccess của chainQuery? KHÔNG, dùng useMemo + previous ref.
};
Implementation chi tiết: dùng useEffect với guard kiểm tra cũ-mới của chainQuery.data (vẫn compatible nếu setState ở event chứ không phải render).
Hoặc gọn hơn: Toast inline ngay khi user click bị-disabled-slot. Không auto-reset, user thấy slot grey/disabled → tự click slot khác.
→ Quyết định: chọn pattern slot grey-out: chain query trả slots, slot nào không trong list → render disabled. Không cần "auto reset" logic — UI tự thể hiện trạng thái.
Edge cases:
- Items empty →
ChainTimePickershow empty state - Date đổi → chainQuery refetch tự động (queryKey change)
- Staff đổi giữa các service → chainQuery refetch tự động
- Khách add service AFTER pick time → slot có thể không còn fit → grey out
Estimate: 0.5d
Phase 5 — E2E tests
File: booking-web/e2e/public-booking.spec.ts
Setup mới: Test fixture phải set tenant.settings.bookingUiVersion = 'v2' qua seed/API trước khi chạy v2 tests.
Tests V1 (giữ nguyên):
1.1 single service happy path1.2 multi-service totals aggregate— không sửa, vẫn assert v1 behavior
Tests V2 (mới):
2.1 v2 chain multi-service: 2 services → 1 time picker → sequential preview2.2 v2 chain mix staff: service A staff X, service B staff Y → slot phải free cả 22.3 v2 chain re-validate: pick time → add service → slot vanish hoặc preview update2.4 v2 allowDoubleBooking ON: slot grid hiển thị cả slot đang conflict2.5 version switch: tenant.bookingUiVersion=v2 → render BookingPageV2; =v1 → render BookingPage
Unblock 1.3 unassigned booking nếu có thời gian (out of scope sprint này nếu phức tạp).
Estimate: 0.5d
Phase 6 — Docs
File:
docs/flows/booking-flow.md— thêm section "Multi-service customer flow" với sequence diagram chaindocs/progress/features.md— flip Multi-service booking UX → done datedocs/progress/changelog.md— entry mới
Estimate: 1h
Rủi ro & mitigation
| Rủi ro | Mitigation |
|---|---|
getChainedSlots chậm khi N × M lớn |
Reuse bookingMap (1 query/ngày). N ≈ 96 slots, M ≤ 5 services → 480 ops, không vấn đề. React Query cache 30s. |
allowDoubleBooking bật + staff khác cho mỗi service → vẫn check business hours? |
Có. shouldSkipConflict chỉ skip booking overlap; business hours / schedule / closure vẫn enforce. |
Backward compat link ?serviceId=xxx&from=yyy |
Logic resolve giữ; chỉ khác pre-selected items không có selectedSlot. Khách pick time 1 lần. |
| Khách cố tình gọi API với per-item startTime | Backend không thay đổi — DTO vẫn accept. BookingService.create validation cũ vẫn run. Customer page chỉ không TẠO ra payload kiểu đó. |
| Admin drawer vẫn chạy code cũ | Tách biệt — admin gọi /bookings/availability (per-service), customer gọi /public/:slug/availability/chain. Không đụng nhau. |
Out of scope (defer)
- Admin drawer refactor — giữ per-item time, sau này nếu muốn đồng bộ thì làm sprint riêng
- Reorder services UI — Nhiều platform không cho khách reorder; admin có thể qua drag handle (sprint sau)
- Gap giữa services (multi-stage with intentional break) — không có salon nào yêu cầu, defer indefinitely
- Service dependencies (service B yêu cầu service A trước đó) — out of scope hoàn toàn
Tracking
- P0 — PlatformSetting global flag
bookingUiVersion+ super-admin radio group (shipped 2026-05-29) - P1 — Backend
getChainedSlots+ endpoint + tests - P2 — FE API client + types regen
- P3 — FE V2 components song song V1 (
components-v2/) + router switch - P4 — Auto re-validate edge cases (V2 only)
- P5 — E2E tests (V2 happy path + version switch)
- P6 — Docs sync (booking-flow.md + features.md + changelog.md)
V1 deprecation strategy (sau khi v2 stable)
Pattern feature flag chuẩn:
- V2 ship + super-admin enable từng tenant rollout
- Sau ~2-4 tuần stable + ≥80% tenant đã chuyển → flip default
'v1'→'v2'trongDEFAULT_TENANT_SETTINGS - Tenant còn lại tự rollout
- Sau khi 100% tenant trên v2 + ≥1 tháng không issue → xoá V1 (
components/folder) + fieldbookingUiVersion+ migration drop field - Cập nhật docs + remove plan doc này
Không để code v1/v2 song song quá 3 tháng — dead code rot nhanh.