/** * Water Log — internationalization (i18n). * * Single source of truth for the user-facing strings in the mobile * (`/water`) and admin (`/water/admin`) portals. Both portals share * the same `wl_lang` cookie and the same audience (Tuxedo field * workers + ditch supervisors, mostly Spanish-first). * * **Why this lives in `lib/` rather than each component:** * * - The two portals used to have divergent i18n implementations: * `WaterAdminClient.tsx` had a full `LABELS` dict; the mobile * screens had no i18n at all. Lifting the labels here lets the * admin client drop its copy and lets the mobile app adopt * Spanish without re-inventing the dictionary. * - A single source means when we add a string to one screen we * notice the other and add it there too (or at least see that * it's missing). * - The labels are pure data — no React, no `next/headers`, no * `cookies()` — so they're safe to import from client * components, server components, and unit tests alike. * * Persistence: the `wl_lang` cookie. See `./session-server.ts` * (currently `setWaterLang` in `actions/water-log/field.ts` sets it * via a thin wrapper; we keep that wrapper for back-compat). * * Default: Spanish — the deployed audience is predominantly * Spanish-speaking. Override with the cookie or with * `detectInitialLang()`. */ export const WL_LANG_COOKIE = "wl_lang"; export type Lang = "en" | "es"; /** * The full label dictionary. Organized by screen so the writer can * find the right string quickly and so we can see at a glance which * screens are missing translations. */ export type Labels = { /** Strings shared by multiple screens. */ common: { cancel: string; save: string; saving: string; back: string; signOut: string; 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: { waterLog: string; }; /** `/water` PIN entry screen. */ pin: { title: string; caption: string; verifying: string; unlock: string; forgot: string; genericError: string; a11yPin: string; a11yPinEntry: string; }; /** `/water` headgate list screen. */ headgates: { title: string; signedInAs: (name: string) => string; activeSection: string; emptyTitle: string; emptyBody: string; pullToRefresh: string; thresholdHigh: string; thresholdLow: string; refresh: string; refreshing: string; loadError: string; }; /** `/water` measurement entry screen. */ log: { title: string; measurement: string; unit: string; timestamp: string; loggedNow: string; notes: string; notesOptional: string; notesPlaceholder: string; notesVisibility: string; measurementError: string; 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; }; /** `/water` success screen after submit. */ success: { title: string; logAnother: string; backToHeadgates: string; thanks: string; loggedBy: (name: string) => string; }; /** Headgate status string shown in card subtitles. */ status: { open: string; openReady: string; closed: string; maintenance: string; }; /** Unit labels. The keys are the canonical English codes stored in * the DB. Values are the display strings per language. */ units: { CFS: string; GPM: string; gal: string; "ac-in": string; "ac-ft": string; holes: string; }; /** Short relative-time phrases for the headgate card meta row. */ format: { neverLogged: string; justNow: string; minutesAgo: (n: number) => string; hoursAgo: (n: number) => string; }; /** Tuxedo worker post-PIN shell (Cycle 4) — names for the two * top tabs and the inline logout affordance. */ tab: { headgates: string; time: string; logout: string; }; }; export const LABELS: Record = { en: { common: { cancel: "Cancel", save: "Save", saving: "Saving…", back: "Back", signOut: "Sign out", a11yBackToHeadgates: "Back to headgates", a11yChangeHeadgate: "Change headgate", a11ySignOut: "Sign out", a11yLanguage: "Language", }, brand: { waterLog: "TUXEDO WATER LOG", }, pin: { title: "Enter your PIN", caption: "Sign in to log a water reading", verifying: "Verifying…", unlock: "Unlock", forgot: "Forgot your PIN? Ask the ditch supervisor.", genericError: "Something went wrong. Try again.", a11yPin: "PIN", a11yPinEntry: "PIN entry — tap to focus", }, headgates: { title: "Headgates", signedInAs: (name) => `Signed in as ${name}`, activeSection: "Active headgates", emptyTitle: "No active headgates", emptyBody: "Once your supervisor activates a headgate, it'll show up here. Pull down to check for updates.", pullToRefresh: "Pull down to refresh the list", thresholdHigh: "Hi", thresholdLow: "Lo", refresh: "Refresh", refreshing: "Checking…", loadError: "Couldn't load headgates. Pull down to try again.", }, log: { title: "Log Reading", measurement: "Measurement", unit: "Unit", timestamp: "Timestamp", loggedNow: "Logged now", notes: "Notes", notesOptional: "optional", notesPlaceholder: "Anything the supervisor should know about this reading?", notesVisibility: "Visible to admins only", measurementError: "Enter a measurement greater than or equal to 0", 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}`, alertBelow: (low, unit) => `Alert below ${low} ${unit}`, }, success: { title: "Reading logged", logAnother: "Log Another Entry", backToHeadgates: "Back to Headgates", thanks: "Thanks for keeping the records flowing.", loggedBy: (name) => `Logged by ${name}`, }, status: { open: "Open", openReady: "Open · Ready to log", closed: "Closed", maintenance: "Under maintenance", }, units: { CFS: "CFS", GPM: "GPM", gal: "gal", "ac-in": "ac-in", "ac-ft": "ac-ft", holes: "holes", }, format: { neverLogged: "Never logged", justNow: "Just now", minutesAgo: (n) => `${n}m ago`, hoursAgo: (n) => `${n}h ago`, }, tab: { headgates: "Headgates", time: "Time", logout: "Log out", }, }, es: { common: { cancel: "Cancelar", save: "Guardar", saving: "Guardando…", back: "Atrás", signOut: "Salir", a11yBackToHeadgates: "Volver a compuertas", a11yChangeHeadgate: "Cambiar compuerta", a11ySignOut: "Cerrar sesión", a11yLanguage: "Idioma", }, brand: { waterLog: "REGISTRO DE AGUA TUXEDO", }, pin: { title: "Ingresa tu PIN", caption: "Inicia sesión para registrar una lectura de agua", verifying: "Verificando…", unlock: "Desbloquear", forgot: "¿Olvidaste tu PIN? Pregúntale al supervisor.", genericError: "Algo salió mal. Inténtalo de nuevo.", a11yPin: "PIN", a11yPinEntry: "Entrada de PIN — toca para enfocar", }, headgates: { title: "Compuertas", signedInAs: (name) => `Sesión iniciada como ${name}`, activeSection: "Compuertas activas", emptyTitle: "No hay compuertas activas", emptyBody: "Cuando tu supervisor active una compuerta, aparecerá aquí. Jala hacia abajo para actualizar.", pullToRefresh: "Jala hacia abajo para actualizar la lista", thresholdHigh: "Alto", thresholdLow: "Bajo", refresh: "Actualizar", refreshing: "Verificando…", loadError: "No se pudieron cargar las compuertas. Jala para intentar de nuevo.", }, log: { title: "Registrar lectura", measurement: "Medición", unit: "Unidad", timestamp: "Hora", loggedNow: "Registrando ahora", notes: "Notas", notesOptional: "opcional", notesPlaceholder: "¿Algo que el supervisor deba saber sobre esta lectura?", notesVisibility: "Visible solo para administradores", measurementError: "Ingresa una medición mayor o igual a 0", 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}`, alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`, }, success: { title: "Lectura registrada", logAnother: "Registrar otra", backToHeadgates: "Volver a compuertas", thanks: "Gracias por mantener los registros al día.", loggedBy: (name) => `Registrado por ${name}`, }, status: { open: "Abierta", openReady: "Abierta · Lista para registrar", closed: "Cerrada", maintenance: "En mantenimiento", }, units: { CFS: "CFS", GPM: "GPM", gal: "gal", "ac-in": "ac-in", "ac-ft": "ac-ft", holes: "hoyos", }, format: { neverLogged: "Nunca registrada", justNow: "Ahora", minutesAgo: (n) => `hace ${n}m`, hoursAgo: (n) => `hace ${n}h`, }, tab: { headgates: "Compuertas", time: "Tiempo", logout: "Salir", }, }, }; /** * Detect the initial language for a freshly-hydrated client. * * Order of preference: * 1. The `wl_lang` cookie (the user's last explicit choice). * 2. `navigator.language` (with weak "starts with" matching). * 3. Spanish (the deployed audience is predominantly Spanish-speaking). * * Safe to call during SSR — when `document` is undefined, returns the * default (`es`). The component calling this should pair the read * with a `useEffect` to re-detect on the client if needed. */ export function detectInitialLang(): Lang { if (typeof document === "undefined") return "es"; const cookie = document.cookie.match(/wl_lang=(en|es)/)?.[1]; if (cookie === "en" || cookie === "es") return cookie; if (typeof navigator !== "undefined") { const lang = navigator.language?.toLowerCase() ?? ""; if (lang.startsWith("en")) return "en"; } return "es"; } /** Persist the user's language choice. Client-side only. */ export function setLangCookie(lang: Lang): void { if (typeof document === "undefined") return; // 1 year max-age — long enough that returning users see their // preferred language without re-prompting. document.cookie = `${WL_LANG_COOKIE}=${lang}; path=/; max-age=31536000; SameSite=Lax`; } /** Get the labels dictionary for a given lang. */ export function getT(lang: Lang): Labels { return LABELS[lang]; } /** * Display string for a headgate's stored `unit` value, localized. * Falls back to the raw unit string when not in the catalog (so we * don't break on legacy or admin-typed custom units). */ export function displayUnit(unit: string, lang: Lang): string { const t = LABELS[lang].units; if (unit === "CFS") return t.CFS; if (unit === "GPM") return t.GPM; if (unit === "gal") return t.gal; if (unit === "ac-in") return t["ac-in"]; if (unit === "ac-ft") return t["ac-ft"]; if (unit === "holes") return t.holes; return unit; }