Merge pull request 'feat(web): page vitrine publique à la racine (#41)' (#57) from feat/landing-page into main
This commit is contained in:
commit
9174f80282
11 changed files with 492 additions and 3 deletions
35
apps/web/cypress/e2e/landing.feature
Normal file
35
apps/web/cypress/e2e/landing.feature
Normal 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"
|
||||
16
apps/web/cypress/e2e/landing.ts
Normal file
16
apps/web/cypress/e2e/landing.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
5
apps/web/public/favicon.svg
Normal file
5
apps/web/public/favicon.svg
Normal 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 |
19
apps/web/public/landing/README.md
Normal file
19
apps/web/public/landing/README.md
Normal 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.
|
||||
|
|
@ -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}</>;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
apps/web/src/pages/landing/LandingPage.tsx
Normal file
130
apps/web/src/pages/landing/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
apps/web/src/pages/landing/landing-page.scss
Normal file
213
apps/web/src/pages/landing/landing-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue