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";
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", {
statusCode: 401,
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.contains("h1", "Se connecter").should("be.visible");
});

View file

@ -3,7 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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>
<body>
<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 { Navigate } from "react-router-dom";
import { Navigate, useLocation } from "react-router-dom";
import { LandingPage } from "../../pages/landing/LandingPage";
import { useAuth } from "./AuthContext";
/**
@ -7,9 +8,18 @@ import { useAuth } from "./AuthContext";
* home page). Renders nothing while the initial session check is pending,
* to avoid a flash-then-redirect; once resolved, either renders `children`
* 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 }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
// 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;
}
if (!user) {
if (location.pathname === "/") {
return <LandingPage />;
}
return <Navigate to="/login" replace />;
}
return <>{children}</>;

View file

@ -1317,5 +1317,45 @@
"prunedNone": "Aucun placeholder orphelin à purger.",
"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
que la vérification initiale est en cours, pour éviter un flash de contenu suivi
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ù
`isLoading` passe à `false`), au lieu de réagir à chaque changement de `user` —
bug trouvé en construisant le wizard d'inscription (voir plus bas) : un