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.dataToute 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
| Axe | Dev | Staging | Production |
|---|---|---|---|
| Vite mode | development | staging | production (défaut) |
| Dotenv | .env.development.local | .env.staging | .env |
| API backend | local / horizon-dev | horizon-staging.buchard.ch | horizon.buchard.ch |
| Bundle id iOS | com.buchard.app.development | com.buchard.app.staging | com.buchard.app |
| Capgo channel | dev | staging | production |
| Coexistence | oui (renommage app) | oui | base |
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)bookingConstructorn'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.tsetdocs/capgo-distribution.md).
Préoccupations transverses
- Back navigation : historique navigateur réel comme unique back-stack (ADR
0009) ; modulestores/backButton.ts(goBack), overlays viacomposables/useOverlayRoute.ts, docdocs/back-navigation.md. - Transitions de page : direction dérivée du delta de
history.state.positiondanscomposables/transition.ts(resolvePageTransition, appelé enafterEach) ; suppression iOS par-nav dérivée au resolve-time (peekProgrammaticNav: nav arrière non marquée sur iOS →NONE). ADR0010(amendé), docdocs/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:rootet consommés via les classes utilitaires.pt-safe*/.pb-safe*(assets/scss/_safe-area.scss). Le refuiInsetsreste la source JS pouruseScrollPositionTrigger. Cf. ADR0013,patterns.md. - Loading overlay :
composables/loading.ts, drivé parmeta.needsLoading. - Modal système global :
composables/modal.ts(singleton + router injecté viasetModalRouter) + hostSystemModal.vuedansBuchardApp— message/confirm ou formulaire de login, ouvrable depuis du code hors composant, back-able via?overlay=system-modal(ADR0018). Câblé sur la session expirée dansapi-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 ADR0020). - Analytics produit : PostHog (EU), strictement opt-in — façade feuille
services/analytics.ts, seul point d'import deposthog-js, wrappers no-op tant queinitAnalytics()n'a pas tourné. L'init n'a lieu qu'après consentement persisté (storeanalyticsConsent+ modal système au démarrage — avant, rien ne part, pas même la remote-config d'init). Events custom viacaptureEvent,identifydu customer connecté,captureError→posthog.captureExceptiondepuis les funnels d'erreur (handleError/logError, handler Vue,router.onError— cf. ADR0020, remplace l'ex-télémétrie Faro des ADR0016/0017). Inexistant en e2e (token vidé, consentement refusé auto-seedé). Cf. ADR0019,patterns.md,business-rules.md.

