9ecb496a7c
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
- Session-expired bounce-to-PIN branch now matches the actual strings
returned by requireFieldSession (was 'Session expired or invalid'
which never matched). Added 'Session not found' and 'User is inactive'
to the intercept list. The user-visible 'Your session expired. Please
sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
was sitting OUTSIDE the provider in b29caa0) now calls useLang()
directly, so the loadHeadgates fallback error string is always
rendered in the user's chosen language — even if they toggled
after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
selector pattern). Per-button aria-labels dropped — the visible
EN/ES text is read by SR as the radio name. Group label switched
to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
to the navigator (injection guard on the cookie regex).
Refs /tmp/refactor-water-log.md (review followups to Commit B)
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* MobileWaterApp — state machine + screen switcher for the mobile
|
|
* Tuxedo Water Log experience.
|
|
*
|
|
* Flow:
|
|
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
|
|
* ▲ │ │
|
|
* │ └───────────────(logout)─────────────────┤
|
|
* └─(logout)─────(anywhere)──────────────────────────────┘
|
|
* │
|
|
* success ── Log Another ─▶ log (cleared) ─┘
|
|
* success ── Back to Headgates ─▶ headgates
|
|
*
|
|
* Hydration:
|
|
* - On mount we peek at `document.cookie` for the `wl_session`
|
|
* cookie (set by the server action `verifyWaterPin`). If it's
|
|
* present we skip the PIN screen and load headgates directly.
|
|
* - This mirrors the existing field-client behavior — irrigators
|
|
* who already signed in once in their shift shouldn't have to
|
|
* re-enter their PIN every time they reopen the page.
|
|
*
|
|
* Server actions used:
|
|
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
|
* - `getWaterHeadgates` → loads the list of active gates
|
|
* - `submitWaterEntry` → posts a new reading
|
|
* - `logoutWater` → clears the cookie + session row
|
|
*/
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
verifyWaterPin,
|
|
submitWaterEntry,
|
|
getWaterHeadgates,
|
|
logoutWater,
|
|
} from "@/actions/water-log/field";
|
|
import type { FieldHeadgate, Screen, SubmittedLog } from "./types";
|
|
import { MobileWaterPinScreen } from "./PinScreen";
|
|
import { MobileWaterHeadgateList } from "./HeadgateList";
|
|
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 { 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<Screen>("pin");
|
|
// `useLang()` reads from the <LangProvider> 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<string | null>(null);
|
|
const [headgates, setHeadgates] = useState<FieldHeadgate[]>([]);
|
|
const [loadingHeadgates, setLoadingHeadgates] = useState(false);
|
|
const [headgateError, setHeadgateError] = useState<string | null>(null);
|
|
const [selectedHeadgate, setSelectedHeadgate] =
|
|
useState<FieldHeadgate | null>(null);
|
|
const [submittedLog, setSubmittedLog] = useState<SubmittedLog | null>(null);
|
|
// Monotonic counter used to remount the log form on "Log Another"
|
|
// (so the input + notes clear deterministically without an
|
|
// impure Date.now() call in render).
|
|
const [logFormKey, setLogFormKey] = useState(0);
|
|
|
|
// ── Headgate fetch (shared by initial load + pull-to-refresh) ──
|
|
const loadHeadgates = useCallback(async () => {
|
|
setLoadingHeadgates(true);
|
|
setHeadgateError(null);
|
|
try {
|
|
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
|
|
setHeadgates(hgs.filter((h) => h.active));
|
|
} catch (err) {
|
|
setHeadgateError(
|
|
err instanceof Error ? err.message : t.headgates.loadError,
|
|
);
|
|
} finally {
|
|
setLoadingHeadgates(false);
|
|
}
|
|
}, [t.headgates.loadError]);
|
|
|
|
// ── Initial bootstrap: if `wl_session` exists, jump straight to
|
|
// the headgate list. We defer the cookie read into an effect
|
|
// so SSR markup matches (avoids hydration warnings). ────────
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return;
|
|
const hasSession = document.cookie
|
|
.split(";")
|
|
.some((c) => c.trim().startsWith(`${WL_SESSION_COOKIE}=`));
|
|
if (!hasSession) return;
|
|
// Wrap setScreen in a microtask so the call doesn't fire
|
|
// synchronously inside the effect body.
|
|
queueMicrotask(() => {
|
|
setScreen("headgates");
|
|
void loadHeadgates();
|
|
});
|
|
}, [loadHeadgates]);
|
|
|
|
// ── PIN submit ────────────────────────────────────────────────
|
|
const handlePinSubmit = useCallback(
|
|
async (pin: string) => {
|
|
const result = await verifyWaterPin(TUXEDO_BRAND_ID, pin);
|
|
if (!result.success) {
|
|
// Trigger the shake animation on the PIN screen.
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new Event("water-pin-shake"));
|
|
}
|
|
throw new Error(result.error);
|
|
}
|
|
// If the user turns out to be a water_admin, the legacy flow
|
|
// bounces them to /water/admin. Preserve that for parity.
|
|
if (result.role === "water_admin") {
|
|
window.location.href = "/water/admin";
|
|
return;
|
|
}
|
|
setIrrigatorName(result.name ?? null);
|
|
setScreen("headgates");
|
|
void loadHeadgates();
|
|
},
|
|
[loadHeadgates],
|
|
);
|
|
|
|
// ── Headgate select ───────────────────────────────────────────
|
|
const handleHeadgateSelect = useCallback((hg: FieldHeadgate) => {
|
|
setSelectedHeadgate(hg);
|
|
setScreen("log");
|
|
}, []);
|
|
|
|
// ── Submit entry ──────────────────────────────────────────────
|
|
const handleLogSubmit = useCallback(
|
|
async (input: { measurement: number; unit: string; notes: string }) => {
|
|
if (!selectedHeadgate) {
|
|
throw new Error(t.log.noHeadgateSelected);
|
|
}
|
|
const result = await submitWaterEntry(
|
|
selectedHeadgate.id,
|
|
input.measurement,
|
|
input.unit,
|
|
input.notes,
|
|
);
|
|
if (!result.success) {
|
|
if (SESSION_ERRORS.includes(result.error)) {
|
|
setScreen("pin");
|
|
throw new Error(t.log.sessionExpired);
|
|
}
|
|
throw new Error(t.log.submitError);
|
|
}
|
|
setSubmittedLog({
|
|
headgateName: selectedHeadgate.name,
|
|
measurement: input.measurement,
|
|
unit: input.unit,
|
|
timestamp: new Date(),
|
|
});
|
|
setScreen("success");
|
|
},
|
|
[selectedHeadgate, t.log.noHeadgateSelected, t.log.sessionExpired, t.log.submitError],
|
|
);
|
|
|
|
// ── Success actions ───────────────────────────────────────────
|
|
const handleLogAnother = useCallback(() => {
|
|
// Stay on the same headgate but remount the form so the input
|
|
// + notes clear. Bumping the state key is deterministic —
|
|
// never call Date.now() (or any impure fn) during render.
|
|
setLogFormKey((k) => k + 1);
|
|
setSubmittedLog(null);
|
|
setScreen("log");
|
|
}, []);
|
|
|
|
const handleBackToHeadgates = useCallback(() => {
|
|
setSubmittedLog(null);
|
|
setSelectedHeadgate(null);
|
|
setScreen("headgates");
|
|
void loadHeadgates();
|
|
}, [loadHeadgates]);
|
|
|
|
// ── Logout (from anywhere) ────────────────────────────────────
|
|
const handleLogout = useCallback(async () => {
|
|
try {
|
|
await logoutWater();
|
|
} catch {
|
|
// Even if the server logout fails, clear local state so the
|
|
// user isn't stuck.
|
|
}
|
|
setIrrigatorName(null);
|
|
setHeadgates([]);
|
|
setSelectedHeadgate(null);
|
|
setSubmittedLog(null);
|
|
setScreen("pin");
|
|
}, []);
|
|
|
|
// ── Switch on the current screen ──────────────────────────────
|
|
// Compute the screen element first, then wrap it once in
|
|
// <LangProvider>. Wrapping per-case would remount the provider on
|
|
// every screen change, wiping lang state.
|
|
let screenElement: React.ReactNode = null;
|
|
switch (screen) {
|
|
case "pin":
|
|
screenElement = <MobileWaterPinScreen onSubmit={handlePinSubmit} />;
|
|
break;
|
|
|
|
case "headgates":
|
|
screenElement = (
|
|
<MobileWaterHeadgateList
|
|
irrigatorName={irrigatorName ?? undefined}
|
|
headgates={headgates}
|
|
loading={loadingHeadgates}
|
|
error={headgateError}
|
|
onSelect={handleHeadgateSelect}
|
|
onRefresh={loadHeadgates}
|
|
onLogout={handleLogout}
|
|
/>
|
|
);
|
|
break;
|
|
|
|
case "log":
|
|
if (!selectedHeadgate) {
|
|
// Defensive: shouldn't happen, but bounce back to list.
|
|
queueMicrotask(() => setScreen("headgates"));
|
|
screenElement = null;
|
|
break;
|
|
}
|
|
screenElement = (
|
|
<MobileWaterLogForm
|
|
key={`${selectedHeadgate.id}-${logFormKey}`}
|
|
headgate={selectedHeadgate}
|
|
irrigatorName={irrigatorName ?? undefined}
|
|
onSubmit={handleLogSubmit}
|
|
onChangeHeadgate={handleBackToHeadgates}
|
|
onLogout={handleLogout}
|
|
/>
|
|
);
|
|
break;
|
|
|
|
case "success":
|
|
if (!submittedLog) {
|
|
queueMicrotask(() => setScreen("headgates"));
|
|
screenElement = null;
|
|
break;
|
|
}
|
|
screenElement = (
|
|
<MobileWaterSuccessScreen
|
|
headgateName={submittedLog.headgateName}
|
|
measurement={submittedLog.measurement}
|
|
unit={submittedLog.unit}
|
|
timestamp={submittedLog.timestamp}
|
|
irrigatorName={irrigatorName ?? undefined}
|
|
onLogAnother={handleLogAnother}
|
|
onBackToHeadgates={handleBackToHeadgates}
|
|
/>
|
|
);
|
|
break;
|
|
}
|
|
|
|
// `initialLang` is computed by the parent <LangProvider> in
|
|
// WaterFieldClient — we just return the screen element here.
|
|
return screenElement;
|
|
}
|
|
|
|
export default MobileWaterApp; |