pages/ mélangeait 8 fichiers directement à sa racine (LoginPage, SignupPage, PlanningPage+scss, RecipesPage, RecipeFormPage, ImportRecipePage, ShoppingListPage, ComingSoonPage+scss) à côté de deux sous-dossiers déjà groupés (onboarding/, settings/) — incohérent, et difficile à parcourir une fois le nombre de pages monté. Un sous-dossier par section routée, même règle que onboarding/settings existants : - pages/auth/ — LoginPage, SignupPage - pages/planning/ — PlanningPage + planning-page.scss - pages/recipes/ — RecipesPage, RecipeFormPage, ImportRecipePage - pages/shopping-list/ — ShoppingListPage ComingSoonPage (+ .scss) déménage vers components/ui/ — ce n'est pas une page routée elle-même (ShoppingListPage l'enveloppe), c'est un composant UI générique réutilisable, sa place est aux côtés de Dialog/Tooltip/etc., pas dans pages/. Chemins relatifs internes de chaque fichier déplacé mis à jour (un niveau de profondeur en plus), imports dans App.tsx repointés, tri Biome réappliqué. specs/frontend-architecture.md mis à jour (arborescence + références de chemin). Vérifié : pnpm build clean (apps/web, 1952 modules), pnpm lint clean sur tout le repo, testé en live dans le navigateur (login/signup, planning, recettes, nouvelle recette, liste de courses, paramètres) — aucune route cassée. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
771 lines
46 KiB
Markdown
771 lines
46 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 (common.*, errors.*, auth.*, layout.*, planning.*, recipes.*, shoppingList.*, onboarding.*, household.*, account.*, preferences.*, userPreferences.*, credits.*, catalog.*)
|
||
├── services/
|
||
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
|
||
├── components/
|
||
│ └── ui/ # primitives réutilisables partout, voir plus bas
|
||
│ ├── Dialog.tsx + dialog.scss # modale (élément <dialog> natif)
|
||
│ ├── Checkbox.tsx / Radio.tsx # "carte sélectionnable" (CheckboxOption/RadioOption)
|
||
│ ├── Tooltip.tsx + tooltip.scss # infobulle CSS-only
|
||
│ └── ComingSoonPage.tsx + .scss # placeholder générique, section sans backend (ex. Liste de courses) — pas une page routée elle-même, un composant que la page routée (pages/shopping-list/ShoppingListPage.tsx) enveloppe
|
||
├── features/
|
||
│ ├── auth/ # authentification
|
||
│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount)
|
||
│ │ ├── RequireAuth.tsx / RedirectIfAuthenticated.tsx # gardes de route
|
||
│ │ └── auth-form.scss # styles partagés par LoginPage et SignupPage
|
||
│ ├── theme/
|
||
│ │ └── ThemeContext.tsx # clair/sombre/système, voir plus bas
|
||
│ ├── profile/ # champs du parcours régime/allergènes/goûts (personnels)
|
||
│ │ ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx / DislikedIngredientsField.tsx
|
||
│ │ └── profile-forms.scss
|
||
│ ├── house/ # préférences propres au foyer
|
||
│ │ ├── SourceSelect.tsx # quelles sources externes le foyer voit
|
||
│ │ └── house-forms.scss
|
||
│ ├── planning/
|
||
│ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
|
||
│ │ └── recipe-picker-dialog.scss
|
||
│ └── recipes/ # catalogue, import, édition — voir plus bas
|
||
│ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx
|
||
│ ├── RecipeSourcesPanel.tsx / SourceItemTable.tsx / useEnabledSources.ts
|
||
│ ├── RecipeImportForm.tsx / recipe-import-draft.ts
|
||
│ ├── IngredientPicker.tsx / IngredientRow.tsx / StepListEditor.tsx / StepDescription.tsx
|
||
│ ├── highlight-tech-steps.ts / ingredient-icons.tsx
|
||
│ ├── DietTagSelect.tsx / DietBadges.tsx / AllergenBadges.tsx / ReproducibleBadge.tsx / FavoriteStarButton.tsx
|
||
│ └── recipes.scss
|
||
├── layouts/
|
||
│ ├── AppLayout.tsx + .scss # sidebar (nav, sous-menu Paramètres, menu compte) commune à tout l'espace connecté, voir plus bas
|
||
│ └── nav-icons.tsx # ré-export nommé des icônes lucide-react utilisées par la sidebar
|
||
├── pages/ # un sous-dossier par section routée — jamais tous les fichiers à plat, un seul composant par sous-dossier n'est pas un problème (cohérence de rangement avant tout)
|
||
│ ├── auth/
|
||
│ │ └── LoginPage.tsx / SignupPage.tsx (via features/auth/auth-form.scss, partagé)
|
||
│ ├── planning/
|
||
│ │ └── PlanningPage.tsx + planning-page.scss # grille de la semaine (routée sur "/"), voir plus bas
|
||
│ ├── recipes/
|
||
│ │ ├── RecipesPage.tsx # vue maître-détail du catalogue (routée sur /recettes, /recettes/:id, /recettes/sources/:sourceKey/:externalId)
|
||
│ │ ├── RecipeFormPage.tsx # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier)
|
||
│ │ └── ImportRecipePage.tsx # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId)
|
||
│ ├── shopping-list/
|
||
│ │ └── ShoppingListPage.tsx # enveloppe components/ui/ComingSoonPage.tsx — section sans backend
|
||
│ ├── settings/ # ancienne HouseholdPage éclatée en 5 pages, voir plus bas
|
||
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
|
||
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
|
||
│ │ └── settings-pages.scss
|
||
│ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
|
||
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
|
||
│ └── onboarding.scss
|
||
├── styles/
|
||
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) — variantes clair/sombre
|
||
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
|
||
├── lib/
|
||
│ ├── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
|
||
│ └── client-key.ts # makeClientKey() — identité React locale/éphémère pour une ligne de brouillon (ingrédient/étape en cours d'édition), jamais envoyée au serveur ; volontairement pas crypto.randomUUID() (indisponible hors contexte sécurisé, ex. Capacitor)
|
||
├── App.tsx # table de routes
|
||
└── main.tsx # point d'entrée : providers (Router, AuthProvider, ThemeProvider) + 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 (`PlanningPage.tsx` +
|
||
`planning-page.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["/ → PlanningPage"]
|
||
LAYOUT --> ROUTE_RECIPES["/recettes, /recettes/:id,<br/>/recettes/sources/:sourceKey/:externalId → RecipesPage"]
|
||
LAYOUT --> ROUTE_RECIPE_FORM["/recettes/nouvelle,<br/>/recettes/:id/modifier → RecipeFormPage"]
|
||
LAYOUT --> ROUTE_IMPORT["/recettes/importer/:sourceKey/:externalId → ImportRecipePage"]
|
||
LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
|
||
LAYOUT --> ROUTE_SETTINGS["/parametres/* → pages de paramètres"]
|
||
ROUTE_OLD["/foyer"] -->|"redirige"| ROUTE_SETTINGS
|
||
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 "/".
|
||
- **Table de routes complète** (`App.tsx`) : sous l'unique parent
|
||
`RequireAuth`+`AppLayout` — `/` (`PlanningPage`), `/recettes` /
|
||
`/recettes/:id` / `/recettes/sources/:sourceKey/:externalId` (les trois
|
||
rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle`
|
||
/ `/recettes/:id/modifier` (`RecipeFormPage`),
|
||
`/recettes/importer/:sourceKey/:externalId` (`ImportRecipePage`, route de
|
||
secours autonome), `/liste-de-courses` (`ShoppingListPage`, toujours un stub),
|
||
et les cinq pages `/parametres/*` (compte, préférences, foyer,
|
||
préférences-utilisateur, crédits — voir plus bas). `/foyer` (l'URL de
|
||
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
|
||
un lien existant.
|
||
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
|
||
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
|
||
`sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
|
||
foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
|
||
|
||
---
|
||
|
||
## `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`, voir la
|
||
table complète plus haut). `AppLayout` (`layouts/AppLayout.tsx`) rend une
|
||
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).
|
||
|
||
La sidebar a grandi avec l'app :
|
||
|
||
- **Icônes** (`layouts/nav-icons.tsx`) — ré-export nommé d'icônes
|
||
`lucide-react` (remplace un ancien jeu de SVG dessinés à la main) :
|
||
`PlanningIcon`, `RecipesIcon`, `ShoppingListIcon`, `SettingsIcon`,
|
||
`AccountIcon`, `DietPreferencesIcon`, `HouseholdIcon`,
|
||
`UserPreferencesIcon`, `CreditsIcon`, `FavoriteIcon`, `PublicIcon`,
|
||
`SourcesIcon`, `SourceLinkIcon`, `ChevronLeftIcon`. Toujours accompagnée d'un
|
||
libellé/tooltip, donc `aria-hidden="true"` est posé par chaque appelant.
|
||
- **Structure** : bloc marque ("batchCooking" complet, réduit à "bC" en mode
|
||
replié), nav principale (Planning `/`, Recettes `/recettes`, Liste de
|
||
courses `/liste-de-courses`), un `SettingsMenu` repliable, un `AccountMenu`,
|
||
un pied de page avec le numéro de version.
|
||
- **Rail repliable** — `isCollapsed` persisté dans `localStorage`
|
||
(`batchcooking:sidebarCollapsed`) : un simple toggle de classe
|
||
(`.app-sidebar.collapsed`) masque les `.label` en CSS ; chaque item garde son
|
||
icône et gagne un `title` en tooltip.
|
||
- **`SettingsMenu`** — révèle Compte (`/parametres/compte`), Préférences
|
||
(`/parametres/preferences`), Foyer (`/parametres/foyer`), Préférences
|
||
utilisateur (`/parametres/preferences-utilisateur`), Crédits
|
||
(`/parametres/credits`). Ouvert par défaut si la route courante est déjà
|
||
sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar.
|
||
- **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un
|
||
menu déroulant depuis l'avatar/nom en pied de sidebar, avec un raccourci vers
|
||
`/parametres/compte` et la déconnexion ; se referme après l'une ou l'autre
|
||
action.
|
||
- La correspondance route↔nav utilise les clés i18n `layout.nav.<clé>` /
|
||
`layout.settings.nav.<clé>` — ajouter une entrée de nav est un item de
|
||
tableau + une clé de locale, rien d'autre.
|
||
|
||
### Sections sans backend — `ComingSoonPage`
|
||
|
||
Seule `Liste de courses` (`ShoppingListPage`) n'a pas encore de backend dédié
|
||
(le module « Calcul batch-cooking » reste `TODO`, voir
|
||
[batch-cooking-architecture.md](./batch-cooking-architecture.md)) et rend le
|
||
composant partagé `ComingSoonPage` (`title`/`description`). `Recettes` a
|
||
maintenant un vrai backend complet (catalogue, import depuis des sources
|
||
externes, favoris — voir plus bas) et ne passe plus par ce stub.
|
||
|
||
---
|
||
|
||
## Parcours profil — onboarding et pages de paramètres
|
||
|
||
Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
|
||
`features/house/`) : le wizard d'inscription (une fois) et les pages
|
||
`/parametres/*` (à tout moment).
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
|
||
OB1 --> OB2["/onboarding/foyer"]
|
||
OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
|
||
OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
|
||
OB3 --> OB4
|
||
OB4 --> HOME["/ (PlanningPage)"]
|
||
|
||
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
|
||
SETTINGSMENU --> S2["/parametres/preferences"]
|
||
SETTINGSMENU --> S3["/parametres/foyer"]
|
||
SETTINGSMENU --> S4["/parametres/preferences-utilisateur"]
|
||
SETTINGSMENU --> S5["/parametres/credits"]
|
||
```
|
||
|
||
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
|
||
allergènes), 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`). 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. `/onboarding/sources` est **conditionnelle** :
|
||
seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
|
||
(`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
|
||
`/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
|
||
silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
|
||
erreur, plutôt que d'afficher une étape sans rien à choisir.
|
||
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
|
||
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
|
||
section suivante pour le détail de chacune), toutes en **hot saving** (pas
|
||
de bouton "Enregistrer" — chaque section sauvegarde peu après la dernière
|
||
modification, 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 quand 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`,
|
||
et **`DislikedIngredientsField`** (nouveau) : champs contrôlés, "dumb"
|
||
(reçoivent leurs données 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. 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. `DislikedIngredientsField` — recherche + ajout +
|
||
puces retirables pour la liste personnelle d'ingrédients "pas aimés" ;
|
||
réutilise `IngredientPicker` (voir plus bas) tel quel. **Explicitement
|
||
distinct d'`AllergySelect`** : une préférence de **goût**, jamais une
|
||
contrainte médicale — ne déclenche jamais un avertissement de sécurité,
|
||
juste un badge 🚫 discret sur la fiche recette (`RecipeDetailPanel`).
|
||
Sauvegardé via `GET`/`PATCH /profile/disliked-ingredients` (remplacement
|
||
complet, pas de fusion).
|
||
|
||
### 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/regime`, pas `/`, ce qui a rendu visible une course de state
|
||
auparavant invisible.
|
||
2. **`user.dietId` périmé sur les pages de paramètres** — l'ancienne page
|
||
combinée 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, toujours en place dans `PreferencesPage` :
|
||
la page fetch son propre profil frais (`apiClient.me()`) au montage plutôt
|
||
que de dépendre du contexte, et `AuthContext.refreshUser()` (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.
|
||
|
||
---
|
||
|
||
## Thème (clair/sombre/système)
|
||
|
||
`features/theme/ThemeContext.tsx` — ce que la note "future switch de thème"
|
||
plus bas (SCSS et theming) anticipait est maintenant réel.
|
||
|
||
- `ThemePreference` = `"LIGHT" | "DARK" | "SYSTEM"` (`packages/shared`'s
|
||
`THEME_PREFERENCES`). `ThemeProvider` doit être imbriqué **dans**
|
||
`AuthProvider` (il lit `useAuth().user`).
|
||
- L'effet qui charge la préférence est indexé sur **`user?.id`**, pas sur
|
||
`user` lui-même — `AuthContext`'s `user` change de référence à chaque
|
||
`refreshUser()`, ce qui ne doit pas redéclencher un fetch des préférences
|
||
(documenté par un commentaire `biome-ignore
|
||
lint/correctness/useExhaustiveDependencies`).
|
||
- Sans utilisateur : retombe sur `"SYSTEM"`. Avec un utilisateur :
|
||
`apiClient.getPreferences()` (`GET /preferences`), échec avalé
|
||
silencieusement — même posture "non fatale" que `AuthContext`'s propre
|
||
`me()`.
|
||
- **`applyTheme(theme)`** : `SYSTEM` **supprime** l'attribut
|
||
`document.documentElement.dataset.theme` plutôt que d'y écrire la chaîne
|
||
littérale `"system"` — sans attribut `data-theme`, la media query
|
||
`prefers-color-scheme` de `_theme.scss` reprend la main naturellement.
|
||
`LIGHT`/`DARK` posent `data-theme="light"`/`"dark"`.
|
||
- **`setTheme(newTheme)`** : appelle `apiClient.updatePreferences(theme)`
|
||
(`PATCH /preferences`) **avant** de mettre à jour l'état local — la
|
||
persistance est **côté serveur** (via l'API/la base), contrairement au repli
|
||
de la sidebar qui, lui, ne vit que dans `localStorage`.
|
||
- Consommé par `UserPreferencesPage` (`/parametres/preferences-utilisateur`)
|
||
via `useTheme()` — un simple groupe de boutons radio (`RadioOption` ×
|
||
`THEME_PREFERENCES`).
|
||
|
||
---
|
||
|
||
## Pages de paramètres (`/parametres/*`)
|
||
|
||
L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées
|
||
(`pages/settings/*.tsx`, styles partagés `settings-pages.scss`) :
|
||
|
||
- **`AccountSettingsPage`** (`/compte`) — identité en **lecture seule**
|
||
(prénom/nom/email, éditer n'est pas encore une fonctionnalité demandée) plus
|
||
une "zone dangereuse" : suppression du compte après re-saisie du mot de
|
||
passe (`useAuth().deleteAccount(password)` → `DELETE /auth/me`).
|
||
- **`PreferencesPage`** (`/preferences`, "Préférences alimentaires") — le
|
||
volet **personnel** : régime (`DietSelect`, sauvegarde immédiate +
|
||
`refreshUser()`), allergies/intolérances (deux `AllergySelect`, debounce
|
||
500ms), et `DislikedIngredientsField` (debounce 500ms) — le pendant
|
||
"toujours modifiable" des étapes régime/allergènes du wizard, mêmes
|
||
composants de champ.
|
||
- **`HouseholdSettingsPage`** (`/foyer`) — le volet **foyer**. Deux layouts
|
||
selon `useAuth().user.houseId` :
|
||
- **Sans foyer** : créer (`POST /house`) ou rejoindre par code d'invitation
|
||
(`POST /house/join`).
|
||
- **Avec foyer** : nom renommable (debounce 600ms, `PATCH /house/current`),
|
||
code d'invitation affiché + copie presse-papier, liste des membres avec
|
||
badge admin, bouton de retrait réservé à l'admin (`DELETE
|
||
/house/members/:id`), une section **Sources** (`SourceSelect` — n'importe
|
||
quel membre, pas seulement l'admin, peut activer/désactiver quelles
|
||
sources externes le foyer voit, debounce 500ms, `PATCH
|
||
/house/current/sources`), et soit la suppression du foyer (admin,
|
||
confirmation en deux temps, `DELETE /house/current`) soit le départ
|
||
(non-admin, sans confirmation puisque ça n'affecte que le partant, `POST
|
||
/house/leave`). Recharge `GET /house/current` après chaque mutation
|
||
plutôt qu'un patch optimiste — délibéré, ce sont des actions peu
|
||
fréquentes/réfléchies.
|
||
- **`UserPreferencesPage`** (`/preferences-utilisateur`) — juste le thème
|
||
(voir la section précédente). Nom volontairement distinct de `/preferences`
|
||
malgré la collision terminologique : "comment l'app se présente" vs "les
|
||
contraintes alimentaires du foyer".
|
||
- **`CreditsPage`** (`/credits`) — page d'attribution pure, existe pour
|
||
satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients
|
||
(foodiconpack.com, voir plus bas).
|
||
|
||
---
|
||
|
||
## Planning (`/`)
|
||
|
||
`pages/planning/PlanningPage.tsx` (+ `planning-page.scss`) — remplace l'ancienne
|
||
`HomePage`. Grille complète de la semaine, pas un simple tableau du jour :
|
||
7 colonnes (jours) × 5 lignes (`petit-dejeuner`, `collation`, `dejeuner`,
|
||
`gouter`, `diner` — `WEEK_DAYS`/`MEALS` de `packages/shared`), avec un
|
||
regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via
|
||
une bordure appuyée après `collation`/`dejeuner`/`gouter`.
|
||
|
||
- **Navigation de semaine** — `WeekNavigator` (flèches précédent/suivant,
|
||
`addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé
|
||
cliquable ouvrant un `CalendarPopover` (grille mensuelle via
|
||
`buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un
|
||
badge "aujourd'hui" s'affiche quand la semaine visible est la semaine
|
||
courante.
|
||
- `GET /planning?date=YYYY-MM-DD` renvoie `PlanningView | null` — `null` est
|
||
un état normal (rien à afficher pour cette semaine), pas un message
|
||
d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer
|
||
l'état vide.
|
||
- **Ajouter une recette** — le "+" d'une case ouvre `RecipePickerDialog` pour
|
||
ce créneau `(date, weekDay, meal)`, monté **conditionnellement** (seulement
|
||
tant que la case est ouverte) pour que son état interne reparte à zéro à
|
||
chaque ouverture, sans reset manuel.
|
||
- **Mise à jour locale** — après ajout/retrait, `planning.items` est patché
|
||
côté client plutôt que refetché (un objet `Planning` factice `id: -1` est
|
||
construit si aucun n'existait encore, puisque seul `.items` est jamais lu
|
||
sur cette page). Le retrait est **optimiste** (retiré localement
|
||
immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
|
||
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
|
||
un bouton de retrait (✕).
|
||
|
||
### `RecipePickerDialog` — parcourir, prévisualiser, importer
|
||
|
||
`features/planning/RecipePickerDialog.tsx` (+ `recipe-picker-dialog.scss`) —
|
||
un seul dialogue, jusqu'à 3 étapes, conçu autour d'un principe : **on
|
||
prévisualise avant de confirmer**, rien n'est engagé par un simple clic de
|
||
ligne.
|
||
|
||
1. **Parcourir** (étape par défaut) — embarque `RecipeTabs` +
|
||
`RecipeTable`/`RecipeDetailPanel` (onglets réguliers) ou
|
||
`RecipeSourcesPanel` (onglet d'une source) : exactement l'UI du catalogue
|
||
`/recettes`, avec en plus trois filtres propres au contexte "je cherche
|
||
quoi cuisiner" (pas juste "je consulte") : un filtre multi-ingrédients
|
||
(`IngredientPicker`), un filtre multi-régimes (`DietTagSelect`), et une
|
||
case "convient à tout le foyer" (affichée seulement si le viewer a un
|
||
foyer) — tous branchés sur les query params de `GET /recipes`. Cliquer une
|
||
ligne **sélectionne/prévisualise seulement**, jamais ne valide — un pied de
|
||
dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe)
|
||
est ce qui agit réellement.
|
||
2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit
|
||
formulaire "combien de portions ?" (pré-rempli depuis
|
||
`RecipeSummaryView.portions`), puis `POST /planning/items`.
|
||
3. **Revue intégrée** (un item de source *pas encore importé* est prévisualisé
|
||
et nécessite une intervention humaine) — rend `RecipeImportForm`
|
||
directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour
|
||
qu'un import réussi ajoute aussi au planning en un seul submit) : évite de
|
||
naviguer vers `ImportRecipePage` et de perdre la recherche/les filtres/le
|
||
créneau du picker.
|
||
|
||
**L'import transparent** — décrit dans le composant comme "la seule action de
|
||
toute l'app qui importe vraiment un item de source... puisqu'un item de
|
||
source ne devient une vraie Recipe sauvegardée qu'en conséquence du fait que
|
||
quelqu'un l'ajoute à son planning" :
|
||
1. `GET /sources/:sourceKey/preview/:externalId` → `RecipeImportDraftView`.
|
||
2. `tryBuildCompleteImport(draft)` (`recipe-import-draft.ts`) tente de
|
||
construire un `CreateRecipeInput` soumissible **sans aucun formulaire, sans
|
||
personne impliquée** — renvoie `null` dès qu'un jugement humain est
|
||
nécessaire (une ligne d'ingrédient non résolue, `portions` manquant).
|
||
3. Si non-null : `POST /sources/:sourceKey/import/:externalId` puis `POST
|
||
/planning/items` — ajouté au créneau **sans écran supplémentaire**, comme
|
||
n'importe quelle autre recette.
|
||
4. Si `tryBuildCompleteImport` renvoie `null`, ou si l'un des deux appels
|
||
échoue : bascule sur l'étape 3 ci-dessus (revue intégrée).
|
||
5. Cas limite géré explicitement : si l'import réussit mais que l'ajout au
|
||
planning échoue ensuite, la recette est **déjà sauvegardée** — plutôt que
|
||
de retenter tout l'import, navigation vers `/recettes/:id` (même repli que
|
||
`RecipeImportForm`'s propre submit, voir plus bas).
|
||
|
||
---
|
||
|
||
## Recettes — catalogue, favoris, import depuis une source externe
|
||
|
||
`pages/recipes/RecipesPage.tsx` — routée sur `/recettes`, `/recettes/:id` **et**
|
||
`/recettes/sources/:sourceKey/:externalId` (le **même composant** pour les
|
||
trois) : une vue **maître-détail**, pas une navigation vers une page séparée —
|
||
la barre d'onglets + le tableau restent montés, seul le panneau de détail
|
||
change avec le paramètre d'URL.
|
||
|
||
### Onglets — `RecipeTabs.tsx`
|
||
|
||
Quatre onglets réels, en base — `favoris` / `perso` / `foyer` / `publique` —
|
||
pas de "toutes" : toute recette tombe sous exactement un des trois derniers
|
||
via sa propre `visibility`, `favoris` est un filtre transverse orthogonal.
|
||
**Plus un onglet par source externe activée pour le foyer** (chaque source
|
||
activée devient sa propre tab) — valeur `"source:<key>"`, icône propre à la
|
||
source (`iconUrl` si elle en a un, sinon `SourcesIcon` générique), nom non
|
||
traduit (nom propre). Concept **propre au web** : absent du type `RecipeTab`
|
||
partagé, l'API n'a pas de valeur `tab=source:...` — parcourir une source est
|
||
un endpoint entièrement différent (`GET /sources/:sourceKey/browse`).
|
||
`useEnabledSources.ts` (`Promise.all([getSources(), getHouseSourceIds()])`)
|
||
calcule la liste des sources activées, partagé par `RecipesPage` et
|
||
`RecipePickerDialog`.
|
||
|
||
### Parcourir une source — `RecipeSourcesPanel.tsx`
|
||
|
||
Contenu de l'onglet d'une source : sa propre paire maître-détail —
|
||
`SourceItemTable` (liste paginée, `GET /sources/:sourceKey/browse?query=&cursor=`,
|
||
`nextCursor`) + `RecipeDetailPanel` pour la prévisualisation. Scopé à un seul
|
||
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
|
||
source, même convention "monté seulement tant que pertinent" que `Dialog`.
|
||
Clic sur une ligne :
|
||
- **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET
|
||
/recipes/:id`, prévisualisé en état `"loaded"`.
|
||
- **Pas encore importé** : `GET /sources/:sourceKey/preview/:externalId`,
|
||
prévisualisé en état `"loaded-draft"`.
|
||
|
||
### Flux d'import complet (parcourir → prévisualiser → revue → sauvegarder)
|
||
|
||
- **Prévisualisation** — `RecipeDetailPanel`'s état `"loaded-draft"` : photo,
|
||
icône de lien vers la source (`SourceLinkIcon`, ouvre l'URL d'origine dans
|
||
un nouvel onglet), nom, portions, description, étapes (avec surlignage des
|
||
techniques, voir plus bas) — **pas** d'actions favori/modifier/supprimer, et
|
||
volontairement pas de bouton "importer" manuel : un item de source n'est
|
||
sauvegardé qu'en conséquence de son ajout au planning (voir
|
||
`RecipePickerDialog` plus haut) ou d'une soumission de revue explicite.
|
||
- **Formulaire de revue — `RecipeImportForm.tsx`** — pré-rempli depuis le
|
||
brouillon, structurellement identique à `RecipeFormPage` (mêmes
|
||
`IngredientRow`/`IngredientPicker`/`StepListEditor`/`DietTagSelect`, même
|
||
forme de payload `CreateRecipeInput`), plus une section **"à compléter"**
|
||
pour les lignes d'ingrédient non résolues automatiquement
|
||
(`ingredient-matcher.ts`, côté API) : chaque ligne montre son texte brut, un
|
||
bouton "Choisir un ingrédient" (ouvre `IngredientPicker`, la quantité brute
|
||
est conservée) ou un bouton pour l'écarter. `canSubmit` exige zéro ligne non
|
||
résolue restante — **aucune recette invalide n'est jamais silencieusement
|
||
devinée/abandonnée** (décision produit explicite). Soumet vers `POST
|
||
/sources/:sourceKey/import/:externalId` au lieu de `POST /recipes`.
|
||
Prop optionnelle `planningSlot` : en cas de succès, appelle aussi `POST
|
||
/planning/items` avec les portions du formulaire.
|
||
- **`RecipeImportForm` a été extrait d'`ImportRecipePage`** pour que
|
||
`RecipePickerDialog` puisse l'embarquer directement comme une de ses étapes
|
||
— `pages/recipes/ImportRecipePage.tsx` (`/recettes/importer/:sourceKey/:externalId`)
|
||
n'en est plus que le wrapper d'une **route de secours autonome et
|
||
partageable** (favori enregistré, page rechargée en plein milieu du flux),
|
||
plus le chemin principal. Elle décode toujours défensivement
|
||
`?planningDate=&planningWeekDay=&planningMeal=` (validés contre
|
||
`WEEK_DAYS`/`MEALS`) pour ce chemin historique.
|
||
|
||
### Surlignage des techniques et infobulle
|
||
|
||
- `highlight-tech-steps.ts`'s `splitDescriptionByTechSteps(description,
|
||
techSteps)` découpe une description d'étape en segments texte/technique à
|
||
partir des offsets `start`/`end` de chaque `StepTechStepView` (calculés
|
||
côté API par `matchTechStepSpans`, voir
|
||
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)).
|
||
Trie défensivement par `start` et élimine silencieusement tout span aux
|
||
bornes invalides (négatif, hors texte, chevauchant un span déjà accepté) —
|
||
dégrade en "ne pas surligner celui-ci" plutôt que de planter/déformer
|
||
l'affichage.
|
||
- `StepDescription.tsx` rend les segments : texte brut tel quel, technique
|
||
entourée d'un vrai `<button type="button">` (pas un `<mark>` — nativement
|
||
focusable au clavier/lecteur d'écran) dans un `Tooltip`
|
||
(`components/ui/Tooltip.tsx`) dont le contenu est
|
||
`t(\`catalog.techSteps.${techStep.key}\`)`.
|
||
|
||
### Icônes d'ingrédients et badge "reproductible"
|
||
|
||
- `ingredient-icons.tsx` — ~20 pictogrammes génériques (remplace un ancien
|
||
schéma à un emoji par ingrédient, 437 cas, jugé peu professionnel/incohérent
|
||
en revue produit), groupés par **type de chose** (légume, bouteille,
|
||
fromage…) plutôt que par ingrédient précis. La plupart viennent du pack CC
|
||
BY 4.0 de foodiconpack.com (voir `CreditsPage`) via un wrapper `FilledIcon`
|
||
(glyphes pleins) ; trois (`BreadIcon`, `DoughIcon`, `SproutIcon`) sans bon
|
||
équivalent dans ce pack restent dessinés à la main (wrapper `Icon`,
|
||
traits) — dimensionnés en CSS pour que le mélange se lise comme un seul jeu
|
||
cohérent. `CATEGORY_ICON`/`SUBCATEGORY_ICON` donnent une icône
|
||
*représentative* par catégorie/sous-catégorie pour les lignes de
|
||
`IngredientPicker` (un choix éditorial, pas une donnée dérivée).
|
||
- `ReproducibleBadge.tsx` — rien si `!reproducible`
|
||
(`IngredientView.reproducible`, "raisonnablement faisable maison"). Pastille
|
||
simple dans la grille du picker, ou (avec `searchLabel`, dans
|
||
`IngredientRow`) un lien `<a>` classique (pas un `<Link>` router,
|
||
volontairement, pour ne jamais faire quitter un formulaire de recette en
|
||
cours) ouvrant `/recettes?search=<nom>` dans un nouvel onglet.
|
||
|
||
### Badges régime/allergènes et autres pièces
|
||
|
||
- `AllergenBadges.tsx` / `DietBadges.tsx` — listes de pastilles simples,
|
||
rendent `null` sur un tableau vide. `DietBadges` utilise le token
|
||
`--color-tag` (jamais `--color-allergen`) pour rester visuellement distinct
|
||
d'un avertissement de sécurité.
|
||
- `DietTagSelect.tsx` — fieldset multi-sélection (via `CheckboxOption`) pour
|
||
taguer manuellement le(s) régime(s) associé(s) d'une recette — utilisé dans
|
||
`RecipeFormPage`, `RecipeImportForm`, et les filtres de `RecipePickerDialog`.
|
||
- `RecipeTable.tsx` — tableau du catalogue (photo/nom+marque favori/allergènes/
|
||
régimes), clic sur une ligne = sélection (pas de navigation, le détail
|
||
s'affiche à côté dans `RecipeDetailPanel`).
|
||
- `RecipeDetailPanel.tsx` — union discriminée `"empty" | "loading" | "loaded" |
|
||
"loaded-draft" | "not-found" | "error"`. Croise les ingrédients de la recette
|
||
avec `dislikedIngredientIds` (préférence de goût du viewer) pour n'afficher
|
||
un badge 🚫 que sur les ingrédients concernés. Prop `showActions` (défaut
|
||
`true`) masque l'étoile favori + les boutons Modifier/Supprimer dans les
|
||
contextes de prévisualisation seule (`RecipePickerDialog`,
|
||
`RecipeSourcesPanel`).
|
||
- `FavoriteStarButton.tsx` — bascule optimiste (`POST`/`DELETE
|
||
/recipes/:id/favorite`, restaurée en cas d'échec).
|
||
- `IngredientPicker.tsx` — parcours à deux niveaux catégorie→sous-catégorie
|
||
(rayons de supermarché) + recherche + grille de cartes, remplace un ancien
|
||
dropdown autocomplete plat (400+ ingrédients, la recherche seule ne suffit
|
||
pas). Un menu "options d'affichage" bascule les badges
|
||
allergène/régime/reproductible par carte (préférence UI locale, pas
|
||
persistée). Réutilisé par `RecipeFormPage`, `RecipeImportForm`, le filtre
|
||
ingrédients de `RecipePickerDialog`, et `DislikedIngredientsField`.
|
||
- `IngredientRow.tsx` / `StepListEditor.tsx` — ligne d'ingrédient sélectionnée
|
||
(icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné
|
||
(boutons monter/descendre, pas de drag-and-drop) du formulaire recette.
|
||
- `SourceItemTable.tsx` — tableau de parcours d'une source (photo+nom, badge
|
||
"déjà importé" au lieu des colonnes allergènes/régime — un item de source
|
||
n'est résolu contre les catalogues qu'à la prévisualisation).
|
||
|
||
---
|
||
|
||
## Foyer — sources externes activées
|
||
|
||
- **`features/house/SourceSelect.tsx`** — grille de cases à cocher pour quelles
|
||
sources un foyer voit, partagée telle quelle par `OnboardingSourcesPage` et
|
||
`HouseholdSettingsPage`'s section Sources. Chaque ligne : logo optionnel
|
||
(`iconUrl`), nom propre (non traduit), badge officiel/non-officiel
|
||
(`household.sources.official`/`unofficial`) pour juger la fiabilité d'une
|
||
source scrapée vs une API officielle. Sélection vide = état de départ
|
||
normal (aucune ligne `HouseSource` = masqué).
|
||
|
||
---
|
||
|
||
## Composants UI partagés (`components/ui/`)
|
||
|
||
- **`Dialog.tsx`** (+ `dialog.scss`) — primitive de modale bâtie sur l'élément
|
||
**`<dialog>` natif** (`showModal()`), pas une div `role="dialog"` — piège de
|
||
focus, fermeture sur Échap et arrière-plan obtenus gratuitement. Première
|
||
modale de l'app (chaque confirmation avant, ex. les zones dangereuses des
|
||
pages de paramètres, était une révélation en deux temps inline). Montée
|
||
seulement pendant qu'elle est ouverte. Le clic sur l'arrière-plan pour
|
||
fermer compare les coordonnées du clic au rectangle du panneau
|
||
(`getBoundingClientRect()` — un clic sur l'élément `<dialog>` lui-même se
|
||
produit à la fois pour un vrai clic d'arrière-plan *et* pour son propre
|
||
padding non rempli, seule la comparaison de coordonnées distingue les deux),
|
||
attaché impérativement plutôt que via `onClick` JSX (Échap couvre déjà le
|
||
cas clavier). Prop `footer` optionnelle pour un bandeau d'action épinglé
|
||
sous le corps défilant — utilisé par `RecipePickerDialog`.
|
||
- **`Checkbox.tsx`** (`CheckboxOption`) — factorise le balisage "carte
|
||
sélectionnable" (input natif caché + coche + texte) auparavant dupliqué
|
||
entre `AllergySelect`, `DietTagSelect`, le menu d'affichage
|
||
d'`IngredientPicker`. La classe `is-selected` est posée en JS depuis le
|
||
booléen `checked` (un chaînage CSS `:has(:checked)` s'est révélé peu fiable
|
||
entre navigateurs).
|
||
- **`Radio.tsx`** (`RadioOption<T extends string>`) — pendant `type="radio"`
|
||
de `CheckboxOption`, même balisage/apparence, sémantique radio native pour
|
||
l'exclusion mutuelle via un `name` partagé — utilisé par le sélecteur de
|
||
thème d'`UserPreferencesPage`.
|
||
- **`Tooltip.tsx`** (+ `tooltip.scss`) — infobulle **CSS-only** (pas de
|
||
librairie de positionnement) : wrapper `position: relative`, affichée via
|
||
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
|
||
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
|
||
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
|
||
- **`ComingSoonPage.tsx`** (+ `.scss`) — placeholder générique (`title`/
|
||
`description`) pour une section routée sans backend, voir
|
||
[Sections sans backend](#sections-sans-backend--comingsoonpage) plus haut.
|
||
|
||
---
|
||
|
||
## 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 de premier niveau : `common` (libellés génériques réutilisés
|
||
partout), `errors` (voir [error-handling.md](./error-handling.md)), `auth`
|
||
(`auth.login.*`/`auth.signup.*`), `layout` (nav de la sidebar dont
|
||
`layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`),
|
||
`planning` (grille de la semaine, `RecipePickerDialog`), `recipes`
|
||
(catalogue, tabs, import), `shoppingList` (page stub, voir `ComingSoonPage`
|
||
plus haut), `onboarding` (wizard d'inscription), `household` (titre +
|
||
`form.*`, champs partagés par le wizard et `/parametres/foyer`, plus
|
||
`household.sources.*` pour le badge officiel/non-officiel), `account` /
|
||
`preferences` / `userPreferences` / `credits` (les quatre autres pages de
|
||
paramètres), `catalog` (libellés des tables de référence —
|
||
`catalog.diets.<key>`, `catalog.allergens.<key>`, `catalog.ingredients.<key>`,
|
||
`catalog.units.<key>`, `catalog.techSteps.<key>` — un slug `key` de
|
||
`schema.prisma` par entrée, jamais le libellé lui-même stocké en base, voir
|
||
[batch-cooking-modele.md](./batch-cooking-modele.md)).
|
||
- 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 a permis le switch de thème clair/sombre/système (voir
|
||
[Thème](#thème-clairsombresystème) plus haut) en redéfinissant juste ces
|
||
variables sous `[data-theme="dark"]` (et sous `prefers-color-scheme: dark`
|
||
quand aucun `data-theme` n'est posé, cas `SYSTEM`), 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.
|
||
|
||
---
|
||
|
||
## Tests (Cypress + Cucumber)
|
||
|
||
`apps/web/cypress.config.ts` déclare deux "testing types" indépendants :
|
||
|
||
- **`e2e`** — `specPattern` couvre à la fois les specs Cypress classiques
|
||
(`cypress/e2e/**/*.cy.ts`) **et** des fichiers Gherkin
|
||
(`cypress/e2e/**/*.feature`), via `@badeball/cypress-cucumber-preprocessor`
|
||
(+ un bundler esbuild).
|
||
- **`component`** (nouveau) — `cypress/component/**/*.cy.tsx`, monte un seul
|
||
composant UI générique à la fois (`components/ui/*`), sans routeur ni
|
||
backend — lancé via le script `cy:run:component` (nouveau, à côté de
|
||
`cy:open`/`cy:run`/`e2e`). Premier test de ce type dans le repo : mounter
|
||
`CheckboxOption` isolément, sans jamais visiter une page routée complète.
|
||
|
||
### Motif Gherkin
|
||
|
||
Pour chaque `.feature` (ex. `planning.feature`), un fichier de steps `.ts` du
|
||
même nom dans le même dossier (`planning.ts`) porte les fixtures/steps
|
||
**propres à cette feature** (mocks `cy.intercept`, interactions DOM
|
||
spécifiques à ce parcours) — délibérément **pas** partagés entre features, le
|
||
lookup de steps du préprocesseur Cucumber n'étant pas global à tout
|
||
`cypress/e2e/`. Features présentes : `account`, `auth`, `household-settings`,
|
||
`onboarding`, `planning`, `preferences`, `recipe-form`, `recipes`,
|
||
`recipe-sources`, `user-preferences`.
|
||
|
||
Les steps réellement partagés (par **toutes** les features) vivent dans
|
||
`cypress/support/step_definitions/` : `common.steps.ts` (connexion, navigation
|
||
générique — ex. `"I am signed in as {string} {string}"`), plus
|
||
`household-mutations.steps.ts`, `profile-mutations.steps.ts`,
|
||
`reference-data.steps.ts`. `common.steps.ts` évite délibérément un hook
|
||
Cucumber `Before()` : l'enregistrer ferait lire par le runtime navigateur du
|
||
préprocesseur un membre d'enum (`messages.HookType.BEFORE_TEST_CASE`) absent
|
||
de la version CommonJS de `@cucumber/messages` sur laquelle ce repo est pinné
|
||
(`pnpm.overrides`) — chaque scénario planterait avec "Cannot read properties
|
||
of undefined". La réinitialisation du profil se fait donc en ligne, dans le
|
||
step "I am signed in as..." par lequel commence de toute façon chaque chaîne
|
||
de scénario.
|
||
|
||
### Migration en cours — `.cy.ts` et `.feature` coexistent
|
||
|
||
Les fichiers `.cy.ts` classiques restants (`account`, `household-settings`,
|
||
`layout`, `planning-page`, `preferences`, `recipes`, `sidebar`, `smoke`,
|
||
`user-preferences`) ne sont **pas** un découpage définitif voulu — c'est une
|
||
migration en cours vers Cucumber. Certains parcours (bascule favori,
|
||
suppression de recette) ont déjà été migrés vers un couple `.feature`+`.ts`
|
||
dédié, laissant dans le `.cy.ts` d'origine ce qui n'est pas encore migré
|
||
(parcours de navigation/affichage plus larges, contrôles structurels de layout,
|
||
le check global "redirection si non authentifié" de `smoke.cy.ts`). Les deux
|
||
styles tournent dans la même commande `cy:run` puisqu'ils partagent le même
|
||
`specPattern`.
|
||
|
||
Toujours vrai par ailleurs (hérité de l'état précédent) : les tests mockent
|
||
l'API via `cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e
|
||
de la CI ne provisionne pas de Postgres/API, seulement le serveur de dev Vite.
|
||
Le comportement réel de l'API est couvert par la suite Mocha d'`apps/api` (voir
|
||
[backend-architecture.md](./backend-architecture.md), contre une vraie base).
|
||
|
||
> **Cypress ne peut pas tourner en local dans un environnement Windows
|
||
> sandboxé** : Chromium/Electron headless plante au lancement du process GPU
|
||
> (`GPU process isn't usable`) — pas un problème introduit par une
|
||
> modification du code. `pnpm --filter web e2e` fonctionne normalement en CI
|
||
> et sur une machine de dev classique ; dans cet environnement précis, vérifier
|
||
> manuellement via le serveur de dev (`pnpm dev:web` + `pnpm dev:api`, pas le
|
||
> conteneur Docker qui sert le frontend buildé, pas le serveur Vite).
|