diff --git a/README.md b/README.md
index bb2f2a8..3bff53d 100644
--- a/README.md
+++ b/README.md
@@ -258,17 +258,54 @@ Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — s
` ` 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`
(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
-courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour
-l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule
-route parente, pourquoi un composant stub partagé) :
+voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
+courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
+composant `ComingSoonPage` — `Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
+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é).
+## 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 `
`/`` plutôt qu'un
+ `` — 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 `` 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` +
-`home-planning.cy.ts` 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 les suites
-Mocha/Cucumber d'`apps/api` (contre une vraie base).
+`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` 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 les suites Mocha/Cucumber d'`apps/api` (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
diff --git a/apps/web/cypress/e2e/household.cy.ts b/apps/web/cypress/e2e/household.cy.ts
new file mode 100644
index 0000000..93fba34
--- /dev/null
+++ b/apps/web/cypress/e2e/household.cy.ts
@@ -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] });
+ });
+});
diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts
new file mode 100644
index 0000000..adce3f4
--- /dev/null
+++ b/apps/web/cypress/e2e/onboarding.cy.ts
@@ -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}/`);
+ });
+});
diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md
index 3933206..46b51a3 100644
--- a/specs/frontend-architecture.md
+++ b/specs/frontend-architecture.md
@@ -14,15 +14,18 @@ apps/web/src/
├── 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.*, household.*)
+│ └── 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)
-│ ├── 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
+│ ├── 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/
@@ -30,7 +33,11 @@ apps/web/src/
│ ├── 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 / 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/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── 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
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 ``, invisible tant que les deux ciblaient "/".
---
@@ -110,12 +122,62 @@ README racine).
### Sections sans backend — `ComingSoonPage`
-`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend
-dédié (seul `/planning/current` existe, 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.
+`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 (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 ``/`` + grille de cases à
+ cocher, pas un `` — 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)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
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")`.
- Ajouter une langue : créer `locales//translation.json` avec les mêmes clés,
ajouter `resources.` dans `i18n/i18n.ts` — aucun composant à toucher.