Tableau de bord voyageur
Vue rendue sous /dashboard (views/TravellerDashboard.vue, via Dashboard.vue quand la casquette voyageur est active), portée pour le design depuis .lovable-ref/.../TravelerDashboard.tsx (voir le skill inspiration-to-vue). C'est l'accueil de la casquette voyageur (voir Routage & gardes) : seules les casquettes traveller y accèdent.
Composition
views/TravellerDashboard.vue— coquille reprenant celle du portail super-admin :SidebarProvider+ barre latérale + barre supérieure (fil d'Ariane, déconnexion) + section active. La section courante est liée à l'URL (/dashboard/:section,useDashboardSection). La déconnexion passe par le store de session puis redirige vers/login.components/traveller/TravellerSidebar.vue— barre latérale repliable (primitive shadcn-vuesidebar), navigation plate (pas de groupes, contrairement au super-admin). Propactive, émetselect. Son en-tête rend leRoleSwitcher(sélecteur de casquette réutilisable, cf. doc super-admin).components/traveller/MyJourneysView.vue— vue « Mes voyages » : voyages réels du voyageur (GET /activation-codes/mine), filtres en pastilles (À faire / Passés / Cadeaux en attente / Tous) et les actions par voyage : rembourser, offrir/reprendre, prolonger, annuler une programmation.La liste est un point d'entrée, pas un inventaire : « À faire » ouvre l'écran (
DEFAULT_JOURNEY_FILTER) et « Tous » devient la porte de sortie en fin de rangée. « Cadeaux en attente » n'apparaît que si un cadeau est réellement en attente (visibleJourneyFilters) ; comme la reprise d'un cadeau se fait dans cette vue même, l'onglet peut disparaître sous le voyageur qui s'y trouve — unwatchle ramène alors au filtre par défaut plutôt que de le laisser sur une pastille absente de l'écran.Le champ « J'ai un code » (réclamer / rouvrir) vit dans l'onglet « À faire », en tête de liste : voici les voyages que vous pouvez commencer, et voici comment en ajouter un. Il est rendu au-dessus de l'état vide, donc il survit à un « À faire » vide — précisément le cas d'un voyageur tout neuf. Les messages d'état vide sont par panier (
journeys.empty.{todo,past,gifted,all}), la variantetodorenvoyant vers le champ juste au-dessus.Une tuile porte le nom du parcours choisi à l'activation dès qu'elle en a un (
journeyTitle) ; un voyage non activé garde le titre produit générique. Cliquer une tuile mène là où le voyage en est : non activé →CardDetailView(seul chemin vers l'activation et le cadeau, inchangé) ; planifié → son compte à rebours de départ ; en cours → sa timeline ; terminé → la même page en récap scellé. Autrement dit, tout voyage activé ouvre son roadtrip (opensRoadtrip, routeroadtrip-journey) — ce queMyRoadtripViewsait déjà rendre dans ses trois états. Un voyage qui n'a jamais eu lieu (offert, expiré, annulé, remboursé) n'est pas cliquable, et l'affordance suit ce que le clic fait vraiment. Le quatrième onglet dit « Cadeaux en attente » et non « Voyages offerts » parce que c'est ce que la donnée permet :ClaimActivationCodeControllerréassigne lecustomer_idau destinataire et effacegifted_at, donc un cadeau réclamé disparaît entièrement de/activation-codes/minecôté offrant. Seuls les cadeaux non réclamés peuvent être listés ; un historique « voyages que j'ai offerts » demanderait une notion backend de transfert, hors périmètre. Le champ « J'ai un code » ne rouvre un voyage déjà possédé que sicanOpen()(l'apiStatusriche) l'autorise — un voyage offert, planifié ou en demande de remboursement est refusé avec le toast « non activable ». Après un claim réussi (POST /activation-codes/claim), le détail ne s'ouvre que si le voyage figure bien dans la liste rechargée ; sinon un toast d'erreur explicite s'affiche (redeem.missingAfterClaim) plutôt que d'ouvrir un détail fantôme.components/traveller/CardDetailView.vue— page de détail d'un voyage utilisable : révélation du code, choix d'usage (« Pour moi » / « L'offrir en digital » / « L'imprimer pour offrir »), chaque mode ouvrant son panneau de confirmation en place. Présentationnel : les actions réelles remontent au conteneur (activate,gift,gift-print).Offrir un voyage (digital / impression) — les deux panneaux du détail frappent
POST /activation-codes/{id}/gift(giftMyCard) via le flux partagérunGiftFlowdeMyJourneysView: un onglet est réservé synchroniquement dans le geste utilisateur (deferredWindow,src/lib/popup.ts— patron anti-popup-blocker : onglet blanc réservé avant l'await, navigué après, repli en ouverture directenoopenersi le blanc est lui-même bloqué). Succès digital → l'onglet part surhttps://wa.me/?text=…(messagegift.shareMessage: code d'activation + lienpdfUrl) ; succès impression → l'onglet part sur lepdfUrldu voyage (URL signée éternelle du bon cadeau A4, rendu à la volée côté backend — jamais préchargé). Un 422already_offeredest traité comme un succès (l'état visé est déjà acquis — course entre onglets) ; tout autre 422 ferme l'onglet réservé avecgift.error, un échec non-422 avecredeem.serverError. Le bouton « Partager » d'un voyage déjà offert réutilise le même message WhatsApp (code + lien PDF), en ouverture directe (pas d'awaitavant le geste).components/traveller/CardActivationFlow.vue— wizard d'activation. Étapes : code (sautée quand le voyage est connu) → programmation (« Maintenant » / « Demain » / « Plus tard ») → planification (région, parcours, puis nombre de voyageurs — la taille du groupe, entier ≥ 1, défaut 1, requise par le backend ; un compte invalide bloque le CTA). « Maintenant » est gardé parActivateNowDialog.vue(avertissement : activation instantanée, étapes déjà passées potentiellement manquées, irréversible) ; « Plus tard » ouvrePickActivationDateDialog.vue(primitivecalendar, borné entre demain et l'expiration du voyage). Les régions/parcours viennent deGET /tour(les voyageurs ne voient que les tours actifs), regroupés côté client sur la région embarquée — il n'existe pas d'endpoint « régions » accessible au voyageur. L'étape région affiche de grandes cartes image 16/10 (porté deActivate.tsx, section « 01 — Région ») : photomediumde l'image vitrine embarquée danstour.region(regionImage/regionDescriptionsurTravellerTour), dégradé de lisibilité, titre et description superposés en bas à gauche (le nombre de parcours sert de sous-titre quand la description manque), anneauring-primary+ pastille ✓ quand la région est choisie. Une région sans image (l'état de lancement) garde le même cadre en carte pointillée délibérée (icôneMountain, texte centré), mêmes états de sélection. Le choix du parcours est requis et part avec l'activation : activer, c'est désigner le parcours, la taille du groupe et le départ d'un même geste (tourIdettravellerCountdans l'intentionactivate). Dès qu'un parcours est choisi, une carte « À prendre avec vous » (ThingsToBringCard.vue, coquille portée du panneau packing de la référence, pastilles statiques en tonprimary, libellés viatagName— locale active, repli français) s'affiche entre l'étape 03 et le CTA :fetchTourThingsToBring(tourId)→GET /tour/{id}/things-to-bring(union dédupliquée des tagsthing_to_bringde toutes les étapes et activités plan A/B, sans nom d'étape ni d'activité — l'itinéraire reste une surprise). Un watcher sur le parcours sélectionné vide la liste immédiatement, recharge, et n'applique la réponse que si la sélection n'a pas bougé (garde anti-course) ; l'appel est non bloquant par conception — échec avalé sans UI d'erreur, carte masquée si vide,canStartRoadtripne dépend jamais de lui.components/traveller/activation-api.ts— wrapper API local à la fonctionnalité (précédenttags-api.ts;src/api/reste en lecture seule) :fetchTravellerTours(),activateTravellerCard(id, tourId, travellerCount, date?)(POST /activation-codes/{id}/activate,tour_idettraveller_count(≥ 1, le nombre que ce voyage ajoute aux agrégats d'occupation) requis ; sans date = immédiat, avec date ISO = planifié à 09:00 locales viamorningOfLocalDayIso) etcancelPlannedActivation(id)(DELETE /activation-codes/{id}/activation, qui efface aussi letraveller_countcôté backend).components/traveller/journeys.ts— modèle des voyages : statuts, filtres,STATUS_META.categoryOf()est une fonction pure de l'apiStatusriche, pas du statut booléen dérivé — seul le statut riche distingue les nuances (un voyage offert litpurchasedsur les booléens, un voyage planifié litused). Les paniers suivent ce que le voyageur a vécu, pas ce qu'a fait la facturation :todo←purchased,pre-active,active;past←usedseul ;gifted←offered(testé en premier) ;- aucun panier (seulement « Tous ») ←
expired,cancelled,reimbursed,pending-extension,pending-reimbursement,in stock.
usedest un voyage qui a eu lieu ;expired/cancelled/reimbursedsont des voyages qui n'ont jamais eu lieu — un remboursement et un vrai souvenir de voyage ne partagent plus le même panier.pending-extensionimpliqueused_at IS NULLet une date dépassée : un voyage expiré qui espère un sursis, pas un voyage à faire. Leswitchest exhaustif, donc un nouveau statut du contrat est un trou à la compilation et non unnullsilencieux ; un voyage sansapiStatusne tombe dans aucun panier volontairement — un repli sur le statut dérivé masquerait un défaut de mapping au lieu de le révéler.isScheduledActivation()reste, mais ne classe plus : il ne sert qu'au badge « Activation prévue le {date} » (un voyage planifié arrive mappéusedavec unactivatedAtencore futur). Un voyage planifié ne rouvre pas le détail et n'offre plus le remboursement. Le module porte aussi les prédicats de prolongation, source unique pour la vue :canExtend()(voyage acheté ou expiré sans demande ouverte — l'apiStatusriche écarte les nuances refusées par le backend : offerte, planifiée, remboursement en attente),extensionRequested()(drapeauhas_open_extension_request, orthogonal au statut : un voyage encore valide reste utilisable pendant l'instruction) etextensionDenied()(dernier refus non supplanté — ni redemande ouverte, ni octroi postérieur — sur un voyage encore prolongeable). Les prédicats d'état généraux (isOffered,canOpen,canRequestReimbursement,isReimbursementPending,reimbursementDenied) vivent au même endroit, tous clés sur l'apiStatusriche — la vue ne garde plus de copies locales à base de timestamps.Prolongation d'un voyage — le bouton « Prolonger » apparaît sur les voyages achetés et expirés (
ExtendCardDialog.vue, dont le texte distingue les deux cas) et frappePOST /activation-codes/{id}/extension-request(requestCardExtension,src/api/codes.ts). Une demande ouverte s'affiche « Demande de prolongation envoyée » avec le sablier ambre partagé (components/CodePendingExtensionIcon.vue, tooltip « Extension en attente »). Un refus (motif posé par l'admin) s'affiche en rouge sur le voyage — « Demande de prolongation refusée : {motif} » — tant que rien ne le supplante, et le voyage peut redemander aussitôt.Remboursement d'un voyage — une demande en cours ne remplace plus la pastille de statut : le voyage garde son statut réel, le bouton « Rembourser » reste visible mais désactivé avec la mention « Remboursement demandé » (le backend gèle le voyage : il ne rouvre pas son détail tant que la demande est ouverte). Un refus admin rend le voyage à son état naturel : le motif s'affiche en rouge — « Remboursement refusé : {motif} » — le bouton se réarme et le détail rouvre. L'approbation est le remboursement lui-même (statut
reimbursed).components/traveller/MyRoadtripView.vue— section « Mon Roadtrip » (première de la navigation) : projecteur sur le road trip courant du voyageur. Un voyage en cours affiche l'en-tête du parcours (nom du tour embarqué, pastille « En cours ») puis la timeline des étapes (RoadtripStepList, sous le titre « Itinéraire ») ; sans tour embarqué (défensif), l'ancien emplacement en pointillés reste. Un voyage planifié (pre-active) affiche un compte à rebours vers le départ (DepartureCountdown.vue, cellules jours / heures / minutes, cible =used_at— le backend y écrit le départ planifié futur, même bizarrerie queisScheduledActivation) qui recharge la vue à zéro (@done) ; sans voyage qualifiant, un état vide renvoie vers « Mes voyages » (navigate, même patron queMySwitzerlandView). La section reçoit le voyage à afficher en prop (cardId, lu depuis la route parTravellerDashboard.vue— la section est montée sans routeur dans son propre spec, donc unuseRoute()interne la casserait) : renseigné, elle ouvre ce voyage précis (fetchMyRoadtripCardById) ; nul, elle garde le choix automatique. Un voyage terminé (used) garde exactement le même en-tête et la même timeline, en lecture seule, avec le sous-titreroadtrip.past.subtitle(« Votre aventure est terminée. ») au lieu deactive.subtitle: auparavant le roadtrip entier — parcours, étapes, contenus déjà révélés — basculait dans l'état vide à la seconde où le voyage se terminait.Timeline des étapes du roadtrip actif — mobile-first (l'app sera bientôt encapsulée dans Capacitor), fidèle à la DA existante, branchée sur les endpoints voyageur du contrat (
GET /activation-codes/{id}/steps,POST /activation-codes/{id}/steps/{stepId}/reveal) :components/traveller/roadtrip-steps.ts— modèle pur.RoadtripStepreprend les champs du contrat :revealableAt(instant absolu ISO-8601 avec offset, ou nul = jamais révélable),revealedAt(posé par le serveur au premier affichage) etname/startTime/dayNumbernuls tant que l'étape n'est pas révélée — une étape non révélée est anonyme sur le fil (rétention côté serveur). Sémantique de révélation :revealableAtfait foi, côté serveur — l'instant est calculé par le backend (tablestep_routings), le client ne dérive plus rien : il compare seulementrevealableAtà son horloge.stepDisplayState(step, now, sealed?)(locked/revealable/revealed— révélée dès que la charge est nommée ou querevealedAtest posé ; instant imparsable → verrouillée, défensif) etnextUpcomingLockedStep()(la seule étape verrouillée qui porte l'indice « Se dévoile dans … » : le plus petitrevealableAtfutur).sealedmarque un voyage terminé : une étape non révélée y reste une silhouette pour toujours, quel que soit son instant de révélation — le voyage a eu lieu, la surprise non, et proposer de la dévoiler après coup vendrait un moment qui ne peut plus être vécu. Une étape déjà révélée garde son contenu : c'est le souvenir.components/traveller/roadtrip-steps-api.ts— wrapper API local à la fonctionnalité (précédentactivation-api.ts;src/api/reste en lockstep avec le contrat) :fetchMyRoadtripSteps()relitGET /activation-codes/{id}/steps— la liste complète, bornée par la révélation —,fetchMyRoadtripStep()lit une étape et son contenu (GET /activation-codes/{id}/steps/{stepId}: l'activité routée, résolue côté serveur viastep_routings.activity_step_id, donc le client n'arbitre jamais entre le plan A et ses alternatives), etrevealRoadtripStep()frappePOST /activation-codes/{id}/steps/{stepId}/reveal, qui enregistre le premier affichage (la lignestep_routings) et renvoie la même charge détaillée que la lecture — la navigation qu'il déclenche pourrait donc être amorcée sans second appel. Les décimales arrivent en chaînes (price_min« 45.00 »,latitude« 46.0037000 » — les castsdecimal:du backend) : elles sont converties à la frontière de mapping, et une valeur illisible dégrade ennull(le bloc disparaît) plutôt qu'en « NaN ». Un reveal prématuré (avantrevealableAt) est un 422 et suit le chemin d'échec existant (toast, carte toujours révélable). Le serveur ne révèle jamais de lui-même — plusieurs étapes peuvent être révélables en même temps, la révélation n'a lieu qu'à l'appel explicite.components/traveller/RoadtripStepList.vue— conteneur : fetch local (pas de store Pinia), un seul ticker 1 s pour toutes les cartes qui recalculenow(jamais de décrément — l'étranglement des onglets en arrière-plan est sans effet), et un écouteurvisibilitychangequi resynchronise l'horloge au retour au premier plan (leresumede Capacitor réutilisera ce point d'entrée). Toucher une carte révélable monte l'overlay et lance la requête de révélation en parallèle (le compte à rebours masque la latence) ; la charge nommée n'est appliquée qu'à la fin du compte à rebours ; un échec affiche un toast et laisse la carte révélable. Jamais d'auto-révélation : une étape dépassée depuis longtemps attend quand même le toucher. Itinéraire vide → message dédié ; erreur → relance. Sur un voyage terminé (card.state === "used") la liste passe en lecture seule :sealedest propagé àstepDisplayState, aucune carte n'est révélable, aucun indice de compte à rebours n'est calculé et l'overlay n'est jamais monté. Un 403 n'est pas traité comme une panne mais comme un itinéraire absent (panneau vide) : le backend sert les voyages planifiés, en cours et terminés, donc un 403 désigne un voyage jamais activé — une erreur que le voyageur ne pourrait que relancer dans le même refus serait plus alarmante qu'utile.components/traveller/RoadtripStepCard.vue— carte présentationnelle à trois états (rounded-2xl, à laJourneyCard) : verrouillée (fond sourd, cadenas, « Étape {n} » anonyme ; la seule prochaine à venir ajoute « Se dévoile dans {time} ») ; révélable (bouton pleine largeur stylé à la main — pas deButtonshadcn, ses varianteshover:bg-accententrent en collision avec le jeton accent rouge suisse — halo rouge accentanimate-reveal-pulse, cible tactile ≥ 56 px, anneau statique enmotion-reduce) ; révélée (nom, « Jour {n} » et heure de rendez-vous, sans pastille de statut — l'absence de cadenas et de fond sourd dit déjà l'état, et sur écran étroit la pastille volait la largeur du nom, qui passe désormais à la ligne au lieu d'être tronqué). Une carte révélée devient unRouterLinkvers l'écran de détail dès qu'une destination lui est passée (to) : un vrai lien, pour que le clic milieu et l'ouverture dans un onglet se comportent normalement ; sansto, elle reste inerte et n'affiche pas de chevron.components/traveller/RevealCountdownOverlay.vue— compte à rebours 3-2-1 plein écran entre le toucher et la révélation : Teleport artisanal (pas de Dialog — aucun piège de focus/ESC voulu, il s'auto-conclut en ~3 s en émettantfinishedune seule fois), dégradé rougeaccent → destructive, marque Travelise en ligne (le tracé du favicon,currentColor), chiffre géant remonté à chaque seconde, margesenv(safe-area-inset-*)pour Capacitor, zonerole="status"aria-live="polite".src/style.css— jeton d'animation--animate-reveal-pulse(@keyframes reveal-pulse: ombre respirante encolor-mix(var(--accent))) et jeton--font-display(serif d'affichage cantonné aux surfaces « surprise » du voyageur, pas un changement global de design system : pile système et non webfont, parce que l'app part sous Capacitor où un téléchargement de police à l'exécution est précisément ce qu'un voyageur hors ligne ne peut pas se permettre).
Écran de détail d'une étape — atterrissage après la révélation (le compte à rebours se termine, la charge est appliquée à la liste, puis la navigation part) et au toucher d'une carte déjà révélée :
- Route dédiée
/dashboard/:section(roadtrip)/:cardId/steps/:stepId(ROUTE_NAME.roadtripStepDetail), même mécanique que le détail revendeur :Dashboard.vuereste l'écran rendu, le paramètresectionest contraint au slugroadtrippour queuseDashboardSectiongarde la section active sans logique dédiée, etTravellerDashboard.vuesubstitue le détail à la section quandstepIdest présent. Pas un échange de vue en place : sous Capacitor, le bouton retour matériel d'Android quitterait l'application au lieu de revenir à la timeline, et un rechargement ou une reprise d'app doit garder le voyageur sur son étape. components/traveller/roadtrip-step-detail.ts— modèle pur :RoadtripStepDetail(étendRoadtripStep:planRetired,activitynulle tant que l'étape n'est pas révélée),StepActivityDetail,StepProvider, et les formateurs testablesformatAddress()(ordre postal),mapsUrl()(coordonnées sinon recherche sur l'adresse),formatPriceRange()(prix fixe ou fourchette,Intl),durationParts(),needsExpander()(budget de caractères plutôt que mesure DOM : même comportement en test, en webview et sous Capacitor, et jamais de « Plus » sur un texte déjà entier) etisRevealedDetail(). Il porte aussinextPlanRequestableAtet son gardecanRequestNextPlan(detail, now)— voir « Demander une autre proposition » ci-dessous.components/traveller/RoadtripStepDetailView.vue— l'écran. Résout le voyage par son id d'URL (fetchMyRoadtripCardById()) puis l'étape, donc un lien profond fonctionne à froid et vise le bon voyage — il rejouait auparavant le choix déterministe, ce qui renvoyait un voyageur ayant fait deux fois le même parcours sur l'étape du mauvais voyage ; la lecture ne révèle jamais : une étape non révélée revient en silhouette anonyme avec un 200 et le voyageur est renvoyé à la timeline (replace) plutôt que de voir un écran vide. Deux en-têtes, et celui sans photo est le cas par défaut, pas un repli : sur les dix activités réellement atteignables aujourd'hui, une seule porte une image, aucune n'a de description, de durée ni de prix. L'en-tête sans photo est donc un panneau typographique délibéré (lavis de marque, glypheRoutesurdimensionné, même échelle de titre) qui s'illumine en héros photographique quand une image arrive (crédit Pexels embarqué avec elle). Chaque bloc en dessous est gardé individuellement et disparaît en entier — jamais de conteneur vide ni de titre orphelin. Quand l'activité n'apporte rien en propre, l'écran se referme sur un panneau centré (min-h-[44vh], pastilleSparkles, heure en serif d'affichage puisdetail.bare) qui occupe la hauteur restante au lieu de laisser une ligne isolée en haut d'une colonne vide. Le prédicat qui le déclenche (hasActivityContent) est volontairement aveugle àstartTime: l'heure décrit le créneau, pas l'activité, et les étapes en portent une dans les données réelles alors que leurs activités sont vides — la compter comme contenu revenait à n'afficher qu'un titre, une pastille d'heure esseulée et du blanc. Dans ce cas la rangée de pastilles est supprimée et l'heure est promue en tête du panneau (detail.rendezvous), donc jamais affichée deux fois. Le prix est masqué quand il est absent (absent = inconnu, jamais « gratuit ») ; le nom du prestataire n'est pas rendu (il duplique le nom de l'activité dans les données réelles) ;planRetiredn'a aucun traitement visuel (le voyageur y est allé — griser son souvenir serait faux), le drapeau reste au modèle pour un usage admin ultérieur. Le bouton « Retour » préfère l'historique réel et retombe sur la timeline à froid.- « Demander une autre proposition » — le repli quand ça coince sur place (restaurant complet, bateau annulé). L'action vit uniquement sur l'écran de détail ; la carte de la timeline reste un pur résumé. Elle apparaît à partir de l'heure de rendez-vous de l'étape, pas de la révélation, et le basculement est irréversible : le serveur enregistre le nouveau routage, sans retour possible.
- Toute la règle client tient en une comparaison, désormais sur une fenêtre :
at !== null && now >= at && (until === null || now < until). Semi-ouverte en haut : à l'instantuntilexact, l'offre a déjà disparu. La fenêtre court du rendez-vous de l'étape au début de l'étape suivante (borné par la fin du voyage pour la dernière), mais toute cette arithmétique est au serveur — le client ne compare que des instants, comme pourrevealableAt, et ne raisonne jamais en rangs de plan. Aucune branche n'est donc nécessaire pour un voyage scellé, fini ou sans alternative : le serveur envoienullet le bouton n'existe pas. - Les deux bornes doivent être lues. Le serveur continue de servir
next_plan_requestable_ataprès la fermeture de la fenêtre : une fenêtre close, c'estatpassé etuntilpassé, jamais unatabsent. Unatpassé seul ne veut donc plus dire « ouvert ». - Défense asymétrique, volontaire : une borne absente ou illisible — l'une comme l'autre — ferme l'offre. Tout doute se résout en « pas de bouton » : proposer une action irréversible que le serveur refuserait coûte quelque chose au voyageur, la cacher à tort ne lui coûte qu'un toucher.
- Vocabulaire : jamais de rang ni de lettre. Le voyageur ignore qu'il existe un « plan A » — il demande une autre proposition, et c'est tout ce que l'interface dit, dans les trois langues.
RequestAnotherPlanDialog.vue— la confirmation, bâtie sur@/components/ui/dialog(il n'y a pas d'AlertDialogshadcn-vue dans ce dépôt) avec l'anatomie d'ActivateNowDialog(glyphe d'alerte, corps sobre, annulationoutline) parce qu'elle existe pour dire une seule chose : c'est définitif. Contrairement àActivateNowDialog, la confirmation déclenche la vraie requête, d'où l'étatpending(traitement deReimburseCardDialog) : les deux boutons se verrouillent et la confirmation tourne — un double toucher brûlerait deux plans.- Pas de ticker sur cet écran : rien n'y décompte, donc un unique
setTimeoutarmé sur la borne que le voyageur n'a pas encore franchie — l'ouverture tant que la fenêtre est devant, la fermeture une fois ouverte. Le rappel ré-entre danssyncPlanClock()au lieu de simplement rafraîchir l'horloge : franchir l'ouverture est exactement ce qui arme la fermeture, donc une fenêtre qui s'ouvre puis se referme pendant la lecture bascule le bouton deux fois, sans ticker et sans rechargement. Un seul minuteur est vivant à la fois, et un test épingle ce compte à chaque étape (il échoue si le ré-armement disparaît). Un delta au-delà de ~24,9 jours n'arme aucun minuteur (setTimeouty déborde sur 32 bits signés et se déclencherait immédiatement), et l'horloge est resynchronisée survisibilitychange— le même crochet que la timeline, celui que reprendra leresumede Capacitor. Cette resynchronisation compte davantage depuis la fenêtre : celle-ci peut se refermer pendant que l'application est en arrière-plan. - Une modale déjà ouverte n'est PAS refermée quand la fenêtre expire sous elle. Une boîte de dialogue qui s'évapore en pleine décision n'explique rien ; confirmer se heurte au refus du serveur, ce qui donne au voyageur un vrai message et ne lui coûte rien (le plan reste intact). Décision épinglée par un test pour qu'elle reste un choix.
- Rafraîchissement en place : la réponse est le nouveau plan en entier (même
TravellerStepDetailResourceque la lecture et la révélation), doncdetailest remplacé sans seconde requête — motif 2, comme la timeline substitue son étape révélée.expandedest remis à zéro au passage : l'état « Plus » de la description précédente ne dit rien de la nouvelle. Un refus est définitif (trop tôt, plus de plan, voyage pas en cours) : on ne peut que prévenir (toast.error) et laisser le plan courant intact. - Le déclencheur est volontairement discret — une rangée bordée et atténuée, pleine largeur, en fin de colonne pour passer sous les deux en-têtes et sous le panneau centré de l'activité vide. Écrit à la main plutôt qu'en
Buttonshadcn, dont lehover:bg-accententre en collision avec le jetonaccentrouge suisse (même raison que la carte révélable). C'est une porte de sortie, pas un appel à l'action : il ne doit jamais concurrencer l'activité qu'il remplacerait.
- Toute la règle client tient en une comparaison, désormais sur une fenêtre :
- Route dédiée
components/traveller/active-tour.ts— modèle pur :ActiveTourCard(étatactive/pre-active/used,startsAt, tour embarqué nullable),pickActiveTourCard()(choix déterministe quand plusieurs voyages qualifient : en cours avant planifiée avant terminée ; parmi les en-cours la plus récemment démarrée, parmi les planifiées le prochain départ, parmi les terminées la plus récemment démarrée — le souvenir le plus frais) etcountdownParts()(jours / heures / minutes, borné à zéro). Un voyage passé n'est jamais qu'un repli : un voyageur en route ne se voit jamais proposer l'été dernier.components/traveller/active-tour-api.ts— wrapper API local à la fonctionnalité (même précédent qu'activation-api.ts). Il relitGET /activation-codes/mineet mappe lui-même la ressource (le mapper desrc/api/codes.tsjette encoretour_idettraveller_count, dont cette section a besoin ; il porte en revanche letourdepuis que les tuiles de « Mes voyages » en ont besoin). Deux points d'entrée volontairement distincts, sur le même fetch :fetchMyActiveTourCard()—active/pre-activeseulement. C'est la sonde d'atterrissage (useTravellerLanding) : un voyageur dont le seul voyage est terminé depuis longtemps a sa place dans « Mes voyages », pas garé sur une vieille timeline.fetchMyRoadtripCard()— y ajouteused. C'est le choix automatique de la section « Mon Roadtrip » sur/dashboard/roadtripnu.fetchMyRoadtripCardById(id)— un voyage nommé, quel qu'eût été le choix automatique : c'est ce qui rend un voyage adressable et ce qui empêche un lien profond d'étape de viser une autre course du même parcours. Un id que le voyageur ne possède pas, ou dont le voyage n'a pas de roadtrip à montrer, renvoienullet retombe sur l'état vide existant. Surtout pasfetchActivationCode(src/api/codes.ts), qui part avec le contexte de consultation super-admin et serait refusé à un voyageur.
composables/useTravellerLanding.ts— atterrissage par défaut du portail : à l'entrée sur/dashboardnu (sans slug de section), sondefetchMyActiveTourCard()sous un spinner ; voyage actif ou planifié →router.replacevers/dashboard/roadtrip. One-shot par montage du portail : la navigation interne (barre latérale, fil d'Ariane) vers la section par défaut n'est jamais détournée ; un lien profond avec slug ne sonde pas ; un échec « fail open » retombe sur « Mes voyages » ; garde anti-course à lauseOrganizationAccess(une réponse tardive ne redirige pas un voyageur déjà parti ailleurs).components/traveller/MySwitzerlandView.vue+SwissExplorationMap.vue— section « Ma Suisse » (carte gamifiée).components/profile/ProfileView.vue— section « Mon profil », partagée par tous les portails (voir Mon profil).components/traveller/sections.ts— modèle de navigation (sections plates + icônes).
État d'avancement
Les quatre sections (« Mon Roadtrip », « Mes voyages », « Ma Suisse », « Mon profil ») sont construites sur les données réelles du backend. Le contrat embarque désormais le parcours choisi à l'activation (tour sur le voyage), consommé par « Mon Roadtrip ». La timeline des étapes de « Mon Roadtrip » tourne sur les endpoints voyageur du contrat : l'instant de révélation (revealableAt) est calculé et servi par le backend, la rétention des champs des étapes non révélées et l'écriture des step_routings au premier affichage sont serveur — le client ne garde aucune persistance locale de révélation. L'écran de détail d'une étape est branché sur son endpoint (l'activité routée, avec description, image, prix, durée, difficulté, tags et prestataire). Le contenu réel est aujourd'hui très pauvre : sur les dix activités réellement rattachées à des étapes de parcours, une seule porte une image, une seule a une adresse, et aucune n'a de description, de durée ni de prix — le catalogue large est plus riche (53 activités sur 79 décrites) mais ces activités ne sont branchées sur aucun parcours. L'écran est donc conçu pour l'état pauvre d'abord ; il s'enrichit tout seul à mesure que le contenu est saisi. Restent hors périmètre v1 : le point de rendez-vous (texte distinct de l'adresse, lot ultérieur), la seconde photo de la maquette, et le mode de réception (« Digital » marqué hardcode-value-dev dans le détail).
Le vocabulaire de l'espace voyageur est unifié autour du voyage : le mot « carte » a disparu de l'interface (il appartenait à la facturation — dans le code, un voyage est un code d'activation), et les paniers de filtres suivent désormais le statut riche du backend plutôt que les booléens dérivés.
La timeline d'un voyage terminé est servie par le backend : le garde partagé ResolvesTravellerJourney est scindé côté serveur entre la lecture (planifié, en cours ou used_at déjà passé) et la révélation (planifié ou en cours seulement), donc un voyage fini garde ses étapes en lecture sans jamais pouvoir en dévoiler une de plus. Le contrat n'a bougé que sur les réponses d'erreur des trois opérations d'itinéraire (403 / 404 désormais documentés) — aucun changement de schéma.
Le repli de plan ajoute une opération et un seul champ. POST /activation-codes/{id}/steps/{stepId}/next-plan (contexte traveler) route le voyageur vers le plan suivant et renvoie le même TravellerStepDetailResource que la lecture et la révélation ; refus en 403 (mauvais contexte, ou voyage pas en cours), 404 (voyage inconnu ou étranger, étape hors du parcours) et 422 (avant le rendez-vous, étape non révélée, ou plus aucun plan). Le champ next_plan_requestable_at n'existe que sur TravellerStepDetailResource — pas sur le TravellerStepResource allégé de la timeline, qui n'en a aucun usage.
Le contrat a été régénéré (npm run api:update) : roadtrip-steps-api.ts appelle désormais l'opération générée requestActivationCodeStepPlan et TravellerStepDetailResource porte le champ lui-même — l'alias de forme filaire local et le POST écrit à la main sur le client brut ont tous deux disparu. La régénération n'a rien touché d'autre : un chemin ajouté, un champ ajouté sur un seul schéma, aucune suppression.
Le serveur dérive « le plan suivant » lui-même : ni rang ni identifiant d'alternative ne circule, dans aucun sens. L'appel n'est donc pas idempotent — chaque appel avance d'un rang — d'où le verrouillage des boutons pendant la requête.
next_plan_requestable_until (même forme, même conditionnement que sa borne basse, sur TravellerStepDetailResource seulement) borne la fenêtre par le haut. Le contrat la déclare exclusive (« à cet instant il est déjà trop tard ») et précise que la borne basse est conservée après la fermeture : c'est ce qui oblige le client à lire les deux.
Résidu connu, côté données, à ne pas prendre pour la norme : un voyage dont la duration est NULL ne passe jamais par active — il saute de pre-active à used dès l'activation. Un voyageur en pleine aventure voit alors son voyage rangé dans « Passés » et peut consulter sa timeline sans pouvoir révéler la moindre étape. Le comportement n'est pas nouveau (used tombait déjà dans « Terminées » auparavant) et le correctif appartient à la donnée, pas à l'affichage : il est suivi dans les questions ouvertes du backend.
i18n
Vocabulaire produit : dans l'espace voyageur on dit « voyage », jamais « carte » — le mot appartenait à la facturation. « Roadtrip » ne survit que comme marque, capitalisé (« Mon Roadtrip », « Voyage Roadtrip ») ; en nom commun il s'écrit en minuscules (« votre roadtrip »), et les graphies « road-trip » / « road trip » ont disparu. Le genre français suit le masculin (« Acheté », « Offert », « Émis par »), et journeys.status.used comme dates.used disent « Terminé » : on ne consomme pas un voyage, on le vit.
Deux exceptions, délibérées :
- la carte de Suisse reste une carte (
switzerland.subtitle,switzerland.map.empty,switzerland.map.progress). Le piège est surtout allemand, oùKartecouvre les deux sens : le produit y devientReisetandis que la carte gardeKarte— y comprisroadtrip.active.steps.detail.location.open(« In der Karte öffnen »), qui ouvre l'application de cartographie. En anglais la séparation est naturelle (map≠card). En revancheswitzerland.erroretswitzerland.cta.subtitleparlent bien du produit et sont passés à « voyage ». - le support physique sur lequel le code est imprimé est nommé pour ce qu'il est — « votre bon cadeau » (
activate.code.subtitle) — plutôt que transformé en voyage, un code ne pouvant pas figurer « sur un voyage ».
gift.shareMessage dit « voyage » lui aussi, par décision produit, bien qu'il s'adresse au destinataire hors de l'espace voyageur.
Dans cette page, « carte » ne subsiste donc que pour la carte de Suisse et pour les cartes d'interface (les vignettes rounded-2xl de la timeline, les grandes cartes image du choix de région…). Les identifiants du code gardent leur nom (ActivationCode, CardDetailView, ActiveTourCard) : les renommer est un lot à part.
Toutes les chaînes passent par t() sous les clés traveller.* et roles.* (libellés des casquettes utilisés par le RoleSwitcher), présentes dans les trois catalogues (fr / de / en). Le wizard vit sous traveller.activate.* (schedule, nowDialog, dateDialog, planning), l'état planifié sous traveller.journeys.scheduled.*, la prolongation sous traveller.journeys.empty.* (un message par panier, la variante todo renvoyant vers le champ « J'ai un code »), traveller.journeys.extend.* (descriptionValid pour un voyage encore valide, denied avec le motif interpolé), l'offre sous traveller.journeys.gift.* (shareMessage interpole {code} et {link} ; le panneau d'impression vit sous printTitle / printBody / printAction / printSuccess), et la section « Mon Roadtrip » sous traveller.roadtrip.* (active, past — le sous-titre d'un voyage terminé —, countdown, empty) — l'écran de détail sous active.steps.detail.* (back, overline, bare, more / less, duration.*, difficulty.* (light / moderate / sustained), location.*, rendezvous, photoCredit, error / retry, et planB.* — trigger / title / body / cancel / confirm / error, dont aucun ne nomme un rang ni une lettre de plan) — plus son libellé de navigation traveller.sections.roadtrip. La timeline des étapes vit sous traveller.roadtrip.active.steps.* (locked avec l'indice nextHint, countdown — les trois formats de durée de l'indice —, revealable, revealed, overlay, et les états error / revealError / retry / empty) ; active.timeline.* reste pour le titre « Itinéraire » et le placeholder du cas sans tour embarqué.

