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:
parent
5d88e28810
commit
9722f4a27b
4 changed files with 346 additions and 22 deletions
53
README.md
53
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
94
apps/web/cypress/e2e/household.cy.ts
Normal file
94
apps/web/cypress/e2e/household.cy.ts
Normal 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] });
|
||||||
|
});
|
||||||
|
});
|
||||||
129
apps/web/cypress/e2e/onboarding.cy.ts
Normal file
129
apps/web/cypress/e2e/onboarding.cy.ts
Normal 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}/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue