batchCooking/specs/frontend-architecture.md
Nicolas 9722f4a27b Tests + docs: onboarding/foyer Cypress coverage, specs updates (step 6/6)
- apps/web/cypress/e2e/onboarding.cy.ts — parcours complet (rempli et
  entièrement skippé) signup → 3 étapes → home, mêmes conventions
  cy.intercept que le reste.
- apps/web/cypress/e2e/household.cy.ts — /foyer : préremplissage, et
  sauvegarde indépendante de chacune des 3 sections.
- specs/frontend-architecture.md : nouvelle section "Parcours profil —
  foyer, régime, allergènes" (diagramme mermaid, les deux bugs de state
  trouvés en testant dans le navigateur), arborescence et namespaces
  i18n à jour.
- README.md : nouvelle section "Parcours profil — foyer, régime,
  allergènes", section sidebar mise à jour (Foyer & profil n'est plus
  un stub).

Cypress lui-même ne peut pas tourner en local dans ce sandbox (voir la
note existante dans le README) — vérifié via `tsc --noEmit` sur les
specs + parcours manuel complet dans le navigateur (les deux à travers
les 5 commits précédents de cette feature).

Clôt la feature profil/foyer/régime/allergènes (6 commits, cette PR) :
seed+référence -> endpoints foyer/profil -> composants partagés ->
wizard d'inscription -> page /foyer -> ce commit.
2026-08-16 23:42:54 +02:00

13 KiB

Architecture frontend — Projet Batch-cooking

Documentation de l'organisation d'apps/web : structure des dossiers, routing, gestion des erreurs, et conventions de style (SCSS/theming).


Structure des dossiers

apps/web/src/
├── api/
│   └── client.ts            # ApiClient — appels fetch vers l'API (voir error-handling.md)
├── i18n/
│   └── i18n.ts               # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
│   └── fr/translation.json   # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, onboarding.*, household.*)
├── services/
│   └── error-message.service.ts  # ErrorMessageService — code d'erreur → clé i18next
├── features/
│   ├── auth/                # tout ce qui concerne l'authentification
│   │   ├── AuthContext.tsx       # état global (profil connecté, login/signup/logout, refreshUser)
│   │   ├── RequireAuth.tsx       # garde de route : redirige vers /login si non connecté
│   │   ├── RedirectIfAuthenticated.tsx  # garde de route inverse (pour /login, /signup)
│   │   └── auth-form.scss        # styles partagés par LoginPage et SignupPage
│   └── profile/              # champs du parcours foyer/régime/allergènes, voir plus bas
│       ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx
│       └── profile-forms.scss    # styles partagés par les trois
├── layouts/
│   └── AppLayout.tsx + .scss  # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
├── pages/
│   ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
│   ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│   ├── HomePage.tsx + HomePage.scss   # planning de la semaine (routée sur "/")
│   ├── ComingSoonPage.tsx + .scss     # placeholder partagé par les sections sans backend encore
│   ├── RecipesPage.tsx / ShoppingListPage.tsx  # fines enveloppes autour de ComingSoonPage
│   ├── HouseholdPage.tsx + .scss      # foyer/régime/allergènes, éditable à tout moment (routée sur "/foyer")
│   └── onboarding/            # wizard d'inscription (foyer → régime → allergènes), voir plus bas
│       ├── OnboardingHouseholdPage.tsx / OnboardingDietPage.tsx / OnboardingAllergensPage.tsx
│       └── onboarding.scss       # styles partagés par les trois
├── styles/
│   ├── _theme.scss          # tokens de design (couleurs, espacements, typographie)
│   └── global.scss          # reset minimal + import du theme — importé une seule fois (main.tsx)
├── lib/
│   └── zod-errors.ts        # utilitaire : erreurs zod → { champ: message }
├── App.tsx                  # table de routes
└── main.tsx                 # point d'entrée : providers (Router, AuthProvider) + imports i18n/CSS globaux

Règle de placement des styles : un style spécifique à un seul composant/page vit dans un fichier .scss au même niveau que ce composant (HomePage.tsx + HomePage.scss). Un style partagé par plusieurs composants d'une même feature vit dans le dossier de la feature (features/auth/auth-form.scss, utilisé par LoginPage et SignupPage). Seuls le reset et les tokens globaux vivent dans styles/.


Routing et gardes d'authentification

flowchart TB
  START(("Visite de l'app"))
  CHECK{"AuthProvider :<br/>GET /auth/me"}
  START --> CHECK

  CHECK -->|"200 (session valide)"| AUTHED["user défini"]
  CHECK -->|"401 (pas de session)"| ANON["user = null"]

  AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"]
  LAYOUT --> ROUTE_HOME["/ → HomePage (planning)"]
  LAYOUT --> ROUTE_RECIPES["/recettes → RecipesPage"]
  LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
  LAYOUT --> ROUTE_HOUSEHOLD["/foyer → HouseholdPage"]
  AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
  ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME

  ANON --> ROUTE_HOME_A["/, /recettes, ..."]
  ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
  ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
  • AuthContext (features/auth/AuthContext.tsx) appelle GET /auth/me une seule fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui permet à un rechargement de page de garder l'utilisateur connecté.
  • RequireAuth et RedirectIfAuthenticated sont deux gardes de route (react-router-dom) qui lisent cet état : la première protège tout l'espace connecté (voir AppLayout ci-dessous), la seconde protège /login et /signup (redirige un utilisateur déjà connecté vers /). Les deux affichent null tant que la vérification initiale est en cours, pour éviter un flash de contenu suivi d'une redirection.
  • RedirectIfAuthenticated verrouille sa décision une seule fois (au moment où isLoading passe à false), au lieu de réagir à chaque changement de user — bug trouvé en construisant le wizard d'inscription (voir plus bas) : un navigate() explicite dans le formulaire qu'elle protège entrait en course avec son propre <Navigate>, invisible tant que les deux ciblaient "/".

AppLayout — sidebar commune à l'espace connecté

App.tsx monte un seul RequireAuth + AppLayout comme route parente de toutes les routes authentifiées (routes imbriquées react-router-dom) :

<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
  <Route path="/" element={<HomePage />} />
  <Route path="/recettes" element={<RecipesPage />} />
  <Route path="/liste-de-courses" element={<ShoppingListPage />} />
  <Route path="/foyer" element={<HouseholdPage />} />
</Route>

AppLayout (layouts/AppLayout.tsx) rend une sidebar (marque, nav des sections, nom de l'utilisateur + déconnexion en pied de sidebar) et un <main> qui affiche la route enfant matchée via <Outlet /> — la garde d'auth et le chrome de navigation ne sont donc écrits qu'une fois, pas dupliqués par page comme RequireAuth l'était individuellement avant cette feature. En dessous de 640px la sidebar devient une barre horizontale (voir AppLayout.scss) — pertinent tôt puisque l'app est prévue pour être embarquée par Capacitor plus tard (voir le README racine).

Sections sans backend — ComingSoonPage

Recettes et Liste de courses n'ont pas encore de backend dédié (seuls /planning/current et le parcours foyer/profil ci-dessous existent, voir le README). Chacune a néanmoins sa propre route/page (RecipesPage.tsx, etc. — choix délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une route générique), mais toutes rendent le même composant ComingSoonPage (title/description) pour éviter de tripler un même bloc de markup.


Parcours profil — foyer, régime, allergènes

Deux surfaces, mêmes composants de champ (features/profile/) :

flowchart LR
  SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/foyer"]
  OB1 --> OB2["/onboarding/regime"]
  OB2 --> OB3["/onboarding/allergenes"]
  OB3 --> HOME["/ (home)"]

  SIDEBAR["Sidebar : Foyer & profil"] --> SETTINGS["/foyer (HouseholdPage)"]
  • pages/onboarding/ — wizard de 3 écrans, lancé une seule fois juste après l'inscription. Routes top-level RequireAuth, pas nichées sous AppLayout : wizard plein écran sans sidebar (onboarding.scss, même langage visuel que /login//signup, délibérément un fichier à part plutôt qu'un import de auth-form.scss — même choix que HomePage.scss avant elle, voir plus haut). Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de bouton "Passer" séparé, une valeur "aucune"/vide est le skip.
  • pages/HouseholdPage.tsx (routée /foyer, dans AppLayout) — les mêmes trois réglages, éditables à tout moment. Trois sections, trois boutons "Enregistrer" indépendants (3 ressources API distinctes : PATCH /house/current, /profile/diet, /profile/allergies).
  • features/profile/HouseNameField, DietSelect, AllergySelect : champs contrôlés, "dumb" (reçoivent diets/allergies en props plutôt que de les fetcher). AllergySelect est un <fieldset>/<legend> + grille de cases à cocher, pas un <select multiple> — bien plus repérable/tapable, notamment sur mobile (voir la note Capacitor plus haut).

Deux bugs de state trouvés en testant dans le navigateur

  1. Course entre navigate() et RedirectIfAuthenticated — voir la note sur RedirectIfAuthenticated plus haut. SignupPage doit maintenant rediriger vers /onboarding/foyer, pas /, ce qui a rendu visible une course de state auparavant invisible.
  2. user.dietId périmé sur /foyerHouseholdPage initialisait le régime affiché depuis useAuth().user.dietId, un instantané d'AuthContext jamais rafraîchi après une modification faite directement via apiClient (qui ne touche pas le contexte). Une navigation SPA aller-retour sans rechargement complet ré-affichait donc l'ancienne valeur après une sauvegarde. Fix : HouseholdPage fetch son propre profil frais (apiClient.me()) au montage plutôt que de dépendre du contexte, et AuthContext.refreshUser() (nouvelle méthode, re-fetch GET /auth/me) est appelée après une sauvegarde réussie du régime — pour que le reste de l'app (pas seulement cette page) reste cohérent.

Client API et gestion des erreurs

Voir error-handling.md pour le détail du contrat d'erreurs partagé avec l'API. En résumé côté frontend :

  • ApiClient (api/client.ts) — classe avec instance unique exportée (apiClient), enveloppe fetch avec credentials: "include" (requis pour que le cookie de session httpOnly parte/revienne, l'API et le web étant sur des origines différentes). Lève ApiError (porteuse du code d'erreur) pour toute réponse non-2xx.
  • ErrorMessageService (services/error-message.service.ts) — convertit un code d'erreur numérique en clé de traduction, résolue via i18next.

i18n (internationalisation)

i18next + react-i18next — pas de solution maison : tout le texte affiché (libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale JSON, jamais codé en dur dans un composant.

  • i18n/i18n.ts — initialise l'instance i18next (langue par défaut fr), importé une seule fois pour son effet de bord dans main.tsx, avant le premier rendu.
  • locales/fr/translation.json — toutes les chaînes françaises, organisées par namespace : errors.* (voir error-handling.md), auth.login.* / auth.signup.*, layout.* (nav de la sidebar, salutation, déconnexion — AppLayout), home.* (planning), recipes.* / shoppingList.* (copie des pages stub, voir ComingSoonPage plus haut), onboarding.* (wizard d'inscription) et household.* (titre + form.*, champs partagés par le wizard et /foyer).
  • Dans un composant : const { t } = useTranslation(); t("auth.login.title").
  • Ajouter une langue : créer locales/<lng>/translation.json avec les mêmes clés, ajouter resources.<lng> dans i18n/i18n.ts — aucun composant à toucher.

Note sur les fichiers .d.ts

Aucun fichier .d.ts écrit à la main dans apps/web : le /// <reference types="vite/client" /> généré par défaut par Vite (habituellement vite-env.d.ts) est remplacé par "types": ["vite/client"] dans tsconfig.app.json — même effet (typage de import.meta.env, imports d'assets), sans fichier dédié.

Côté apps/api, aucune augmentation de type globale n'est utilisée du tout — voir
backend-architecture.md
le profil authentifié passe par res.locals (mécanisme natif d'Express), pas par un declare global sur Express.Request.

SCSS et theming

  • sass (Dart Sass) est utilisé via le support natif de Vite — aucune config supplémentaire needed au-delà d'avoir le package installé (vite.config.ts fixe juste l'API moderne de Sass pour éviter un warning de dépréciation).
  • styles/_theme.scss — tokens de design exposés en custom properties CSS sur :root (--color-primary, --space-md, etc.), pas en simples variables SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui permettrait un futur switch de thème (ex. mode sombre) en redéfinissant juste ces variables, sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit référencer var(--token), jamais une couleur/valeur en dur.
  • styles/global.scss — importé une seule fois, dans main.tsx. Contient uniquement le reset minimal et l'import du thème (@use "./theme"). Rien de spécifique à une page/un composant n'y va.
  • Les tokens étant des custom properties CSS (pas des variables Sass), ils sont disponibles globalement au runtime dès que global.scss a été chargé une fois — un fichier .scss de composant/page les consomme directement via var(--token), sans avoir besoin de @use le partiel theme (ce serait un import sans effet, puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en commentaire à quoi correspond chaque règle un peu non-triviale.