From da7b0770985321378dacfad1bdfff7876062e1f1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 04:27:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20page=20vitrine=20publique=20=C3=A0?= =?UTF-8?q?=20la=20racine=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` ; toutes les autres routes protégées redirigent vers `/login` exactement comme avant. Aucun changement de la table de routes — `/` reste `` 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 ») + `` — `/` 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 --- apps/web/cypress/e2e/landing.feature | 35 +++ apps/web/cypress/e2e/landing.ts | 16 ++ apps/web/cypress/e2e/smoke.cy.ts | 7 +- apps/web/index.html | 5 + apps/web/public/favicon.svg | 5 + apps/web/public/landing/README.md | 19 ++ apps/web/src/features/auth/RequireAuth.tsx | 15 +- apps/web/src/locales/fr/translation.json | 40 ++++ apps/web/src/pages/landing/LandingPage.tsx | 130 +++++++++++ apps/web/src/pages/landing/landing-page.scss | 213 +++++++++++++++++++ specs/frontend-architecture.md | 10 + 11 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 apps/web/cypress/e2e/landing.feature create mode 100644 apps/web/cypress/e2e/landing.ts create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/landing/README.md create mode 100644 apps/web/src/pages/landing/LandingPage.tsx create mode 100644 apps/web/src/pages/landing/landing-page.scss diff --git a/apps/web/cypress/e2e/landing.feature b/apps/web/cypress/e2e/landing.feature new file mode 100644 index 0000000..4567e7d --- /dev/null +++ b/apps/web/cypress/e2e/landing.feature @@ -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" diff --git a/apps/web/cypress/e2e/landing.ts b/apps/web/cypress/e2e/landing.ts new file mode 100644 index 0000000..2c58911 --- /dev/null +++ b/apps/web/cypress/e2e/landing.ts @@ -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"); +}); diff --git a/apps/web/cypress/e2e/smoke.cy.ts b/apps/web/cypress/e2e/smoke.cy.ts index 5333aa7..b315b11 100644 --- a/apps/web/cypress/e2e/smoke.cy.ts +++ b/apps/web/cypress/e2e/smoke.cy.ts @@ -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"); }); diff --git a/apps/web/index.html b/apps/web/index.html index cff1aec..edce363 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,7 +3,12 @@ + batchCooking +
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..106e009 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,5 @@ + + batchCooking + + bC + diff --git a/apps/web/public/landing/README.md b/apps/web/public/landing/README.md new file mode 100644 index 0000000..52fdd79 --- /dev/null +++ b/apps/web/public/landing/README.md @@ -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/.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. diff --git a/apps/web/src/features/auth/RequireAuth.tsx b/apps/web/src/features/auth/RequireAuth.tsx index 50059d3..94a072a 100644 --- a/apps/web/src/features/auth/RequireAuth.tsx +++ b/apps/web/src/features/auth/RequireAuth.tsx @@ -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 ; + } return ; } return <>{children}; diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 0ac1ae6..c7818e9 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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" + } } } diff --git a/apps/web/src/pages/landing/LandingPage.tsx b/apps/web/src/pages/landing/LandingPage.tsx new file mode 100644 index 0000000..48b800a --- /dev/null +++ b/apps/web/src/pages/landing/LandingPage.tsx @@ -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: , key: "planning" }, + { icon: , key: "import" }, + { icon: , key: "health" }, + { icon: , 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 ( +
+
+
+

batchCooking

+

{t("landing.hero.title")}

+

{t("landing.hero.lede")}

+
+ + {t("landing.hero.ctaSignup")} + + + {t("landing.hero.ctaLogin")} + +
+
+ +
+ +
+

{t("landing.features.title")}

+
    + {features.map(({ icon, key }) => ( +
  • + {icon} +

    {t(`landing.features.${key}.title`)}

    +

    {t(`landing.features.${key}.body`)}

    +
  • + ))} +
+
+ +
+

{t("landing.preview.title")}

+
+ {shots.map(({ src, key }) => ( +
+ {t(`landing.preview.${key}Alt`)} +
{t(`landing.preview.${key}Caption`)}
+
+ ))} +
+
+ +
+

{t("landing.finalCta.title")}

+ + {t("landing.finalCta.button")} + +
+
+ ); +} + +/** + * 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 ( + + {/* week grid */} + + + + + + + + {/* a couple of "planned" chips */} + + + + {/* simmering pot, front-right */} + + + + + + + + ); +} diff --git a/apps/web/src/pages/landing/landing-page.scss b/apps/web/src/pages/landing/landing-page.scss new file mode 100644 index 0000000..f3954ec --- /dev/null +++ b/apps/web/src/pages/landing/landing-page.scss @@ -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; + } +} diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index ead1693..faf02a1 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -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 `` nichée sous + `RequireAuth`+`AppLayout` ; `RequireAuth` teste juste `useLocation().pathname === "/"` + dans sa branche `!user`, avant le `` 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