Web: HomePage becomes the weekly planning view (step 4/5)
- ApiClient.getCurrentPlanning() — GET /planning/current.
- HomePage.tsx: replaces the old greeting card (now redundant with
AppLayout's sidebar) with the household's current planning — loading /
error / empty ("aucun planning pour cette semaine") / loaded (table of
weekDay/meal/recipe) states, modeled as a discriminated union so an
impossible combination (e.g. loading with data) can't be represented.
No invented weekday/meal grid — the API's `weekDay`/`meal` are free-form
strings (no enum exists yet in the schema), so this renders the items
as returned rather than assuming a specific vocabulary.
- locales/fr/translation.json: home.* replaced (title/loading/error/
empty/table.*), old greeting/logout keys removed (superseded by
layout.greeting/layout.logout from the AppLayout commit).
- cypress/e2e/auth.cy.ts: updated the now-stale "Bonjour Alice Martin"
assertions (greeting moved to the sidebar, first name only) and added
GET /planning/current intercepts so these specs don't depend on a real
backend. Manually verified end-to-end against a real API in the browser
preview (empty state + a seeded planning) — Cypress itself can't run
headless Chromium in this sandboxed dev environment (confirmed
pre-existing on main, unrelated to this change); CI runs the real
suite.
Verified with `pnpm --filter web build` after each of steps 2-4 to keep
every commit in this sequence independently buildable.
This commit is contained in:
parent
c07f8ad82e
commit
ac7f8d9685
5 changed files with 130 additions and 64 deletions
|
|
@ -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("/");
|
||||
|
|
|
|||
|
|
@ -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<SafeUserProfile> {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlanningState>({ 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 (
|
||||
<main className="home-page">
|
||||
<div className="home-card">
|
||||
<h1>batchCooking</h1>
|
||||
<p>{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}</p>
|
||||
<button type="button" onClick={handleLogout}>
|
||||
{t("home.logout")}
|
||||
</button>
|
||||
<div className="home-page">
|
||||
<h1>{t("home.title")}</h1>
|
||||
|
||||
{state.status === "loading" && <p className="home-page__status">{t("home.loading")}</p>}
|
||||
|
||||
{state.status === "error" && (
|
||||
<p className="home-page__status home-page__status--error">{t("home.error")}</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue