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 wizardviews/BookingDetails.vue— détail d'un booking confirméviews/MyTravels.vue— mes réservationsviews/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}.vuetravel-booking/{BookingConfirmation,PassengerSelectModal,TBPassengerInfoForm}.vuecards/{BookingCardBorder,PendingTripCard,TinyBookingCardSummary}.vuebases/BookingBase.vue
- Stores :
stores/bookings.ts— collection des bookings du client (bookings,bookingIdsTravelOccmapping) ; exposefutureBookings(asc),pastBookings(desc),cancelledBookings(desc, bucket séparé — cf.business-rules.md→ « Tri des sections “Mes voyages” »)stores/bookingConstructor.ts— state-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-objetgeneral(id, customerId), passagers, rooms, pricing, occurrenceId, transactionIdBookingPassenger,Passenger,BookingPassengerExt(extension front)BookingStatus:InCreation,Estimate,Draft,WaitingList,Confirmed,Billed,Canceled,PendingWebOrMobileRoom,BookingRoomTypeCategory,BookingRoomTypeCategoryDayTripType:OneWay,RoundTrip(défaut),ReturnPassengerType:Adult,Junior,Child,Baby(calculé viautils/age-tiers.ts)
Wizard — étapes (drivées par useBCProgression)
DepartureDate— choix de l'OccurrenceDepartureLocation— choix duStopde chargement (pour les voyages dynamiques)PassengerCounts— nombre de passagers par tier d'âgeAccommodation— choix de l'hôtel / type de chambre (skippé si non applicable)AccommodationPassengersInformation— répartition des passagers dans les chambres + meal plan (Seaside)PassengerCountsInformation— saisie des infos passagers (nom, civilité, date de naissance, …)SeatSelection— sélection des sièges sur le plan véhiculeWaitingListForm— formulaire si bascule en liste d'attentePayment— 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
TravelBookinggarde 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 (bookingConstructornon persisté). (Les sauts de pastilles ne sont plus enreplace— historique contigu ci-dessus, ADR0011.)
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_SELECTIONauto-skippée sinoVehiclePlan) — appartient à la progression (composables/booking-constructor/progression.ts). La vueTravelBookingne 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 pasnoVehiclePlanelle-même. (C'est l'erreur que corrige ADR0011: un gardenoVehiclePlaninline dans la vue avait normalisé le contraire.) travelOccurrenceIdwatch : 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 = []) appliquetravel.firstNightInTheBus/lastNightInTheBus—checkIn = occurrence.start + 1/checkOut = occurrence.end − 1jour. 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
Bookingréel — c'est juste l'endpointpublicApi.mail.addWaitingListqui envoie un mail. La routewaiting-list-confirmationaffiche un accusé après envoi. Le flagforceWaitingListdans le constructor sert à forcer ce flow. Le payload reproduit le DTOWaitingListRequestde Horizon (requestedStop/requestedLinerésolus depuis l'arrêt choisi,occurrenceDatesen 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)dansoccurrence-capacity. PaymentDepositOnly: porté par le booking, ne s'applique qu'au paiement web/mobile (cf.billing-payment). En back-office Horizon, ce sont les statutsConfirmedvsBilledqui 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 :
useBCPricingcollecte les entrées (chambres, passagers, assurances, suppléments, early booking, scopes Regle du paiement) et délègue la chaîne à la fonction purecomputeBookingPricing(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) : dansfinalBooking, chaque passager reçoitp.price = getFullPricePerPassenger(p)(composables/booking-constructor/pricing.ts) = part chambre (getRoomPriceForPassenger,-1→0) + transport par tranche si voyage sans chambre (getPriceForPassengerType,OneDay/vol-sec) + upcharge pension (getMealPlanPrice). L'assurance est exclue du prix et émise en lignep.itemPrices(« Assurances »). Miroir duCost.getRealCostPerPassengerdu 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'ancienp.price = getRoomPriceForPassenger(p.id)qui renvoyait-1pour tout voyage sans chambre — cf.business-rules.md→ « Prix par passager calculé au submit ».) - Suppléments globaux (
globalSupplements) :useBCPricingcalcule le montant des suppléments globaux (taxes) portés par l'occurrence sélectionnée viautils/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 dansTBPayment_Summary.vue) et envoyé dansfinalBooking.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émetoutput.booking.discounts= les lignesDiscountrésolues parresolveSpecialOfferDiscounts(utils/special-offers.ts, exposées paruseBCPricing.specialOfferDiscounts) — toujours (y compris[]). Indispensable : Horizon ne re-dérive pas les offres spéciales (contrairement à l'early booking), il appliquebooking.discountsverbatim à la facture — sans émission, la facture dépasseraitamountToPay. 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 quegifts/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,BABYinclus — 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
mealPlanniinsuranceId(choix conscient forcé, cf.business-rules.md).passengerInfosValidne les exige que quand l'UI les présente : pension viarequiresMealPlan(bookingConstructor), assurance viapassengerHasInsuranceOptions(passengerId)— vrai ssi la chambre du passager offre ≥1 assurance en plage (getInsurancesForRoomKey, le même signal que le formulairehasInsurances). Ce predicate par-passager remplace l'ancien proxy d'étaperequiresInsurance: 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 :loadingStopIdn'est exigé (Regle +passengerInfosValid, via le predicate injectéhasStopOptions) que sistopOptionsest 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 :
TBPassengerInfoFormaffiche un champ « Numéro de téléphone » (type="tel") uniquement pour les passagersPassengerType.ADULT, obligatoire sur les deux gates (règle Regle par-item via le$eachen forme fonction +passengerInfosValid). Pré-rempli : passager 1 ←customer.mobile(blankPassengerFactory), passager enregistré ← sonphoneprécédent (PassengerSelectModal).BookingPassenger.phonepart tel quel dansfinalBooking(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(getMineenrichit avec infos travel issues dubookingsstore)
Points d'attention
rawActiveStepIndexvsactiveStepIndex: la progression a un index « brut » et un index visible (qui skippe les étapes non applicables, ex: pas d'Accommodationsi pas d'hôtel). Toujours utiliser l'index visible pour l'UI, le brut pour le miroir:stepNameet la détection avant/arrière. (Voir mémoireproject_back_nav, ADR0009, ADR0010.)- 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. ADR0009). Le header ✕ (chrome au niveau shell) sort de n'importe quelle étape d'un coup viauseBookingExit()(composables/booking-constructor/navigation.ts) et tronque l'historique entier à[Home, travel-details/:slug](cf. ADR0014), 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.gojusqu'à la position 0 (Home, l'app démarre toujours sur/), puis unrouter.pushfrais 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 lepushfinal résolve. Le modal de sélection passager est un overlay?overlay=passenger-select(useOverlayRoute). Voirdocs/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é dansTravelBooking.vue: uneMap<stepName, scrollTop>est alimentée par le watcher step→:stepName(qui tourneflush: 'pre', donc avant le swap DOM — il lit le scrollTop du conteneur.travelBooking__mainde l'étape sortante) et réappliquée sur le hook@enterdu<Transition>interne (doublerequestAnimationFramepour 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èreforwarddu watcher sert à la<Transition>interne (stepTransitionlocal), 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'ancienonBeforeMountdeTBStepPaymenta été retiré). reset()ne touche pas la progression ;fullReset()remet tout à zéro y compris le travel chargé et le flagforceWaitingList.- Les invariants entre
passengerMap,roomReservationsetseatAssignmentsdoivent 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, étapesPASSENGER_COUNT_INFO/ACCOMMODATIONS_PASSENGERS) sontpseudo-disabledplutôt quedisabled; un tap sur un formulaire invalide déclencher$.$validate()pour surligner les champs manquants. Le footer n'ayant pas accès aur$du formulaire inline, il passe parvalidationRequestNonce/requestValidation()du store, que le formulaire montéwatch. (Voirpatterns.md→ « Bouton « valider » guidant ».) - Étape
ACCOMMODATIONS_PASSENGERS— single vs multi-chambre viaonActivated:TBStepAccommodationPassengersInformationa deux modes selontotalBookedRoomCount(1 chambre → formulaire inline auto-ouvert ; >1 → liste de chambres à tapoter). Comme l'étape vit dans<KeepAlive>,onMountedne suffit pas : le nombre de chambres peut changer entre deux visites (retour à l'étapeAccommodation). La dérivation de l'overlay single-chambre est donc rejouée à chaqueonActivated(et au mount), et l'else(multi/zéro) videpassengerIdsToModifypour 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 ouvreTBPassengerInfoFormen fenêtre plein écran (asWindow) pilotée par le ref localpassengerIdsToModify— pas une entrée d'historique?overlay=(écart connu avec ADR0009: le back natif ne la ferme pas, seuls ✕ et Valider le font). « Valider » (valide) ferme via la prop:on-validatequi vide le ref ; la ✕ (backAction) fait pareil. Le footer wizard est masqué tant qu'une fenêtre passager est ouverte :TBPassengerInfoFormpose le flag storepassengerFormWindowOpenen modeasWindowsur les quatre hooks — mount/onActivated→true,onDeactivated/unmount →false(la fenêtre apporte sa propre barre « Valider », qui remplace le footer — vaut aussi pour la fenêtre multi-chambre deACCOMMODATIONS_PASSENGERS), etTravelBookingFooter.showFooterle consomme. Les étapes vivant dans<KeepAlive>, un saut de pastille vers une autre étape ne fait que désactiver le formulaire : sansonDeactivated, le flag restait posé et le footer ne réapparaissait jamais ; au retour sur l'étape,onActivatedre-masque le footer si la fenêtre est restée ouverte. En modesingleType(formulaire inline, pas de fenêtre), le footer reste le gate « Suivant ». - Étape
ACCOMMODATIONS_PASSENGERS— fenêtre multi-chambre = overlayaccommodation-<n>: en mode multi-chambre, tapoter une chambre ouvreTBPassengerInfoFormen fenêtre plein écran qui est une entrée d'historique réelle?overlay=accommodation-<n>(n= position de la chambre dansroomReservations) — le back natif/Android/navigateur la ferme comme tout overlay (ADR0009). Particularité : la fenêtre embarque l'overlay enfantpassenger-select, qui remplace le paramoverlayquand il s'ouvre. Donc la fenêtre n'est pas gardée par unisOpenexact (sinon elle se démonterait dès l'ouverture du modal enfant) : elle reste montée sur le ref localpassengerIdsToModify, et unwatch(route.query.overlay)ne la ferme que quand le param devient totalement nul (sortie de tout le sous-arbre). Lesrouter.push/router.backsont inlinés dans l'étape (nom paramétré →useOverlayRoutepas 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 flagcommitted(mis àtruepar Valider via la nouvelle proponValidatedu formulaire) et fait lerollbackPassengerHistory()dans le watch tant qu'il estfalse— le back natif, qui ne passe jamais par les handlers du formulaire, annule donc comme la ✕. (Voirdocs/back-navigation.md→ « Parameterized overlay + nested child ».) BookingBase— chargementonMounted(avec retry) + récupérationonActivated:BookingBasecapturebooking.valuedepuis le store (getBooking, lookup local) à sononMounted, puis chargetravel/occurrencevia la fonction extraiteloadTravelData(b). Si le booking n'est pas encore dans le store — cas du deep-link « Voir ma réservation » juste après paiement, oùBookingDetailsmonte avant que la résa fraîchement créée n'ait atterri viafetchBookings—onMountedfaitawait fetchBookings()puis réessaiegetBookingavant d'abandonner, de sorte que la page charge sur place au lieu de rester blanche. Sous<keep-alive>(BookingDetails),onMountedne rejoue pas ;onActivatedfait alors deux choses à chaque retour : (1) re-capturebooking.valuefrais (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), rejoueloadTravelDatapour récupérer la page. Garde surhasError: une page déjà chargée ne fait que rafraîchir le ref booking (pas de refetchtravel/occurrence, pas de flash de loading). (Corrige le bug « page blanche jusqu'au restart » : l'ancienonActivatedne re-capturait quebooking.value—travel/occurrence/hasErrorrestaient bloqués, donc revenir par MyTravels ne réparait rien.) La donnée fraîche vient defetchBookings(MyTravelsonMountedinconditionnel ; HomeonActivatedinconditionnel — 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é pendantPENDING_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édicatisInvoicePending(booking)=status === PENDING_WEB_OR_MOBILE(même partition que l'ancien gate, pasisPendingWorkerProcessdont 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) etcancelledBookings(desc) viennent du storebookings; 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 dansbookingIdsTravelOcc, orGET /occurrences/:id(et/travels/:slug) 404 si le voyage n'est pasPublished→ une résa annulée sur un voyage dépublié/Draftdisparaît de toutes les sections. Détail + limite + options backend :business-rules.md→ « Tri des sections “Mes voyages” ».BookingDetails— état annulé : une résaStatus === CANCELED(prédicat localisCancelled, distinct de l'annulation partielleBookingPassenger.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 depuistravel.duration. Cf.business-rules.md→ «BookingDetails— badge “Voyage annulé” » et « Durée d'un voyage affichée = calcul depuis l'occurrence ».

