Skip to main content

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.md

BFF contracts: bff-consumer-service · bff-tenant-booking-service


1. Scope

This specification covers the three guest-facing surfaces of the Ghasi Melmastoon platform:

#SurfaceForm factorStackSource location
1Consumer Meta WebBrowser (desktop + mobile web)Next.js 14 (App Router), TypeScript strict, TailwindCSS, React Query, Zustand, Leaflet, PWAapps/web-meta
2Tenant Booking WebBrowser (desktop + mobile web), per-tenant subdomain or pathNext.js 14 (App Router), TypeScript strict, TailwindCSS, React Query, Zustandapps/web-tenant-booking
3Consumer MobileiOS + Android (single binary via Expo)React Native 0.74+, TypeScript strict, Expo, React Navigation, React Query, Zustand, MMKV, Reanimated 3apps/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.

SurfaceAudienceContextPrimary KPIsPrimary BFFSecondary BFF
Consumer Meta WebTravelers exploring across tenants — discovery, comparison, shortlistingPre-tenant. No tenant theme; uses platform-default tokens.Meta-conversion (search → handoff); search-to-detail click-through; wishlist add-rate; LCP / INPbff-consumer-service
Tenant Booking WebTravelers 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-serviceFunnel completion (bootstrap → confirmation); time-to-confirm; payment-success rate; abandonment-stepbff-tenant-booking-service
Consumer MobileTravelers on phones, browsing and booking and managing post-stay artifactsBoth. App switches its bootstrap when navigating from list/map view into a tenant detail / bookingBrowse-to-book conversion; push-notification opt-in; biometric login adoption; cold-start timebff-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

ConcernChoiceWhy
FrameworkNext.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
LanguageTypeScript strict (noImplicitAny, strictNullChecks, exactOptionalPropertyTypes, noUncheckedIndexedAccess)Single source of error surface; lines up with shared @ghasi/contracts-melmastoon
StylingTailwindCSS with a tailwind.preset.ts from @ghasi/ui-melmastoon; CSS variables back every utilityUtility velocity + token enforcement; no raw hex in app code (lint-blocked)
Server stateTanStack 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 stateZustand for ephemeral / cross-route state (booking draft, filter UI)Tiny, no provider tree, persist middleware to IndexedDB for the booking draft
URL statenuqs (or next-usequerystate) for filter/sort/pagination — shareable URLsSearch results must be deep-linkable for SEO and copy-paste
i18nnext-intl with ICU MessageFormat; per-locale message bundlesRTL + plurals + gender + complex date formats
Formsreact-hook-form + zod resolver; same zod schemas reused on the BFFOne schema per shape, validated client + server
MapsLeaflet (no Google Maps tiles by default — privacy + cost). Tile sources: MapTiler (default) with OSM fallbackFree / low-cost tiles; can self-host later
AnimationFramer Motion, sparingly (motion tokens from the design system)Page transitions, modal/drawer entry, skeleton fades; reduced-motion respected
PWAnext-pwa (or hand-written service worker) — caches static, tenant bootstrap responses, last viewed property pagesOffline browse for the meta surface and post-stay screens of the booking surface
Image optimizationnext/image with the loader pointed at GCP Cloud CDN (signed URLs from file-storage-service)WebP / AVIF / responsive srcset, blur-placeholder LQIP
HTTP clientBuilt-in fetch (with signal: AbortSignal everywhere) wrapped by typed BFF clientsEdge-runtime compatible; trivial cancellation
TestingVitest (unit), Testing Library (component), Playwright (e2e), Lighthouse-CI (perf budgets), Chromatic (visual regression)See §16
ObservabilityOpenTelemetry web SDK → Cloud Trace; basic RUM via web-vitals → BFF telemetry endpointsTraces tie to BFF traceparent; Core Web Vitals in dashboards
BuildNext.js + Turborepo (incremental, remote-cached)Monorepo speed
Node20 LTSMatch BFF + desktop main process

4. Tech stack — mobile

ConcernChoiceWhy
FrameworkReact Native 0.74+ with the New Architecture (Fabric + TurboModules) enabledModern bridgeless RN; Hermes baseline
Tooling shellExpo (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
LanguageTypeScript strict (same tsconfig posture as web)Shared @ghasi/contracts-melmastoon types
JS engineHermesSmaller bundle, faster startup, better memory profile on low-end Android
NavigationReact Navigation v7 (native stack + bottom tabs + modal stack)RTL-aware out of the box; deep linking with typed routes
Server stateReact Query v5 with react-native-mmkv-backed query persisterSame mental model as web; offline reads of last-known data
Client stateZustand with MMKV persist middlewareBooking draft survives app backgrounding
Local storagereact-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
Animationreact-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
i18nexpo-localization + the same next-intl-compatible message bundles via a shared formatter (@ghasi/i18n)Locale parity with web
Pushexpo-notifications (APNs + FCM) for booking confirmations, reminders, mobile-key delivery, special offersOne push surface across both stores
Biometricexpo-local-authentication (Face ID / Touch ID / Android BiometricPrompt)Quick re-auth for the manage-bookings tab
Mapsreact-native-maps (Apple Maps on iOS, Google Maps on Android) — UI parity with Leaflet web listNative map perf; identical pin schema
Formsreact-hook-form + zod (parity with web)Same schemas
Networkfetch polyfilled by RN; typed BFF clients shared with webOne client package
Storage encryptioniOS Keychain + Android Keystore for tokens; MMKV with encryption key from Keychain/KeystoreSecrets not stored in plaintext
TestingJest (unit), React Native Testing Library (component), Detox (e2e), Reassure (perf budgets)See §16
Observability@sentry/react-native (errors + performance); OTel for trace continuation with BFFCrashes + traces
DistributionEAS 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.select is 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-melmastoonicons + 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

RoutePageBFF call(s)Cacheable
/Home — search bar + popular destinationsGET /facetsyes (60 s edge)
/searchResults — list + toggleable mapPOST /search, POST /search/mapyes (15 s edge, query-keyed)
/hotels/[propertyId]Hotel detailGET /hotels/{id} + GET /hotels/{id}/availabilityyes (60 s edge)
/wishlistSaved propertiesGET /wishlistno (cookie-keyed)
/handoffInternal route (server action) → mints handoff then 302 to tenant URLPOST /handoff/{tenant}/{property}no
/legal/{kind}Privacy / Terms / Cookiesstaticyes
/sitemap.xml, /robots.txtSEO surfaceplatform-generatedyes
/* (catch-all)404yes

6.2 Tenant Booking Web — sitemap

Hosted under https://<tenantSlug>.melmastoon.app/ (preferred) or https://melmastoon.app/t/<tenantSlug>/ (fallback).

RoutePageBFF call(s)Notes
/Home (preset-driven: hero / about / amenities / featured rooms)GET /bootstrap + content blocksSSR; theme injected at SSR time
/roomsRooms catalogueGET /properties/{id}/roomsSSR
/rooms/[roomTypeId]Room detailGET /properties/{id}/rooms (filtered)SSR
/bookBooking funnel rootGET /bootstrap + GET /availabilityclient-driven steps
/book?h=<handoff-token>Same as /book but auto-consumes POST /handoff/consume then routes into the funnel pre-populatedPOST /handoff/consume, POST /quote, POST /hold
/booking/confirmation/[reservationId]ConfirmationGET /confirmation/{id}
/policies/[kind]Tenant policy viewGET /policies
/contactContact blockcontent-block-driven
/legal/{kind}Tenant legal pagescontent-block-driven
/manage(Phase 2) authenticated guest arearequires 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

StrategyURL exampleProsConsWhen to use
Subdomain (preferred)kabul-grand-hotel.melmastoon.appCleanest brand; per-tenant CSP / cookie domain; SEO independenceWildcard cert; DNS automation neededDefault for every tenant
Path prefix (fallback)melmastoon.app/t/kabul-grand-hotelNo DNS; simpler in early environmentsBrand dilution; cookie scoping subtlerPre-prod, free-tier tenants, demo
Custom domain (Phase 2)book.kabulgrand.afTenant fully white-labeledPer-tenant cert, CAA records, ops costPaid 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

LayerOwnerPersistenceExamples
Server stateReact QueryIn-memory + IndexedDB (web) / MMKV (mobile) — query-key based persistersearchHotels(query), getHotel(id), getBootstrap(tenant), getQuote(quoteId)
Client stateZustandIndexedDB (web) / MMKV (mobile) for the booking-draft slice; in-memory for UI ephemeraBooking-draft (room, rate, dates, guests, special requests, payment selection); filter-panel-open; map-vs-list toggle
URL statenuqs (web) / React Navigation params (mobile)URL itselfSearch filters, sort key, pagination cursor, map bounds — must be shareable
Session stateBFF + cookieServer-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.

  1. Resolution. Request enters BFF → BFF resolves tenant slug → fetches active ThemeVersion bundle from theme-config-service (Memorystore-cached, CDN-cached). Bundle = design tokens + layout selections + content blocks + navigation + booking-flow config + email theme + locale packs.
  2. 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.
  3. Mobile injection. App calls GET /bootstrap on tenant entry; tokens are loaded into a React context (ThemeProvider); theme switching when navigating between tenants triggers a full unmount of BookingStack — no per-screen theme animation. Performance trade: ~120 ms reload vs guaranteed no flicker.
  4. Logo + hero injection. bootstrap.theme.logoUrl and per-page hero asset URLs are CDN-served WebP/AVIF; rendered via next/image (web) or a FastImage wrapper (mobile).
  5. Locale fallback chain. requested → tenant.defaultLocale → en per Theme.fallbackChain (see theme-config-service/DOMAIN_MODEL §4).
  6. 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

LocaleDirectionDefault font (UI)Default font (numerals)Notes
ps-AF (Pashto)RTLVazirmatn / Noto Naskh ArabicLatin numeralsDefault for AF tenants
fa-AF (Dari)RTLVazirmatn / Noto Naskh ArabicLatin numerals
fa-IR (Persian)RTLVazirmatnLatin numerals (toggle to Persian numerals via locale pack)
ar-SA / ar-EG (Arabic)RTLNoto Naskh ArabicLatin numerals
en-US (English)LTRInterLatin numeralsPlatform fallback
fr-FR (French)LTRInterLatin numeralsPhase 2
ur-PK (Urdu)RTLNoto Nastaliq UrduLatin numeralsPhase 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 exposes ps-*, 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-melmastoon provide <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 via Intl.DateTimeFormat. Server payloads always carry ISO-8601 Gregorian.
  • Currency: display per bootstrap.currencies[]; presentation via Intl.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 /bootstrap per 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-Data request header; in this mode: drop hero video, serve srcset capped at w=640, switch to lower-fidelity map tiles, disable parallax animations.
  • Compression. Accept-Encoding: br, gzip honored; 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's expo-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).

StepScreenBFFNotes
1. Select datesDatesScreen (mobile) / inline header (web)GET /availability (debounced)Skipped if dates pre-populated by handoff
2. Choose room typeRoomChoiceScreenGET /availability (cached)Cards from RoomTypeCard primitive
3. Upgrade optionsUpgradesScreen(no call; uses bootstrap rate plan add-ons)Optional — tenant can disable
4. Guest detailsGuestDetailsScreenPATCH /draft/{id} (debounced 500 ms)Fields per flowConfig.fieldRequirements (e.g., guest.passportNumber for AF tax-resident captures)
5. Payment selectionPaymentScreenPOST /draft/{id}/payment-intentMethods per bootstrap.paymentMethods[]. Cash-on-arrival is a first-class radio option — selecting it does NOT call payment-intent; it advances to confirm
6. ConfirmConfirmationScreen (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

CapabilitySurfaceMechanism
Location autocompleteSearch barLocal edge in-memory cache (top 100 cities) → GET /facets?country=... for completion below threshold
Map ↔ list viewSearch resultsTwo query keys against same logical search; toggle is URL-state (?view=map); state synced via Zustand
FiltersDrawer (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[]
SortPill barrecommended (default), price-asc, price-desc, rating-desc, distance-asc (when geo provided)
Deep handoffListing card CTAPOST /handoff/{tenant}/{property} with current dates + occupancy + currency + locale + sourceCampaign → 302 to redirectUrl (web) / Linking.openURL to in-app deep link (mobile)
Map clusteringMap viewClient-side clustering above 50 pins (Leaflet markercluster web; react-native-maps cluster lib mobile)
PaginationResultsOffset (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 recommended based on aggregate signals only. Personalized ranking is a Phase 2 feature gated by guest sign-in.


13. Mobile-specific

13.1 Push notifications

TopicChannelTriggerBody example
booking.confirmedtransactionalreservation.confirmed.v1"Your booking at {property} is confirmed for {checkIn}"
booking.reminder.checkintransactionalT-24 h before check-in"Check-in tomorrow at {property} — directions"
booking.key.deliveredtransactionallock.key.issued.v1 (mobile-key vendors)"Your mobile key is ready — tap to view"
booking.review_requesttransactionalT+24 h after check-out"How was your stay at {property}?"
marketing.special_offermarketing (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>BootstrapTenantScreen with 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-controls per pattern.
  • Keyboard navigation: every actionable element reachable in DOM order; visible focus rings via --focus-ring token; skip-to-content link in every page header.
  • Screen reader labels: every icon-only button has an aria-label from next-intl; mobile uses accessibilityLabel. 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-describedby on the field; error summary at top of form on submit.
  • CI: axe-core runs on every Storybook story (web) and on a Detox a11y pass (mobile).

15. Performance budgets

15.1 Web

MetricTargetGating
LCP (Largest Contentful Paint, p75)< 2.5 s on Moto G4 / Slow 4GLighthouse-CI on PR
INP (Interaction to Next Paint, p75)< 200 msRUM dashboard alarm
CLS (Cumulative Layout Shift, p75)< 0.1Lighthouse-CI on PR
TTFB (Time to First Byte, p75)< 600 msRUM 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 requiredbuild-time check

15.2 Mobile

MetricTargetGating
Cold start (TTI)< 3 s on Pixel 4a / Android 11Reassure benchmark
Warm start< 1 sReassure
Screen transition< 250 msReassure + manual perf review
JS bundle (Android, Hermes-compiled)< 6 MiBEAS Build report
APK / IPA sizeAPK ≤ 35 MiB, IPA ≤ 50 MiBEAS Build gate
Memory steady-state< 220 MiB on Discover screenManual 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

LayerWebMobile
UnitVitestJest
ComponentTesting Library (@testing-library/react)React Native Testing Library
Visual regressionChromatic (per Storybook story; per primary locale + RTL)Loki (per story for shared components compiled with Storybook for RN)
End-to-endPlaywright (Chromium + WebKit + Firefox; mobile emulation profile for Pixel 5 + iPhone 13)Detox (iOS Simulator + Android Emulator; one Pixel 4a profile gates main)
Accessibilityaxe-core in Storybook + Playwright @axe-core/playwrightDetox + manual VoiceOver/TalkBack scripts
PerformanceLighthouse-CI (3 runs median)Reassure on hot screens
ContractGenerated OpenAPI clients are typecheck-validated; mock BFF via mswmsw for native (works in Expo)
i18n / pseudo-localeen-XA pseudo-locale rendered for every PR; truncation + RTL flips checkedsame

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

EventWhenPropertiesSink
view.page (web) / view.screen (mobile)Route changepath, tenantId?, propertyId?, referer?, viewedAtPOST /telemetry/page-view (consumer BFF) or in-tenant equivalent
search.executedSubmit searchgeo, dates, occupancy, filters, sortKey, searchSessionIdserver-derived from POST /search
card.clickedListing-card clickpropertyId, tenantId, position, searchSessionId, page, sortKeyPOST /telemetry/click
handoff.initiated"Book with this hotel"tenantId, propertyId, handoffId, currency, locale, sourceCampaign?POST /handoff/{...}
funnel.stepEach booking step entered/completedstep (bootstrap/quote/hold/details/payment/return/confirm), result (enter/success/abandon/error), tenantId, draftId?, reservationId?, errorCode?tenant BFF event publication
booking.confirmedConfirmation screen mountreservationId, tenantId, currency, totalMinor, paymentMethod, nightstenant BFF
error.surfacedError toast / bannererrorCode, httpStatus, path, traceIdtenant or consumer BFF
theme.flicker.detectedHydration mismatch heuristic on webtenantId, themeVersion, routePathconsumer 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, HttpOnly for all session cookies (gms_id, tnt_id); Path=/api on 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 an Origin/Referer check 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 — see theme-config-service ContentBlock.body); never dangerouslySetInnerHTML in app code without a // security-reviewed: comment + reviewer initials.
  • Signed BFF handoff. Handoff tokens are HMAC-signed; verification is replay-safe (single-use consumed flag 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-issued redirectResult token to POST /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-patternWhy it's bannedCorrect approach
Calling domain microservices directly from web/mobileBypasses tenant resolution, theming, rate limiting, CSP, and auditAlways go through the appropriate BFF
if (tenantId === 'tnt_acme') { ... } in shared codeTenant-coupled code prevents safe deploysDrive variation through theme-config-service (tokens, flow toggles, content blocks)
Importing Tailwind raw colors (bg-blue-500)Defeats tenant theming; breaks contrast invariantsUse semantic tokens (bg-primary, text-on-surface) — see 03-design-system.md
Hardcoded English strings or aria-labelsBreaks i18n; trips RTL auditsAlways go through next-intl / @ghasi/i18n
Heavy synchronous work on the main thread (large JSON parse, image transforms)Blocks INP; tanks low-end AndroidOff-thread via Worker (web) / runOnJS/native module (mobile); chunk parses
useEffect for deriving render stateCauses hydration flicker, re-render stormsCompute during render or use useMemo/useSyncExternalStore
Storing booking PII in localStorage / AsyncStorageXSS-readable; breaks session-clear UXServer-side session blob; only minimal IDs in cookies; mobile uses encrypted MMKV
Direct <a href="https://other-tenant.com"> cross-tenant linksLeaks tenant branding into another tenant contextAlways route via POST /handoff/{tenant}/{property}
Per-tenant React component filesDoesn't scale; contradicts the single-codebase commitmentCompose from primitives + content blocks driven by config
dangerouslySetInnerHTML from BFF responses without explicit sanitization layerXSS surfaceRender via primitives that consume the sanitized I18nMarkup from the BFF
OTA-pushing native codeBreaks store policiesNative changes go through EAS Build + store review only
Skipping X-Idempotency-Key on POSTsDouble-bookings, double-charges, double-handoffsAlways generate a ULID per attempt and reuse on retry

20. References