refactor(water-log): add i18n foundation for mobile EN/ES
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
NEW infrastructure for upcoming mobile app localization:
- src/lib/water-log/i18n.ts
Shared EN/ES label dictionary for the mobile (/water) portal.
Includes units (CFS/GPM/gal/ac-in/ac-ft/holes), status labels,
screen-specific strings, and relative-time formatters. No React,
no next/headers — safe to import from client, server, and tests.
- src/lib/water-log/lang-context.tsx
<LangProvider> + useLang() hook. Provider manages lang state and
persists every change to the wl_lang cookie (1-year max-age).
- src/components/water/LanguageToggle.tsx
Reusable EN | ES pill component for the mobile nav bar.
Themed for the Tuxedo green palette.
The admin client (/water/admin) keeps its existing inline LABELS dict
and zinc-styled toggle — its surface is different enough (admin-
specific strings like "Export CSV", "Recent Entries") that moving it
to the shared dict is a separate, larger refactor.
No behavior change. Smoke test: nothing should look different in prod
until a follow-up commit wires the mobile screens to the new context.
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LanguageToggle — EN | ES segmented pill.
|
||||||
|
*
|
||||||
|
* A compact two-button control that switches the active language.
|
||||||
|
* Used in both the mobile app (header of the headgate list) and
|
||||||
|
* the admin client (settings page).
|
||||||
|
*
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Visual size. `sm` fits inside a 44pt iOS-style nav bar. */
|
||||||
|
size?: "sm" | "md";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LanguageToggle({ size = "sm" }: Props) {
|
||||||
|
const { lang, setLang } = useLang();
|
||||||
|
|
||||||
|
const isSmall = size === "sm";
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "stretch",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(120,120,128,0.14)",
|
||||||
|
padding: 2,
|
||||||
|
height: isSmall ? 28 : 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseButton: React.CSSProperties = {
|
||||||
|
appearance: "none",
|
||||||
|
border: 0,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: isSmall ? "0 9px" : "0 12px",
|
||||||
|
fontSize: isSmall ? 12 : 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 160ms, color 160ms",
|
||||||
|
minWidth: isSmall ? 30 : 36,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeStyle: React.CSSProperties = {
|
||||||
|
...baseButton,
|
||||||
|
background: "#14532d",
|
||||||
|
color: "#ffffff",
|
||||||
|
boxShadow: "0 1px 2px rgba(20,83,45,0.20)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inactiveStyle: React.CSSProperties = {
|
||||||
|
...baseButton,
|
||||||
|
background: "transparent",
|
||||||
|
color: "rgba(60,60,67,0.65)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Language"
|
||||||
|
data-testid="language-toggle"
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLang("en")}
|
||||||
|
aria-pressed={lang === "en"}
|
||||||
|
aria-label="English"
|
||||||
|
style={lang === "en" ? activeStyle : inactiveStyle}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLang("es")}
|
||||||
|
aria-pressed={lang === "es"}
|
||||||
|
aria-label="Español"
|
||||||
|
style={lang === "es" ? activeStyle : inactiveStyle}
|
||||||
|
>
|
||||||
|
ES
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageToggle;
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
/** `/water` PIN entry screen. */
|
||||||
|
pin: {
|
||||||
|
title: string;
|
||||||
|
caption: string;
|
||||||
|
verifying: string;
|
||||||
|
unlock: string;
|
||||||
|
forgot: string;
|
||||||
|
genericError: string;
|
||||||
|
};
|
||||||
|
/** `/water` headgate list screen. */
|
||||||
|
headgates: {
|
||||||
|
title: string;
|
||||||
|
signedInAs: (name: string) => string;
|
||||||
|
activeSection: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyBody: string;
|
||||||
|
pullToRefresh: string;
|
||||||
|
thresholdHigh: string;
|
||||||
|
thresholdLow: string;
|
||||||
|
};
|
||||||
|
/** `/water` measurement entry screen. */
|
||||||
|
log: {
|
||||||
|
title: string;
|
||||||
|
measurement: string;
|
||||||
|
timestamp: string;
|
||||||
|
loggedNow: string;
|
||||||
|
notes: string;
|
||||||
|
notesPlaceholder: string;
|
||||||
|
notesVisibility: string;
|
||||||
|
measurementError: string;
|
||||||
|
implausiblyLarge: string;
|
||||||
|
submit: string;
|
||||||
|
submitting: 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LABELS: Record<Lang, Labels> = {
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Save",
|
||||||
|
saving: "Saving…",
|
||||||
|
back: "Back",
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
title: "Log Reading",
|
||||||
|
measurement: "Measurement",
|
||||||
|
timestamp: "Timestamp",
|
||||||
|
loggedNow: "Logged now",
|
||||||
|
notes: "Notes",
|
||||||
|
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…",
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
common: {
|
||||||
|
cancel: "Cancelar",
|
||||||
|
save: "Guardar",
|
||||||
|
saving: "Guardando…",
|
||||||
|
back: "Atrás",
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
title: "Registrar lectura",
|
||||||
|
measurement: "Medición",
|
||||||
|
timestamp: "Hora",
|
||||||
|
loggedNow: "Registrando ahora",
|
||||||
|
notes: "Notas",
|
||||||
|
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…",
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Water Log — React context for the current language.
|
||||||
|
*
|
||||||
|
* Wraps a `LangProvider` around a tree of components that need to
|
||||||
|
* read or change the active language. The provider keeps `lang` in
|
||||||
|
* React state, persists every change to the `wl_lang` cookie, and
|
||||||
|
* exposes a `setLang` callback.
|
||||||
|
*
|
||||||
|
* The provider does NOT call `detectInitialLang()` itself — callers
|
||||||
|
* pass `initialLang` in (typically computed in a `useState`
|
||||||
|
* initializer so SSR and the first client render agree).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
type Lang,
|
||||||
|
type Labels,
|
||||||
|
getT,
|
||||||
|
setLangCookie,
|
||||||
|
} from "./i18n";
|
||||||
|
|
||||||
|
type LangContextValue = {
|
||||||
|
lang: Lang;
|
||||||
|
/** Pre-resolved label dictionary for the current lang. */
|
||||||
|
t: Labels;
|
||||||
|
setLang: (lang: Lang) => void;
|
||||||
|
toggleLang: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LangContext = createContext<LangContextValue | null>(null);
|
||||||
|
|
||||||
|
export function LangProvider({
|
||||||
|
initialLang,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLang: Lang;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [lang, setLangState] = useState<Lang>(initialLang);
|
||||||
|
|
||||||
|
const setLang = useCallback((next: Lang) => {
|
||||||
|
setLangState(next);
|
||||||
|
setLangCookie(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleLang = useCallback(() => {
|
||||||
|
setLangState((prev) => {
|
||||||
|
const next: Lang = prev === "en" ? "es" : "en";
|
||||||
|
setLangCookie(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<LangContextValue>(
|
||||||
|
() => ({ lang, t: getT(lang), setLang, toggleLang }),
|
||||||
|
[lang, setLang, toggleLang],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LangContext.Provider value={value}>{children}</LangContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current language + labels from context. Throws if used
|
||||||
|
* outside a `<LangProvider>` — that's almost certainly a bug.
|
||||||
|
*/
|
||||||
|
export function useLang(): LangContextValue {
|
||||||
|
const ctx = useContext(LangContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useLang() must be called inside <LangProvider>. " +
|
||||||
|
"Wrap your tree (e.g. MobileWaterApp) in <LangProvider initialLang={...}>.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user