01 — Web & Mobile Specification
Scope: Consumer meta-search web app, tenant-branded booking web app, and the consumer mobile app (React Native). The Electron desktop backoffice is out of scope for this document and lives at
desktop/06-desktop-app-specification.md.Companions:
README·02-theming-and-tenant-config.md·03-design-system.md·docs/05-api-design.md·docs/02-enterprise-architecture.mdBFF contracts:
bff-consumer-service·bff-tenant-booking-service
1. Scope
This specification covers the three guest-facing surfaces of the Ghasi Melmastoon platform:
| # | Surface | Form factor | Stack | Source location |
|---|---|---|---|---|
| 1 | Consumer Meta Web | Browser (desktop + mobile web) | Next.js 14 (App Router), TypeScript strict, TailwindCSS, React Query, Zustand, Leaflet, PWA | apps/web-meta |
| 2 | Tenant Booking Web | Browser (desktop + mobile web), per-tenant subdomain or path | Next.js 14 (App Router), TypeScript strict, TailwindCSS, React Query, Zustand | apps/web-tenant-booking |
| 3 | Consumer Mobile | iOS + Android (single binary via Expo) | React Native 0.74+, TypeScript strict, Expo, React Navigation, React Query, Zustand, MMKV, Reanimated 3 | apps/mobile |
Out of scope here: the Electron desktop backoffice — see desktop/06-desktop-app-specification.md. Anything desktop-specific in this document is a cross-reference, not a duplicate.
2. Surfaces overview
The three surfaces serve two distinct audiences in two distinct contexts but share design tokens, i18n, and BFF clients. The boundary that matters is whether the user is inside a tenant context yet — the consumer meta surfaces are pre-tenant; the booking web surface is post-handoff (inside one tenant); the mobile app crosses both.
| Surface | Audience | Context | Primary KPIs | Primary BFF | Secondary BFF |
|---|---|---|---|---|---|
| Consumer Meta Web | Travelers exploring across tenants — discovery, comparison, shortlisting | Pre-tenant. No tenant theme; uses platform-default tokens. | Meta-conversion (search → handoff); search-to-detail click-through; wishlist add-rate; LCP / INP | bff-consumer-service | — |
| Tenant Booking Web | Travelers committed to one tenant after a handoff (or arriving direct via the tenant's URL) | In-tenant. Theme + flow loaded at bootstrap from bff-tenant-booking-service | Funnel completion (bootstrap → confirmation); time-to-confirm; payment-success rate; abandonment-step | bff-tenant-booking-service | — |
| Consumer Mobile | Travelers on phones, browsing and booking and managing post-stay artifacts | Both. App switches its bootstrap when navigating from list/map view into a tenant detail / booking | Browse-to-book conversion; push-notification opt-in; biometric login adoption; cold-start time | bff-consumer-service (browse) | bff-tenant-booking-service (book + post-stay) |
ASCII overview of how a user moves between them:
┌──────────────────────────┐
Consumer Meta Web │ bff-consumer-service │
(apps/web-meta) │ /search /handoff /... │
Default tokens, no tenant ─► │
└──────────────┬───────────┘
│ POST /handoff/{tenant}/{property}
▼
┌─────────────────────────────────┐
│ bff-tenant-booking-service │
│ /bootstrap /quote /hold /... │
│ Tenant-themed bootstrap │
└────────────────┬────────────────┘
▼
Tenant Booking Web (apps/web-tenant-booking) → Confirmation
Consumer Mobile (apps/mobile)
Browse tab ──────────────────► bff-consumer-service
Book tab ──────────────────► bff-tenant-booking-service
Manage tab ──────────────────► bff-tenant-booking-service (/confirmation/{id})
3. Tech stack — web
| Concern | Choice | Why |
|---|---|---|
| Framework | Next.js 14+ (App Router, Server Components default; 'use client' for interactive islands) | SSR for SEO + first-paint on slow networks; streaming; nested layouts for theme injection |
| Language | TypeScript strict (noImplicitAny, strictNullChecks, exactOptionalPropertyTypes, noUncheckedIndexedAccess) | Single source of error surface; lines up with shared @ghasi/contracts-melmastoon |
| Styling | TailwindCSS with a tailwind.preset.ts from @ghasi/ui-melmastoon; CSS variables back every utility | Utility velocity + token enforcement; no raw hex in app code (lint-blocked) |
| Server state | TanStack Query (React Query) v5 with persisted query cache (IndexedDB on web) | Aggressive cache reuse on flaky networks; staleTime per resource matches BFF cache directives |
| Client state | Zustand for ephemeral / cross-route state (booking draft, filter UI) | Tiny, no provider tree, persist middleware to IndexedDB for the booking draft |
| URL state | nuqs (or next-usequerystate) for filter/sort/pagination — shareable URLs | Search results must be deep-linkable for SEO and copy-paste |
| i18n | next-intl with ICU MessageFormat; per-locale message bundles | RTL + plurals + gender + complex date formats |
| Forms | react-hook-form + zod resolver; same zod schemas reused on the BFF | One schema per shape, validated client + server |
| Maps | Leaflet (no Google Maps tiles by default — privacy + cost). Tile sources: MapTiler (default) with OSM fallback | Free / low-cost tiles; can self-host later |
| Animation | Framer Motion, sparingly (motion tokens from the design system) | Page transitions, modal/drawer entry, skeleton fades; reduced-motion respected |
| PWA | next-pwa (or hand-written service worker) — caches static, tenant bootstrap responses, last viewed property pages | Offline browse for the meta surface and post-stay screens of the booking surface |
| Image optimization | next/image with the loader pointed at GCP Cloud CDN (signed URLs from file-storage-service) | WebP / AVIF / responsive srcset, blur-placeholder LQIP |
| HTTP client | Built-in fetch (with signal: AbortSignal everywhere) wrapped by typed BFF clients | Edge-runtime compatible; trivial cancellation |
| Testing | Vitest (unit), Testing Library (component), Playwright (e2e), Lighthouse-CI (perf budgets), Chromatic (visual regression) | See §16 |
| Observability | OpenTelemetry web SDK → Cloud Trace; basic RUM via web-vitals → BFF telemetry endpoints | Traces tie to BFF traceparent; Core Web Vitals in dashboards |
| Build | Next.js + Turborepo (incremental, remote-cached) | Monorepo speed |
| Node | 20 LTS | Match BFF + desktop main process |
4. Tech stack — mobile
| Concern | Choice | Why |
|---|---|---|
| Framework | React Native 0.74+ with the New Architecture (Fabric + TurboModules) enabled | Modern bridgeless RN; Hermes baseline |
| Tooling shell | Expo (managed → bare via prebuild when a custom native module forces it) | OTA updates via EAS Update for non-binary changes; faster dev cycle; easy Detox + EAS Build |
| Language | TypeScript strict (same tsconfig posture as web) | Shared @ghasi/contracts-melmastoon types |
| JS engine | Hermes | Smaller bundle, faster startup, better memory profile on low-end Android |
| Navigation | React Navigation v7 (native stack + bottom tabs + modal stack) | RTL-aware out of the box; deep linking with typed routes |
| Server state | React Query v5 with react-native-mmkv-backed query persister | Same mental model as web; offline reads of last-known data |
| Client state | Zustand with MMKV persist middleware | Booking draft survives app backgrounding |
| Local storage | react-native-mmkv (synchronous, fast, encrypted with platform keystore on iOS/Android) | Suitable for last 50 search results, last 20 viewed properties, booking draft, session blob |
| Animation | react-native-reanimated v3 + react-native-gesture-handler (UI thread animations) | 60 fps on low-end Android |
| Lists | @shopify/flash-list (recycler-backed) | Smooth long lists for search results |
| i18n | expo-localization + the same next-intl-compatible message bundles via a shared formatter (@ghasi/i18n) | Locale parity with web |
| Push | expo-notifications (APNs + FCM) for booking confirmations, reminders, mobile-key delivery, special offers | One push surface across both stores |
| Biometric | expo-local-authentication (Face ID / Touch ID / Android BiometricPrompt) | Quick re-auth for the manage-bookings tab |
| Maps | react-native-maps (Apple Maps on iOS, Google Maps on Android) — UI parity with Leaflet web list | Native map perf; identical pin schema |
| Forms | react-hook-form + zod (parity with web) | Same schemas |
| Network | fetch polyfilled by RN; typed BFF clients shared with web | One client package |
| Storage encryption | iOS Keychain + Android Keystore for tokens; MMKV with encryption key from Keychain/Keystore | Secrets not stored in plaintext |
| Testing | Jest (unit), React Native Testing Library (component), Detox (e2e), Reassure (perf budgets) | See §16 |
| Observability | @sentry/react-native (errors + performance); OTel for trace continuation with BFF | Crashes + traces |
| Distribution | EAS Build + EAS Submit (TestFlight + Play Console internal track on every main); EAS Update for OTA JS-only patches (semver-gated) | Fast iteration without store review for non-binary fixes |
Single codebase. One React Native project produces both iOS and Android.
Platform.selectis allowed for behavioral differences (Maps provider, biometric prompt) and forbidden for visual differences (those go through tokens).
5. Repository layout
The implementation lives in a separate monorepo named ghasi-melmastoon (this docs repo is ghasi-e-documentation). Layout:
ghasi-melmastoon/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
├── tsconfig.base.json
├── apps/
│ ├── web-meta/ # Next.js 14 — Consumer Meta Web
│ ├── web-tenant-booking/ # Next.js 14 — Tenant Booking Web
│ ├── mobile/ # React Native (Expo) — Consumer Mobile
│ └── desktop-backoffice/ # Electron + Vite + React (separate spec)
├── packages/
│ ├── ui-melmastoon/ # @ghasi/ui-melmastoon — design system
│ ├── i18n/ # @ghasi/i18n — message bundles + ICU runtime
│ ├── api-clients/ # @ghasi/api-clients — typed BFF clients (OpenAPI-generated)
│ ├── icons/ # @ghasi/icons — line/filled SVGs + RN equivalents
│ ├── feature-meta-search/ # cross-app feature: search, filters, map↔list sync
│ ├── feature-booking-flow/ # cross-app feature: booking funnel state machine
│ ├── contracts-melmastoon/ # branded types, ULIDs, AIProvenance, MediaRef, Locale
│ ├── domain-primitives/ # tenant-agnostic primitives
│ ├── eslint-config/ # shared ESLint rules (incl. token-only)
│ └── tsconfig/ # shared tsconfigs
├── tooling/
│ ├── changesets/
│ └── ci/ # Lighthouse-CI configs, Reassure thresholds
└── .github/workflows/
Tooling: pnpm workspaces, Turborepo (with Vercel Remote Cache or self-hosted), Changesets for semver of internal packages.
Module boundaries: apps depend on packages; packages depend only on packages with a strictly lower position in a layered DAG (ui-melmastoon → icons + i18n; feature-* → ui-melmastoon + api-clients). Apps never import from each other. Enforced by eslint-plugin-boundaries.
6. Routing
6.1 Consumer Meta Web — sitemap
| Route | Page | BFF call(s) | Cacheable |
|---|---|---|---|
/ | Home — search bar + popular destinations | GET /facets | yes (60 s edge) |
/search | Results — list + toggleable map | POST /search, POST /search/map | yes (15 s edge, query-keyed) |
/hotels/[propertyId] | Hotel detail | GET /hotels/{id} + GET /hotels/{id}/availability | yes (60 s edge) |
/wishlist | Saved properties | GET /wishlist | no (cookie-keyed) |
/handoff | Internal route (server action) → mints handoff then 302 to tenant URL | POST /handoff/{tenant}/{property} | no |
/legal/{kind} | Privacy / Terms / Cookies | static | yes |
/sitemap.xml, /robots.txt | SEO surface | platform-generated | yes |
/* (catch-all) | 404 | — | yes |
6.2 Tenant Booking Web — sitemap
Hosted under https://<tenantSlug>.melmastoon.app/ (preferred) or https://melmastoon.app/t/<tenantSlug>/ (fallback).
| Route | Page | BFF call(s) | Notes |
|---|---|---|---|
/ | Home (preset-driven: hero / about / amenities / featured rooms) | GET /bootstrap + content blocks | SSR; theme injected at SSR time |
/rooms | Rooms catalogue | GET /properties/{id}/rooms | SSR |
/rooms/[roomTypeId] | Room detail | GET /properties/{id}/rooms (filtered) | SSR |
/book | Booking funnel root | GET /bootstrap + GET /availability | client-driven steps |
/book?h=<handoff-token> | Same as /book but auto-consumes POST /handoff/consume then routes into the funnel pre-populated | POST /handoff/consume, POST /quote, POST /hold | |
/booking/confirmation/[reservationId] | Confirmation | GET /confirmation/{id} | |
/policies/[kind] | Tenant policy view | GET /policies | |
/contact | Contact block | content-block-driven | |
/legal/{kind} | Tenant legal pages | content-block-driven | |
/manage | (Phase 2) authenticated guest area | requires guest sign-in |
6.3 Consumer Mobile — screen graph
RootStack
├── BootstrapStack
│ ├── SplashScreen
│ └── PermissionsScreen (notifications, location)
├── MainTabs
│ ├── DiscoverTab
│ │ ├── HomeScreen
│ │ ├── SearchScreen ── opens MapModal
│ │ ├── PropertyDetailScreen ── CTA: "Book with this hotel"
│ │ └── WishlistScreen
│ ├── TripsTab
│ │ ├── UpcomingScreen
│ │ ├── PastScreen
│ │ └── TripDetailScreen
│ └── ProfileTab
│ ├── ProfileScreen
│ ├── SettingsScreen (locale, currency, notifications, biometric)
│ └── HelpScreen
└── BookingStack (modal stack, presented over MainTabs)
├── BootstrapTenantScreen ── consumes handoff or tenant deep-link
├── DatesScreen ── (skipped if dates known)
├── RoomChoiceScreen
├── UpgradesScreen
├── GuestDetailsScreen
├── PaymentScreen
├── PaymentReturnScreen ── handles redirect-back via app links
└── ConfirmationScreen
6.4 Tenant routing strategies
| Strategy | URL example | Pros | Cons | When to use |
|---|---|---|---|---|
| Subdomain (preferred) | kabul-grand-hotel.melmastoon.app | Cleanest brand; per-tenant CSP / cookie domain; SEO independence | Wildcard cert; DNS automation needed | Default for every tenant |
| Path prefix (fallback) | melmastoon.app/t/kabul-grand-hotel | No DNS; simpler in early environments | Brand dilution; cookie scoping subtler | Pre-prod, free-tier tenants, demo |
| Custom domain (Phase 2) | book.kabulgrand.af | Tenant fully white-labeled | Per-tenant cert, CAA records, ops cost | Paid tier; verified-domain workflow |
Resolution always happens server-side in middleware: extract slug → call bff-tenant-booking-service /bootstrap → inject theme tokens into the SSR response. Tenants in suspended state return a platform-rendered "temporarily unavailable" page (no theme leak).
7. State model
| Layer | Owner | Persistence | Examples |
|---|---|---|---|
| Server state | React Query | In-memory + IndexedDB (web) / MMKV (mobile) — query-key based persister | searchHotels(query), getHotel(id), getBootstrap(tenant), getQuote(quoteId) |
| Client state | Zustand | IndexedDB (web) / MMKV (mobile) for the booking-draft slice; in-memory for UI ephemera | Booking-draft (room, rate, dates, guests, special requests, payment selection); filter-panel-open; map-vs-list toggle |
| URL state | nuqs (web) / React Navigation params (mobile) | URL itself | Search filters, sort key, pagination cursor, map bounds — must be shareable |
| Session state | BFF + cookie | Server-side (Memorystore) keyed by gms_id (consumer) or tnt_id (tenant) | Locale preference, currency preference, wishlist (anonymous), recently viewed |
7.1 React Query key conventions
// Per BFF, namespaced with v1 to allow contract evolution
queryKey: ['consumer.v1', 'search', sanitizedQuery]
queryKey: ['consumer.v1', 'hotel', propertyId]
queryKey: ['consumer.v1', 'hotel', propertyId, 'availability', { from, to, adults, rooms }]
queryKey: ['consumer.v1', 'wishlist']
queryKey: ['tenant.v1', tenantSlug, 'bootstrap']
queryKey: ['tenant.v1', tenantSlug, 'availability', { propertyId, checkIn, checkOut, ... }]
queryKey: ['tenant.v1', tenantSlug, 'draft', draftId]
staleTime and gcTime mirror the BFF's Cache-Control directives. Mutations invalidate the minimal set: addToWishlist → ['consumer.v1','wishlist']; patchDraft → ['tenant.v1', slug, 'draft', draftId] only.
7.2 Booking-draft state machine (Zustand)
type BookingDraftState =
| { kind: 'idle' }
| { kind: 'searching'; criteria: SearchCriteria }
| { kind: 'selectingRoom'; tenantSlug: string; propertyId: string; criteria: SearchCriteria }
| { kind: 'quoting'; quoteId: string; ... }
| { kind: 'holding'; draftId: string; reservationId: string; holdExpiresAt: ISODate; ... }
| { kind: 'collectingDetails'; draftId: string; guest: GuestForm; ... }
| { kind: 'paying'; draftId: string; intentId: string; redirectUrl?: string; ... }
| { kind: 'awaitingReturn'; draftId: string; intentId: string; ... }
| { kind: 'confirmed'; reservationId: string }
| { kind: 'failed'; reason: BookingFailureReason; recoverable: boolean };
Transitions are explicit functions (startSearch, selectRoom, quote, hold, patchGuest, createIntent, handleReturn). kind: 'holding' | 'collectingDetails' | 'paying' are persisted; kind: 'idle' | 'searching' are not.
8. Multi-tenant theming on web/mobile
Full spec:
02-theming-and-tenant-config.md. This section summarises the consumer side.
- Resolution. Request enters BFF → BFF resolves tenant slug → fetches active
ThemeVersionbundle fromtheme-config-service(Memorystore-cached, CDN-cached). Bundle = design tokens + layout selections + content blocks + navigation + booking-flow config + email theme + locale packs. - Web injection (SSR). Next.js middleware reads slug; layout fetches bootstrap; tokens are emitted as
<style>:root { --color-primary: #...; ... }</style>in the SSR HTML, plus<link rel="stylesheet" href="/themes/{themeVersionId}.css">(CDN-edge cached). No client JS runtime theme switch — full SSR avoids hydration flicker. - Mobile injection. App calls
GET /bootstrapon tenant entry; tokens are loaded into a React context (ThemeProvider); theme switching when navigating between tenants triggers a full unmount ofBookingStack— no per-screen theme animation. Performance trade: ~120 ms reload vs guaranteed no flicker. - Logo + hero injection.
bootstrap.theme.logoUrland per-page hero asset URLs are CDN-served WebP/AVIF; rendered vianext/image(web) or aFastImagewrapper (mobile). - Locale fallback chain.
requested → tenant.defaultLocale → enperTheme.fallbackChain(seetheme-config-service/DOMAIN_MODEL §4). - SSR safety (Next.js). The bootstrap is fetched in a Server Component; client code reads tokens from
useTheme()(which is a no-op on web — tokens are in CSS) but uses it for type hints + mobile parity.
9. i18n & RTL/LTR
9.1 Languages
| Locale | Direction | Default font (UI) | Default font (numerals) | Notes |
|---|---|---|---|---|
ps-AF (Pashto) | RTL | Vazirmatn / Noto Naskh Arabic | Latin numerals | Default for AF tenants |
fa-AF (Dari) | RTL | Vazirmatn / Noto Naskh Arabic | Latin numerals | |
fa-IR (Persian) | RTL | Vazirmatn | Latin numerals (toggle to Persian numerals via locale pack) | |
ar-SA / ar-EG (Arabic) | RTL | Noto Naskh Arabic | Latin numerals | |
en-US (English) | LTR | Inter | Latin numerals | Platform fallback |
fr-FR (French) | LTR | Inter | Latin numerals | Phase 2 |
ur-PK (Urdu) | RTL | Noto Nastaliq Urdu | Latin numerals | Phase 1 |
Numerals stable. Latin numerals across all UI; locale-pack per tenant can enable Persian-Indic numerals where the tenant insists. Money/quantity values are always Latin in confirmations — finance audits depend on it.
9.2 ICU MessageFormat
import { useTranslations } from 'next-intl';
const t = useTranslations('booking');
t('roomsCount', { count: 2 });
// en: "2 rooms"
// ps: "{count, plural, one {# خونه} other {# خونې}}"
9.3 RTL strategy
- Direction injection:
<html dir={isRtl ? 'rtl' : 'ltr'} lang={locale}>. Mobile:I18nManager.forceRTL(isRtl)at app start; toggle requires reload. - Logical CSS properties everywhere:
padding-inline-start,margin-inline-end,inset-inline-start,border-start-start-radius. Tailwind preset exposesps-*,pe-*,ms-*,me-*utilities. - Mirrored iconography: chevrons, arrows, back buttons, progress arrows mirror; logos, brand glyphs, numerals, media playback do not (per
@ghasi/icons/manifest.json— see §7 of the design system). - Bi-directional text: mixed-direction strings use Unicode bidi marks (
\u200E/\u200F); React components in@ghasi/ui-melmastoonprovide<BidiText>helper. - Per-tenant overrides: a tenant in an RTL market can override the platform default Latin label for the brand mark in their locale pack.
9.4 Date / time / currency
- Calendars: Gregorian backend; presentational variants Hijri (
ar-SA-u-ca-islamic) and Solar Hijri (fa-IR-u-ca-persian) computed client-side viaIntl.DateTimeFormat. Server payloads always carry ISO-8601 Gregorian. - Currency: display per
bootstrap.currencies[]; presentation viaIntl.NumberFormat; FX-snapshot included in every priced response so reload doesn't shift totals.
10. Offline & low-bandwidth
10.1 Web
- PWA shell. App shell (HTML, JS, fonts, design-token CSS) is cached via service worker on first visit.
- Tenant bootstrap cache. Last fetched
/bootstrapper tenant slug cached for 24 h; on cold offline open of a tenant URL the app renders shell + cached bootstrap; functional limit: cannot quote / hold / pay. - Last viewed properties. Up to 20 detail responses cached for 7 days.
- Image LQIP. Blur placeholder (low-quality image placeholder) served inline (≤ 1 KB) from
next/image; full-quality lazy on viewport intersection. - Data-saver mode. Detected via the
Save-Datarequest header; in this mode: drop hero video, servesrcsetcapped atw=640, switch to lower-fidelity map tiles, disable parallax animations. - Compression.
Accept-Encoding: br, gziphonored; static assets pre-compressed (.br+.gz) on the CDN.
10.2 Mobile
- MMKV persisters. React Query persister stores last 50 search results + last 20 viewed properties. Bytes budget: ≤ 5 MiB total cached payload.
- Image caching.
react-native-fast-image(or Expo'sexpo-image) with disk LRU capped at 50 MiB. - Offline browse. Search results, property details, and confirmation views readable offline. Booking flow steps requiring server interaction (
/quote,/hold,/payment-intent,/return) clearly disable their CTA with a "You're offline" banner. - Data-saver opt-in. Settings toggle that pins the app to the data-saver code path even on Wi-Fi.
- OTA update budget. EAS Update payload capped at 1.5 MiB; updates fetched only on Wi-Fi unless user opts in.
11. Booking flow specifics (tenant booking surface)
The funnel maps 1:1 to states in the BookingDraft aggregate of bff-tenant-booking-service. Each step's visibility and required fields is controlled by bootstrap.flowConfig (see §7 of the theming spec).
| Step | Screen | BFF | Notes |
|---|---|---|---|
| 1. Select dates | DatesScreen (mobile) / inline header (web) | GET /availability (debounced) | Skipped if dates pre-populated by handoff |
| 2. Choose room type | RoomChoiceScreen | GET /availability (cached) | Cards from RoomTypeCard primitive |
| 3. Upgrade options | UpgradesScreen | (no call; uses bootstrap rate plan add-ons) | Optional — tenant can disable |
| 4. Guest details | GuestDetailsScreen | PATCH /draft/{id} (debounced 500 ms) | Fields per flowConfig.fieldRequirements (e.g., guest.passportNumber for AF tax-resident captures) |
| 5. Payment selection | PaymentScreen | POST /draft/{id}/payment-intent | Methods per bootstrap.paymentMethods[]. Cash-on-arrival is a first-class radio option — selecting it does NOT call payment-intent; it advances to confirm |
| 6. Confirm | ConfirmationScreen (after /return or /confirm) | POST /draft/{id}/return (card/PayPal/MFS) or POST /draft/{id}/confirm (cash) → GET /confirmation/{rsv} | Idempotent re-entry returns kind: 'already_confirmed' |
11.1 Multi-currency display
Every priced view-model from the BFF carries:
amountMinor(integer in minor units) +currency(ISO 4217).display.formatted(server-localized string).fxSnapshotId+capturedAt+ttlExpiresAt+isStale: boolean.
The frontend renders display.formatted verbatim. If isStale === true, a non-blocking banner offers re-quote; the previously quoted price is honored until the hold expires.
11.2 Cash-on-arrival UX
Renders identically to other payment radios. The CTA wording is locale-aware (payments.confirm_cash → "د راتګ پر مهال نقدې تادیه" for ps-AF). On confirm, the ConfirmationScreen shows: amount due on arrival, accepted currencies, the property's check-in window, and a downloadable PDF voucher with QR code.
11.3 Hold-expiry handling
bootstrap.flowConfig includes holdTtlSeconds. The BookingStack runs a per-mount countdown; at T-60 s a non-blocking toast warns; at T-0 the screen surfaces a HoldExpiredBanner and offers Re-quote with current availability (which restarts at step 2).
12. Meta-search specifics
| Capability | Surface | Mechanism |
|---|---|---|
| Location autocomplete | Search bar | Local edge in-memory cache (top 100 cities) → GET /facets?country=... for completion below threshold |
| Map ↔ list view | Search results | Two query keys against same logical search; toggle is URL-state (?view=map); state synced via Zustand |
| Filters | Drawer (mobile) / sidebar (web) | Price band, amenities, star rating, guest-rating min, halal-kitchen, prayer-room, women-only-floor, parking, airport-shuttle. Wired to POST /search.filters.amenities[] |
| Sort | Pill bar | recommended (default), price-asc, price-desc, rating-desc, distance-asc (when geo provided) |
| Deep handoff | Listing card CTA | POST /handoff/{tenant}/{property} with current dates + occupancy + currency + locale + sourceCampaign → 302 to redirectUrl (web) / Linking.openURL to in-app deep link (mobile) |
| Map clustering | Map view | Client-side clustering above 50 pins (Leaflet markercluster web; react-native-maps cluster lib mobile) |
| Pagination | Results | Offset (max offset=1000) — cursor-paged is unavailable on /search because cross-tenant ranking re-orders |
Privacy note. No per-user personalization on the meta layer in Phase 0/1 — ranking is
recommendedbased on aggregate signals only. Personalized ranking is a Phase 2 feature gated by guest sign-in.
13. Mobile-specific
13.1 Push notifications
| Topic | Channel | Trigger | Body example |
|---|---|---|---|
booking.confirmed | transactional | reservation.confirmed.v1 | "Your booking at {property} is confirmed for {checkIn}" |
booking.reminder.checkin | transactional | T-24 h before check-in | "Check-in tomorrow at {property} — directions" |
booking.key.delivered | transactional | lock.key.issued.v1 (mobile-key vendors) | "Your mobile key is ready — tap to view" |
booking.review_request | transactional | T+24 h after check-out | "How was your stay at {property}?" |
marketing.special_offer | marketing (opt-in) | Tenant campaign | "Spring deals at {tenant} — save up to 25%" |
Permission request is deferred — asked the first time the user creates a booking, with an explanatory primer screen first; OS prompt only if user accepts the primer.
13.2 Biometric login
Triggered on entry to the Trips tab. Tokens stored in Keychain/Keystore; biometric prompt unlocks the JWT for re-auth. Falls back to OTP via SMS if biometric unavailable or rejected.
13.3 Deep linking
Universal Links (iOS) + App Links (Android) for https://*.melmastoon.app/*. Open behavior:
melmastoon.app/hotels/{id}→PropertyDetailScreen<tenant>.melmastoon.app/book?h=<token>→BootstrapTenantScreenwith handoff token<tenant>.melmastoon.app/booking/confirmation/{rsv}→ConfirmationScreen(requires guest sign-in if guest sign-in enabled)
13.4 In-app messaging with hotel
Phase 2 — a chat surface that proxies to the hotel via notification-service. Phase 0/1 ships a "Contact hotel" CTA that opens phone dialer, WhatsApp, or email per the property's support block in /confirmation/{rsv}.
14. Accessibility
- Target: WCAG 2.2 AA across every surface, audited per release.
- Semantic HTML on web:
<header>,<nav>,<main>,<form>,<button>(never<div onClick>);aria-current,aria-expanded,aria-controlsper pattern. - Keyboard navigation: every actionable element reachable in DOM order; visible focus rings via
--focus-ringtoken; skip-to-content link in every page header. - Screen reader labels: every icon-only button has an
aria-labelfromnext-intl; mobile usesaccessibilityLabel. Critical flows (booking funnel) tested with VoiceOver, TalkBack, and NVDA. - Focus management: modals trap focus; closing a modal returns focus to the trigger; route changes move focus to the page heading (
<h1 tabIndex={-1}>). - Reduced motion: respect
prefers-reduced-motion(web) /Reduce Motion(mobile); animations collapse to opacity fades. - High contrast: an opt-in high-contrast theme variant (token-based; see
03-design-system.md§9). - Touch targets: minimum 44×44 pt mobile, 32×32 px web (per WCAG 2.5.5).
- Form errors:
aria-describedbyon the field; error summary at top of form on submit. - CI:
axe-coreruns on every Storybook story (web) and on a Detox a11y pass (mobile).
15. Performance budgets
15.1 Web
| Metric | Target | Gating |
|---|---|---|
| LCP (Largest Contentful Paint, p75) | < 2.5 s on Moto G4 / Slow 4G | Lighthouse-CI on PR |
| INP (Interaction to Next Paint, p75) | < 200 ms | RUM dashboard alarm |
| CLS (Cumulative Layout Shift, p75) | < 0.1 | Lighthouse-CI on PR |
| TTFB (Time to First Byte, p75) | < 600 ms | RUM dashboard |
| Initial JS per route (gzipped) | < 250 KiB | @next/bundle-analyzer budget |
| Initial CSS (gzipped) | < 30 KiB (incl. tenant theme) | budget gate |
| Web Font payload | ≤ 2 weights × 2 styles per locale; subsetting required | build-time check |
15.2 Mobile
| Metric | Target | Gating |
|---|---|---|
| Cold start (TTI) | < 3 s on Pixel 4a / Android 11 | Reassure benchmark |
| Warm start | < 1 s | Reassure |
| Screen transition | < 250 ms | Reassure + manual perf review |
| JS bundle (Android, Hermes-compiled) | < 6 MiB | EAS Build report |
| APK / IPA size | APK ≤ 35 MiB, IPA ≤ 50 MiB | EAS Build gate |
| Memory steady-state | < 220 MiB on Discover screen | Manual profiling pre-release |
Budget regressions block CI; a deliberate budget bump requires an ADR-lite note in the PR description and an updated baseline.
16. Testing strategy
| Layer | Web | Mobile |
|---|---|---|
| Unit | Vitest | Jest |
| Component | Testing Library (@testing-library/react) | React Native Testing Library |
| Visual regression | Chromatic (per Storybook story; per primary locale + RTL) | Loki (per story for shared components compiled with Storybook for RN) |
| End-to-end | Playwright (Chromium + WebKit + Firefox; mobile emulation profile for Pixel 5 + iPhone 13) | Detox (iOS Simulator + Android Emulator; one Pixel 4a profile gates main) |
| Accessibility | axe-core in Storybook + Playwright @axe-core/playwright | Detox + manual VoiceOver/TalkBack scripts |
| Performance | Lighthouse-CI (3 runs median) | Reassure on hot screens |
| Contract | Generated OpenAPI clients are typecheck-validated; mock BFF via msw | msw for native (works in Expo) |
| i18n / pseudo-locale | en-XA pseudo-locale rendered for every PR; truncation + RTL flips checked | same |
16.1 Critical journeys (e2e gates)
- Web meta: search → filter → list/map toggle → property detail → handoff → land on tenant booking site → bootstrap → quote → hold → guest details → cash-on-arrival → confirmation.
- Web tenant booking: direct entry via
<tenant>.melmastoon.app/book→ bootstrap → quote → hold → card payment via Adyen test mode → return → confirmation. - Mobile: discover (search) → property detail → "Book" → bootstrap tenant → quote → hold → MFS payment → return → confirmation.
- Mobile offline: open Trips tab offline → see cached upcoming reservation → tap "Get directions" (works) → tap "Modify dates" (cleanly disabled with banner).
17. Analytics & telemetry
| Event | When | Properties | Sink |
|---|---|---|---|
view.page (web) / view.screen (mobile) | Route change | path, tenantId?, propertyId?, referer?, viewedAt | POST /telemetry/page-view (consumer BFF) or in-tenant equivalent |
search.executed | Submit search | geo, dates, occupancy, filters, sortKey, searchSessionId | server-derived from POST /search |
card.clicked | Listing-card click | propertyId, tenantId, position, searchSessionId, page, sortKey | POST /telemetry/click |
handoff.initiated | "Book with this hotel" | tenantId, propertyId, handoffId, currency, locale, sourceCampaign? | POST /handoff/{...} |
funnel.step | Each booking step entered/completed | step (bootstrap/quote/hold/details/payment/return/confirm), result (enter/success/abandon/error), tenantId, draftId?, reservationId?, errorCode? | tenant BFF event publication |
booking.confirmed | Confirmation screen mount | reservationId, tenantId, currency, totalMinor, paymentMethod, nights | tenant BFF |
error.surfaced | Error toast / banner | errorCode, httpStatus, path, traceId | tenant or consumer BFF |
theme.flicker.detected | Hydration mismatch heuristic on web | tenantId, themeVersion, routePath | consumer BFF (debug-only) |
PII (email, phone, name) is never sent to the telemetry surface; PII lives only in domain calls (PATCH /draft/{id}).
Dashboards and computation rules: docs/observability/01-observability.md.
18. Security
- CSP enforced. Per-route CSP with nonces;
default-src 'self',img-src 'self' data: https://cdn.melmastoon.ghasi.io https://*.melmastoon.app,script-src 'self' 'nonce-...',connect-src 'self' https://api.melmastoon.ghasi.io https://*.melmastoon.app. Tenant subdomains add their custom-domain CDN host. - Cookies.
SameSite=Lax,Secure,HttpOnlyfor all session cookies (gms_id,tnt_id);Path=/apion the tenant cookie to scope it tightly. - CSRF. All state-changing requests carry
X-Idempotency-Key(ULID); unsafe methods on cookie-authenticated routes additionally require anOrigin/Referercheck at the BFF. - XSS. All content is rendered via React's escaped slots; the rich-content blocks (
I18nMarkup) are sanitized at the BFF (DOMPurify allow-list per kind — seetheme-config-serviceContentBlock.body); neverdangerouslySetInnerHTMLin app code without a// security-reviewed:comment + reviewer initials. - Signed BFF handoff. Handoff tokens are HMAC-signed; verification is replay-safe (single-use
consumedflag in the consumer BFF). Token format:v1.<base64url(payload)>.<base64url(hmac-sha256)>. - Mobile secrets. JWT access + refresh tokens stored in iOS Keychain / Android Keystore. MMKV is encrypted with a key fetched from the secure store. Biometric prompt re-authenticates before token release.
- Payment surfaces. SPA never sees PAN; redirects to provider's hosted checkout (Adyen, PayPal). On
return, the SPA passes only the provider-issuedredirectResulttoken toPOST /draft/{id}/return. - Network pinning (mobile, Phase 2). SSL pinning to the BFF cert chain. Phase 0/1 relies on platform trust store with strict TLS 1.2+.
- Reduced privilege of telemetry. Telemetry endpoints are write-only and rate-limited per session.
Per-BFF security details live in each SECURITY_MODEL.md.
19. Anti-patterns
| Anti-pattern | Why it's banned | Correct approach |
|---|---|---|
| Calling domain microservices directly from web/mobile | Bypasses tenant resolution, theming, rate limiting, CSP, and audit | Always go through the appropriate BFF |
if (tenantId === 'tnt_acme') { ... } in shared code | Tenant-coupled code prevents safe deploys | Drive variation through theme-config-service (tokens, flow toggles, content blocks) |
Importing Tailwind raw colors (bg-blue-500) | Defeats tenant theming; breaks contrast invariants | Use semantic tokens (bg-primary, text-on-surface) — see 03-design-system.md |
| Hardcoded English strings or aria-labels | Breaks i18n; trips RTL audits | Always go through next-intl / @ghasi/i18n |
| Heavy synchronous work on the main thread (large JSON parse, image transforms) | Blocks INP; tanks low-end Android | Off-thread via Worker (web) / runOnJS/native module (mobile); chunk parses |
useEffect for deriving render state | Causes hydration flicker, re-render storms | Compute during render or use useMemo/useSyncExternalStore |
| Storing booking PII in localStorage / AsyncStorage | XSS-readable; breaks session-clear UX | Server-side session blob; only minimal IDs in cookies; mobile uses encrypted MMKV |
Direct <a href="https://other-tenant.com"> cross-tenant links | Leaks tenant branding into another tenant context | Always route via POST /handoff/{tenant}/{property} |
| Per-tenant React component files | Doesn't scale; contradicts the single-codebase commitment | Compose from primitives + content blocks driven by config |
dangerouslySetInnerHTML from BFF responses without explicit sanitization layer | XSS surface | Render via primitives that consume the sanitized I18nMarkup from the BFF |
| OTA-pushing native code | Breaks store policies | Native changes go through EAS Build + store review only |
Skipping X-Idempotency-Key on POSTs | Double-bookings, double-charges, double-handoffs | Always generate a ULID per attempt and reuse on retry |
20. References
- BFF contracts:
bff-consumer-service/API_CONTRACTS.md,bff-tenant-booking-service/API_CONTRACTS.md,bff-backoffice-service/API_CONTRACTS.md - Theming domain model:
theme-config-service/DOMAIN_MODEL.md - Theming spec (frontend perspective):
02-theming-and-tenant-config.md - Design system:
03-design-system.md - Desktop spec (separate):
desktop/06-desktop-app-specification.md - API design conventions:
docs/05-api-design.md - Operating modes (online / offline / degraded):
docs/01-product-overview.md§9 - Observability:
docs/observability/01-observability.md - Testing strategy:
docs/testing/01-testing-strategy-qa.md