diff --git a/README.md b/README.md index 7eba520..9e40008 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,33 @@ a mis au jour une contrainte générique trop stricte, corrigée à la source : Détail de l'organisation complète (dossiers, routing, SCSS/theming) : [specs/frontend-architecture.md](specs/frontend-architecture.md). -Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.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). +## Accueil, sidebar & sections (apps/web) + +Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar +(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et +`` 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é) : +[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté). + +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). + +> **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`), reproductible sur `main` aussi bien que sur une +> branche de feature — pas un problème introduit par une modification du code. +> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) 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` en local, pas les conteneurs +> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`). ## Gestion des erreurs (API ↔ web) diff --git a/apps/web/cypress/e2e/home-planning.cy.ts b/apps/web/cypress/e2e/home-planning.cy.ts new file mode 100644 index 0000000..7f336c0 --- /dev/null +++ b/apps/web/cypress/e2e/home-planning.cy.ts @@ -0,0 +1,106 @@ +// 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 authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +describe("Sidebar navigation", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + cy.visit("/"); + }); + + it("highlights the current section and navigates between stub pages", () => { + cy.contains("nav a", "Planning").should("have.class", "active"); + + cy.contains("nav a", "Recettes").click(); + cy.url().should("include", "/recettes"); + cy.contains("h1", "Recettes").should("be.visible"); + cy.contains("nav a", "Recettes").should("have.class", "active"); + cy.contains("nav a", "Planning").should("not.have.class", "active"); + + cy.contains("nav a", "Liste de courses").click(); + cy.url().should("include", "/liste-de-courses"); + cy.contains("h1", "Liste de courses").should("be.visible"); + + cy.contains("nav a", "Foyer & profil").click(); + cy.url().should("include", "/foyer"); + cy.contains("h1", "Foyer & profil").should("be.visible"); + + cy.contains("nav a", "Planning").click(); + cy.url().should("eq", `${Cypress.config().baseUrl}/`); + cy.contains("h1", "Planning de la semaine").should("be.visible"); + }); + + it("shows the signed-in user's name and lets them log out from the sidebar", () => { + cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); + + cy.contains("Bonjour Alice").should("be.visible"); + cy.contains("button", "Se déconnecter").click(); + + cy.wait("@logout"); + cy.url().should("include", "/login"); + }); +}); + +describe("Home planning view", () => { + it("shows an empty state when the household has no current planning", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + + cy.visit("/"); + + cy.contains("h1", "Planning de la semaine").should("be.visible"); + cy.contains("Aucun planning pour cette semaine.").should("be.visible"); + cy.get("table").should("not.exist"); + }); + + it("renders the current planning's meals when there is one", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/planning/current", { + statusCode: 200, + body: { + id: 1, + startDate: "2026-08-10T00:00:00.000Z", + finishDate: "2026-08-16T00:00:00.000Z", + items: [ + { id: 1, weekDay: "lundi", meal: "Dîner", recipe: { id: 1, name: "Ratatouille" } }, + { + id: 2, + weekDay: "mardi", + meal: "Déjeuner", + recipe: { id: 2, name: "Curry de lentilles" }, + }, + ], + }, + }); + + cy.visit("/"); + + cy.contains("Aucun planning pour cette semaine.").should("not.exist"); + cy.get("table.planning-table tbody tr").should("have.length", 2); + cy.contains("td", "Ratatouille").should("be.visible"); + cy.contains("td", "Curry de lentilles").should("be.visible"); + }); + + it("shows an error state when the planning request fails", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/planning/current", { + statusCode: 500, + body: { code: 5000, message: "boom" }, + }); + + cy.visit("/"); + + cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible"); + }); +}); diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index 4348234..3933206 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -14,7 +14,7 @@ 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.*, home.*) +│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*) ├── services/ │ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next ├── features/ @@ -23,10 +23,14 @@ apps/web/src/ │ ├── 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 +├── layouts/ +│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas ├── pages/ │ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé) │ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé) -│ └── HomePage.tsx + HomePage.scss +│ ├── 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 ├── styles/ │ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) │ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx) @@ -56,11 +60,15 @@ flowchart TB CHECK -->|"200 (session valide)"| AUTHED["user défini"] CHECK -->|"401 (pas de session)"| ANON["user = null"] - AUTHED --> ROUTE_HOME["/ → HomePage"] + AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"] + LAYOUT --> ROUTE_HOME["/ → HomePage (planning)"] + LAYOUT --> ROUTE_RECIPES["/recettes → RecipesPage"] + LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"] + LAYOUT --> ROUTE_HOUSEHOLD["/foyer → HouseholdPage"] AUTHED --> ROUTE_LOGIN_A["/login ou /signup"] ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME - ANON --> ROUTE_HOME_A["/"] + ANON --> ROUTE_HOME_A["/, /recettes, ..."] ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"] ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"] ``` @@ -69,10 +77,45 @@ flowchart TB 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 `/`, 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. + (`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. + +--- + +## `AppLayout` — sidebar commune à l'espace connecté + +`App.tsx` monte **un seul** `RequireAuth` + `AppLayout` comme route parente de +toutes les routes authentifiées (routes imbriquées `react-router-dom`) : + +```tsx +}> + } /> + } /> + } /> + } /> + +``` + +`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections, +nom de l'utilisateur + déconnexion en pied de sidebar) et un `
` qui affiche +la route enfant matchée via `` — la garde d'auth et le chrome de +navigation ne sont donc écrits qu'une fois, pas dupliqués par page comme +`RequireAuth` l'était individuellement avant cette feature. En dessous de 640px la +sidebar devient une barre horizontale (voir `AppLayout.scss`) — pertinent tôt +puisque l'app est prévue pour être embarquée par Capacitor plus tard (voir le +README racine). + +### Sections sans backend — `ComingSoonPage` + +`Recettes`, `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. --- @@ -101,7 +144,9 @@ JSON, jamais codé en dur dans un composant. une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu. - `locales/fr/translation.json` — toutes les chaînes françaises, organisées par namespace : `errors.*` (voir [error-handling.md](./error-handling.md)), - `auth.login.*` / `auth.signup.*`, `home.*`. + `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). - 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.