Skip to content

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-vue sidebar), navigation plate (pas de groupes, contrairement au super-admin). Prop active, émet select. Son en-tête rend le RoleSwitcher (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 — un watch le 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 variante todo renvoyant 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, route roadtrip-journey) — ce que MyRoadtripView sait 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 : ClaimActivationCodeController réassigne le customer_id au destinataire et efface gifted_at, donc un cadeau réclamé disparaît entièrement de /activation-codes/mine cô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 si canOpen() (l'apiStatus riche) 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é runGiftFlow de MyJourneysView : 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 directe noopener si le blanc est lui-même bloqué). Succès digital → l'onglet part sur https://wa.me/?text=… (message gift.shareMessage : code d'activation + lien pdfUrl) ; succès impression → l'onglet part sur le pdfUrl du voyage (URL signée éternelle du bon cadeau A4, rendu à la volée côté backend — jamais préchargé). Un 422 already_offered est traité comme un succès (l'état visé est déjà acquis — course entre onglets) ; tout autre 422 ferme l'onglet réservé avec gift.error, un échec non-422 avec redeem.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'await avant 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é par ActivateNowDialog.vue (avertissement : activation instantanée, étapes déjà passées potentiellement manquées, irréversible) ; « Plus tard » ouvre PickActivationDateDialog.vue (primitive calendar, borné entre demain et l'expiration du voyage). Les régions/parcours viennent de GET /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é de Activate.tsx, section « 01 — Région ») : photo medium de l'image vitrine embarquée dans tour.region (regionImage / regionDescription sur TravellerTour), dégradé de lisibilité, titre et description superposés en bas à gauche (le nombre de parcours sert de sous-titre quand la description manque), anneau ring-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ône Mountain, 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 (tourId et travellerCount dans l'intention activate). 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 ton primary, libellés via tagName — 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 tags thing_to_bring de 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, canStartRoadtrip ne dépend jamais de lui.

  • components/traveller/activation-api.ts — wrapper API local à la fonctionnalité (précédent tags-api.ts ; src/api/ reste en lecture seule) : fetchTravellerTours(), activateTravellerCard(id, tourId, travellerCount, date?) (POST /activation-codes/{id}/activate, tour_id et traveller_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 via morningOfLocalDayIso) et cancelPlannedActivation(id) (DELETE /activation-codes/{id}/activation, qui efface aussi le traveller_count côté backend).

  • components/traveller/journeys.ts — modèle des voyages : statuts, filtres, STATUS_META. categoryOf() est une fonction pure de l'apiStatus riche, pas du statut booléen dérivé — seul le statut riche distingue les nuances (un voyage offert lit purchased sur les booléens, un voyage planifié lit used). Les paniers suivent ce que le voyageur a vécu, pas ce qu'a fait la facturation :

    • todopurchased, pre-active, active ;
    • pastused seul ;
    • giftedoffered (testé en premier) ;
    • aucun panier (seulement « Tous ») ← expired, cancelled, reimbursed, pending-extension, pending-reimbursement, in stock.

    used est un voyage qui a eu lieu ; expired / cancelled / reimbursed sont des voyages qui n'ont jamais eu lieu — un remboursement et un vrai souvenir de voyage ne partagent plus le même panier. pending-extension implique used_at IS NULL et une date dépassée : un voyage expiré qui espère un sursis, pas un voyage à faire. Le switch est exhaustif, donc un nouveau statut du contrat est un trou à la compilation et non un null silencieux ; un voyage sans apiStatus ne 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é used avec un activatedAt encore 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'apiStatus riche écarte les nuances refusées par le backend : offerte, planifiée, remboursement en attente), extensionRequested() (drapeau has_open_extension_request, orthogonal au statut : un voyage encore valide reste utilisable pendant l'instruction) et extensionDenied() (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'apiStatus riche — 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 frappe POST /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 que isScheduledActivation) qui recharge la vue à zéro (@done) ; sans voyage qualifiant, un état vide renvoie vers « Mes voyages » (navigate, même patron que MySwitzerlandView). La section reçoit le voyage à afficher en prop (cardId, lu depuis la route par TravellerDashboard.vue — la section est montée sans routeur dans son propre spec, donc un useRoute() 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-titre roadtrip.past.subtitle (« Votre aventure est terminée. ») au lieu de active.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. RoadtripStep reprend 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) et name/startTime/ dayNumber nuls 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 : revealableAt fait foi, côté serveur — l'instant est calculé par le backend (table step_routings), le client ne dérive plus rien : il compare seulement revealableAt à son horloge. stepDisplayState(step, now, sealed?) (locked / revealable / revealed — révélée dès que la charge est nommée ou que revealedAt est posé ; instant imparsable → verrouillée, défensif) et nextUpcomingLockedStep() (la seule étape verrouillée qui porte l'indice « Se dévoile dans … » : le plus petit revealableAt futur). sealed marque 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édent activation-api.ts ; src/api/ reste en lockstep avec le contrat) : fetchMyRoadtripSteps() relit GET /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 via step_routings.activity_step_id, donc le client n'arbitre jamais entre le plan A et ses alternatives), et revealRoadtripStep() frappe POST /activation-codes/{id}/steps/{stepId}/reveal, qui enregistre le premier affichage (la ligne step_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 casts decimal: du backend) : elles sont converties à la frontière de mapping, et une valeur illisible dégrade en null (le bloc disparaît) plutôt qu'en « NaN ». Un reveal prématuré (avant revealableAt) 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 recalcule now (jamais de décrément — l'étranglement des onglets en arrière-plan est sans effet), et un écouteur visibilitychange qui resynchronise l'horloge au retour au premier plan (le resume de 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 : sealed est 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, à la JourneyCard) : 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 de Button shadcn, ses variantes hover:bg-accent entrent en collision avec le jeton accent rouge suisse — halo rouge accent animate-reveal-pulse, cible tactile ≥ 56 px, anneau statique en motion-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 un RouterLink vers 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 ; sans to, 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 émettant finished une seule fois), dégradé rouge accent → destructive, marque Travelise en ligne (le tracé du favicon, currentColor), chiffre géant remonté à chaque seconde, marges env(safe-area-inset-*) pour Capacitor, zone role="status"aria-live="polite".
    • src/style.css — jeton d'animation --animate-reveal-pulse (@keyframes reveal-pulse : ombre respirante en color-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.vue reste l'écran rendu, le paramètre section est contraint au slug roadtrip pour que useDashboardSection garde la section active sans logique dédiée, et TravellerDashboard.vue substitue le détail à la section quand stepId est 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 (étend RoadtripStep : planRetired, activity nulle tant que l'étape n'est pas révélée), StepActivityDetail, StepProvider, et les formateurs testables formatAddress() (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) et isRevealedDetail(). Il porte aussi nextPlanRequestableAt et son garde canRequestNextPlan(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, glyphe Route surdimensionné, 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], pastille Sparkles, heure en serif d'affichage puis detail.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) ; planRetired n'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'instant until exact, 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 pour revealableAt, 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 envoie null et le bouton n'existe pas.
      • Les deux bornes doivent être lues. Le serveur continue de servir next_plan_requestable_at après la fermeture de la fenêtre : une fenêtre close, c'est at passé et until passé, jamais un at absent. Un at passé 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'AlertDialog shadcn-vue dans ce dépôt) avec l'anatomie d'ActivateNowDialog (glyphe d'alerte, corps sobre, annulation outline) 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'état pending (traitement de ReimburseCardDialog) : 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 setTimeout armé 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 dans syncPlanClock() 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 (setTimeout y déborde sur 32 bits signés et se déclencherait immédiatement), et l'horloge est resynchronisée sur visibilitychange — le même crochet que la timeline, celui que reprendra le resume de 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 TravellerStepDetailResource que la lecture et la révélation), donc detail est remplacé sans seconde requête — motif 2, comme la timeline substitue son étape révélée. expanded est 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 Button shadcn, dont le hover:bg-accent entre en collision avec le jeton accent rouge 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.
  • components/traveller/active-tour.ts — modèle pur : ActiveTourCard (état active / 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) et countdownParts() (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 relit GET /activation-codes/mine et mappe lui-même la ressource (le mapper de src/api/codes.ts jette encore tour_id et traveller_count, dont cette section a besoin ; il porte en revanche le tour depuis que les tuiles de « Mes voyages » en ont besoin). Deux points d'entrée volontairement distincts, sur le même fetch :

    • fetchMyActiveTourCard()active / pre-active seulement. 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 ajoute used. C'est le choix automatique de la section « Mon Roadtrip » sur /dashboard/roadtrip nu.
    • 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, renvoie null et 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 /dashboard nu (sans slug de section), sonde fetchMyActiveTourCard() sous un spinner ; voyage actif ou planifié → router.replace vers /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 à la useOrganizationAccess (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 TravellerStepDetailResourcepas 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ù Karte couvre les deux sens : le produit y devient Reise tandis que la carte garde Karte — y compris roadtrip.active.steps.detail.location.open (« In der Karte öffnen »), qui ouvre l'application de cartographie. En anglais la séparation est naturelle (mapcard). En revanche switzerland.error et switzerland.cta.subtitle parlent 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é.

Contributors

No contributors

Changelog

No recent changes