"use client"; /** * MobileWaterApp — state machine + screen switcher for the mobile * Tuxedo Water Log experience. * * Flow: * pin ─verify─▶ tabbed shell * │ * ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success * │ ▲ │ * │ │ │ * │ └───────── (logout) ────────────┤ * │ * └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out) * * 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. * * Cycle 4 — the existing PIN is unified with the time-tracking PIN: * after `verifyWaterPin` succeeds we also best-effort call * `verifyTimeTrackingPin` with the same digits. If the worker exists * in `time_tracking_workers`, the time-tracking session cookie is set * too, so they never re-enter their PIN on the Time tab. * * 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 * - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4) * - `logoutTimeTracking` → clears the time-tracking cookie on logout */ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { verifyWaterPin, submitWaterEntry, getWaterHeadgates, logoutWater, } from "@/actions/water-log/field"; import { verifyTimeTrackingPin, logoutTimeTracking, } from "@/actions/time-tracking/field"; import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient"; import type { FieldHeadgate, Screen, SubmittedLog, ActiveTab, } 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, TUXEDO_BRAND_NAME, TUXEDO_BRAND_ACCENT, TUXEDO_BRAND_LOGO_URL, } 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"); const [activeTab, setActiveTab] = useState("headgates"); // `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(); // Cycle 4 — unify the PIN with Time Tracking. Best-effort: a // worker who isn't also set up as a time-tracking worker just // sees the regular PIN screen on the Time tab. Never blocks // the water flow, never surfaces a 500 to the user. void verifyTimeTrackingPin(TUXEDO_BRAND_ID, pin).catch(() => {}); }, [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 () => { // Cycle 4 — clear BOTH cookies. The water flow only knew about // `wl_session`; the Time tab introduced `time_tracking_session`. try { await logoutWater(); } catch { // Even if the server logout fails, clear local state so the // user isn't stuck. } try { await logoutTimeTracking(); } catch { // Same — defensive cleanup. } setIrrigatorName(null); setHeadgates([]); setSelectedHeadgate(null); setSubmittedLog(null); setActiveTab("headgates"); setScreen("pin"); }, []); // ── Tab change (Cycle 4) ────────────────────────────────────── const handleTabChange = useCallback((next: ActiveTab) => { setActiveTab(next); if (next === "time") { // Switching to Time drops the in-flight water flow (selected // headgate + submitted log) so it doesn't linger when the // worker comes back to Headgates. The headgate list itself // is cached and will reload on demand. setSubmittedLog(null); setSelectedHeadgate(null); setScreen("time"); } else { // Switching back to Headgates — restore the flow at the // headgate list (we always have that state cached; the // loadHeadgates effect picks up fresh data on mount). if (headgates.length === 0) void loadHeadgates(); setScreen("headgates"); } }, [headgates.length, loadHeadgates]); // ── 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; case "time": // Cycle 4 — Tuxedo-only Time tab. Same brand id / name / // accent as the standalone /tuxedo/time-clock route so the // embedded surface matches the standalone one pixel-for-pixel. screenElement = ( ); break; } // ── Post-PIN shell ──────────────────────────────────────────── // The pre-PIN screen is bare (no nav). After PIN we wrap the // screen element in a sticky tab bar so the worker can flip // between Headgates and Time without re-entering credentials. const isPostPin = screen !== "pin"; return (
{isPostPin && ( )}
{screenElement}
); } // ── TabBar (Cycle 4 → Apple HIG pass) ────────────────────────── // Sticky top nav for the Tuxedo worker experience. Stays visible // across Headgates → Log → Success AND on the Time tab. // // Two HIG principles in play: // 1. Use UISegmentedControl for the mode switch (not two pills). // The control is a true iOS segmented control: gray track, // sliding white pill, spring timing. The same widget appears // as the unit selector inside the log form, just bigger. // 2. Use a "liquid glass" chrome on the bar itself: gradient // background, strong saturate(180%), inner top highlight, and // a hairline at the bottom edge — not a flat colored band. function TabBar({ activeTab, onChange, onLogout, irrigatorName, }: { activeTab: ActiveTab; onChange: (next: ActiveTab) => void; onLogout: () => void; irrigatorName?: string; }) { const t = useLang().t; return (
{irrigatorName && ( {irrigatorName} )}
); } // ── MiniSegmented (nav-bar-sized UISegmentedControl) ────────────── // Sliding-pill variant of SegmentedControl tuned for the 32pt nav // bar context. Reuses the same measurement + spring-timing approach // as the larger control in `SegmentedControl.tsx` so the two feel // like one widget at different sizes. function MiniSegmented({ options, value, onChange, ariaLabel, }: { options: ReadonlyArray<{ value: V; label: ReactNode }>; value: V; onChange: (next: V) => void; ariaLabel: string; }) { const containerRef = useRef(null); // Sliding indicator rect. The indicator uses // `transform: translateX(...)` with Apple's spring curve so the // transition reads as "physical," not "fade." const [rect, setRect] = useState<{ x: number; w: number } | null>(null); const measure = useCallback(() => { const root = containerRef.current; if (!root) return; const btn = root.querySelector( `[data-seg-key="${value}"]`, ); if (!btn) return; const rootRect = root.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); // `x` is measured from the container's content edge (after the // 2px padding) so the indicator rides just inside the track. setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width }); }, [value]); useLayoutEffect(() => { measure(); }, [measure]); // Re-measure whenever the option set changes (e.g. labels // translate via the Lang context and the active segment width // shifts) so the indicator stays in place. useLayoutEffect(() => { const root = containerRef.current; if (!root) return; const ro = new ResizeObserver(measure); ro.observe(root); return () => ro.disconnect(); }, [measure]); return (
{rect && (
)} {options.map((opt) => { const selected = opt.value === value; return ( ); })}
); } export default MobileWaterApp;