diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts index 825ddb0..0f5db35 100644 --- a/apps/web/cypress/e2e/auth.cy.ts +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -8,6 +8,7 @@ import { ErrorCode } from "@batch-cooking/shared"; describe("Signup", () => { it("creates a profile and lands on the home page", () => { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: { @@ -30,7 +31,7 @@ describe("Signup", () => { cy.wait("@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", () => { @@ -70,6 +71,7 @@ describe("Signup", () => { describe("Login", () => { it("logs in and lands on the home page", () => { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/login", { statusCode: 200, body: { @@ -89,7 +91,7 @@ describe("Login", () => { cy.contains("button", "Se connecter").click(); 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", () => { @@ -123,10 +125,11 @@ describe("Already authenticated", () => { dietId: null, }, }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.visit("/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", () => { @@ -142,6 +145,7 @@ describe("Already authenticated", () => { dietId: null, }, }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); cy.visit("/"); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 49e4779..fe90ce7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,6 +2,7 @@ import { type ApiErrorResponse, ErrorCode, type LoginInput, + type PlanningView, type SafeUserProfile, type SignupInput, } from "@batch-cooking/shared"; @@ -93,6 +94,11 @@ export class ApiClient { public me(): Promise { 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 { + return this.request("/planning/current"); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 3fc42bd..5917c23 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -40,8 +40,15 @@ "logout": "Se déconnecter" }, "home": { - "greeting": "Bonjour {{firstName}} {{lastName}} 👋", - "logout": "Se déconnecter" + "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", diff --git a/apps/web/src/pages/HomePage.scss b/apps/web/src/pages/HomePage.scss index a2e8260..656e51c 100644 --- a/apps/web/src/pages/HomePage.scss +++ b/apps/web/src/pages/HomePage.scss @@ -8,48 +8,49 @@ // styles/global.scss and available globally at runtime — not a Sass-level // variable/mixin that would require an explicit compile-time import. -// Full-viewport centering wrapper, mirroring .auth-page's layout so the app -// doesn't visually jump between the login/signup screens and the home page. +// No outer centering wrapper here (unlike the old version of this file): +// AppLayout's `.app-content` already owns the page background/padding — +// this is just the page's own content. .home-page { - min-height: 100vh; - 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 { + &__status { color: var(--color-text-muted); font-size: var(--font-size-md); } - button { - padding: 0.6rem var(--space-lg); - font-family: var(--font-body); - font-size: var(--font-size-base); - 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); - } + &__status--error { + color: var(--color-error); + } +} + +// The current planning, one row per meal slot. Raised on its own surface, +// same card treatment used elsewhere in the app, so it reads as a distinct +// piece of content rather than bare text on the page background. +.planning-table { + width: 100%; + max-width: 40rem; + margin-top: var(--space-md); + 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; } } diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 525ba72..4181fa6 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,33 +1,81 @@ +import type { PlanningView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { useAuth } from "../features/auth/AuthContext"; +import { apiClient } from "../api/client"; 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} — - * `user` is guaranteed non-null by the time this renders. Static copy - * comes from i18next (`locales/fr/translation.json`, `home` namespace). + * Landing page for an authenticated visitor — the household's current + * planning. Behind {@link RequireAuth} (via `AppLayout`), so this only + * 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() { - const { user, logout } = useAuth(); - const navigate = useNavigate(); const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); - /** Ends the session and returns to the login page. */ - async function handleLogout() { - await logout(); - navigate("/login"); - } + useEffect(() => { + // Guards against setting state after unmount (e.g. the user navigates + // away before the request resolves) — no cleanup-worthy resource here, + // 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 ( -
-
-

batchCooking

-

{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}

- -
-
+
+

{t("home.title")}

+ + {state.status === "loading" &&

{t("home.loading")}

} + + {state.status === "error" && ( +

{t("home.error")}

+ )} + + {state.status === "loaded" && state.planning === null && ( +

{t("home.empty")}

+ )} + + {state.status === "loaded" && state.planning !== null && ( + + + + + + + + + + {state.planning.items.map((item) => ( + + + + + + ))} + +
{t("home.table.day")}{t("home.table.meal")}{t("home.table.recipe")}
{item.weekDay}{item.meal}{item.recipe.name}
+ )} +
); }