Skip to content

Architecture — vue d'ensemble

Pattern global

SPA Vue 3 plate embarquée dans un shell Capacitor 7. Pas de Clean Architecture, pas de modulaire, pas de vertical slices : structure par type technique (views/, components/, stores/, services/, composables/, utils/, types/). Le découpage métier se fait par convention de nommage et de dossiers feature à l'intérieur de components/.

Couches conceptuelles (de haut en bas) :

┌────────────────────────────────────────────────────────────┐
│  views/  (un fichier par route, orchestre)                  │
├────────────────────────────────────────────────────────────┤
│  components/  (présentation + interactions ; feature-grouped)│
├────────────────────────────────────────────────────────────┤
│  stores/  (Pinia, état + actions ; source de vérité globale)│
│  composables/  (logique réutilisable, hooks Vue)             │
├────────────────────────────────────────────────────────────┤
│  services/  (HTTP, cache, error handling, ApiError)         │
├────────────────────────────────────────────────────────────┤
│  Capacitor plugins  (@capacitor/*, @capgo/capacitor-updater) │
│  WebView native (iOS WKWebView / Android WebView)            │
└────────────────────────────────────────────────────────────┘

Cycle de vie d'une requête HTTP

Deux clients HTTP distincts, choisis selon le besoin d'authentification :

Public (publicApi, authApi, paymentApi)

Composant / store

   └─→ publicApi.travels.search(...)

         └─→ cachedPublicFetch (services/api-cache.ts, LRU + TTL 5 min par défaut)
              ├─ hit cache → retour direct
              └─ miss → publicFetch

                         └─→ CapacitorHttp.request({ url, method, headers, data })

                              ├─ 2xx → response.data
                              └─ autre → throw new ApiError(status, msg, url)

Protégé (protectedApi)

Composant / store

   └─→ protectedApi.bookings.create(booking)

         └─→ apiClient.post('/bookings', booking)    (services/api-client.ts, wrapper CapacitorHttp)
              ├─ Authorization: Bearer ${authStore.token}
              ├─ Refresh proactif si token expiré, inline dans request()
              ├─ 401 → authStore.getRefreshedToken() → rejoue une fois (refresh partagé entre appels concurrents)
              └─ response.data

Toute erreur HTTP devient ApiError(status, message, url). Le handler services/error-handler.ts propose showToast() qui est appelé depuis le router (toast d'auth) et certains catch.

Flow d'une navigation

NavigateTo(path)

   ├─ router.beforeEach
   │   ├─ guards.auth(to, from, isLoggedIn)
   │   │   ├─ requiresAuth && !logged → redirect 'login' + toast
   │   │   └─ requiresUnauth && logged → redirect 'account'
   │   │
   │   └─ guards.ui(to, from)
   │       ├─ « même page » (même path, ou step-à-step wizard) → court-circuit
   │       ├─ meta.bottomNavigation → useSafeArea().setAdditionalBottomInset(63)
   │       └─ setPageLoading(meta.needsLoading)
   │   afterEach → resolvePageTransition() (direction = signe du delta history.position)

   ├─ vue-router monte la vue
   │   └─ <keep-alive :include="[...]"> matche meta.keepAlive ; clé = pageKey
   │       (path, ou slug seul pour le wizard → instance persistée entre étapes)

   ├─ La vue déclenche ses fetch (stores + publicApi/protectedApi)
   │   └─ une fois data prête, la vue appelle setPageLoading(false)

   └─ scroll restoration (composables/scroll-restoration.ts) restaure l'offset
      sauvé pour cette route (per-page)

Démarrage de l'app (main.ts)

imports CSS (flag-icons, bootstrap-icons, scss/main.scss, bootstrap JS)

   ├─ CapApp.getInfo() → CapacitorUpdater.setChannel(channel) (selon suffix bundle id)
   ├─ CapacitorUpdater.notifyAppReady()

   └─ bootstrap()  — async, appelée (pas await) au top level ; .catch → '[main] FATAL:'
        await SplashScreen.show({ autoHide: false })
        ScreenOrientation.lock('portrait')
        pinia + piniaPluginPersistedstate
        createApp(BuchardApp)
        errorHandler global → console.error('[Vue error]', ...)
        app.use(pinia)
        setBackButtonRouter(router)   ← AVANT app.use(router), casse cycle
        app.use(router)
        router.onError(...)
        await router.isReady()
        app.mount('#app')
        await SplashScreen.hide()

Pas de top-level await dans src/ : le boot vit dans une fonction bootstrap() précisément parce qu'un await au niveau module deadlockait l'app buildée (web). Dans le bundle, main.ts et @capacitor/core finissent dans le même chunk d'entrée ; les implémentations web des plugins Capacitor (chunks web-*.js, import()és paresseusement au premier appel du plugin) importent statiquement ce chunk d'entrée pour la classe WebPlugin. Un top-level await parque le chunk d'entrée en cours d'évaluation → le chunk web attend le chunk d'entrée → l'await n'est jamais résolu → #app reste vide, sans aucune erreur ni log. Invisible en dev (pas de bundling, @capacitor/core est un module déjà évalué) et en natif (plugins résolus par le bridge, pas d'import web) — seul le build servi en web (la CI e2e via vite preview) le déclenche.

Environnements & flavors

AxeDevStagingProduction
Vite modedevelopmentstagingproduction (défaut)
Dotenv.env.development.local.env.staging.env
API backendlocal / horizon-devhorizon-staging.buchard.chhorizon.buchard.ch
Bundle id iOScom.buchard.app.developmentcom.buchard.app.stagingcom.buchard.app
Capgo channeldevstagingproduction
Coexistenceoui (renommage app)ouibase

Les deux axes (mode build + suffixe bundle id) se choisissent indépendamment au moment du build:*:dev / build CI. La JS bundle d'un canal n'est pas promue à un autre — chaque canal a sa build.

State global (Pinia)

Tous les stores sont créés dans createPinia() + piniaPluginPersistedstate — donc certains stores sont persistés au démarrage :

  • auth (token + refreshToken — survit au redémarrage)
  • customer, searchFilter, autres selon leur opt-in (persist: true)
  • bookingConstructor n'est pas persisté (le wizard repart à zéro à chaque cold start)

Catalogue des stores et leurs rôles : voir .claude/modules/.

Pas de SSR, pas de routing serveur

  • Mode router : createWebHistory(BASE_URL) — fonctionne aussi dans Capacitor WebView (chemins relatifs).
  • Pas de hydration, pas de SSG : tout est rendu côté client.
  • Le deep linking iOS/Android repose sur des intent filters + universal links (voir capacitor.config.ts et docs/capgo-distribution.md).

Préoccupations transverses

  • Back navigation : historique navigateur réel comme unique back-stack (ADR 0009) ; module stores/backButton.ts (goBack), overlays via composables/useOverlayRoute.ts, doc docs/back-navigation.md.
  • Transitions de page : direction dérivée du delta de history.state.position dans composables/transition.ts (resolvePageTransition, appelé en afterEach) ; suppression iOS par-nav dérivée au resolve-time (peekProgrammaticNav : nav arrière non marquée sur iOS → NONE). ADR 0010 (amendé), doc docs/back-navigation.md.
  • Scroll restoration : composables/scroll-restoration.ts, per-page (lié à meta.keepAlive).
  • Safe area : composables/inset-calc.ts, capacitor-plugin-safe-area. Les insets device (--safe-*) et les hauteurs de chrome fixe (--ui-* : bottom-nav, header/footer wizard, barre « Réserver ») sont publiés en variables CSS sur :root et consommés via les classes utilitaires .pt-safe* / .pb-safe* (assets/scss/_safe-area.scss). Le ref uiInsets reste la source JS pour useScrollPositionTrigger. Cf. ADR 0013, patterns.md.
  • Loading overlay : composables/loading.ts, drivé par meta.needsLoading.
  • Modal système global : composables/modal.ts (singleton + router injecté via setModalRouter) + host SystemModal.vue dans BuchardApp — message/confirm ou formulaire de login, ouvrable depuis du code hors composant, back-able via ?overlay=system-modal (ADR 0018). Câblé sur la session expirée dans api-client (sessionExpired).
  • OTA : Capgo bundle update transparent, channel défini en main.ts.
  • Erreurs : services/api-error.ts + services/error-handler.ts + handler Vue global [Vue error] + handler router [router]. Ces funnels remontent aussi vers PostHog (captureError, cf. ci-dessous et ADR 0020).
  • Analytics produit : PostHog (EU), strictement opt-in — façade feuille services/analytics.ts, seul point d'import de posthog-js, wrappers no-op tant que initAnalytics() n'a pas tourné. L'init n'a lieu qu'après consentement persisté (store analyticsConsent + modal système au démarrage — avant, rien ne part, pas même la remote-config d'init). Events custom via captureEvent, identify du customer connecté, captureErrorposthog.captureException depuis les funnels d'erreur (handleError/logError, handler Vue, router.onError — cf. ADR 0020, remplace l'ex-télémétrie Faro des ADR 0016/0017). Inexistant en e2e (token vidé, consentement refusé auto-seedé). Cf. ADR 0019, patterns.md, business-rules.md.

Contributors

No contributors

Changelog

No recent changes