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).
263 lines
14 KiB
Markdown
263 lines
14 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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`) :
|
|
|
|
```tsx
|
|
<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/`) :
|
|
|
|
```mermaid
|
|
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
|
|
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 handler `onChange` de chaque champ, jamais un `useEffect`
|
|
générique sur la valeur — un tel effect se déclencherait aussi au chargement
|
|
initial (le `GET` peuple 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ç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). Prend un `legend` en prop : chaque
|
|
page consommatrice le rend **deux fois** (allergies / intolérances, filtrées
|
|
côté client via `AllergyView.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
|
|
|
|
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 `/foyer`** — `HouseholdPage` 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](./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](./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](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
|
|
: 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.
|