Merge pull request #9 from kyuno053/feat/home-planning-sidebar
Home page: sidebar layout + weekly planning
This commit is contained in:
commit
a14bb4155d
26 changed files with 968 additions and 87 deletions
50
README.md
50
README.md
|
|
@ -168,6 +168,24 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||||
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
|
||||||
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
|
||||||
|
|
||||||
|
## Planning (apps/api)
|
||||||
|
|
||||||
|
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
|
||||||
|
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
|
||||||
|
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
|
||||||
|
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
|
||||||
|
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
|
||||||
|
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
|
||||||
|
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
|
||||||
|
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
|
||||||
|
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
|
||||||
|
consommé tel quel par `apps/web`.
|
||||||
|
|
||||||
|
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
|
||||||
|
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
|
||||||
|
a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
||||||
|
[specs/backend-architecture.md](specs/backend-architecture.md).
|
||||||
|
|
||||||
## Page de connexion / inscription (apps/web)
|
## Page de connexion / inscription (apps/web)
|
||||||
|
|
||||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||||
|
|
@ -186,11 +204,33 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||||
|
|
||||||
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` mockent l'API via
|
## Accueil, sidebar & sections (apps/web)
|
||||||
`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
|
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||||
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une vraie
|
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||||
base).
|
`<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`
|
||||||
|
(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)
|
## Gestion des erreurs (API ↔ web)
|
||||||
|
|
||||||
|
|
|
||||||
24
apps/api/features/planning.feature
Normal file
24
apps/api/features/planning.feature
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
Feature: Household weekly planning
|
||||||
|
As a signed-in user
|
||||||
|
I want to see my household's current planning
|
||||||
|
So that I know what meals are planned this week
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot view the planning
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no planning yet sees an empty state
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should be empty
|
||||||
|
|
||||||
|
Scenario: A signed-in user sees their household's current planning
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner"
|
||||||
|
When I request the current planning
|
||||||
|
Then the response status should be 200
|
||||||
|
And the current planning response should include recipe "Ratatouille" on "monday" for "dinner"
|
||||||
59
apps/api/features/step-definitions/planning.steps.ts
Normal file
59
apps/api/features/step-definitions/planning.steps.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Given, Then, When } from "@cucumber/cucumber";
|
||||||
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I request the current planning", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.get("/planning/current");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the current planning response should be empty", function (this: CustomWorld) {
|
||||||
|
assert.equal(this.response.body, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creates the planning/recipe rows directly via Prisma rather than through
|
||||||
|
// the API — there's no "create a planning" endpoint yet (see
|
||||||
|
// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still
|
||||||
|
// TODO), so this is the only way to get a household into a state where it
|
||||||
|
// has one. Reads the household off the already-authenticated agent (via
|
||||||
|
// `GET /auth/me`) rather than taking it as a step argument, since the
|
||||||
|
// scenario never names it explicitly.
|
||||||
|
Given(
|
||||||
|
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
||||||
|
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
|
const me = await this.agent.get("/auth/me");
|
||||||
|
const houseId: number = me.body.houseId;
|
||||||
|
|
||||||
|
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
||||||
|
const today = new Date();
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2),
|
||||||
|
),
|
||||||
|
finishDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.create({
|
||||||
|
data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the current planning response should include recipe {string} on {string} for {string}",
|
||||||
|
function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
|
const items = this.response.body.items as Array<{
|
||||||
|
weekDay: string;
|
||||||
|
meal: string;
|
||||||
|
recipe: { name: string };
|
||||||
|
}>;
|
||||||
|
const item = items.find((i) => i.recipe.name === recipeName);
|
||||||
|
assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`);
|
||||||
|
assert.equal(item.weekDay, weekDay);
|
||||||
|
assert.equal(item.meal, meal);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -4,6 +4,7 @@ import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
|
|
@ -22,6 +23,7 @@ export function createServer(): ExpressServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
|
server.mountRouter("/planning", planningRouter);
|
||||||
|
|
||||||
// No route matched — same shape as every other error response, via the
|
// No route matched — same shape as every other error response, via the
|
||||||
// shared ErrorCode contract, so clients never special-case 404s.
|
// shared ErrorCode contract, so clients never special-case 404s.
|
||||||
|
|
|
||||||
21
apps/api/src/modules/planning/planning.routes.ts
Normal file
21
apps/api/src/modules/planning/planning.routes.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
import { getCurrentPlanning } from "./planning.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/planning` in app.ts. */
|
||||||
|
export const planningRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated user's household's planning for today, or
|
||||||
|
* `null` if none exists yet — a valid, common response, not an error (see
|
||||||
|
* {@link getCurrentPlanning}).
|
||||||
|
*/
|
||||||
|
planningRouter.get(
|
||||||
|
"/current",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||||
|
const planning = await getCurrentPlanning(res.locals.userProfile.houseId);
|
||||||
|
res.status(200).json(planning);
|
||||||
|
}),
|
||||||
|
);
|
||||||
60
apps/api/src/modules/planning/planning.service.ts
Normal file
60
apps/api/src/modules/planning/planning.service.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { PlanningView } from "@batch-cooking/shared";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the household's planning that covers today's date and shapes it
|
||||||
|
* into a {@link PlanningView} (recipes resolved to `{id, name}`).
|
||||||
|
*
|
||||||
|
* Returns `null` for two distinct, both entirely normal states — a `house_id`
|
||||||
|
* of `null` (a profile always gets a house at signup today, but the column
|
||||||
|
* is nullable) and "no planning row covers today" (the expected case until
|
||||||
|
* planning creation is built) — neither is an error, so both collapse to
|
||||||
|
* the same "nothing to show yet" result rather than throwing.
|
||||||
|
*/
|
||||||
|
export async function getCurrentPlanning(houseId: number | null): Promise<PlanningView | null> {
|
||||||
|
if (houseId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||||
|
// component) — compare against today's date at UTC midnight so the
|
||||||
|
// comparison lines up with how Postgres stores/returns them, regardless
|
||||||
|
// of the server's local timezone.
|
||||||
|
const today = new Date();
|
||||||
|
const todayDateOnly = new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const planning = await prisma.planning.findFirst({
|
||||||
|
where: {
|
||||||
|
houseId,
|
||||||
|
startDate: { lte: todayDateOnly },
|
||||||
|
finishDate: { gte: todayDateOnly },
|
||||||
|
},
|
||||||
|
// A household should never have two plannings covering the same day,
|
||||||
|
// but nothing in the schema enforces that yet — pick the most recently
|
||||||
|
// started one rather than letting the query fail if it ever happens.
|
||||||
|
orderBy: { startDate: "desc" },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!planning) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: planning.id,
|
||||||
|
startDate: planning.startDate.toISOString(),
|
||||||
|
finishDate: planning.finishDate.toISOString(),
|
||||||
|
items: planning.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
weekDay: item.weekDay,
|
||||||
|
meal: item.meal,
|
||||||
|
recipe: item.recipe,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
101
apps/api/test/planning.test.ts
Normal file
101
apps/api/test/planning.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Planning", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /planning/current", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/planning/current");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the household has no planning covering today", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/planning/current");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the household's planning covering today, with recipes resolved", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseId: number = signupRes.body.houseId;
|
||||||
|
|
||||||
|
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
|
||||||
|
const today = new Date();
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2),
|
||||||
|
),
|
||||||
|
finishDate: new Date(
|
||||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.create({
|
||||||
|
data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/planning/current");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.id).to.equal(planning.id);
|
||||||
|
expect(res.body.items).to.have.length(1);
|
||||||
|
expect(res.body.items[0]).to.include({ weekDay: "monday", meal: "dinner" });
|
||||||
|
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the household's planning does not cover today", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseId: number = signupRes.body.houseId;
|
||||||
|
|
||||||
|
// A planning entirely in the past — shouldn't be picked up as "current".
|
||||||
|
await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: new Date(Date.UTC(2000, 0, 1)),
|
||||||
|
finishDate: new Date(Date.UTC(2000, 0, 7)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/planning/current");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.equal(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import { ErrorCode } from "@batch-cooking/shared";
|
||||||
describe("Signup", () => {
|
describe("Signup", () => {
|
||||||
it("creates a profile and lands on the home page", () => {
|
it("creates a profile and lands on the home page", () => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
cy.intercept("POST", "**/auth/signup", {
|
cy.intercept("POST", "**/auth/signup", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -30,7 +31,7 @@ describe("Signup", () => {
|
||||||
|
|
||||||
cy.wait("@signup");
|
cy.wait("@signup");
|
||||||
cy.url().should("not.include", "/signup");
|
cy.url().should("not.include", "/signup");
|
||||||
cy.contains("Bonjour Alice Martin").should("be.visible");
|
cy.contains("Bonjour Alice").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a client-side validation error without calling the API", () => {
|
it("shows a client-side validation error without calling the API", () => {
|
||||||
|
|
@ -70,6 +71,7 @@ describe("Signup", () => {
|
||||||
describe("Login", () => {
|
describe("Login", () => {
|
||||||
it("logs in and lands on the home page", () => {
|
it("logs in and lands on the home page", () => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
cy.intercept("POST", "**/auth/login", {
|
cy.intercept("POST", "**/auth/login", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -89,7 +91,7 @@ describe("Login", () => {
|
||||||
cy.contains("button", "Se connecter").click();
|
cy.contains("button", "Se connecter").click();
|
||||||
|
|
||||||
cy.wait("@login");
|
cy.wait("@login");
|
||||||
cy.contains("Bonjour Alice Martin").should("be.visible");
|
cy.contains("Bonjour Alice").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error on invalid credentials", () => {
|
it("shows an error on invalid credentials", () => {
|
||||||
|
|
@ -123,10 +125,11 @@ describe("Already authenticated", () => {
|
||||||
dietId: null,
|
dietId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
|
|
||||||
cy.visit("/login");
|
cy.visit("/login");
|
||||||
cy.url().should("not.include", "/login");
|
cy.url().should("not.include", "/login");
|
||||||
cy.contains("Bonjour Alice Martin").should("be.visible");
|
cy.contains("Bonjour Alice").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs out and returns to the login page", () => {
|
it("logs out and returns to the login page", () => {
|
||||||
|
|
@ -142,6 +145,7 @@ describe("Already authenticated", () => {
|
||||||
dietId: null,
|
dietId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||||
|
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
|
||||||
106
apps/web/cypress/e2e/home-planning.cy.ts
Normal file
106
apps/web/cypress/e2e/home-planning.cy.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,27 +1,37 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { HouseholdPage } from "./pages/HouseholdPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { RecipesPage } from "./pages/RecipesPage";
|
||||||
|
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||||
import { SignupPage } from "./pages/SignupPage";
|
import { SignupPage } from "./pages/SignupPage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level route table. `/` requires an authenticated session (see
|
* Top-level route table. Every authenticated section is nested under one
|
||||||
* {@link RequireAuth}); `/login` and `/signup` redirect an already-logged-in
|
* `RequireAuth` + `AppLayout` parent route (sidebar chrome + `/auth`
|
||||||
* visitor to `/` instead (see {@link RedirectIfAuthenticated}). Anything
|
* guard applied once, not per-page — see {@link AppLayout}); `/login` and
|
||||||
* else falls back to `/`, which itself redirects to `/login` if needed.
|
* `/signup` redirect an already-logged-in visitor to `/` instead (see
|
||||||
|
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
||||||
|
* itself redirects to `/login` if needed.
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<HomePage />
|
<AppLayout />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
|
<Route path="/foyer" element={<HouseholdPage />} />
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
|
type PlanningView,
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
@ -93,6 +94,11 @@ export class ApiClient {
|
||||||
public me(): Promise<SafeUserProfile> {
|
public me(): Promise<SafeUserProfile> {
|
||||||
return this.request("/auth/me");
|
return this.request("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
|
||||||
|
public getCurrentPlanning(): Promise<PlanningView | null> {
|
||||||
|
return this.request("/planning/current");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — this client is stateless, no need for one per caller. */
|
/** Single shared instance — this client is stateless, no need for one per caller. */
|
||||||
|
|
|
||||||
134
apps/web/src/layouts/AppLayout.scss
Normal file
134
apps/web/src/layouts/AppLayout.scss
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles for AppLayout — the shell (sidebar + content area) wrapping every
|
||||||
|
// authenticated page. Colocated next to AppLayout.tsx since nothing else
|
||||||
|
// uses these classes.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// No `@use` of the theme partial needed here — see HomePage.scss's identical
|
||||||
|
// note: every token below is a CSS custom property, available at runtime.
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed-width nav rail. Its own surface (not the page background), same
|
||||||
|
// elevation language as a card, so it reads as a distinct, permanent piece
|
||||||
|
// of chrome rather than part of the scrolling content.
|
||||||
|
.app-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 15rem;
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
padding: 0 var(--space-sm) var(--space-xl);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied by NavLink itself (see the `className` prop in
|
||||||
|
// AppLayout.tsx), not a router-provided class — plain `.active`.
|
||||||
|
&.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer button {
|
||||||
|
padding: 0.5rem var(--space-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
// Content can scroll independently of the sidebar (e.g. a long planning
|
||||||
|
// table) without the fixed-width rail ever needing to shrink.
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below this width the fixed-width rail would eat too much of the
|
||||||
|
// viewport (relevant early given this app is meant to be embedded via
|
||||||
|
// Capacitor later, see the root README) — collapse it into a horizontal
|
||||||
|
// top bar instead of a side rail.
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
padding: 0 var(--space-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
flex-direction: row;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/web/src/layouts/AppLayout.tsx
Normal file
75
apps/web/src/layouts/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
import "./AppLayout.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in the sidebar nav. `key` maps to `layout.nav.<key>` in
|
||||||
|
* `locales/fr/translation.json` — adding a section is one array entry plus
|
||||||
|
* one locale key, no other file to touch.
|
||||||
|
*/
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "/", key: "planning" },
|
||||||
|
{ to: "/recettes", key: "recipes" },
|
||||||
|
{ to: "/liste-de-courses", key: "shoppingList" },
|
||||||
|
{ to: "/foyer", key: "household" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell for every authenticated page: a sidebar (brand, section nav, and
|
||||||
|
* the signed-in user + logout at the bottom) plus a main content area
|
||||||
|
* rendering the matched child route via `<Outlet />`.
|
||||||
|
*
|
||||||
|
* Mounted once as the parent element of the whole authenticated route
|
||||||
|
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is
|
||||||
|
* therefore guaranteed non-null by the time this renders.
|
||||||
|
*/
|
||||||
|
export function AppLayout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/** Ends the session and returns to the login page. */
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
<aside className="app-sidebar">
|
||||||
|
<div className="app-sidebar__brand">batchCooking</div>
|
||||||
|
|
||||||
|
<nav className="app-sidebar__nav">
|
||||||
|
{NAV_ITEMS.map(({ to, key }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
// Only the home ("/") entry needs exact matching — every
|
||||||
|
// other path is a leaf with nothing nested under it (yet),
|
||||||
|
// so NavLink's default prefix matching already behaves the
|
||||||
|
// same way `end` would.
|
||||||
|
end={to === "/"}
|
||||||
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
|
>
|
||||||
|
{t(`layout.nav.${key}`)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="app-sidebar__footer">
|
||||||
|
<span className="app-sidebar__user">
|
||||||
|
{t("layout.greeting", { firstName: user?.firstName })}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={handleLogout}>
|
||||||
|
{t("layout.logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="app-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,8 +29,37 @@
|
||||||
"loginLink": "Se connecter"
|
"loginLink": "Se connecter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"layout": {
|
||||||
"greeting": "Bonjour {{firstName}} {{lastName}} 👋",
|
"nav": {
|
||||||
|
"planning": "Planning",
|
||||||
|
"recipes": "Recettes",
|
||||||
|
"shoppingList": "Liste de courses",
|
||||||
|
"household": "Foyer & profil"
|
||||||
|
},
|
||||||
|
"greeting": "Bonjour {{firstName}} 👋",
|
||||||
"logout": "Se déconnecter"
|
"logout": "Se déconnecter"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Planning de la semaine",
|
||||||
|
"loading": "Chargement du planning…",
|
||||||
|
"error": "Impossible de charger le planning, réessayez plus tard",
|
||||||
|
"empty": "Aucun planning pour cette semaine.",
|
||||||
|
"table": {
|
||||||
|
"day": "Jour",
|
||||||
|
"meal": "Repas",
|
||||||
|
"recipe": "Recette"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recettes",
|
||||||
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
|
},
|
||||||
|
"shoppingList": {
|
||||||
|
"title": "Liste de courses",
|
||||||
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
|
},
|
||||||
|
"household": {
|
||||||
|
"title": "Foyer & profil",
|
||||||
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
apps/web/src/pages/ComingSoonPage.scss
Normal file
12
apps/web/src/pages/ComingSoonPage.scss
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles for ComingSoonPage — shared by every stub section page.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.coming-soon-page {
|
||||||
|
max-width: 40rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/src/pages/ComingSoonPage.tsx
Normal file
23
apps/web/src/pages/ComingSoonPage.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import "./ComingSoonPage.scss";
|
||||||
|
|
||||||
|
interface ComingSoonPageProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder rendered by every section that has a route/sidebar entry but
|
||||||
|
* no real feature behind it yet (Recettes, Liste de courses, Foyer &
|
||||||
|
* profil — see `RecipesPage.tsx` etc.). One shared component instead of
|
||||||
|
* three near-identical markup blocks; each page still gets its own file
|
||||||
|
* (and its own copy, via i18n) so building out a real feature later means
|
||||||
|
* rewriting one dedicated file, not splitting a generic route.
|
||||||
|
*/
|
||||||
|
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="coming-soon-page">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,48 +8,49 @@
|
||||||
// styles/global.scss and available globally at runtime — not a Sass-level
|
// styles/global.scss and available globally at runtime — not a Sass-level
|
||||||
// variable/mixin that would require an explicit compile-time import.
|
// variable/mixin that would require an explicit compile-time import.
|
||||||
|
|
||||||
// Full-viewport centering wrapper, mirroring .auth-page's layout so the app
|
// No outer centering wrapper here (unlike the old version of this file):
|
||||||
// doesn't visually jump between the login/signup screens and the home page.
|
// AppLayout's `.app-content` already owns the page background/padding —
|
||||||
|
// this is just the page's own content.
|
||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
&__status {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The greeting itself sits on its own surface, same treatment as the auth
|
|
||||||
// card, so the two screens read as one coherent app rather than two.
|
|
||||||
.home-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: var(--space-xl);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
&__status--error {
|
||||||
padding: 0.6rem var(--space-lg);
|
color: var(--color-error);
|
||||||
font-family: var(--font-body);
|
}
|
||||||
font-size: var(--font-size-base);
|
}
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
// The current planning, one row per meal slot. Raised on its own surface,
|
||||||
border-radius: var(--radius-base);
|
// same card treatment used elsewhere in the app, so it reads as a distinct
|
||||||
border: 1px solid var(--color-border);
|
// piece of content rather than bare text on the page background.
|
||||||
background: var(--color-surface);
|
.planning-table {
|
||||||
color: var(--color-text);
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
&:hover {
|
margin-top: var(--space-md);
|
||||||
background: var(--color-surface-alt);
|
border-collapse: collapse;
|
||||||
}
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,81 @@
|
||||||
|
import type { PlanningView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { apiClient } from "../api/client";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
|
||||||
import "./HomePage.scss";
|
import "./HomePage.scss";
|
||||||
|
|
||||||
|
/** Load state for the `GET /planning/current` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||||
|
type PlanningState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; planning: PlanningView | null }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page for an authenticated visitor. Behind {@link RequireAuth} —
|
* Landing page for an authenticated visitor — the household's current
|
||||||
* `user` is guaranteed non-null by the time this renders. Static copy
|
* planning. Behind {@link RequireAuth} (via `AppLayout`), so this only
|
||||||
* comes from i18next (`locales/fr/translation.json`, `home` namespace).
|
* renders once a session is confirmed; the planning itself still has to be
|
||||||
|
* fetched separately, hence the loading/error/empty/loaded states below.
|
||||||
|
* `null` from the API is a normal, common state (no planning created yet),
|
||||||
|
* not an error — see `apps/api`'s `planning.service.ts`.
|
||||||
*/
|
*/
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||||
|
|
||||||
/** Ends the session and returns to the login page. */
|
useEffect(() => {
|
||||||
async function handleLogout() {
|
// Guards against setting state after unmount (e.g. the user navigates
|
||||||
await logout();
|
// away before the request resolves) — no cleanup-worthy resource here,
|
||||||
navigate("/login");
|
// just avoids a "set state on unmounted component" warning.
|
||||||
}
|
let cancelled = false;
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getCurrentPlanning()
|
||||||
|
.then((planning) => {
|
||||||
|
if (!cancelled) setState({ status: "loaded", planning });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="home-page">
|
<div className="home-page">
|
||||||
<div className="home-card">
|
<h1>{t("home.title")}</h1>
|
||||||
<h1>batchCooking</h1>
|
|
||||||
<p>{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}</p>
|
{state.status === "loading" && <p className="home-page__status">{t("home.loading")}</p>}
|
||||||
<button type="button" onClick={handleLogout}>
|
|
||||||
{t("home.logout")}
|
{state.status === "error" && (
|
||||||
</button>
|
<p className="home-page__status home-page__status--error">{t("home.error")}</p>
|
||||||
</div>
|
)}
|
||||||
</main>
|
|
||||||
|
{state.status === "loaded" && state.planning === null && (
|
||||||
|
<p className="home-page__status">{t("home.empty")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "loaded" && state.planning !== null && (
|
||||||
|
<table className="planning-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("home.table.day")}</th>
|
||||||
|
<th>{t("home.table.meal")}</th>
|
||||||
|
<th>{t("home.table.recipe")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{state.planning.items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.weekDay}</td>
|
||||||
|
<td>{item.meal}</td>
|
||||||
|
<td>{item.recipe.name}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
apps/web/src/pages/HouseholdPage.tsx
Normal file
8
apps/web/src/pages/HouseholdPage.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ComingSoonPage } from "./ComingSoonPage";
|
||||||
|
|
||||||
|
/** Household & profile section — routed at `/foyer`. No backend yet beyond auth, stub for now. */
|
||||||
|
export function HouseholdPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <ComingSoonPage title={t("household.title")} description={t("household.comingSoon")} />;
|
||||||
|
}
|
||||||
8
apps/web/src/pages/RecipesPage.tsx
Normal file
8
apps/web/src/pages/RecipesPage.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ComingSoonPage } from "./ComingSoonPage";
|
||||||
|
|
||||||
|
/** Recipes section — routed at `/recettes`. No backend yet (see README's "Planning" section), stub for now. */
|
||||||
|
export function RecipesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <ComingSoonPage title={t("recipes.title")} description={t("recipes.comingSoon")} />;
|
||||||
|
}
|
||||||
10
apps/web/src/pages/ShoppingListPage.tsx
Normal file
10
apps/web/src/pages/ShoppingListPage.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ComingSoonPage } from "./ComingSoonPage";
|
||||||
|
|
||||||
|
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
|
||||||
|
export function ShoppingListPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,19 @@ import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||||
* Only `ResBody`/`Locals` are made generic (what this codebase actually
|
* Only `ResBody`/`Locals` are made generic (what this codebase actually
|
||||||
* varies per-route) — params/request-body/query stay at Express's own
|
* varies per-route) — params/request-body/query stay at Express's own
|
||||||
* internal defaults, same as an unparameterized `Request`.
|
* internal defaults, same as an unparameterized `Request`.
|
||||||
|
*
|
||||||
|
* `Locals` is constrained to `Record<string, any>`, matching Express's own
|
||||||
|
* `Response<ResBody, LocalsObj>` exactly (see `@types/express-serve-static-
|
||||||
|
* core`) rather than the stricter `Record<string, unknown>`: a plain
|
||||||
|
* `interface` (e.g. `AuthLocals` in `require-auth.ts`) has no index
|
||||||
|
* signature, so under `unknown` it fails this generic's constraint even
|
||||||
|
* though it's assignable to `Response`'s own `Locals` param directly —
|
||||||
|
* `any` is what lets that structural gap close.
|
||||||
*/
|
*/
|
||||||
export type AsyncRequestHandler<
|
export type AsyncRequestHandler<
|
||||||
ResBody = unknown,
|
ResBody = unknown,
|
||||||
Locals extends Record<string, unknown> = Record<string, unknown>,
|
// biome-ignore lint/suspicious/noExplicitAny: mirrors Express's own Response<ResBody, LocalsObj extends Record<string, any>> constraint (see comment above) — `unknown` here would reject plain interfaces like AuthLocals that Response itself accepts fine.
|
||||||
|
Locals extends Record<string, any> = Record<string, any>,
|
||||||
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
|
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,7 +35,8 @@ export type AsyncRequestHandler<
|
||||||
*/
|
*/
|
||||||
export function wrapAsyncHandler<
|
export function wrapAsyncHandler<
|
||||||
ResBody = unknown,
|
ResBody = unknown,
|
||||||
Locals extends Record<string, unknown> = Record<string, unknown>,
|
// biome-ignore lint/suspicious/noExplicitAny: same constraint as AsyncRequestHandler above, for the same reason.
|
||||||
|
Locals extends Record<string, any> = Record<string, any>,
|
||||||
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
|
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
handler(req, res as Response<ResBody, Locals>, next).catch(next);
|
handler(req, res as Response<ResBody, Locals>, next).catch(next);
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
|
export * from "./types/planning.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
28
packages/shared/src/types/planning.ts
Normal file
28
packages/shared/src/types/planning.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* A single meal slot within a household's planning, with its recipe
|
||||||
|
* resolved to just enough info for display (id + name) — a caller needing
|
||||||
|
* more than that fetches the recipe itself separately.
|
||||||
|
*/
|
||||||
|
export interface PlanningItemView {
|
||||||
|
id: number;
|
||||||
|
/** Day of the week this item falls on (free-form for now — no enum exists yet, see schema.prisma). */
|
||||||
|
weekDay: string;
|
||||||
|
/** Which meal of the day this item is for (free-form for now, same reason). */
|
||||||
|
meal: string;
|
||||||
|
recipe: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A household's planning for a date range, as returned by the API. Dates
|
||||||
|
* are ISO 8601 strings (JSON has no native date type) — parse with
|
||||||
|
* `new Date(...)` client-side if arithmetic is needed.
|
||||||
|
*/
|
||||||
|
export interface PlanningView {
|
||||||
|
id: number;
|
||||||
|
startDate: string;
|
||||||
|
finishDate: string;
|
||||||
|
items: PlanningItemView[];
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,22 @@ Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
|
||||||
middleware d'erreur d'Express — chaque route devait faire son propre
|
middleware d'erreur d'Express — chaque route devait faire son propre
|
||||||
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
|
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
|
||||||
|
|
||||||
|
### `AsyncRequestHandler`/`wrapAsyncHandler` — `Locals` contraint par `Record<string, any>`, pas `unknown`
|
||||||
|
|
||||||
|
Le paramètre générique `Locals` est contraint par `Record<string, any>`, à
|
||||||
|
l'identique du propre `Response<ResBody, LocalsObj>` d'Express
|
||||||
|
(`@types/express-serve-static-core`) — volontairement, pas `Record<string,
|
||||||
|
unknown>` (plus strict, ce qui serait la contrainte "par défaut" attendue).
|
||||||
|
Raison concrète : une `interface` sans signature d'index (ex. `AuthLocals`
|
||||||
|
dans `require-auth.ts`) échoue la contrainte générique sous `unknown` alors
|
||||||
|
qu'elle s'assigne très bien à `Response`'s own `Locals` param directement —
|
||||||
|
observé en committant `wrapAsyncHandler<unknown, AuthLocals>(...)` sur
|
||||||
|
`GET /planning/current` (premier endpoint à combiner authentification et
|
||||||
|
handler async). `any` referme cet écart structurel ; les deux occurrences
|
||||||
|
portent un commentaire `biome-ignore lint/suspicious/noExplicitAny` expliquant
|
||||||
|
pourquoi (le lint interdit `any` par défaut, à raison, mais ce cas précis
|
||||||
|
imite un type de la lib standard Express qui fait le même choix).
|
||||||
|
|
||||||
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
|
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
|
||||||
|
|
||||||
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ 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.*, home.*)
|
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, 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/
|
||||||
|
|
@ -23,10 +23,14 @@ apps/web/src/
|
||||||
│ ├── 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
|
||||||
|
├── layouts/
|
||||||
|
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
|
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
|
||||||
│ ├── SignupPage.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/
|
├── 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)
|
||||||
|
|
@ -56,11 +60,15 @@ flowchart TB
|
||||||
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
|
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
|
||||||
CHECK -->|"401 (pas de session)"| ANON["user = null"]
|
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"]
|
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
|
||||||
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
|
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
|
||||||
|
|
||||||
ANON --> ROUTE_HOME_A["/"]
|
ANON --> ROUTE_HOME_A["/, /recettes, ..."]
|
||||||
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
|
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
|
||||||
ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
|
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
|
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é.
|
permet à un rechargement de page de garder l'utilisateur connecté.
|
||||||
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
|
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
|
||||||
(`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde
|
(`react-router-dom`) qui lisent cet état : la première protège tout l'espace
|
||||||
protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`).
|
connecté (voir `AppLayout` ci-dessous), la seconde protège `/login` et `/signup`
|
||||||
Les deux affichent `null` tant que la vérification initiale est en cours, pour
|
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
|
||||||
éviter un flash de contenu suivi d'une redirection.
|
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
|
||||||
|
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
|
<Route path="/foyer" element={<HouseholdPage />} />
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections,
|
||||||
|
nom de l'utilisateur + déconnexion en pied de 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).
|
||||||
|
|
||||||
|
### 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.
|
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
|
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
|
||||||
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
|
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")`.
|
- 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