Retour fonctionnel : allergies et intolérances doivent être distinguées dans l'UI, et /foyer doit sauvegarder à la volée plutôt que via des boutons "Enregistrer". - AllergySelect prend un `legend` en prop au lieu d'un libellé interne fixe — le même composant est rendu deux fois par chaque page consommatrice (HouseholdPage, OnboardingAllergensPage), une fois par `kind` (ALLERGY / INTOLERANCE), la sélection restant une seule liste d'IDs partagée. - HouseholdPage : suppression des boutons "Enregistrer", autosave déclenché depuis le handler onChange de chaque champ (jamais un useEffect générique sur la valeur — se déclencherait aussi au chargement initial, sans distinction propre "chargé" vs "modifié"). Nom du foyer et allergènes/intolérances debouncés (600ms/500ms), régime sauvegardé immédiatement (sélection discrète). Validation client (nom vide) empêche l'autosave plutôt que de déclencher un aller-retour API voué à l'échec. - i18n : household.form.allergiesLabel devient "Allergies" (au lieu de "Allergies & intolérances"), nouvelle clé intolerancesLabel, save/ saved remplacés par saving/saved (plus de bouton à libeller). - Cypress (household.cy.ts réécrit, onboarding.cy.ts mis à jour) + specs/frontend-architecture.md + README.md. Vérifié dans le navigateur : wizard d'inscription affiche bien les deux groupes (12 allergies / 2 intolérances) ; /foyer sans aucun bouton, chaque section sauvegarde automatiquement (vérifié en base après édition du nom du foyer et du régime) ; compte de test nettoyé. Clôt le retour fonctionnel sur la feature profil/foyer/régime/ allergènes (8 commits au total sur cette PR).
14 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) appelleGET /auth/meune 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é.RequireAuthetRedirectIfAuthenticatedsont deux gardes de route (react-router-dom) qui lisent cet état : la première protège tout l'espace connecté (voirAppLayoutci-dessous), la seconde protège/loginet/signup(redirige un utilisateur déjà connecté vers/). Les deux affichentnulltant que la vérification initiale est en cours, pour éviter un flash de contenu suivi d'une redirection.RedirectIfAuthenticatedverrouille sa décision une seule fois (au moment oùisLoadingpasse àfalse), au lieu de réagir à chaque changement deuser— bug trouvé en construisant le wizard d'inscription (voir plus bas) : unnavigate()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-levelRequireAuth, pas nichées sousAppLayout: 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 deauth-form.scss— même choix queHomePage.scssavant 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, dansAppLayout) — les mêmes réglages, éditables à tout moment, en hot saving (retour fonctionnel : pas de bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière modification (nom du foyer et allergènes/intolérances debouncés — 600ms/500ms — régime immédiat) — 3 ressources API indépendantes (PATCH /house/current,/profile/diet,/profile/allergies), 3 cycles de sauvegarde indépendants. Déclenché depuis le handleronChangede chaque champ, jamais unuseEffectgénérique sur la valeur — un tel effect se déclencherait aussi au chargement initial (leGETpeuple le même state), sans distinction propre entre "vient d'être chargé" et "vient d'être modifié".features/profile/—HouseNameField,DietSelect,AllergySelect: champs contrôlés, "dumb" (reçoiventdiets/allergiesen props plutôt que de les fetcher).AllergySelectest 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). Prend unlegenden prop : chaque page consommatrice le rend deux fois (allergies / intolérances, filtrées côté client viaAllergyView.kind), mais la sélection reste une seule liste d'IDs partagée entre les deux groupes.
Deux bugs de state trouvés en testant dans le navigateur
- Course entre
navigate()etRedirectIfAuthenticated— voir la note surRedirectIfAuthenticatedplus haut.SignupPagedoit maintenant rediriger vers/onboarding/foyer, pas/, ce qui a rendu visible une course de state auparavant invisible. user.dietIdpérimé sur/foyer—HouseholdPageinitialisait le régime affiché depuisuseAuth().user.dietId, un instantané d'AuthContextjamais rafraîchi après une modification faite directement viaapiClient(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 :HouseholdPagefetch son propre profil frais (apiClient.me()) au montage plutôt que de dépendre du contexte, etAuthContext.refreshUser()(nouvelle méthode, re-fetchGET /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), enveloppefetchaveccredentials: "include"(requis pour que le cookie de session httpOnly parte/revienne, l'API et le web étant sur des origines différentes). LèveApiError(porteuse ducoded'erreur) pour toute réponse non-2xx.ErrorMessageService(services/error-message.service.ts) — convertit uncoded'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éfautfr), importé une seule fois pour son effet de bord dansmain.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, voirComingSoonPageplus haut),onboarding.*(wizard d'inscription) ethousehold.*(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.jsonavec les mêmes clés, ajouterresources.<lng>dansi18n/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 undeclare globalsurExpress.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.tsfixe 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érencervar(--token), jamais une couleur/valeur en dur.styles/global.scss— importé une seule fois, dansmain.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.scssa été chargé une fois — un fichier.scssde composant/page les consomme directement viavar(--token), sans avoir besoin de@usele 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.