Some checks failed
Sur un vrai navigateur mobile Android (isAndroidPhoneBrowser(), déjà utilisé par le ticket #84), la ligne de CTA de la landing page affiche désormais "Ouvrir dans l'application" comme action PRIMAIRE — plus utile qu'un compte web puisque l'expérience web n'est de toute façon pas optimisée mobile (au cœur de ce chantier), et un visiteur avec l'appli déjà installée peut s'inscrire/se connecter depuis l'appli elle-même. "Créer un compte"/"Se connecter" sont rétrogradés en actions secondaires plutôt que masqués : certains visiteurs préfèrent sciemment le web. Le clic déclenche attemptOpenNativeApp (tentative d'ouverture du scheme, repli automatique après le délai) plutôt que d'afficher un second lien séparé — remplace .landing__mobile-link, ne le duplique pas. Sur desktop, comportement inchangé (CTA "Créer un compte" toujours primaire) sauf le lien Android discret, qui pointe maintenant vers /telecharger (page de repli du ticket #84) au lieu du lien direct /mobile/download — un seul point d'entrée "obtenir l'appli" pour tout le site plutôt que deux mécanismes légèrement différents à maintenir en synchro. Couverture Cypress : landing.feature étendu avec un nouveau scénario mobile (UA Android spoofée via onBeforeLoad, même chaîne que le test unitaire existant d'isAndroidPhoneBrowser dans mobile-app-redirect.cy.tsx) — vérifie l'affichage du bouton et la rétrogradation de Créer un compte/Se connecter, SANS jamais cliquer le bouton réel (son onClick appellerait un vrai window.location.href vers un scheme personnalisé, qui bloque indéfiniment le runner Cypress/ Electron — piège déjà documenté, la fonction elle-même est déjà testée unitairement avec un stub injecté). Le scénario desktop existant est mis à jour pour le nouveau href /telecharger. Suite complète : biome check, tsc -b --force, build, 145/145 e2e, 71/71 composants — tous verts. Closes #85 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { attemptOpenNativeApp, isAndroidPhoneBrowser } from "../../lib/mobile-app-redirect";
|
|
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 navigate = useNavigate();
|
|
// UA-based, not `useIsMobileViewport()` — same reasoning as
|
|
// `attemptOpenNativeApp`'s own doc comment: this decides whether trying
|
|
// to open the *native app* makes sense at all (a platform question), not
|
|
// whether the viewport is narrow (a resized desktop window shouldn't
|
|
// attempt a scheme navigation just because it's a small window).
|
|
const isMobile = isAndroidPhoneBrowser();
|
|
|
|
function handleOpenApp() {
|
|
attemptOpenNativeApp({ onFallback: () => navigate("/telecharger") });
|
|
}
|
|
|
|
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">
|
|
{/*
|
|
* Issue #85: on a real Android phone browser, opening/getting
|
|
* the app is more useful than a web account — the web
|
|
* experience isn't optimized for mobile at all (the whole
|
|
* point of this chantier), and someone with the app already
|
|
* installed can sign up/log in from inside it just as well.
|
|
* So this becomes the *primary* action, demoting Signup/Login
|
|
* to secondary rather than hiding them — some visitors
|
|
* genuinely want the browser flow, e.g. to try it before
|
|
* installing anything. Desktop is untouched: a scheme-open
|
|
* attempt makes no sense there, so Signup stays primary and
|
|
* this button doesn't render at all (see the plain `/telecharger`
|
|
* link below instead, replacing the old direct-APK link).
|
|
*/}
|
|
{isMobile && (
|
|
<button
|
|
type="button"
|
|
className="landing__cta landing__cta--primary"
|
|
onClick={handleOpenApp}
|
|
>
|
|
<Smartphone aria-hidden />
|
|
{t("landing.hero.ctaOpenApp")}
|
|
</button>
|
|
)}
|
|
<Link
|
|
className={
|
|
isMobile
|
|
? "landing__cta landing__cta--secondary"
|
|
: "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>
|
|
{/*
|
|
* Desktop only — a scheme-open attempt (the mobile button above)
|
|
* has no meaning here. Routed through `/telecharger` (the same
|
|
* fallback page a failed mobile attempt lands on) rather than
|
|
* linking `${API_BASE_URL}/mobile/download` directly, so "get
|
|
* the app" has exactly one canonical destination app-wide
|
|
* instead of two slightly different mechanisms to keep in sync.
|
|
*/}
|
|
{!isMobile && (
|
|
<Link className="landing__mobile-link" to="/telecharger">
|
|
<Smartphone aria-hidden />
|
|
{t("landing.hero.mobileDownload")}
|
|
</Link>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|