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.
This commit is contained in:
Nicolas 2026-08-16 23:42:54 +02:00
parent 5d88e28810
commit 9722f4a27b
4 changed files with 346 additions and 22 deletions

View file

@ -258,17 +258,54 @@ Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — s
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout `<Outlet />` pour la route active — montée une seule fois comme route parente de tout
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx` l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`, (routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes`, `Liste de voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule composant `ComingSoonPage``Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
route parente, pourquoi un composant stub partagé) : est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
parente, pourquoi un composant stub partagé) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté). [specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
## Parcours profil — foyer, régime, allergènes (apps/web)
- `src/features/profile/``HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile.
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage`
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes trois réglages,
modifiables à tout moment, chaque section (foyer/régime/allergènes) avec son
propre bouton "Enregistrer" (3 ressources API indépendantes).
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
page fetch son propre profil frais (`apiClient.me()`) au montage, et
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
régime pour que le reste de l'app reste cohérent aussi.
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` + Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` +
`home-planning.cy.ts` mockent l'API via `cy.intercept` plutôt que de dépendre d'un `home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le `cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
serveur de dev Vite. Le comportement réel de l'API est couvert par les suites provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
Mocha/Cucumber d'`apps/api` (contre une vraie base). réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
vraie base).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** : > **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU > Chromium/Electron headless plante au lancement du process GPU

View file

@ -0,0 +1,94 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: 2,
};
describe("Household & profile settings (/foyer)", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
{ id: 1, name: "Omnivore" },
{ id: 2, name: "Végétarien" },
],
});
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
{ id: 1, name: "Arachides" },
{ id: 2, name: "Gluten" },
],
});
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
});
it("loads the current household name, regime and allergens", () => {
cy.visit("/foyer");
cy.get("#houseName").should("have.value", "Chez Alice");
cy.get("#diet").should("have.value", "2");
cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked");
cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked");
});
it("saves the household name independently of the other sections", () => {
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez les Martin" },
}).as("renameHouse");
cy.visit("/foyer");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez les Martin");
cy.get("#houseName")
.closest("form")
.within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
cy.get("#houseName").closest("form").contains("Enregistré ✓").should("be.visible");
});
it("saves the regime independently of the other sections", () => {
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...authenticatedProfile, dietId: 1 },
}).as("updateDiet");
cy.visit("/foyer");
cy.get("#diet").select("Omnivore");
cy.get("#diet")
.closest("form")
.within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
cy.get("#diet").closest("form").contains("Enregistré ✓").should("be.visible");
});
it("saves the allergen selection independently of the other sections", () => {
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1, 2] }).as(
"updateAllergies",
);
cy.visit("/foyer");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.contains("fieldset", "Allergies").within(() => {
cy.contains("button", "Enregistrer").click();
});
cy.wait("@updateAllergies")
.its("request.body")
.should("deep.equal", { allergyIds: [2, 1] });
});
});

View file

@ -0,0 +1,129 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const signupResponse = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
describe("Onboarding wizard (household → regime → allergens)", () => {
it("walks through all three steps after signup and lands on the home", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
}).as("renameHouse");
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
{ id: 1, name: "Omnivore" },
{ id: 2, name: "Végétarien" },
],
});
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...signupResponse, dietId: 2 },
}).as("updateDiet");
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
{ id: 1, name: "Arachides" },
{ id: 2, name: "Gluten" },
],
});
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.wait("@signup");
// Step 1/3 — household name, prefilled with the auto-generated default.
cy.url().should("include", "/onboarding/foyer");
cy.contains("Étape 1 sur 3").should("be.visible");
cy.get("#houseName").should("have.value", "Foyer de Alice");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez Alice");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
// Step 2/3 — dietary regime.
cy.url().should("include", "/onboarding/regime");
cy.contains("Étape 2 sur 3").should("be.visible");
cy.get("#diet").select("Végétarien");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
// Step 3/3 — allergens/intolerances, then finish.
cy.url().should("include", "/onboarding/allergenes");
cy.contains("Étape 3 sur 3").should("be.visible");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies")
.its("request.body")
.should("deep.equal", { allergyIds: [1] });
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("lets every step be skipped without changing anything", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
}).as("renameHouse");
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
"updateDiet",
);
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.url().should("include", "/onboarding/foyer");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Foyer de Alice" });
cy.url().should("include", "/onboarding/regime");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null });
cy.url().should("include", "/onboarding/allergenes");
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
});
});

View file

@ -14,15 +14,18 @@ apps/web/src/
├── i18n/ ├── i18n/
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord │ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/ ├── locales/
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*) │ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, onboarding.*, household.*)
├── services/ ├── services/
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next │ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── features/ ├── features/
│ └── auth/ # tout ce qui concerne l'authentification │ ├── auth/ # tout ce qui concerne l'authentification
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout) │ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser)
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté │ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup) │ │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage │ │ └── 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/ ├── layouts/
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas │ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
├── pages/ ├── pages/
@ -30,7 +33,11 @@ apps/web/src/
│ ├── SignupPage.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 "/") │ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore │ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore
│ ├── RecipesPage.tsx / ShoppingListPage.tsx / HouseholdPage.tsx # fines enveloppes autour de ComingSoonPage │ ├── 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/ ├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) │ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx) │ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
@ -82,6 +89,11 @@ flowchart TB
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant (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 que la vérification initiale est en cours, pour éviter un flash de contenu suivi
d'une redirection. 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 "/".
--- ---
@ -110,12 +122,62 @@ README racine).
### Sections sans backend — `ComingSoonPage` ### Sections sans backend — `ComingSoonPage`
`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend `Recettes` et `Liste de courses` n'ont pas encore de backend dédié (seuls
dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa `/planning/current` et le parcours foyer/profil ci-dessous existent, voir le
propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la README). Chacune a néanmoins sa propre route/page (`RecipesPage.tsx`, etc. — choix
vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un
route générique), mais toutes rendent le même composant `ComingSoonPage` fichier dédié, pas éclater une route générique), mais toutes rendent le même
(`title`/`description`) pour éviter de tripler un même bloc de markup. 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
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 `/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.
--- ---
@ -146,7 +208,9 @@ JSON, jamais codé en dur dans un composant.
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)), namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation, `auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*` déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*`
/ `household.*` (copie des pages stub, voir `ComingSoonPage` plus haut). (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")`. - 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 une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher. ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.