Skip to content

Module booking (mobile)

Ce fichier doit rester synchronisé avec le code du module. À mettre à jour à chaque changement structurel.

Rôle : pilier fonctionnel du mobile — wizard de construction d'une réservation, listing « mes voyages », détails d'une réservation, formulaire liste d'attente. Aligné sur booking backend Horizon.

Code

  • Views :
    • views/TravelBooking.vue — le wizard
    • views/BookingDetails.vue — détail d'un booking confirmé
    • views/MyTravels.vue — mes réservations
    • views/WaitingListConfirmation.vue — confirmation après envoi du mail liste d'attente
  • Components :
    • travel-booking/TBStep* — étapes du wizard (DepartureDate, DepartureLocation, PassengerCounts, PassengerCountsInformation, Accommodation, AccommodationPassengersInformation, SeatSelection, WaitingListForm, Payment)
    • travel-booking/{TravelBookingFooter,Header,Intro,MonthCalendar,travelBookingNote}.vue
    • travel-booking/{BookingConfirmation,PassengerSelectModal,TBPassengerInfoForm}.vue
    • cards/{BookingCardBorder,PendingTripCard,TinyBookingCardSummary}.vue
    • bases/BookingBase.vue
  • Stores :
    • stores/bookings.ts — collection des bookings du client (bookings, bookingIdsTravelOcc mapping) ; expose futureBookings (asc), pastBookings (desc), cancelledBookings (desc, bucket séparé — cf. business-rules.md → « Tri des sections “Mes voyages” »)
    • stores/bookingConstructor.tsstate-machine du wizard (~576 LOC, unique source de vérité)
  • Composables : composables/booking-constructor/{progression,vehicle,rooms,passengers,waitingList,pricing,navigation}.ts (navigation = useBookingExit, la sortie wizard ✕)
  • Utils : utils/booking-transformer.ts
  • Types : types/travelBooking.ts, types/extensions.ts (BookingPassengerExt, BookingForPayment)
  • API : protectedApi.bookings.{getAll,create,getInvoice}, publicApi.mail.addWaitingList

Entités principales

  • Booking (booking_Booking) — la réservation, avec sous-objet general (id, customerId), passagers, rooms, pricing, occurrenceId, transactionId
  • BookingPassenger, Passenger, BookingPassengerExt (extension front)
  • BookingStatus : InCreation, Estimate, Draft, WaitingList, Confirmed, Billed, Canceled, PendingWebOrMobile
  • Room, BookingRoomTypeCategory, BookingRoomTypeCategoryDay
  • TripType : OneWay, RoundTrip (défaut), Return
  • PassengerType : Adult, Junior, Child, Baby (calculé via utils/age-tiers.ts)

Wizard — étapes (drivées par useBCProgression)

  1. DepartureDate — choix de l'Occurrence
  2. DepartureLocation — choix du Stop de chargement (pour les voyages dynamiques)
  3. PassengerCounts — nombre de passagers par tier d'âge
  4. Accommodation — choix de l'hôtel / type de chambre (skippé si non applicable)
  5. AccommodationPassengersInformation — répartition des passagers dans les chambres + meal plan (Seaside)
  6. PassengerCountsInformation — saisie des infos passagers (nom, civilité, date de naissance, …)
  7. SeatSelection — sélection des sièges sur le plan véhicule
  8. WaitingListForm — formulaire si bascule en liste d'attente
  9. Payment — Saferpay (cf. billing-payment)

L'étape active est mirroirée dans le segment de chemin :stepName (/reservation/:travelSlug/:stepName, voir ADR 0010 — auparavant un hash d'URL). Le router ne re-déclenche pas le loading overlay sur une nav « même page » : même path, ou une nav d'étape à étape du wizard (même name + travelSlug). Le shell clé la page par slug seul (pageKey) pour que l'instance persiste entre étapes. Le header et le footer du wizard sont rendus au niveau du shell (BuchardApp, meta.bookingChrome), en chrome fixed hors transition de page (cf. ADR 0010).

Les sauts de pastilles (header) maintiennent un historique contigu [étape1 … courante] quel que soit le chemin pris dans le wizard : un saut avant n→k push une entrée par étape navigable dans (n, k], un saut arrière go(-distance) en dépile autant d'un coup. Le back simple retombe donc toujours sur l'étape chronologiquement précédente. La notion d'étape navigable (porteuse d'une entrée d'historique — toutes sauf SeatSelection auto-skippée) est détenue par la progression (navigableStepNamesBetween), pas re-dérivée par la vue (cf. ADR 0011).

CAVEAT (reporté) : seule la représentation URL est passée du hash au segment de chemin. Le wizard 100% routes (route layout parente, transition centralisée pilotant les étapes, guards de skip/deep-link) est reporté. Aujourd'hui TravelBooking garde l'état d'étape, la <Transition> interne, la mémoire de scroll par étape et la direction locale ; un deep link vers une URL d'étape ré-init le wizard à l'étape 0 (bookingConstructor non persisté). (Les sauts de pastilles ne sont plus en replace — historique contigu ci-dessus, ADR 0011.)

Invariants & règles spécifiques

  • Source unique : tout le state du wizard (booking en construction, passagers, rooms, sièges, progression, pricing, waitingList) vit dans bookingConstructor. Ne pas dupliquer dans des composants.
  • Propriété des couches (cf. patterns.md → « Qui possède quoi ») : le graphe d'étapes — quelles étapes existent, leur ordre, leur atteignabilité et surtout leur skip (ex. SEAT_SELECTION auto-skippée si noVehiclePlan) — appartient à la progression (composables/booking-constructor/progression.ts). La vue TravelBooking ne fait que mirroiter l'étape active vers l'URL/l'historique et piloter les transitions ; elle ne re-dérive jamais une règle de skip. Si le miroir a besoin de connaître la navigabilité d'une étape, la progression l'expose comme primitive nommée (navigableStepNamesBetween) — elle ne lit pas noVehiclePlan elle-même. (C'est l'erreur que corrige ADR 0011 : un garde noVehiclePlan inline dans la vue avait normalisé le contraire.)
  • travelOccurrenceId watch : changer l'occurrence reset systématiquement rooms + passengers + sièges + occupancy.
  • CheckIn/CheckOut Seaside décalés des nuits en bus : la période « Séjour » fabriquée par finalBooking (Seaside avec hôtel, travel.days = []) applique travel.firstNightInTheBus/lastNightInTheBuscheckIn = occurrence.start + 1 / checkOut = occurrence.end − 1 jour. Horizon persiste ces dates verbatim et la rooming list hôtel filtre dessus. Cf. business-rules.md → « CheckIn/CheckOut Seaside : les nuits en bus décalent le séjour hôtel ».
  • incrementRoomReservation / decrementRoomReservation : seul chemin pour ajouter/retirer des passagers à une chambre (capacité de la chambre = nombre exact de passagers créés).
  • Liste d'attente : sur ce mobile, la liste d'attente n'est pas un Booking réel — c'est juste l'endpoint publicApi.mail.addWaitingList qui envoie un mail. La route waiting-list-confirmation affiche un accusé après envoi. Le flag forceWaitingList dans le constructor sert à forcer ce flow. Le payload reproduit le DTO WaitingListRequest de Horizon (requestedStop/requestedLine résolus depuis l'arrêt choisi, occurrenceDates en plage formatée) et le dropdown d'arrêt groupe les arrêts par ligne avec l'heure de bus ("locality, place (hour)") — cf. business-rules.md → « Liste d'attente — dropdown d'arrêt groupé par ligne, avec l'heure du bus ».
  • Édition d'un booking existant : pour ne pas exclure ses propres sièges de la map véhicule, voir getVehicleOccupancy(occId, currentBookingId) dans occurrence-capacity.
  • PaymentDepositOnly : porté par le booking, ne s'applique qu'au paiement web/mobile (cf. billing-payment). En back-office Horizon, ce sont les statuts Confirmed vs Billed qui font foi. Envoyé par le mobile quand l'acompte est choisi ; taux = travel.depositPercentage (cf. business-rules.md → « Acompte »).
  • Booking Legacy (champ flag) : importé de l'ancien système Globe → lecture seule, ne pas tenter d'éditer.
  • Pricing : useBCPricing collecte les entrées (chambres, passagers, assurances, suppléments, early booking, scopes Regle du paiement) et délègue la chaîne à la fonction pure computeBookingPricing (utils/booking-pricing.ts), qui réplique le calcul autoritaire de Horizon — cf. business-rules.md → « Chaîne de calcul du prix au paiement ».
  • Prix par passager au submit (p.price + itemPrices) : dans finalBooking, chaque passager reçoit p.price = getFullPricePerPassenger(p) (composables/booking-constructor/pricing.ts) = part chambre (getRoomPriceForPassenger, -10) + transport par tranche si voyage sans chambre (getPriceForPassengerType, OneDay/vol-sec) + upcharge pension (getMealPlanPrice). L'assurance est exclue du prix et émise en ligne p.itemPrices (« Assurances »). Miroir du Cost.getRealCostPerPassenger du site web, borné au périmètre mobile (pas d'excursions/flight-codes/remise par passager). Invariant : Σ p.price + Σ assurances === tabulatedFinalPrice.fullPrice − suppléments globaux (les suppléments vivent au niveau booking, pas répartis par passager ; la méthode « Facture » n'ajoute aucun frais). (Remplace l'ancien p.price = getRoomPriceForPassenger(p.id) qui renvoyait -1 pour tout voyage sans chambre — cf. business-rules.md → « Prix par passager calculé au submit ».)
  • Suppléments globaux (globalSupplements) : useBCPricing calcule le montant des suppléments globaux (taxes) portés par l'occurrence sélectionnée via utils/global-supplements.ts (computeGlobalSupplements), formule répliquée du site web (recomputeGlobalSupplements) et du backend Horizon (BookingService.ApplyGlobalSupplementsAsync). Le montant entre dans le total (fullPrice/subTotal) comme dans Horizon — proraté par l'acompte, absorbable par un bon, compté dans le plafond des points (cf. business-rules.md → « Suppléments globaux » et « Chaîne de calcul du prix au paiement »). Le tableau calculé est aussi affiché (lignes « dont … » sous le prix total dans TBPayment_Summary.vue) et envoyé dans finalBooking.booking.globalSupplements, indispensable car Horizon recalcule le montant de façon autoritaire mais ne l'applique pas si le client ne l'envoie pas.
  • Offres spéciales (discounts) : finalBooking émet output.booking.discounts = les lignes Discount résolues par resolveSpecialOfferDiscounts (utils/special-offers.ts, exposées par useBCPricing.specialOfferDiscounts) — toujours (y compris []). Indispensable : Horizon ne re-dérive pas les offres spéciales (contrairement à l'early booking), il applique booking.discounts verbatim à la facture — sans émission, la facture dépasserait amountToPay. Résolution (fenêtre, éligibilité membre, isPerPerson ×nbPax, couple ×2) et application au prix : cf. business-rules.md → « Offres spéciales ». Émis au même endroit que gifts/globalSupplements.
  • Tier Bébé sur les parcours room-less : les étapes PASSENGER_COUNT/PASSENGER_COUNT_INFO (single-day + vol-sec Seaside) proposent les 4 tiers, BABY inclus — toujours à 0 CHF (one-day : règle client, borne [0-3) divergente d'Horizon — cf. DETTE_TECHNIQUE.md §4 ; vol-sec : aligné Horizon, hardcode 0 pour âge <2). getPriceForPassengerType = point de passage unique (sélecteur, tabulation, p.price). Infos passager collectées pour les bébés comme les autres. Libellés « Adulte / Adolescent / Enfant / Bébé » + tranche dynamique via les helpers d'utils/age-tiers.ts (passengerTypeName, passengerTypeIcon, formatAgeRangeSentence — wording « de X ans révolus à Y ans » / « de moins de X ans ») — cf. business-rules.md → « Tranches d'âge & prix bébé sur les parcours sans chambre ».
  • Pension & assurance vierges par défaut : un nouveau passager naît sans mealPlan ni insuranceId (choix conscient forcé, cf. business-rules.md). passengerInfosValid ne les exige que quand l'UI les présente : pension via requiresMealPlan (bookingConstructor), assurance via passengerHasInsuranceOptions(passengerId) — vrai ssi la chambre du passager offre ≥1 assurance en plage (getInsurancesForRoomKey, le même signal que le formulaire hasInsurances). Ce predicate par-passager remplace l'ancien proxy d'étape requiresInsurance : store et Regle s'accordent par construction, plus de dead-end « bouton grisé sans champ à surligner ». Même doctrine pour le lieu de départ : loadingStopId n'est exigé (Regle + passengerInfosValid, via le predicate injecté hasStopOptions) que si stopOptions est non vide — certains single-day n'ont aucun stop (cf. business-rules.md → « Lieu de départ exigé seulement quand des arrêts sont proposés »).
  • Téléphone requis pour les adultes : TBPassengerInfoForm affiche un champ « Numéro de téléphone » (type="tel") uniquement pour les passagers PassengerType.ADULT, obligatoire sur les deux gates (règle Regle par-item via le $each en forme fonction + passengerInfosValid). Pré-rempli : passager 1 ← customer.mobile (blankPassengerFactory), passager enregistré ← son phone précédent (PassengerSelectModal). BookingPassenger.phone part tel quel dans finalBooking (aucun mapping ajouté). Cf. business-rules.md → « Téléphone obligatoire pour les passagers adultes ».

Dépendances

  • Dépend de : travel-catalog, product-catalog, occurrence-capacity, customer-membership (client connecté), billing-payment (paiement final), gifts (codes cadeaux appliqués)
  • Consommé par : comments-moderation (getMine enrichit avec infos travel issues du bookings store)

Points d'attention

  • rawActiveStepIndex vs activeStepIndex : la progression a un index « brut » et un index visible (qui skippe les étapes non applicables, ex: pas d'Accommodation si pas d'hôtel). Toujours utiliser l'index visible pour l'UI, le brut pour le miroir :stepName et la détection avant/arrière. (Voir mémoire project_back_nav, ADR 0009, ADR 0010.)
  • Retour & sortie wizard : chaque étape est une entrée d'historique réelle (miroir step↔:stepName). Le back natif/Android/navigateur recule d'une étape, et depuis la 1ʳᵉ étape sort vers travel-details — plus de pile de callbacks (supprimée, cf. ADR 0009). Le header (chrome au niveau shell) sort de n'importe quelle étape d'un coup via useBookingExit() (composables/booking-constructor/navigation.ts) et tronque l'historique entier à [Home, travel-details/:slug] (cf. ADR 0014), peu importe le chemin emprunté pour arriver au wizard (recherche, sous-pages voyage, plusieurs étapes) : un back ultérieur depuis travel-details ramène toujours sur Home. Comme la Web History API ne permet pas de sauter directement vers une entrée qui n'existe pas encore, ça prend deux navigations réelles — router.go jusqu'à la position 0 (Home, l'app démarre toujours sur /), puis un router.push frais vers travel-details qui tronque tout ce qui était poussé après (recherche, sous-pages, étapes). Le passage transitoire par Home est masqué par l'overlay de chargement (usePageLoading/LoadingOverlay.vue) le temps que le push final résolve. Le modal de sélection passager est un overlay ?overlay=passenger-select (useOverlayRoute). Voir docs/back-navigation.md.
  • Restauration du scroll par étape : le scroll de chaque étape est mémorisé et restauré, pas via useScrollRestoration (les étapes partagent une seule instance de page, clée par slug). C'est centralisé dans TravelBooking.vue : une Map<stepName, scrollTop> est alimentée par le watcher step→:stepName (qui tourne flush: 'pre', donc avant le swap DOM — il lit le scrollTop du conteneur .travelBooking__main de l'étape sortante) et réappliquée sur le hook @enter du <Transition> interne (double requestAnimationFrame pour survivre au reset post-layout). On restaure dès qu'un offset a été sauvé (étape déjà visitée), donc le retour comme le geste « forward » iOS/navigateur retombent sur la position quittée ; une étape jamais vue (parcours « Suivant » normal) n'a pas d'entrée et s'ouvre donc en haut. Le sens avant/arrière forward du watcher sert à la <Transition> interne (stepTransition local), au scroll et au choix push (avant) / go(-n) (arrière) du miroir d'historique. Les étapes n'ont pas à scroller elles-mêmes en haut (l'ancien onBeforeMount de TBStepPayment a été retiré).
  • reset() ne touche pas la progression ; fullReset() remet tout à zéro y compris le travel chargé et le flag forceWaitingList.
  • Les invariants entre passengerMap, roomReservations et seatAssignments doivent rester cohérents — passer par les helpers du store, jamais muter les Maps directement.
  • Validation guidante des infos passagers : les boutons « Valider » (overlay TBPassengerInfoForm) et « Suivant » (footer, étapes PASSENGER_COUNT_INFO / ACCOMMODATIONS_PASSENGERS) sont pseudo-disabled plutôt que disabled ; un tap sur un formulaire invalide déclenche r$.$validate() pour surligner les champs manquants. Le footer n'ayant pas accès au r$ du formulaire inline, il passe par validationRequestNonce / requestValidation() du store, que le formulaire monté watch. (Voir patterns.md → « Bouton « valider » guidant ».)
  • Étape ACCOMMODATIONS_PASSENGERS — single vs multi-chambre via onActivated : TBStepAccommodationPassengersInformation a deux modes selon totalBookedRoomCount (1 chambre → formulaire inline auto-ouvert ; >1 → liste de chambres à tapoter). Comme l'étape vit dans <KeepAlive>, onMounted ne suffit pas : le nombre de chambres peut changer entre deux visites (retour à l'étape Accommodation). La dérivation de l'overlay single-chambre est donc rejouée à chaque onActivated (et au mount), et l'else (multi/zéro) vide passengerIdsToModify pour ne pas laisser un formulaire fantôme ouvert. Sinon : écran blanc en passant de 2+ → 1, ou overlay résiduel en passant de 1 → 2+.
  • Étape PASSENGER_COUNT_INFO — fenêtre par type de passager : quand plusieurs types de passagers existent (!singleType), tapoter un groupe ouvre TBPassengerInfoForm en fenêtre plein écran (asWindow) pilotée par le ref local passengerIdsToModifypas une entrée d'historique ?overlay= (écart connu avec ADR 0009 : le back natif ne la ferme pas, seuls ✕ et Valider le font). « Valider » (valide) ferme via la prop :on-validate qui vide le ref ; la ✕ (backAction) fait pareil. Le footer wizard est masqué tant qu'une fenêtre passager est ouverte : TBPassengerInfoForm pose le flag store passengerFormWindowOpen en mode asWindow sur les quatre hooks — mount/onActivatedtrue, onDeactivated/unmount → false (la fenêtre apporte sa propre barre « Valider », qui remplace le footer — vaut aussi pour la fenêtre multi-chambre de ACCOMMODATIONS_PASSENGERS), et TravelBookingFooter.showFooter le consomme. Les étapes vivant dans <KeepAlive>, un saut de pastille vers une autre étape ne fait que désactiver le formulaire : sans onDeactivated, le flag restait posé et le footer ne réapparaissait jamais ; au retour sur l'étape, onActivated re-masque le footer si la fenêtre est restée ouverte. En mode singleType (formulaire inline, pas de fenêtre), le footer reste le gate « Suivant ».
  • Étape ACCOMMODATIONS_PASSENGERS — fenêtre multi-chambre = overlay accommodation-<n> : en mode multi-chambre, tapoter une chambre ouvre TBPassengerInfoForm en fenêtre plein écran qui est une entrée d'historique réelle ?overlay=accommodation-<n> (n = position de la chambre dans roomReservations) — le back natif/Android/navigateur la ferme comme tout overlay (ADR 0009). Particularité : la fenêtre embarque l'overlay enfant passenger-select, qui remplace le param overlay quand il s'ouvre. Donc la fenêtre n'est pas gardée par un isOpen exact (sinon elle se démonterait dès l'ouverture du modal enfant) : elle reste montée sur le ref local passengerIdsToModify, et un watch(route.query.overlay) ne la ferme que quand le param devient totalement nul (sortie de tout le sous-arbre). Les router.push/router.back sont inlinés dans l'étape (nom paramétré → useOverlayRoute pas directement utilisable comme garde de montage). Annuler vs garder : le back natif et la font un rollback des saisies passager ; seul un Valider valide les garde. L'étape porte un flag committed (mis à true par Valider via la nouvelle prop onValidate du formulaire) et fait le rollbackPassengerHistory() dans le watch tant qu'il est false — le back natif, qui ne passe jamais par les handlers du formulaire, annule donc comme la ✕. (Voir docs/back-navigation.md → « Parameterized overlay + nested child ».)
  • BookingBase — chargement onMounted (avec retry) + récupération onActivated : BookingBase capture booking.value depuis le store (getBooking, lookup local) à son onMounted, puis charge travel/occurrence via la fonction extraite loadTravelData(b). Si le booking n'est pas encore dans le store — cas du deep-link « Voir ma réservation » juste après paiement, où BookingDetails monte avant que la résa fraîchement créée n'ait atterri via fetchBookingsonMounted fait await fetchBookings() puis réessaie getBooking avant d'abandonner, de sorte que la page charge sur place au lieu de rester blanche. Sous <keep-alive> (BookingDetails), onMounted ne rejoue pas ; onActivated fait alors deux choses à chaque retour : (1) re-capture booking.value frais (statut/facture modifiés côté serveur — bascule de statut, nom de passager back-office), et (2) si le premier chargement a échoué (hasError — booking absent du store au mount), rejoue loadTravelData pour récupérer la page. Garde sur hasError : une page déjà chargée ne fait que rafraîchir le ref booking (pas de refetch travel/occurrence, pas de flash de loading). (Corrige le bug « page blanche jusqu'au restart » : l'ancien onActivated ne re-capturait que booking.valuetravel/occurrence/hasError restaient bloqués, donc revenir par MyTravels ne réparait rien.) La donnée fraîche vient de fetchBookings (MyTravels onMounted inconditionnel ; Home onActivated inconditionnel — cf. business-rules.md → « Rechargement des bookings à l'arrivée Home »). Le PDF de facture, lui, est toujours à jour car re-généré backend à chaque tap (indépendant du store). Cf. billing-payment.md.
  • Bouton « Facture » visible mais grisé tant que la facture n'existe pas : sur BookingDetails, le bouton facture n'est plus masqué pendant PENDING_WEB_OR_MOBILE — il reste visible, grisé (pseudo-disabled, « Facture bientôt disponible »), et un tap toaste une explication au lieu de télécharger un PDF factice. Prédicat isInvoicePending(booking) = status === PENDING_WEB_OR_MOBILE (même partition que l'ancien gate, pas isPendingWorkerProcess dont la formule backend n'est pas vérifiable). Cf. business-rules.md → « Facture PDF : cache-buster réseau… ».
  • MyTravels — trois buckets, annulés séparés : futureBookings (asc), pastBookings (desc) et cancelledBookings (desc) viennent du store bookings ; les annulés (Status === CANCELED) sont exclus de future/past pour ne pas apparaître en double. Piège : les trois getters exigent l'occurrence dans bookingIdsTravelOcc, or GET /occurrences/:id (et /travels/:slug) 404 si le voyage n'est pas Published → une résa annulée sur un voyage dépublié/Draft disparaît de toutes les sections. Détail + limite + options backend : business-rules.md → « Tri des sections “Mes voyages” ».
  • BookingDetails — état annulé : une résa Status === CANCELED (prédicat local isCancelled, distinct de l'annulation partielle BookingPassenger.cancelled) affiche un badge « Voyage annulé » (prioritaire sur « Voyage terminé ») et masque la barre d'actions (Facture + Programme) — pas d'endpoint mobile de facture d'annulation. La durée y est calculée depuis l'occurrence (computeTravelDuration, fix Seaside), plus depuis travel.duration. Cf. business-rules.md → « BookingDetails — badge “Voyage annulé” » et « Durée d'un voyage affichée = calcul depuis l'occurrence ».

Contributors

No contributors

Changelog

No recent changes