diff --git a/src/components/water/LanguageToggle.tsx b/src/components/water/LanguageToggle.tsx index 41271b9..3680b3a 100644 --- a/src/components/water/LanguageToggle.tsx +++ b/src/components/water/LanguageToggle.tsx @@ -7,20 +7,32 @@ * Used in both the mobile app (header of the headgate list) and * the admin client (settings page). * + * ARIA: implemented as a `radiogroup` with two `radio` items. We + * intentionally avoid the `aria-pressed` toggle pattern because the + * two buttons are mutually exclusive — pressing one deselects the + * other. The `radiogroup` pattern communicates that to assistive + * tech and matches the segmented visual intent. + * * The pill is intentionally narrow so it can fit in a sticky nav bar * next to the logout icon. The active button has the brand-green * background; the inactive is a subtle pill outline. */ import { useLang } from "@/lib/water-log/lang-context"; +import type { Lang } from "@/lib/water-log/i18n"; type Props = { /** Visual size. `sm` fits inside a 44pt iOS-style nav bar. */ size?: "sm" | "md"; }; +const LANG_OPTIONS: ReadonlyArray<{ lang: Lang; label: string }> = [ + { lang: "en", label: "EN" }, + { lang: "es", label: "ES" }, +]; + export function LanguageToggle({ size = "sm" }: Props) { - const { lang, setLang } = useLang(); + const { lang, setLang, t } = useLang(); const isSmall = size === "sm"; const containerStyle: React.CSSProperties = { @@ -63,29 +75,23 @@ export function LanguageToggle({ size = "sm" }: Props) { return (
- - + {LANG_OPTIONS.map((opt) => ( + + ))}
); } diff --git a/src/components/water/WaterFieldClient.tsx b/src/components/water/WaterFieldClient.tsx index 0aa829f..879d98a 100644 --- a/src/components/water/WaterFieldClient.tsx +++ b/src/components/water/WaterFieldClient.tsx @@ -12,14 +12,47 @@ * `/water` flow is now a clean three-screen Apple-HIG experience: * PIN → Headgates → Log * + * This client component also owns the `` wrapping + * the tree so `MobileWaterApp` (and any of its descendants, + * including the orchestrator's `loadHeadgates` catch branch) can + * call `useLang()` directly. The initial language comes from + * `useSyncExternalStore` against the `wl_lang` cookie (server + * returns `"es"`, client agrees after the first paint). + * * The wrapping div uses `100dvh` so the iOS Safari address bar * collapse doesn't trigger layout jumps, and `100vh` as a * fallback for older browsers. */ -import { Suspense } from "react"; +import { Suspense, useSyncExternalStore } from "react"; import { MobileWaterApp } from "./mobile/MobileWaterApp"; +import { LangProvider } from "@/lib/water-log/lang-context"; +import { detectInitialLang, type Lang } from "@/lib/water-log/i18n"; + +const subscribeNoop = () => () => {}; +const getServerLangSnapshot = (): Lang => "es"; + +// Memoize the cookie read so `getSnapshot` always returns the same +// reference between renders (React 19 enforces getSnapshot purity for +// `useSyncExternalStore` more strictly than 18). +let cachedSnapshot: { cookie: string; lang: Lang } | null = null; +const getClientLangSnapshot = (): Lang => { + if (typeof document === "undefined") return "es"; + const cookie = document.cookie; + if (cachedSnapshot && cachedSnapshot.cookie === cookie) { + return cachedSnapshot.lang; + } + const lang = detectInitialLang(); + cachedSnapshot = { cookie, lang }; + return lang; +}; export default function WaterFieldClient() { + const lang = useSyncExternalStore( + subscribeNoop, + getClientLangSnapshot, + getServerLangSnapshot, + ); + return (
} > - + + +
); diff --git a/src/components/water/mobile/LogForm.tsx b/src/components/water/mobile/LogForm.tsx index c571029..da3e573 100644 --- a/src/components/water/mobile/LogForm.tsx +++ b/src/components/water/mobile/LogForm.tsx @@ -341,6 +341,7 @@ export function MobileWaterLogForm({ rows={3} maxLength={NOTES_MAX} placeholder={t.log.notesPlaceholder} + aria-label={t.log.notes} className="min-h-[68px] w-full resize-none bg-transparent text-[16px] leading-[1.4] text-[#1d1d1f] outline-none placeholder:text-[rgba(60,60,67,0.4)]" style={{ fontFamily: diff --git a/src/components/water/mobile/MobileWaterApp.tsx b/src/components/water/mobile/MobileWaterApp.tsx index cf1829c..14ad66f 100644 --- a/src/components/water/mobile/MobileWaterApp.tsx +++ b/src/components/water/mobile/MobileWaterApp.tsx @@ -27,7 +27,7 @@ * - `submitWaterEntry` → posts a new reading * - `logoutWater` → clears the cookie + session row */ -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useState } from "react"; import { verifyWaterPin, submitWaterEntry, @@ -41,25 +41,25 @@ import { MobileWaterLogForm } from "./LogForm"; import { MobileWaterSuccessScreen } from "./SuccessScreen"; import { WL_SESSION_COOKIE } from "@/lib/water-log/session"; import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand"; -import { LangProvider } from "@/lib/water-log/lang-context"; -import { - detectInitialLang, - getT, - type Lang, -} from "@/lib/water-log/i18n"; +import { useLang } from "@/lib/water-log/lang-context"; + +// Server-side auth errors that should bounce the user to the PIN +// screen rather than surface as a read error. Matched against the +// exact strings returned by `requireFieldSession` in +// src/actions/water-log/auth.ts. +const SESSION_ERRORS = [ + "Session expired", + "Session not found", + "User is inactive", + "Not logged in", +]; export function MobileWaterApp() { const [screen, setScreen] = useState("pin"); - // Language comes from `document.cookie` (the wl_lang cookie) which - // doesn't exist on the server. We use `useSyncExternalStore` so - // the server snapshot ("es") and the client snapshot (cookie or - // navigator) are read from stable getSnapshot functions — no - // hydration mismatch, no cascading setState in an effect. - const lang = useSyncExternalStore( - subscribeNoop, - getClientLangSnapshot, - getServerLangSnapshot, - ); + // `useLang()` reads from the in WaterFieldClient, + // so it's always up-to-date when the user toggles via the toggle + // in the headgate list header. + const { t } = useLang(); const [irrigatorName, setIrrigatorName] = useState(null); const [headgates, setHeadgates] = useState([]); const [loadingHeadgates, setLoadingHeadgates] = useState(false); @@ -73,10 +73,6 @@ export function MobileWaterApp() { const [logFormKey, setLogFormKey] = useState(0); // ── Headgate fetch (shared by initial load + pull-to-refresh) ── - // `lang` is read here (instead of via `useLang`) because this - // orchestrator lives outside the ; the provider - // wraps each child screen. We just want the fallback error string - // in the user's chosen language. const loadHeadgates = useCallback(async () => { setLoadingHeadgates(true); setHeadgateError(null); @@ -85,14 +81,12 @@ export function MobileWaterApp() { setHeadgates(hgs.filter((h) => h.active)); } catch (err) { setHeadgateError( - err instanceof Error - ? err.message - : getT(lang).headgates.loadError, + err instanceof Error ? err.message : t.headgates.loadError, ); } finally { setLoadingHeadgates(false); } - }, [lang]); + }, [t.headgates.loadError]); // ── Initial bootstrap: if `wl_session` exists, jump straight to // the headgate list. We defer the cookie read into an effect @@ -145,7 +139,7 @@ export function MobileWaterApp() { const handleLogSubmit = useCallback( async (input: { measurement: number; unit: string; notes: string }) => { if (!selectedHeadgate) { - throw new Error("No headgate selected"); + throw new Error(t.log.noHeadgateSelected); } const result = await submitWaterEntry( selectedHeadgate.id, @@ -154,15 +148,11 @@ export function MobileWaterApp() { input.notes, ); if (!result.success) { - // Session gone? Bounce back to PIN. - if ( - result.error === "Session expired or invalid" || - result.error === "Not logged in" - ) { + if (SESSION_ERRORS.includes(result.error)) { setScreen("pin"); - throw new Error("Your session expired. Please sign in again."); + throw new Error(t.log.sessionExpired); } - throw new Error(result.error); + throw new Error(t.log.submitError); } setSubmittedLog({ headgateName: selectedHeadgate.name, @@ -172,7 +162,7 @@ export function MobileWaterApp() { }); setScreen("success"); }, - [selectedHeadgate], + [selectedHeadgate, t.log.noHeadgateSelected, t.log.sessionExpired, t.log.submitError], ); // ── Success actions ─────────────────────────────────────────── @@ -270,32 +260,9 @@ export function MobileWaterApp() { break; } - // `initialLang` is computed at module-load time — the value will - // match the first client render (SSR returns "es" because - // `document` is undefined; the client effect then syncs if the - // cookie disagrees). This avoids the hydration mismatch that a - // `useState(detectInitialLang)` initializer would cause. - return ( - {screenElement} - ); + // `initialLang` is computed by the parent in + // WaterFieldClient — we just return the screen element here. + return screenElement; } -/** - * Subscribers don't make sense for the cookie source — it's read - * once on mount and updated when the user toggles via the - * `LanguageToggle` component (which writes to the same cookie + - * dispatches a manual re-render via setState in the provider). - */ -const subscribeNoop = () => () => {}; - -const getServerLangSnapshot = (): Lang => "es"; - -const getClientLangSnapshot = (): Lang => { - // Cache the value so we don't re-read the cookie on every render. - // The cookie is only mutated by the LanguageToggle's setLang → - // setLangCookie path, which causes a re-render through the - // provider's setState. - return detectInitialLang(); -}; - export default MobileWaterApp; \ No newline at end of file diff --git a/src/lib/water-log/i18n.ts b/src/lib/water-log/i18n.ts index 66970ac..c361118 100644 --- a/src/lib/water-log/i18n.ts +++ b/src/lib/water-log/i18n.ts @@ -49,6 +49,8 @@ export type Labels = { a11yBackToHeadgates: string; a11yChangeHeadgate: string; a11ySignOut: string; + /** ARIA group label for the language toggle. */ + a11yLanguage: string; }; /** The brand strip shown on the PIN and Success screens. */ brand: { @@ -94,6 +96,12 @@ export type Labels = { implausiblyLarge: string; submit: string; submitting: string; + /** Shown when a session expires mid-shift; bounce user to PIN. */ + sessionExpired: string; + /** Generic submit-failure fallback (e.g. server-side validation). */ + submitError: string; + /** Defensive guard — shouldn't ever happen. */ + noHeadgateSelected: string; normalRange: (low: number, high: number, unit: string) => string; alertAbove: (high: number, unit: string) => string; alertBelow: (low: number, unit: string) => string; @@ -143,6 +151,7 @@ export const LABELS: Record = { a11yBackToHeadgates: "Back to headgates", a11yChangeHeadgate: "Change headgate", a11ySignOut: "Sign out", + a11yLanguage: "Language", }, brand: { waterLog: "TUXEDO WATER LOG", @@ -187,6 +196,9 @@ export const LABELS: Record = { implausiblyLarge: "That value is implausibly large", submit: "Submit Log", submitting: "Submitting…", + sessionExpired: "Your session expired. Please sign in again.", + submitError: "Something went wrong submitting that entry. Try again.", + noHeadgateSelected: "Pick a headgate first.", normalRange: (low, high, unit) => `Normal range: ${low} – ${high} ${unit}`, alertAbove: (high, unit) => `Alert above ${high} ${unit}`, @@ -230,6 +242,7 @@ export const LABELS: Record = { a11yBackToHeadgates: "Volver a compuertas", a11yChangeHeadgate: "Cambiar compuerta", a11ySignOut: "Cerrar sesión", + a11yLanguage: "Idioma", }, brand: { waterLog: "REGISTRO DE AGUA TUXEDO", @@ -274,6 +287,10 @@ export const LABELS: Record = { implausiblyLarge: "Ese valor es demasiado grande", submit: "Enviar registro", submitting: "Enviando…", + sessionExpired: "Tu sesión expiró. Inicia sesión otra vez.", + submitError: + "Algo salió mal al enviar ese registro. Inténtalo de nuevo.", + noHeadgateSelected: "Elige una compuerta primero.", normalRange: (low, high, unit) => `Rango normal: ${low} – ${high} ${unit}`, alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`, diff --git a/tests/unit/water-log-i18n.test.ts b/tests/unit/water-log-i18n.test.ts index 87d49ea..094b03f 100644 --- a/tests/unit/water-log-i18n.test.ts +++ b/tests/unit/water-log-i18n.test.ts @@ -193,6 +193,16 @@ describe("detectInitialLang()", () => { stub.setNav({ language: "fr-FR" }); expect(detectInitialLang()).toBe("es"); }); + + it("ignores a wl_lang value that contains a JS payload (injection guard)", () => { + // Important: the regex in `detectInitialLang` only matches the + // exact 2-letter codes. Anything else must fall through to the + // navigator/default. This locks in that we never feed untrusted + // cookie contents into React state. + document.cookie = `${WL_LANG_COOKIE}=; path=/`; + stub.setNav({ language: "en-US" }); + expect(detectInitialLang()).toBe("en"); + }); }); // ── setLangCookie ───────────────────────────────────────────────────