13 — Tenant Booking Web Specification
Surface: Tenant Booking Web (Next.js 14+ App Router, multi-tenant themed) BFF:
bff-tenant-booking-serviceAudience: Guests who have selected a specific property and are completing the booking Runtime: Browser; same bundle as Consumer Meta Web but rendered under tenant theme context
1. Overview
The Tenant Booking Web is a single shared Next.js codebase that renders the booking funnel for every tenant, with each tenant's brand applied at runtime via theme-config-service. The guest reaches this surface either via:
- Handoff from the Consumer Meta Web (J-02) — same browser session, context switch.
- Direct URL (
<tenantSlug>.melmastoon.app/bookormelmastoon.app/<tenantSlug>/book) — deep link or marketing. - OTA/partner redirect (future, R3).
Design constraint: The funnel is constrained — tenants configure toggles and content, but cannot change the funnel's step order, payment architecture, or accessibility properties. This keeps the experience consistent and secure across all tenants.
2. Tech stack
Same as 12-meta-web-specification.md with additions:
| Concern | Choice |
|---|---|
| Theme injection | SSR — CSS variables injected into <head> from bff-tenant-booking-service /bootstrap |
| State — booking draft | Zustand + MMKV-compatible persistence (survives page refresh) |
| Payment | Adyen Web Drop-in (card, Apple/Google Pay, local methods) + Cash-on-Arrival (custom component) |
| Idempotency | X-Idempotency-Key: <ulid> on all state-changing BFF calls |
| Hold timer | React countdown component (BFF-issued hold TTL) |
| Form | React Hook Form + Zod (C7) |
3. Booking funnel — step model
[Room selection] → [Rate plan] → [Guest details] → [Payment] → [Confirmation]
↕ (can go back to any prior step)
Each step is a distinct URL:
/<locale>/<tenantSlug>/book/<propertyId>/rooms
/<locale>/<tenantSlug>/book/<propertyId>/rates?room=<roomTypeId>
/<locale>/<tenantSlug>/book/<propertyId>/guest-details?hold=<holdId>
/<locale>/<tenantSlug>/book/<propertyId>/payment?hold=<holdId>
/<locale>/<tenantSlug>/book/<propertyId>/confirmation?res=<reservationId>
Hold is acquired after room + rate selection and before guest-details:
POST /tenant-booking/v1/holds → holdId + expiresAt
Hold TTL: 15 minutes (configurable per booking-hold-timeout-minutes feature flag). A hold timer countdown renders in the funnel header.
3.1 Step 1 — Room selection
Content:
- Tenant header (logo, nav).
- Property info bar (name, star rating, check-in/out dates from URL).
- Room type cards, each showing: photo, name, capacity, key amenities, rate-from per night, availability.
- "Select" button on each card → moves to rate selection.
- Date / guest adjustment affordance (modify search without leaving funnel — reruns availability query).
Data: GET /tenant-booking/v1/availability?propertyId=&from=&to=&adults=&rooms=.
3.2 Step 2 — Rate plan selection
Content:
- Selected room type summary.
- Rate plan cards: name, description, cancellation policy badge (green=free, amber=partial, red=non-refundable), price per night + total for stay.
- Price breakdown on hover/tap: base rate × nights + taxes.
- Hold acquired on "Select rate":
POST /tenant-booking/v1/holds.
FX display: If guest's browser locale differs from property currency, show "≈ USD X,XXX" alongside AFN. FX snapshot from pricing-service.
3.3 Step 3 — Guest details
Fields (see C7 §5.1 for full field spec):
- Full name, email, phone, nationality.
- Check-in time estimate.
- Special requests (textarea).
- Child ages (if children > 0 and
tenant.booking-flow.child-policyenabled). - Company name / VAT (if
tenant.booking-flow.company-bookingenabled). - Loyalty / promo code (if
tenant.booking-flow.loyalty-codeenabled).
Validation: Zod schema from @ghasi/schemas/booking. Client-side + server-side merge (C7 §3.3).
3.4 Step 4 — Payment
Payment method selection (tenant-configurable set):
- Cash on arrival — shows informational card (no card details required). Guest confirms amount to be paid at property.
- Card (Adyen Drop-in) — PCI-compliant Adyen iframe; tenant's Adyen account configured in
payment-gateway-service. 3DS2 flow if required. - Mobile money (feature flag
booking-mobile-money) — Adyen local method or direct MFS API.
Hold timer — visible countdown from hold expiresAt. On expiry: show ER-05 modal "Hold expired — start over".
FX snapshot — if currency differs from display: "Rate locked at AFN X = USD Y as of HH:MM" with lock icon.
Submit: POST /tenant-booking/v1/reservations (idempotency-keyed). On success → confirmation step.
3.5 Step 5 — Confirmation
Content:
- Success illustration (animated check).
- Reservation reference (large, copyable).
- Booking summary: dates, room, rate, total, payment method.
- Pre-arrival info: check-in time, property address, directions link.
- "Add to calendar" (ICS download).
- "Download receipt" (PD-01 PDF via
reporting-service). - "View your booking" → guest account bookings page (requires login or guest token).
- "Book another room" → back to property detail.
Email: notification-service sends EM-01 confirmation email immediately after reservation.confirmed.v1 event.
4. Tenant theming
At bootstrap, the BFF returns the tenant's ThemeVersion as CSS variables injected into <head>:
<style>
:root {
--color-primary: #2563eb;
--color-primaryHover: #1d4ed8;
/* ... all tokens ... */
--font-family-heading: 'Amiri', serif;
/* ... */
}
</style>
The funnel components use only token variables — no hardcoded colors. Theming is transparent to component code.
Loading state during bootstrap: Show a skeleton funnel with neutral tokens until the tenant theme resolves (< 200 ms in normal conditions). Prevents flash of wrong brand.
5. Multi-currency / FX handling
- Rates are stored and settled in
tenant.billingCurrency(e.g., AFN). - Display currency may differ (guest's browser locale preference).
- FX rates are fetched at quote time from
pricing-serviceand snapshotted at hold acquisition. - The snapshot AFN→display-currency rate is locked for the duration of the hold.
- On confirmation, the receipt shows both AFN and the guest's display currency.
- The booking contract is always in AFN (or tenant billing currency); the display currency is informational only.
6. Offline and degraded behavior
| Scenario | Behavior |
|---|---|
| Network lost before step 3 | Block advance; show C3 ER-07 offline banner; last-fetched availability cached |
| Network lost during payment intent creation | Toast + retry button; do not re-submit without idempotency key |
| Adyen Drop-in fails to load | Show fallback "Cash on arrival" option + inline banner "Card payment temporarily unavailable" |
| Hold expires offline | On reconnect: re-check hold validity; if expired, show ER-05 |
7. Funnel analytics
Booking funnel events from C1:
| Event | When |
|---|---|
mel.booking.funnel.step_viewed | Each step mounts |
mel.booking.funnel.step_completed | User advances |
mel.booking.funnel.step_abandoned | User exits without completing |
mel.booking.payment.3ds_started | 3DS challenge opens |
mel.booking.confirmation.shown | Confirmation renders |
Funnel conversion = steps 1 → 5 completion rate. Target: > 60% of holds confirmed.
8. Performance budget
See ../common/09-non-functional-requirements.md §1.1 for canonical web targets.
Additional booking-specific targets:
- Bootstrap (theme + availability) to first step rendered: < 1.2 s p95
- Quote response: < 800 ms p95
- Hold acquisition: < 600 ms p95
- Payment confirmation: < 3 s p95
9. Accessibility
- Funnel step indicator:
aria-label="Step N of 5: <name>". - Hold timer:
aria-live="polite"region announces "5 minutes remaining". - Payment iframe: accessible label for Adyen iframe.
- Error states: all errors
role="alert". - Progress is not lost on Escape (Escape is for dialogs only).
10. Open Questions
- Should step URLs be crawled by search engines? Leaning no (funnel steps are session-bound; robot.txt disallow
/book/). - For cash-on-arrival: should we show a "deposit required" field if the tenant has configured a minimum deposit?
- Hold TTL: 15 minutes is the default — does this need to be shorter for high-demand periods (e.g., Nowruz)?