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 @@
+
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 (
+
+
+