diff --git a/apps/web/cypress/e2e/landing.feature b/apps/web/cypress/e2e/landing.feature index 714fd8b..a1b7bcf 100644 --- a/apps/web/cypress/e2e/landing.feature +++ b/apps/web/cypress/e2e/landing.feature @@ -22,11 +22,17 @@ Feature: Public landing page at the root When I click the link "Créer un compte" Then the URL should include "/signup" - Scenario: The landing page offers the Android app download + Scenario: The landing page offers the Android app download, on desktop Given I am not signed in When I visit "/" Then I should see the Android download link + Scenario: A mobile visitor is offered to open the app first, not just a download link + Given I am not signed in + When I visit "/" as an Android phone browser + Then I should see the button to open the app, with sign-up demoted to secondary + And the Android download link should not be shown + Scenario: Another guarded route still redirects a logged-out visitor to sign in Given I am not signed in When I visit "/recettes" diff --git a/apps/web/cypress/e2e/landing.ts b/apps/web/cypress/e2e/landing.ts index 067aecc..00e9bb2 100644 --- a/apps/web/cypress/e2e/landing.ts +++ b/apps/web/cypress/e2e/landing.ts @@ -1,4 +1,4 @@ -import { Then } from "@badeball/cypress-cucumber-preprocessor"; +import { Then, When } 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 @@ -15,19 +15,50 @@ Then("I should see the landing call to action", () => { .and("have.attr", "href", "/login"); }); -// The Android build is distributed as a Forgejo release asset with a stable -// filename (issue #64 builds/attaches it). Points at this API's own -// `/mobile/download` (a plain server-side redirect to the actual latest -// `.apk`) rather than a Forgejo URL directly: Forgejo has neither a -// GitHub-style `/releases/latest/download/` shortcut nor CORS headers -// on its Releases API, so resolving the real asset URL client-side isn't -// possible either way (both verified live). `VITE_API_URL` is unset in this -// environment, so `API_BASE_URL` (see `api/client.ts`) is `""` — same -// origin, hence the bare `/mobile/download` path here rather than an -// absolute URL. We only assert the link is there and points at the right -// path — the redirect target itself is external to the suite. +// Issue #85: desktop only now (see LandingPage.tsx) — routed through +// `/telecharger` (DownloadPage.tsx, issue #84's fallback page) rather than +// linking the Android build's actual `/mobile/download` redirect directly, +// so "get the app" has exactly one canonical destination app-wide. Then("I should see the Android download link", () => { cy.get(".landing__hero .landing__mobile-link") .should("be.visible") - .and("have.attr", "href", "/mobile/download"); + .and("have.attr", "href", "/telecharger"); +}); + +// Spoofs a real Android phone Chrome UA via `onBeforeLoad` — the one place +// `isAndroidPhoneBrowser()` (`lib/mobile-app-redirect.ts`) actually reads +// from, so this is the only way to reach the landing page's mobile-only +// branch from a real page visit (Cypress's own Electron browser always +// reports a desktop UA otherwise). Same literal UA string already used by +// `mobile-app-redirect.cy.tsx`'s own `isAndroidPhoneBrowser` unit test. +When("I visit {string} as an Android phone browser", (path: string) => { + cy.visit(path, { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "userAgent", { + value: + "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36", + configurable: true, + }); + }, + }); +}); + +Then("I should see the button to open the app, with sign-up demoted to secondary", () => { + // Deliberately never clicked here: its `onClick` calls + // `attemptOpenNativeApp()` with the real (production) `openAppUrl`, a + // genuine `window.location.href` to an unrecognized custom scheme — that + // hangs the Cypress/Electron runner indefinitely rather than safely + // no-op'ing (see dev-environment-gotchas.md). The function itself is + // already fully unit-tested with an injected stub in + // mobile-app-redirect.cy.tsx; this only covers LandingPage's own + // conditional rendering. + cy.get(".landing__hero .landing__cta--primary") + .should("be.visible") + .and("contain.text", "Ouvrir dans l'application"); + cy.contains(".landing__hero .landing__cta--secondary", "Créer un compte").should("be.visible"); + cy.contains(".landing__hero .landing__cta--secondary", "Se connecter").should("be.visible"); +}); + +Then("the Android download link should not be shown", () => { + cy.get(".landing__hero .landing__mobile-link").should("not.exist"); }); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 2828029..3b7e57f 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1330,6 +1330,7 @@ "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", + "ctaOpenApp": "Ouvrir dans l'application", "mobileDownload": "Télécharger pour Android", "mobileHint": "Fichier .apk à installer soi-même : Android demandera d'autoriser l'installation depuis une source inconnue. Ce n'est pas une version du Play Store, et un compte reste nécessaire." }, diff --git a/apps/web/src/pages/landing/LandingPage.tsx b/apps/web/src/pages/landing/LandingPage.tsx index ba9be8a..38b0f5c 100644 --- a/apps/web/src/pages/landing/LandingPage.tsx +++ b/apps/web/src/pages/landing/LandingPage.tsx @@ -1,8 +1,8 @@ import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import { API_BASE_URL } from "../../api/client"; +import { Link, useNavigate } from "react-router-dom"; +import { attemptOpenNativeApp, isAndroidPhoneBrowser } from "../../lib/mobile-app-redirect"; import "./landing-page.scss"; /** @@ -22,6 +22,17 @@ import "./landing-page.scss"; */ 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: , key: "planning" }, @@ -44,7 +55,38 @@ export function LandingPage() {

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

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

- + {/* + * 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 && ( + + )} + {t("landing.hero.ctaSignup")} @@ -52,31 +94,19 @@ export function LandingPage() {
{/* - * Secondary action — an account is needed on any platform, so the - * Android build is a discreet link under the CTA row, not a third - * button. Points at this API's own `/mobile/download` — a plain - * redirect straight to the latest release's actual `.apk`, - * resolved server-side (see `mobile.service.ts`) — rather than a - * Forgejo URL directly: Forgejo, unlike GitHub, has no - * `/releases/latest/download/` shortcut (only - * `/releases/latest` itself resolves, to the release's HTML page, - * not a file), and its Releases API sends no CORS headers, so a - * client-side fetch to resolve the direct URL ourselves isn't an - * option either — both verified live. `target="_blank"` so the - * redirect-then-download happens in a new tab, never navigating - * this one away. The hint is honest that this is a sideloaded - * .apk, not a Play Store listing. + * 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. */} - - - {t("landing.hero.mobileDownload")} - -

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

+ {!isMobile && ( + + + {t("landing.hero.mobileDownload")} + + )} diff --git a/apps/web/src/pages/landing/landing-page.scss b/apps/web/src/pages/landing/landing-page.scss index 97ed857..3f1736c 100644 --- a/apps/web/src/pages/landing/landing-page.scss +++ b/apps/web/src/pages/landing/landing-page.scss @@ -89,9 +89,17 @@ } .landing__cta { + // Issue #85: the mobile "Ouvrir dans l'application" CTA is a real + // `