[UI mobile] Onglet "Paramètres d'application" #100

Merged
kyuno merged 1 commit from feat/mobile-app-settings into main 2026-09-07 17:40:03 +02:00
9 changed files with 362 additions and 25 deletions
Showing only changes of commit e1589e63d0 - Show all commits

View file

@ -0,0 +1,68 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database.
//
// Cypress always runs as a plain web browser — `Capacitor.isNativePlatform()`
// is never true here, so only this page's web-side content (the build
// version + the "stay on web" preference) is reachable from this spec. The
// native-only half (App.getInfo()/checkNow's actual version-comparison
// network call) has no seam to fake native from a web run — same
// untested-on-purpose posture already accepted for `useAppUpdateCheck`'s
// background check and `UpdateBanner` (neither has e2e coverage either);
// the underlying comparison logic itself is unit-tested in
// version-compare.cy.tsx, unchanged by this ticket.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: null,
dietId: null,
};
const STAY_ON_WEB_KEY = "batchcooking:stayOnWeb";
describe("App settings (/parametres/application)", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows the web build version, and the update-check section only on native", () => {
cy.visit("/parametres/application");
cy.contains("h1", "Paramètres d'application").should("be.visible");
// The exact build number isn't asserted (would couple this test to
// package.json's version, bumped independently of this page) — just
// that a "Version X" line rendered at all, proving `useInstalledVersion`
// resolved something rather than being stuck empty.
cy.contains(".app-settings__version", "Version").should("be.visible");
cy.contains("button", "Vérifier les mises à jour").should("not.exist");
cy.contains(
"La vérification des mises à jour n'est disponible que dans l'application mobile",
).should("be.visible");
});
it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => {
cy.visit("/parametres/application");
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("not.be.checked");
cy.contains("label", "Toujours rester sur le site web").click();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
cy.window().then((win) => {
expect(win.localStorage.getItem(STAY_ON_WEB_KEY)).to.equal("true");
});
cy.reload();
cy.contains("label", "Toujours rester sur le site web")
.find('input[type="checkbox"]')
.should("be.checked");
});
});

View file

@ -43,6 +43,11 @@ describe("Sidebar — settings menu and account menu", () => {
"href", "href",
"/parametres/preferences-utilisateur", "/parametres/preferences-utilisateur",
); );
cy.contains("a", "Paramètres d'application").should(
"have.attr",
"href",
"/parametres/application",
);
}); });
it("opens the account menu from the greeting and links to Mon compte", () => { it("opens the account menu from the greeting and links to Mon compte", () => {

View file

@ -24,6 +24,7 @@ import { ImportRecipePage } from "./pages/recipes/ImportRecipePage";
import { RecipeFormPage } from "./pages/recipes/RecipeFormPage"; import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
import { RecipesPage } from "./pages/recipes/RecipesPage"; import { RecipesPage } from "./pages/recipes/RecipesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
import { CreditsPage } from "./pages/settings/CreditsPage"; import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage";
@ -39,11 +40,12 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* itself redirects to `/login` if needed. * itself redirects to `/login` if needed.
* *
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences * `/parametres/*` (compte/préférences alimentaires/foyer/préférences
* utilisateur/crédits) are the settings pages, reachable from the sidebar's * utilisateur/application/crédits) are the settings pages, reachable from
* bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under * the sidebar's bottom "Paramètres" menu and the account menu (see
* `AppLayout` like every other authenticated section. `/foyer` is the old, * `AppLayout`) — nested under `AppLayout` like every other authenticated
* pre-split combined page's path; it now just redirects to * section. `/foyer` is the old, pre-split combined page's path; it now
* `/parametres/foyer` so an existing bookmark/link keeps working. * just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* working.
* *
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also * `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already * `RequireAuth`-gated — reached right after signup, once a session already
@ -97,6 +99,7 @@ export function App() {
<Route path="/parametres/preferences" element={<PreferencesPage />} /> <Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} /> <Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} /> <Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
<Route path="/parametres/application" element={<AppSettingsPage />} />
<Route path="/parametres/credits" element={<CreditsPage />} /> <Route path="/parametres/credits" element={<CreditsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} /> <Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route> </Route>

View file

@ -9,10 +9,43 @@ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt"; const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion"; const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
/** Outcome of a single version comparison — shared by the silent background check and {@link useAppUpdateCheck}'s manual `checkNow`, so a caller showing immediate feedback (issue #86's "Vérifier les mises à jour" button) can switch on it directly instead of re-deriving it from `availableVersion`. */
export type AppUpdateCheckResult =
| { status: "up-to-date" }
| { status: "update-available"; version: string }
| { status: "error" };
/**
* Fetches the installed version (native `App.getInfo()`) and the latest
* tagged release, and compares them — the one place this comparison
* happens, shared by the background effect below and `checkNow`. No
* throttling and no `Preferences` side effects of its own; callers decide
* what to do with the result (persist a last-checked timestamp, filter
* against a dismissed version, etc.).
*/
async function fetchLatestVersionResult(): Promise<AppUpdateCheckResult> {
try {
const { App } = await import("@capacitor/app");
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([
App.getInfo(),
apiClient.getLatestMobileVersion(),
]);
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) {
return { status: "up-to-date" };
}
return { status: "update-available", version: latestTag };
} catch {
return { status: "error" };
}
}
/** /**
* Detects whether a newer version of the Android app has been tagged than * Detects whether a newer version of the Android app has been tagged than
* the one currently installed, and offers a way to dismiss the notice for * the one currently installed, and offers a way to dismiss the notice for
* that specific version. * that specific version — plus, since issue #86, a manual `checkNow` for a
* "Vérifier les mises à jour" button (`AppSettingsPage`) that surfaces this
* same check's result immediately instead of waiting on the silent
* background timer.
* *
* **Native only** — on the web, "the app" is just whatever the server is * **Native only** — on the web, "the app" is just whatever the server is
* currently serving; there is no "installed version" to compare against, * currently serving; there is no "installed version" to compare against,
@ -21,20 +54,37 @@ const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
* none of them enter the web bundle (same pattern as `main.tsx`'s own * none of them enter the web bundle (same pattern as `main.tsx`'s own
* `capacitor-token-store` bootstrap) — this hook is safe to call * `capacitor-token-store` bootstrap) — this hook is safe to call
* unconditionally from `AppLayout`, which renders on both platforms. * unconditionally from `AppLayout`, which renders on both platforms.
* `checkNow` itself still resolves on the web (`{ status: "error" }`,
* nothing to check) rather than rejecting — callers gate the *button* that
* would invoke it behind `Capacitor.isNativePlatform()` themselves
* (`AppSettingsPage` does), so this never actually runs there in practice.
* *
* No Play Store integration exists (this app isn't distributed there) — * No Play Store integration exists (this app isn't distributed there) —
* Google's In-App Update API has no purchase here at all, so this is a * Google's In-App Update API has no purchase here at all, so this is a
* from-scratch check, not a wrapper around it. * from-scratch check, not a wrapper around it.
* *
* Fails silently on any network/parsing error — a background version check * The background effect fails silently on any network/parsing error — it
* must never surface an error banner or block anything, same posture as * must never surface an error banner or block anything, same posture as
* `ThemeContext`'s own preferences fetch. * `ThemeContext`'s own preferences fetch. `checkNow` is the opposite: it's
* a direct response to an explicit click, so it returns its outcome
* (including `"error"`) for the caller to actually display.
*/ */
export function useAppUpdateCheck(): { export function useAppUpdateCheck(): {
/** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */ /** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */
availableVersion: string | null; availableVersion: string | null;
/** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */ /** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */
dismiss: () => void; dismiss: () => void;
/**
* Runs the check immediately, bypassing the background check's own
* throttle — an explicit "Vérifier les mises à jour" click should always
* hit the network, not silently no-op because a background check already
* ran recently. Also bypasses the dismissed-version filter: re-asking is
* itself a reason to show the result again, even for a version dismissed
* earlier. Updates `availableVersion` on `"update-available"` so the
* persistent {@link UpdateBanner} picks it up too, not just this call's
* own return value.
*/
checkNow: () => Promise<AppUpdateCheckResult>;
} { } {
const [availableVersion, setAvailableVersion] = useState<string | null>(null); const [availableVersion, setAvailableVersion] = useState<string | null>(null);
@ -43,28 +93,21 @@ export function useAppUpdateCheck(): {
let cancelled = false; let cancelled = false;
async function checkForUpdate() { async function backgroundCheck() {
try { try {
const [{ App }, { Preferences }] = await Promise.all([ const { Preferences } = await import("@capacitor/preferences");
import("@capacitor/app"),
import("@capacitor/preferences"),
]);
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY }); const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ const result = await fetchLatestVersionResult();
App.getInfo(),
apiClient.getLatestMobileVersion(),
]);
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return; if (result.status !== "update-available") return;
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });
if (dismissedVersion === latestTag) return; if (dismissedVersion === result.version) return;
if (!cancelled) setAvailableVersion(latestTag); if (!cancelled) setAvailableVersion(result.version);
} catch { } catch {
// Silent — see this hook's own doc comment. Nothing to log to // Silent — see this hook's own doc comment. Nothing to log to
// (apps/web has no client-side LoggerService equivalent yet), and a // (apps/web has no client-side LoggerService equivalent yet), and a
@ -72,16 +115,16 @@ export function useAppUpdateCheck(): {
} }
} }
void checkForUpdate(); void backgroundCheck();
// Re-check whenever the app comes back to the foreground — the listener // Re-check whenever the app comes back to the foreground — the listener
// itself is cheap to keep registered, `checkForUpdate`'s own throttle // itself is cheap to keep registered, `backgroundCheck`'s own throttle
// above is what actually keeps this from hitting the network every time. // above is what actually keeps this from hitting the network every time.
let removeListener: (() => void) | undefined; let removeListener: (() => void) | undefined;
void import("@capacitor/app").then(({ App }) => { void import("@capacitor/app").then(({ App }) => {
if (cancelled) return; if (cancelled) return;
App.addListener("appStateChange", ({ isActive }) => { App.addListener("appStateChange", ({ isActive }) => {
if (isActive) void checkForUpdate(); if (isActive) void backgroundCheck();
}).then((handle) => { }).then((handle) => {
removeListener = () => void handle.remove(); removeListener = () => void handle.remove();
}); });
@ -102,5 +145,27 @@ export function useAppUpdateCheck(): {
); );
} }
return { availableVersion, dismiss }; async function checkNow(): Promise<AppUpdateCheckResult> {
try {
const result = await fetchLatestVersionResult();
if (result.status === "update-available") setAvailableVersion(result.version);
// Best-effort — a manual check shouldn't make the *next* background
// check fire again immediately after. Not awaited: nothing the
// caller needs to wait on, and native-only anyway (a no-op import on
// the web build would just resolve to nothing useful).
void import("@capacitor/preferences").then(({ Preferences }) =>
Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }),
);
return result;
} catch {
// `fetchLatestVersionResult` already turns every failure it can hit
// into `{ status: "error" }` rather than throwing — this only exists
// to keep `checkNow`'s own contract (a result, never a rejection)
// true in case that ever changes, per the repo's async/try-catch
// convention (see specs/dev-conventions.md).
return { status: "error" };
}
}
return { availableVersion, dismiss, checkNow };
} }

View file

@ -0,0 +1,43 @@
import { Capacitor } from "@capacitor/core";
import { useEffect, useState } from "react";
/**
* The currently installed app version — `App.getInfo().version` (the real
* native package version, `versionName` from `android/app/build.gradle`) on
* native, the build-time `__APP_VERSION__` constant (baked from
* `package.json`, see `vite.config.ts`) on the web. There is no separate
* "installed" version on the web: the page currently loaded already *is*
* the current version, refreshing always gets the latest deploy.
*
* Starts as `__APP_VERSION__` even on native — a reasonable synchronous
* default (normally identical, both bumped together by the release
* pipeline) shown immediately rather than a blank/loading state — and is
* replaced by the native bridge's own answer once that resolves, in case
* the two ever drift.
*
* Extracted out of `useAppUpdateCheck` (which already does the exact same
* `@capacitor/app` dynamic import for its own background check) rather than
* folded into it: that hook's job is "is a newer version available",
* unrelated to "what version is this", and `AppSettingsPage` (issue #86)
* needs the latter regardless of whether an update check has run yet.
*/
export function useInstalledVersion(): string {
const [version, setVersion] = useState<string>(__APP_VERSION__);
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
let cancelled = false;
void import("@capacitor/app").then(({ App }) =>
App.getInfo().then(({ version: nativeVersion }) => {
if (!cancelled) setVersion(nativeVersion);
}),
);
return () => {
cancelled = true;
};
}, []);
return version;
}

View file

@ -8,6 +8,7 @@ import { useIsMobileViewport } from "../lib/useIsMobileViewport";
import "./AppLayout.scss"; import "./AppLayout.scss";
import { import {
AccountIcon, AccountIcon,
AppSettingsIcon,
ChevronLeftIcon, ChevronLeftIcon,
CreditsIcon, CreditsIcon,
DietPreferencesIcon, DietPreferencesIcon,
@ -51,6 +52,7 @@ const SETTINGS_ITEMS = [
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon }, { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon }, { to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
] as const; ] as const;

View file

@ -30,6 +30,11 @@ export {
Rss as SourcesIcon, Rss as SourcesIcon,
Settings as SettingsIcon, Settings as SettingsIcon,
ShoppingCart as ShoppingListIcon, ShoppingCart as ShoppingListIcon,
// "Paramètres d'application" (issue #86) — distinct from the generic
// Settings glyph (already the "Paramètres" section toggle itself) and
// from UserPreferencesIcon (theme/tutorials): this one is specifically
// about the app installation (version, updates, app-open behavior).
Smartphone as AppSettingsIcon,
Star as FavoriteIcon, Star as FavoriteIcon,
User as AccountIcon, User as AccountIcon,
} from "lucide-react"; } from "lucide-react";

View file

@ -117,6 +117,7 @@
"preferences": "Préférences alimentaires", "preferences": "Préférences alimentaires",
"household": "Foyer", "household": "Foyer",
"userPreferences": "Préférences utilisateur", "userPreferences": "Préférences utilisateur",
"application": "Paramètres d'application",
"credits": "Crédits" "credits": "Crédits"
} }
}, },
@ -1390,5 +1391,22 @@
"update": "Mettre à jour", "update": "Mettre à jour",
"dismiss": "Plus tard" "dismiss": "Plus tard"
} }
},
"appSettings": {
"title": "Paramètres d'application",
"version": {
"title": "Version installée",
"installed": "Version {{version}}",
"checkButton": "Vérifier les mises à jour",
"checking": "Vérification…",
"upToDate": "L'application est à jour.",
"checkError": "Impossible de vérifier les mises à jour pour le moment.",
"webHint": "La vérification des mises à jour n'est disponible que dans l'application mobile — sur le web, actualiser la page charge toujours la dernière version."
},
"redirect": {
"title": "Ouverture de l'application",
"stayOnWebLabel": "Toujours rester sur le site web",
"stayOnWebHint": "Par défaut, ouvrir batchCooking sur votre téléphone propose de basculer vers l'application si elle est installée. Activez cette option pour rester sur le site web à chaque fois."
}
} }
} }

View file

@ -0,0 +1,128 @@
import { Capacitor } from "@capacitor/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { API_BASE_URL } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import {
type AppUpdateCheckResult,
useAppUpdateCheck,
} from "../../features/app-update/useAppUpdateCheck";
import { useInstalledVersion } from "../../features/app-update/useInstalledVersion";
import { STAY_ON_WEB_PREFERENCE_KEY } from "../../lib/mobile-app-redirect";
import "./settings-pages.scss";
/** Status of the manual "Vérifier les mises à jour" click — `"idle"` before it's ever been pressed, `"checking"` while in flight, then whatever {@link AppUpdateCheckResult} it resolved to. */
type CheckState = "idle" | "checking" | AppUpdateCheckResult["status"];
/**
* "Paramètres d'application" — routed at `/parametres/application` (issue
* #86). Deliberately distinct from `/parametres/preferences-utilisateur`
* (theme + "rejouer un tutoriel", chantier #42/#44): those stay exactly
* where they are, this page is new content only — the installed app's own
* identity/behavior, not how it looks.
*
* Shows a genuinely different section depending on platform, not just a
* cosmetic tweak — each half of this page is *inert* on the other
* platform, not merely irrelevant:
* - **Version + "Vérifier les mises à jour"** (native only): there is no
* "installed version" concept on the web (see {@link useInstalledVersion}),
* and `useAppUpdateCheck`'s underlying check itself is a no-op there.
* - **"Toujours rester sur le site web"** (web only, any device — not
* viewport-gated to phone, so it can be set ahead of time): drives
* `attemptOpenNativeApp` (`lib/mobile-app-redirect.ts`), which only ever
* runs from a real mobile *browser* tab. Reachable from *inside* the
* native app too (it's a normal in-app route), but toggling it there
* would be silently pointless: Capacitor's Android WebView runs on its
* own fixed `https://localhost` origin, a completely different
* `localStorage` than the public website a browser visitor actually
* lands on — so writing the preference from inside the app can never
* reach the code that reads it. Gating this section to non-native avoids
* ever presenting a control that visibly does nothing when toggled.
*/
export function AppSettingsPage() {
const { t } = useTranslation();
const isNative = Capacitor.isNativePlatform();
const installedVersion = useInstalledVersion();
const { checkNow } = useAppUpdateCheck();
const [checkState, setCheckState] = useState<CheckState>("idle");
const [foundVersion, setFoundVersion] = useState<string | null>(null);
const [stayOnWeb, setStayOnWeb] = useState(
() => localStorage.getItem(STAY_ON_WEB_PREFERENCE_KEY) === "true",
);
async function handleCheckNow() {
setCheckState("checking");
try {
const result = await checkNow();
setCheckState(result.status);
setFoundVersion(result.status === "update-available" ? result.version : null);
} catch {
// `checkNow` already resolves rather than rejects on every failure it
// can hit (see its own doc comment) — this exists only to keep this
// handler compliant with the repo's async/try-catch convention.
// Setting the error state below is itself the meaningful handling
// (same posture as UserPreferencesPage's own `handleChange`), so
// there's nothing to rethrow to — no caller is awaiting this handler.
setCheckState("error");
}
}
function handleStayOnWebChange(checked: boolean) {
setStayOnWeb(checked);
localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, String(checked));
}
return (
<div className="settings-page">
<h1>{t("appSettings.title")}</h1>
<div className="settings-page__section">
<h2>{t("appSettings.version.title")}</h2>
<p className="app-settings__version">
{t("appSettings.version.installed", { version: installedVersion })}
</p>
{isNative ? (
<>
<button
type="button"
onClick={() => void handleCheckNow()}
disabled={checkState === "checking"}
>
{checkState === "checking"
? t("appSettings.version.checking")
: t("appSettings.version.checkButton")}
</button>
{checkState === "up-to-date" && (
<p className="settings-page__saved">{t("appSettings.version.upToDate")}</p>
)}
{checkState === "update-available" && foundVersion && (
<p className="app-settings__update-found">
{t("appUpdate.banner.text", { version: foundVersion })}{" "}
<a href={`${API_BASE_URL}/mobile/download`} target="_blank" rel="noreferrer">
{t("appUpdate.banner.update")}
</a>
</p>
)}
{checkState === "error" && (
<p className="field-error">{t("appSettings.version.checkError")}</p>
)}
</>
) : (
<p className="settings-page__hint">{t("appSettings.version.webHint")}</p>
)}
</div>
{!isNative && (
<div className="settings-page__section">
<h2>{t("appSettings.redirect.title")}</h2>
<CheckboxOption checked={stayOnWeb} onChange={handleStayOnWebChange}>
{t("appSettings.redirect.stayOnWebLabel")}
</CheckboxOption>
<p className="settings-page__hint">{t("appSettings.redirect.stayOnWebHint")}</p>
</div>
)}
</div>
);
}