"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("pin"); // `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); const [headgateError, setHeadgateError] = useState(null); const [selectedHeadgate, setSelectedHeadgate] = useState(null); const [submittedLog, setSubmittedLog] = useState(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 // . Wrapping per-case would remount the provider on // every screen change, wiping lang state. let screenElement: React.ReactNode = null; switch (screen) { case "pin": screenElement = ; break; case "headgates": screenElement = ( ); break; case "log": if (!selectedHeadgate) { // Defensive: shouldn't happen, but bounce back to list. queueMicrotask(() => setScreen("headgates")); screenElement = null; break; } screenElement = ( ); break; case "success": if (!submittedLog) { queueMicrotask(() => setScreen("headgates")); screenElement = null; break; } screenElement = ( ); break; } // `initialLang` is computed by the parent in // WaterFieldClient — we just return the screen element here. return screenElement; } export default MobileWaterApp;