feat(web): page vitrine publique à la racine (#41)
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build (push) Successful in 2m16s
CI / intent-service-test (push) Successful in 3m47s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m48s

Un visiteur déconnecté sur `/` voit désormais une vraie page de
présentation avant qu'on lui demande de se connecter, au lieu d'être
renvoyé directement vers `/login`.

- `RequireAuth.tsx` devient sensible au chemin : dans sa branche `!user`,
  `pathname === "/"` rend `<LandingPage />` ; toutes les autres routes
  protégées redirigent vers `/login` exactement comme avant. Aucun
  changement de la table de routes — `/` reste `<PlanningPage>` niché sous
  `RequireAuth`+`AppLayout`, et `AppLayout` (donc la sidebar) ne monte
  jamais pour la vitrine.
- `pages/landing/LandingPage.tsx` + `landing-page.scss` : hero (accroche +
  illustration SVG dessinée à la main dans le langage de `lucide-react`,
  zéro dépendance ni asset externe) + deux CTA, grille de 4
  fonctionnalités (icônes lucide), section « aperçu », CTA final. Copie
  entièrement sous le namespace i18n `landing.*`.
- `index.html` : favicon SVG (monogramme « bC ») + `<meta description>` —
  `/` est maintenant une page publique partageable.
- Les 3 captures d'écran réelles de la section « aperçu » sont servies
  statiquement depuis `public/landing/` (un fichier manquant retombe sur
  son `alt`, ne casse pas le build) ; elles restent à produire par un
  humain avec l'app lancée sur des données réelles — cf.
  `public/landing/README.md`.
- Tests Cypress `landing.feature` : vitrine visible déconnecté sur `/`,
  CTA vers `/login` et `/signup`, autre route protégée toujours redirigée,
  visiteur connecté voit bien le planning. `smoke.cy.ts` mis à jour pour
  tester la redirection depuis `/recettes` (puisque `/` ne redirige plus).

Closes #41
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-06 04:27:11 +02:00
parent 961aa151d4
commit da7b077098
11 changed files with 492 additions and 3 deletions

View file

@ -0,0 +1,35 @@
Feature: Public landing page at the root
A logged-out visitor on "/" should see a real presentation of the app
before being asked to sign in — but every other guarded route, and a
logged-in visitor, must behave exactly as before.
Scenario: A logged-out visitor lands on the presentation page
Given I am not signed in
When I visit "/"
Then I should see the heading "Cuisinez une fois, mangez toute la semaine"
And I should see the landing call to action
Scenario: The landing page sends visitors to sign in
Given I am not signed in
And I visit "/"
When I click the link "Se connecter"
Then the URL should include "/login"
Scenario: The landing page sends visitors to create an account
Given I am not signed in
And I visit "/"
When I click the link "Créer un compte"
Then the URL should include "/signup"
Scenario: Another guarded route still redirects a logged-out visitor to sign in
Given I am not signed in
When I visit "/recettes"
Then the URL should include "/login"
Scenario: A signed-in visitor on "/" still gets the planning, not the landing
Given I am signed in as "Alice" "Martin"
And the planning request returns nothing
When I visit "/"
Then I should see the heading "Planning de la semaine"
And I should not see "Cuisinez une fois, mangez toute la semaine"

View file

@ -0,0 +1,16 @@
import { Then } from "@badeball/cypress-cucumber-preprocessor";
// Landing-page-specific steps. Everything else this feature needs
// (signing in / not, visiting a path, URL and heading assertions) comes
// from the shared `cypress/support/step_definitions/common.steps.ts`.
// Mocks nothing itself — the landing page makes no API calls of its own;
// it renders purely from the logged-out state `I am not signed in` sets up.
Then("I should see the landing call to action", () => {
cy.get(".landing__hero .landing__cta--primary")
.should("be.visible")
.and("have.attr", "href", "/signup");
cy.get(".landing__hero .landing__cta--secondary")
.should("be.visible")
.and("have.attr", "href", "/login");
});

View file

@ -1,12 +1,15 @@
import { ErrorCode } from "@batch-cooking/shared"; import { ErrorCode } from "@batch-cooking/shared";
describe("smoke test", () => { describe("smoke test", () => {
it("redirects an unauthenticated visitor to the login page", () => { it("redirects an unauthenticated visitor away from a guarded page to the login page", () => {
cy.intercept("GET", "**/auth/me", { cy.intercept("GET", "**/auth/me", {
statusCode: 401, statusCode: 401,
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" }, body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
}); });
cy.visit("/"); // `/` itself now shows the public landing page to a logged-out visitor
// (see landing.feature / issue #41) — every *other* guarded route still
// bounces straight to /login, which is what this smoke check covers.
cy.visit("/recettes");
cy.url().should("include", "/login"); cy.url().should("include", "/login");
cy.contains("h1", "Se connecter").should("be.visible"); cy.contains("h1", "Se connecter").should("be.visible");
}); });

View file

@ -3,7 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>batchCooking</title> <title>batchCooking</title>
<meta
name="description"
content="batchCooking : planifiez vos repas de la semaine, importez des recettes depuis vos sites préférés, et générez la liste de courses qui va avec — pour tout le foyer."
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="batchCooking">
<title>batchCooking</title>
<rect width="32" height="32" rx="7" fill="#2e6b4a" />
<text x="16" y="22" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="17" font-weight="700" fill="#ffffff">bC</text>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View file

@ -0,0 +1,19 @@
# Landing-page screenshots
`LandingPage.tsx`'s "Un aperçu" section shows three real screenshots of the
running app, served as static files from here (referenced as
`/landing/<name>.png`, no Vite import — a missing file just leaves the
`alt` text, it doesn't break the build).
To fill them in, run the app against a seeded database (`pnpm dev` with a
real API + `prisma db seed`), sign in, and capture at a desktop width
(~1440px), then drop the files here with these exact names:
| File | What to capture |
|---|---|
| `planning.png` | The weekly planning grid (`/`) with a full week of recipes planned |
| `recipe.png` | A recipe detail panel (`/recettes/:id`) with cooking techniques highlighted in the steps |
| `cooking-session.png` | The optimized cooking session (`/cuisiner`) with its grouped phases |
Keep them reasonably sized (≈1400px wide, PNG, optimised). They're
purely presentational — no test depends on them.

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Navigate } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { LandingPage } from "../../pages/landing/LandingPage";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
/** /**
@ -7,9 +8,18 @@ import { useAuth } from "./AuthContext";
* home page). Renders nothing while the initial session check is pending, * home page). Renders nothing while the initial session check is pending,
* to avoid a flash-then-redirect; once resolved, either renders `children` * to avoid a flash-then-redirect; once resolved, either renders `children`
* or redirects to `/login`. * or redirects to `/login`.
*
* One path-specific exception in an otherwise route-agnostic guard: a
* logged-out visitor on `/` exactly gets the public {@link LandingPage}
* instead of an immediate bounce to `/login`, so a first-time visitor sees
* what the app is before being asked to sign in (issue #41). Every other
* guarded route still redirects exactly as before, and once logged in `/`
* falls through to `children` (the planning page) unchanged. See
* `specs/frontend-architecture.md`'s root-routing note.
*/ */
export function RequireAuth({ children }: { children: ReactNode }) { export function RequireAuth({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) { if (isLoading) {
// Initial GET /auth/me still in flight — don't redirect yet, we don't // Initial GET /auth/me still in flight — don't redirect yet, we don't
@ -17,6 +27,9 @@ export function RequireAuth({ children }: { children: ReactNode }) {
return null; return null;
} }
if (!user) { if (!user) {
if (location.pathname === "/") {
return <LandingPage />;
}
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
return <>{children}</>; return <>{children}</>;

View file

@ -1317,5 +1317,45 @@
"prunedNone": "Aucun placeholder orphelin à purger.", "prunedNone": "Aucun placeholder orphelin à purger.",
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)." "pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
} }
},
"landing": {
"hero": {
"title": "Cuisinez une fois, mangez toute la semaine",
"lede": "Planifiez vos repas, importez des recettes depuis vos sites préférés, et générez la liste de courses qui va avec — pour tout le foyer, sans y penser deux fois.",
"ctaSignup": "Créer un compte",
"ctaLogin": "Se connecter"
},
"features": {
"title": "Ce que batchCooking fait pour vous",
"planning": {
"title": "Planning de la semaine",
"body": "Une grille 7 jours × 5 repas : glissez vos recettes dans les créneaux, ajustez les portions, et laissez les restes se recaser tout seuls."
},
"import": {
"title": "Import de recettes",
"body": "Parcourez TheMealDB, Marmiton, 750g, Manger Bouger, Quitoque ou Oh mon Bento et importez une recette en un geste, ingrédients et techniques déjà reconnus."
},
"health": {
"title": "Allergies et régimes",
"body": "Déclarez allergènes, régime alimentaire et ingrédients que vous n'aimez pas : les recettes qui coincent sont signalées avant que vous ne les planifiiez."
},
"household": {
"title": "Foyer partagé",
"body": "Invitez les autres membres du foyer avec un code : planning, liste de courses et préférences de chacun, au même endroit."
}
},
"preview": {
"title": "Un aperçu",
"planningAlt": "Capture d'écran du planning hebdomadaire de batchCooking",
"planningCaption": "Le planning de la semaine, repas par repas.",
"recipeAlt": "Capture d'écran d'une fiche recette avec les techniques de cuisson surlignées",
"recipeCaption": "Chaque recette, avec ses techniques repérées dans le texte.",
"cookingSessionAlt": "Capture d'écran de la séance de cuisine optimisée",
"cookingSessionCaption": "La séance de cuisine, réorganisée en étapes groupées."
},
"finalCta": {
"title": "Prêt à arrêter d'improviser le dîner ?",
"button": "Créer un compte"
}
} }
} }

View file

@ -0,0 +1,130 @@
import { CalendarDays, HeartPulse, Sparkles, Users } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import "./landing-page.scss";
/**
* Public marketing page shown at `/` to a logged-out visitor (see
* `RequireAuth` for how it's wired in without touching the route table).
* All copy comes from i18next under the `landing.*` namespace — no
* hardcoded JSX text, same convention as every other page.
*
* Deliberately dependency-free for visuals: the hero illustration is an
* inline stroke-drawn SVG in the same visual language as `lucide-react`
* (the icon set already used everywhere), not an imported illustration
* library or an external asset — same choice `TechStepCorrectionPopover`
* made for its own hand-drawn icons. The three preview images are real
* app screenshots served statically from `public/landing/` (see that
* folder's README); a missing file just falls back to its `alt` text and
* never breaks the build.
*/
export function LandingPage() {
const { t } = useTranslation();
const features: { icon: ReactNode; key: "planning" | "import" | "health" | "household" }[] = [
{ icon: <CalendarDays aria-hidden />, key: "planning" },
{ icon: <Sparkles aria-hidden />, key: "import" },
{ icon: <HeartPulse aria-hidden />, key: "health" },
{ icon: <Users aria-hidden />, key: "household" },
];
const shots: { src: string; key: "planning" | "recipe" | "cookingSession" }[] = [
{ src: "/landing/planning.png", key: "planning" },
{ src: "/landing/recipe.png", key: "recipe" },
{ src: "/landing/cooking-session.png", key: "cookingSession" },
];
return (
<main className="landing">
<section className="landing__hero">
<div className="landing__hero-copy">
<p className="landing__wordmark">batchCooking</p>
<h1>{t("landing.hero.title")}</h1>
<p className="landing__lede">{t("landing.hero.lede")}</p>
<div className="landing__cta-row">
<Link className="landing__cta landing__cta--primary" to="/signup">
{t("landing.hero.ctaSignup")}
</Link>
<Link className="landing__cta landing__cta--secondary" to="/login">
{t("landing.hero.ctaLogin")}
</Link>
</div>
</div>
<HeroIllustration />
</section>
<section className="landing__features" aria-labelledby="landing-features-title">
<h2 id="landing-features-title">{t("landing.features.title")}</h2>
<ul className="landing__feature-grid">
{features.map(({ icon, key }) => (
<li key={key} className="landing__feature">
<span className="landing__feature-icon">{icon}</span>
<h3>{t(`landing.features.${key}.title`)}</h3>
<p>{t(`landing.features.${key}.body`)}</p>
</li>
))}
</ul>
</section>
<section className="landing__preview" aria-labelledby="landing-preview-title">
<h2 id="landing-preview-title">{t("landing.preview.title")}</h2>
<div className="landing__shots">
{shots.map(({ src, key }) => (
<figure key={key} className="landing__shot">
<img src={src} alt={t(`landing.preview.${key}Alt`)} loading="lazy" />
<figcaption>{t(`landing.preview.${key}Caption`)}</figcaption>
</figure>
))}
</div>
</section>
<section className="landing__final-cta">
<h2>{t("landing.finalCta.title")}</h2>
<Link className="landing__cta landing__cta--primary" to="/signup">
{t("landing.finalCta.button")}
</Link>
</section>
</main>
);
}
/**
* Hero illustration — a week grid with a simmering pot, drawn in the same
* 2px round-stroke `currentColor` language as the lucide icon set so it
* sits visually with the rest of the app. No external asset.
*/
function HeroIllustration() {
return (
<svg
className="landing__hero-art"
viewBox="0 0 240 200"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{/* week grid */}
<rect x="18" y="26" width="150" height="118" rx="10" />
<line x1="18" y1="56" x2="168" y2="56" />
<line x1="63" y1="56" x2="63" y2="144" />
<line x1="108" y1="56" x2="108" y2="144" />
<line x1="138" y1="56" x2="138" y2="144" />
<line x1="18" y1="86" x2="168" y2="86" />
<line x1="18" y1="115" x2="168" y2="115" />
{/* a couple of "planned" chips */}
<rect x="26" y="64" width="30" height="12" rx="6" />
<rect x="71" y="93" width="30" height="12" rx="6" />
<rect x="116" y="123" width="16" height="12" rx="6" />
{/* simmering pot, front-right */}
<path d="M150 150h74a8 8 0 0 1 8 8v18a20 20 0 0 1-20 20h-50a20 20 0 0 1-20-20v-18a8 8 0 0 1 8-8Z" />
<line x1="142" y1="150" x2="128" y2="150" />
<line x1="232" y1="150" x2="246" y2="150" />
<path d="M164 138c-4-6 2-10 0-16" />
<path d="M188 136c-4-6 2-10 0-16" />
<path d="M212 138c-4-6 2-10 0-16" />
</svg>
);
}

View file

@ -0,0 +1,213 @@
// LandingPage — the public page at `/` for a logged-out visitor. Colocated
// with LandingPage.tsx. Every colour is a `var(--color-*)` token so it
// resolves per-theme globally (styles/_theme.scss); no light/dark blocks
// here. The `@media (max-width: 640px)` phone treatment follows the same
// "stack, don't cram" philosophy as the rest of the app (AppLayout.scss).
.landing {
max-width: 68rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg) var(--space-2xl);
color: var(--color-text);
h1,
h2,
h3 {
font-family: var(--font-display);
line-height: 1.15;
}
section + section {
margin-top: var(--space-2xl);
}
@media (max-width: 640px) {
padding: var(--space-lg) var(--space-md) var(--space-xl);
section + section {
margin-top: var(--space-xl);
}
}
}
// --- Hero -----------------------------------------------------------------
.landing__hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
align-items: center;
gap: var(--space-xl);
@media (max-width: 860px) {
grid-template-columns: 1fr;
}
}
.landing__wordmark {
margin: 0 0 var(--space-sm);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-lg);
color: var(--color-primary);
}
.landing__hero h1 {
margin: 0 0 var(--space-md);
font-size: calc(var(--font-size-xl) * 1.5);
@media (max-width: 640px) {
font-size: var(--font-size-xl);
}
}
.landing__lede {
margin: 0 0 var(--space-lg);
font-size: var(--font-size-md);
color: var(--color-text-muted);
max-width: 34rem;
}
.landing__hero-art {
width: 100%;
max-width: 22rem;
height: auto;
color: var(--color-primary);
justify-self: center;
}
// --- CTAs (shared by hero + final section) -------------------------------
.landing__cta-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.landing__cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-base);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-decoration: none;
cursor: pointer;
&--primary {
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
&:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
&--secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
}
// --- Features -----------------------------------------------------------
.landing__features h2,
.landing__preview h2 {
margin: 0 0 var(--space-lg);
font-size: var(--font-size-xl);
text-align: center;
}
.landing__feature-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--space-lg);
}
.landing__feature {
padding: var(--space-lg);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
h3 {
margin: var(--space-sm) 0 var(--space-xs);
font-size: var(--font-size-md);
}
p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
.landing__feature-icon {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-base);
background: var(--color-tag);
color: var(--color-tag-ink);
svg {
width: 1.4rem;
height: 1.4rem;
}
}
// --- App preview ------------------------------------------------------
.landing__shots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-lg);
}
.landing__shot {
margin: 0;
img {
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
display: block;
}
figcaption {
margin-top: var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
}
// --- Final CTA ---------------------------------------------------------
.landing__final-cta {
text-align: center;
padding: var(--space-xl);
background: var(--color-surface-alt);
border-radius: var(--radius-md);
h2 {
margin: 0 0 var(--space-lg);
font-size: var(--font-size-xl);
}
.landing__cta {
// Centred on its own, not in a flex row here.
display: inline-flex;
}
}

View file

@ -129,6 +129,16 @@ flowchart TB
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant (redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
que la vérification initiale est en cours, pour éviter un flash de contenu suivi que la vérification initiale est en cours, pour éviter un flash de contenu suivi
d'une redirection. d'une redirection.
- **Exception racine dans `RequireAuth`** : un visiteur déconnecté sur `/`
*exactement* voit la page vitrine publique (`pages/landing/LandingPage.tsx`) au
lieu d'être renvoyé immédiatement vers `/login` — pour qu'un premier visiteur
découvre l'app avant qu'on lui demande de se connecter (issue #41). La table de
routes ne bouge pas : `/` reste `<PlanningPage>` nichée sous
`RequireAuth`+`AppLayout` ; `RequireAuth` teste juste `useLocation().pathname === "/"`
dans sa branche `!user`, avant le `<Navigate to="/login">` qui reste le
comportement de **toutes** les autres routes protégées. Une fois connecté, `/`
retombe sur `children` (le planning) sans changement, et `AppLayout` (donc la
sidebar) ne monte jamais pour la vitrine.
- **`RedirectIfAuthenticated` verrouille sa décision une seule fois** (au moment où - **`RedirectIfAuthenticated` verrouille sa décision une seule fois** (au moment où
`isLoading` passe à `false`), au lieu de réagir à chaque changement de `user` — `isLoading` passe à `false`), au lieu de réagir à chaque changement de `user` —
bug trouvé en construisant le wizard d'inscription (voir plus bas) : un bug trouvé en construisant le wizard d'inscription (voir plus bas) : un