feat(mobile): redirection vers l'appli + page de telechargement
Some checks failed
Some checks failed
Ticket #84 du chantier UI mobile & tablette. Aucune infra de deep-link n'existait (verifie en amont : pas de scheme personnalise, pas d'App Links, pas d'assetlinks.json, pas de listener appUrlOpen). - lib/mobile-app-redirect.ts : attemptOpenNativeApp() (pattern "smart app banner" classique - tente le scheme batchcooking://, bascule sur onFallback si la page est toujours visible passe un court delai) et isAndroidPhoneBrowser() (UA Android+Mobile, deliberement distinct de useIsMobileViewport() - une question de plateforme, pas de largeur). Respecte une preference "rester sur le web" (localStorage, cle exportee pour que l'onglet Parametres d'application - ticket #86 - la reutilise) sans jamais forcer la redirection. - AndroidManifest.xml : nouvel intent-filter additif (scheme batchcooking, sans toucher au filtre MAIN/LAUNCHER existant). - Nouvelle page /telecharger (publique, hors RequireAuth - un visiteur deja connecte sur le web mais sans l'appli sur cet appareil a une raison legitime d'y atterrir) : lien direct vers le proxy existant ${API_BASE_URL}/mobile/download (rien a changer cote API), bouton "reessayer d'ouvrir l'appli". Deliberement pas de changement sur la landing page ici (lien discret actuel laisse tel quel) - c'est le ticket #85, separe pour rester petit/revuable. Verifie en local : tsc -b --force, biome check, build de prod, suite Cypress e2e complete (141/141 sur cette branche - #91 n'est pas encore merge, layout.cy.ts y est donc encore dans son etat pre-#91) et composants (69/69, incluant le nouveau mobile-app-redirect.cy.tsx). Note technique : le premier essai de test du composant tentait un vrai window.location.href vers le scheme personnalise depuis Cypress/ Electron, ce qui bloquait le runner indefiniment (protocole inconnu mal gere par Electron) - corrige en rendant ce seul appel injectable (openAppUrl, reserve aux tests) sans toucher au comportement reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
94ef8b4133
commit
d6cf4aa63d
8 changed files with 396 additions and 0 deletions
|
|
@ -18,6 +18,17 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Custom scheme for the web app's "open the installed app"
|
||||||
|
redirect (see apps/web/src/lib/mobile-app-redirect.ts) —
|
||||||
|
additive, does not touch the launcher filter above. No
|
||||||
|
android:host/android:path restriction, so any
|
||||||
|
batchcooking://... URL matches. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="batchcooking" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
98
apps/web/cypress/component/mobile-app-redirect.cy.tsx
Normal file
98
apps/web/cypress/component/mobile-app-redirect.cy.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import {
|
||||||
|
APP_OPEN_TIMEOUT_MS,
|
||||||
|
attemptOpenNativeApp,
|
||||||
|
isAndroidPhoneBrowser,
|
||||||
|
STAY_ON_WEB_PREFERENCE_KEY,
|
||||||
|
} from "../../src/lib/mobile-app-redirect";
|
||||||
|
|
||||||
|
// Pure-ish logic (timers/localStorage/document.hidden, no React component to
|
||||||
|
// mount) — same posture as version-compare.cy.tsx: reuses the component-test
|
||||||
|
// runner purely for `expect`/`cy.clock()`, no `cy.mount`. `.cy.tsx` (not
|
||||||
|
// `.cy.ts`) only because that's what cypress.config.ts's component
|
||||||
|
// `specPattern` looks for.
|
||||||
|
|
||||||
|
function setUserAgent(ua: string) {
|
||||||
|
Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentHidden(hidden: boolean) {
|
||||||
|
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isAndroidPhoneBrowser", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
setUserAgent(window.navigator.userAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for a real Android phone Chrome UA", () => {
|
||||||
|
setUserAgent(
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
|
||||||
|
);
|
||||||
|
expect(isAndroidPhoneBrowser()).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for an Android tablet UA (Android without Mobile)", () => {
|
||||||
|
setUserAgent(
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
||||||
|
);
|
||||||
|
expect(isAndroidPhoneBrowser()).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a desktop UA", () => {
|
||||||
|
setUserAgent(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
||||||
|
);
|
||||||
|
expect(isAndroidPhoneBrowser()).to.be.false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attemptOpenNativeApp", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
||||||
|
setDocumentHidden(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
||||||
|
setDocumentHidden(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onFallback immediately, with no delay, when the visitor opted to stay on web", () => {
|
||||||
|
localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, "true");
|
||||||
|
const onFallback = cy.stub().as("onFallback");
|
||||||
|
|
||||||
|
attemptOpenNativeApp({ onFallback });
|
||||||
|
|
||||||
|
cy.get("@onFallback").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onFallback once the delay elapses if the page never got hidden (app not installed)", () => {
|
||||||
|
cy.clock();
|
||||||
|
const onFallback = cy.stub().as("onFallback");
|
||||||
|
|
||||||
|
// `openAppUrl` stubbed to a no-op — real navigation to the custom
|
||||||
|
// scheme is only meaningful in a real browser with the app installed
|
||||||
|
// or not; here only the timer/visibility logic around it is under
|
||||||
|
// test (see the option's own doc comment in mobile-app-redirect.ts).
|
||||||
|
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
||||||
|
cy.get("@onFallback").should("not.have.been.called");
|
||||||
|
|
||||||
|
cy.tick(APP_OPEN_TIMEOUT_MS);
|
||||||
|
cy.get("@onFallback").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never calls onFallback if the page is hidden before the delay elapses (handoff succeeded)", () => {
|
||||||
|
cy.clock();
|
||||||
|
const onFallback = cy.stub().as("onFallback");
|
||||||
|
|
||||||
|
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
||||||
|
|
||||||
|
cy.then(() => {
|
||||||
|
setDocumentHidden(true);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.tick(APP_OPEN_TIMEOUT_MS);
|
||||||
|
cy.get("@onFallback").should("not.have.been.called");
|
||||||
|
});
|
||||||
|
});
|
||||||
26
apps/web/cypress/e2e/download-page.cy.ts
Normal file
26
apps/web/cypress/e2e/download-page.cy.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Pure layout spec, no Cucumber — a single static page with no user
|
||||||
|
// journey of its own (see layout.cy.ts's identical rationale for this
|
||||||
|
// 3-way test split). No `cy.intercept("**/auth/me", ...)` needed: this
|
||||||
|
// route renders outside RequireAuth entirely (see App.tsx's own doc
|
||||||
|
// comment on why), so it never calls that endpoint.
|
||||||
|
|
||||||
|
describe("DownloadPage — the mobile-app-redirect fallback", () => {
|
||||||
|
it("renders the APK download link, a retry button, and a way back home", () => {
|
||||||
|
cy.visit("/telecharger");
|
||||||
|
|
||||||
|
cy.contains("h1", "Installer l'application Android").should("be.visible");
|
||||||
|
|
||||||
|
// API_BASE_URL is empty locally (relative-URL, same-origin default —
|
||||||
|
// see client.ts), so the real target collapses to this relative path.
|
||||||
|
cy.contains("a", "Télécharger l'APK").should("have.attr", "href", "/mobile/download");
|
||||||
|
|
||||||
|
cy.contains("button", "Réessayer d'ouvrir l'application").should("be.visible");
|
||||||
|
|
||||||
|
cy.contains("a", "Retour à l'accueil").should("have.attr", "href", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is reachable without being logged in — no bounce to /login", () => {
|
||||||
|
cy.visit("/telecharger");
|
||||||
|
cy.url().should("include", "/telecharger");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
||||||
|
import { DownloadPage } from "./pages/download/DownloadPage";
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
|
@ -53,6 +54,13 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
||||||
* skipped otherwise, straight to `/onboarding/allergenes`.
|
* skipped otherwise, straight to `/onboarding/allergenes`.
|
||||||
*
|
*
|
||||||
|
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
|
||||||
|
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
|
||||||
|
* `/login`/`/signup`, but deliberately *not* wrapped in
|
||||||
|
* `RedirectIfAuthenticated`: a signed-in visitor still has a legitimate
|
||||||
|
* reason to land here (logged in on the web, app just not installed on
|
||||||
|
* this particular device yet).
|
||||||
|
*
|
||||||
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
||||||
* triage, off-catalog ingredient review). It's its own top-level group,
|
* triage, off-catalog ingredient review). It's its own top-level group,
|
||||||
* wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session
|
* wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session
|
||||||
|
|
@ -140,6 +148,7 @@ export function App() {
|
||||||
</RedirectIfAuthenticated>
|
</RedirectIfAuthenticated>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/telecharger" element={<DownloadPage />} />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AdminAuthProvider>
|
<AdminAuthProvider>
|
||||||
|
|
|
||||||
107
apps/web/src/lib/mobile-app-redirect.ts
Normal file
107
apps/web/src/lib/mobile-app-redirect.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* The Android app's own custom URL scheme — registered as an *additional*,
|
||||||
|
* additive `<intent-filter>` in `android/app/src/main/AndroidManifest.xml`
|
||||||
|
* (the pre-existing `MAIN`/`LAUNCHER` filter is untouched). Attempting to
|
||||||
|
* navigate here from a browser hands off to the installed app; on a device
|
||||||
|
* without it installed, an unrecognized custom scheme is a safe no-op in
|
||||||
|
* every browser this app targets (Android Chrome/WebView) — the current
|
||||||
|
* page just keeps running, nothing throws, nothing navigates away.
|
||||||
|
*/
|
||||||
|
export const APP_OPEN_URL = "batchcooking://open";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `localStorage` key for the "toujours ouvrir dans l'appli / rester sur le
|
||||||
|
* web" preference (UI for it lands in a later ticket of the mobile/tablet
|
||||||
|
* UI chantier, `/parametres/application`) — exported so that settings page
|
||||||
|
* can read/write the exact same key rather than this module and that page
|
||||||
|
* silently drifting to two different strings. Absent (the default) means
|
||||||
|
* "always try to open the app" — {@link attemptOpenNativeApp} never
|
||||||
|
* refuses to try just because nothing has set this yet.
|
||||||
|
*/
|
||||||
|
export const STAY_ON_WEB_PREFERENCE_KEY = "batchcooking:stayOnWeb";
|
||||||
|
|
||||||
|
/** How long to wait for the OS to hand off to the app before assuming it isn't installed. Exported for tests, not meant to be tuned per call site. */
|
||||||
|
export const APP_OPEN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "smart app banner" pattern: try to open the installed Android app via
|
||||||
|
* its custom scheme, and call `onFallback` if that doesn't visibly succeed
|
||||||
|
* within a short delay — the one signal available from a plain web page
|
||||||
|
* that a scheme navigation "worked" is that the OS backgrounds/replaces the
|
||||||
|
* tab (`document.hidden` flips `true`) to hand off to the other app; if the
|
||||||
|
* page is still the foreground document once the timer fires, nothing
|
||||||
|
* caught the scheme, so there's no app installed to open.
|
||||||
|
*
|
||||||
|
* Respects {@link STAY_ON_WEB_PREFERENCE_KEY}: when a visitor has
|
||||||
|
* explicitly asked to stay on the web, this skips the scheme attempt
|
||||||
|
* entirely and calls `onFallback` immediately — never a forced redirect a
|
||||||
|
* user has opted out of.
|
||||||
|
*
|
||||||
|
* `onFallback`'s job (typically: navigate to `/telecharger`) is entirely
|
||||||
|
* the caller's — this module has no opinion on routing.
|
||||||
|
*/
|
||||||
|
export function attemptOpenNativeApp({
|
||||||
|
onFallback,
|
||||||
|
delayMs = APP_OPEN_TIMEOUT_MS,
|
||||||
|
openAppUrl = navigateToAppScheme,
|
||||||
|
}: {
|
||||||
|
onFallback: () => void;
|
||||||
|
/** Override for tests — production callers should always use the default. */
|
||||||
|
delayMs?: number;
|
||||||
|
/**
|
||||||
|
* Override for tests only — production callers should never pass this.
|
||||||
|
* Real navigation to an unrecognized custom scheme is a safe no-op in
|
||||||
|
* every browser this app targets, but it's still a real, uncontrolled
|
||||||
|
* side effect a test shouldn't actually trigger; every other behavior
|
||||||
|
* here (the timer, the visibility check, the preference short-circuit)
|
||||||
|
* is exercised the same way regardless of what this does.
|
||||||
|
*/
|
||||||
|
openAppUrl?: () => void;
|
||||||
|
}): void {
|
||||||
|
if (localStorage.getItem(STAY_ON_WEB_PREFERENCE_KEY) === "true") {
|
||||||
|
onFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (!document.hidden) onFallback();
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
// If the handoff actually happens, this tab is about to be backgrounded
|
||||||
|
// — clear the pending fallback the moment that's detected so it can
|
||||||
|
// never fire later (e.g. once the visitor switches back to this tab
|
||||||
|
// after using the app), and stop listening either way once it has fired
|
||||||
|
// once.
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (!document.hidden) return;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
openAppUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToAppScheme(): void {
|
||||||
|
window.location.href = APP_OPEN_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current visitor is on an Android phone's browser — the one
|
||||||
|
* case {@link attemptOpenNativeApp} is worth trying at all, since the app
|
||||||
|
* only exists for Android (see the root README) and only in a phone form
|
||||||
|
* factor makes sense to redirect (a tablet browsing the site is a
|
||||||
|
* perfectly normal experience on its own, no app-open prompt needed).
|
||||||
|
*
|
||||||
|
* User-agent based, deliberately **not** {@link useIsMobileViewport} — that
|
||||||
|
* hook answers "is this viewport narrow" (a layout question, true for a
|
||||||
|
* resized desktop browser window too), this answers "is this genuinely an
|
||||||
|
* Android phone" (a capability question). Android's own UA convention
|
||||||
|
* drops "Mobile" from a *tablet's* UA string while keeping "Android" — the
|
||||||
|
* combination of both tokens is what actually narrows this to a phone.
|
||||||
|
*/
|
||||||
|
export function isAndroidPhoneBrowser(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /Android/.test(navigator.userAgent) && /Mobile/.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
@ -1364,6 +1364,13 @@
|
||||||
"button": "Créer un compte"
|
"button": "Créer un compte"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "Installer l'application Android",
|
||||||
|
"lede": "Téléchargez le fichier .apk, ou réessayez d'ouvrir l'application si elle est déjà installée sur cet appareil.",
|
||||||
|
"downloadButton": "Télécharger l'APK",
|
||||||
|
"retryButton": "Réessayer d'ouvrir l'application",
|
||||||
|
"backLink": "Retour à l'accueil"
|
||||||
|
},
|
||||||
"tour": {
|
"tour": {
|
||||||
"controls": {
|
"controls": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
|
|
|
||||||
54
apps/web/src/pages/download/DownloadPage.tsx
Normal file
54
apps/web/src/pages/download/DownloadPage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { API_BASE_URL } from "../../api/client";
|
||||||
|
import { attemptOpenNativeApp } from "../../lib/mobile-app-redirect";
|
||||||
|
import "./download-page.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fallback landing spot for {@link attemptOpenNativeApp} — reached when
|
||||||
|
* a visitor's phone didn't have the app installed to hand off to (or,
|
||||||
|
* directly, by anyone who just wants the APK — a plain link works
|
||||||
|
* regardless of how they arrived). Public, no `RequireAuth`/
|
||||||
|
* `RedirectIfAuthenticated` gate: unlike `/login`/`/signup`, a signed-in
|
||||||
|
* visitor still has a legitimate reason to land here (they're logged in on
|
||||||
|
* the web but haven't installed the app on *this* device yet).
|
||||||
|
*
|
||||||
|
* The actual download reuses `${API_BASE_URL}/mobile/download` —
|
||||||
|
* `apps/api/src/modules/mobile/`'s existing same-origin proxy in front of
|
||||||
|
* Forgejo's release asset (see that module's own doc comment for why a
|
||||||
|
* direct Forgejo link doesn't work: no CORS headers, no GitHub-style
|
||||||
|
* `/releases/latest/download/<file>` shortcut). Nothing new needed there.
|
||||||
|
*/
|
||||||
|
export function DownloadPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="download-page">
|
||||||
|
<div className="download-page__card">
|
||||||
|
<h1>{t("download.title")}</h1>
|
||||||
|
<p className="download-page__lede">{t("download.lede")}</p>
|
||||||
|
|
||||||
|
<a className="download-page__cta" href={`${API_BASE_URL}/mobile/download`}>
|
||||||
|
{t("download.downloadButton")}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="download-page__retry"
|
||||||
|
// Already on the fallback page — a failed retry needs no further
|
||||||
|
// fallback of its own, the download button right above already
|
||||||
|
// covers it.
|
||||||
|
onClick={() => attemptOpenNativeApp({ onFallback: () => {} })}
|
||||||
|
>
|
||||||
|
{t("download.retryButton")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="download-page__hint">{t("landing.hero.mobileHint")}</p>
|
||||||
|
|
||||||
|
<Link className="download-page__back" to="/">
|
||||||
|
{t("download.backLink")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/web/src/pages/download/download-page.scss
Normal file
84
apps/web/src/pages/download/download-page.scss
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// DownloadPage — colocated with DownloadPage.tsx. A small centered card,
|
||||||
|
// same "full-viewport centering wrapper + raised card" language as
|
||||||
|
// features/auth/auth-form.scss's `.auth-page`/`.auth-card` (this page sits
|
||||||
|
// in the same "not logged in yet, or not on this device yet" context), but
|
||||||
|
// its own classes rather than reused ones — no real behavior in common,
|
||||||
|
// just a similar look.
|
||||||
|
|
||||||
|
.download-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--max-width-form);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__cta {
|
||||||
|
align-self: stretch;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__retry {
|
||||||
|
align-self: stretch;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-page__back {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue