perf: optimize admin pages for <50ms TTFB

- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms)
- Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts)
- Auth fast-path: short-circuit Neon Auth DNS calls when not configured
- PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking
- Stale build artifacts fix (clean rebuild restores fast behavior)

Measured (production build, sequential requests, dev_session cookie):
  - 102/103 pages: TTFB ≤ 10ms
  - 1 page: TTFB 11-20ms
  - 0 pages exceed 50ms TTFB
  - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
Tyler
2026-06-26 18:55:46 -06:00
parent fdeb2ffd7f
commit fe78645609
111 changed files with 40579 additions and 23712 deletions
File diff suppressed because it is too large Load Diff
+513 -241
View File
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useState, useEffect, Suspense, useMemo, useEffectEvent, useRef } from "react";
import { useReducer, useEffect, Suspense, useMemo, useRef } from "react";
import {
verifyWaterPin,
submitWaterEntry,
@@ -22,6 +22,10 @@ type Headgate = {
type Language = "en" | "es";
type Step = "loading" | "lang" | "role" | "pin" | "form";
type GpsStatus = "idle" | "capturing" | "success" | "error" | "denied";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const UNITS = ["CFS", "GPM", "Inches", "AF/Day"];
@@ -99,6 +103,163 @@ const LABELS = {
},
};
// ──────────────────────────────────────────────────────────────────────────
// State (useReducer)
// ──────────────────────────────────────────────────────────────────────────
type State = {
step: Step;
lang: Language;
pin: string;
irrigatorName: string;
headgates: Headgate[];
selectedHeadgate: string;
headgateLocked: boolean;
measurement: string;
unit: string;
notes: string;
loading: boolean;
error: string;
success: boolean;
photoPreview: string | null;
latitude: number | null;
longitude: number | null;
gpsStatus: GpsStatus;
};
type Action =
| { type: "SET_STEP"; value: Step }
| { type: "SET_LANG"; value: Language }
| { type: "SET_PIN"; value: string }
| { type: "SET_IRRIGATOR_NAME"; value: string }
| { type: "SET_HEADGATES"; value: Headgate[] }
| { type: "SET_SELECTED_HEADGATE"; value: string }
| { type: "SET_HEADGATE_LOCKED"; value: boolean }
| { type: "SET_MEASUREMENT"; value: string }
| { type: "SET_UNIT"; value: string }
| { type: "SET_NOTES"; value: string }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_ERROR"; value: string }
| { type: "SET_SUCCESS"; value: boolean }
| { type: "SET_PHOTO_PREVIEW"; value: string | null }
| { type: "SET_LATITUDE"; value: number | null }
| { type: "SET_LONGITUDE"; value: number | null }
| { type: "SET_GPS_STATUS"; value: GpsStatus }
| { type: "RESET_FORM_FIELDS" }
| { type: "RESET_FULL" };
function makeInitialState(): State {
// Read cookie preferences on first render. Component is client-only via
// <Suspense>, so accessing document.cookie in the lazy initializer is safe.
if (typeof document === "undefined") {
return {
step: "loading",
lang: "en",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
headgateLocked: false,
measurement: "",
unit: "CFS",
notes: "",
loading: false,
error: "",
success: false,
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
}
const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1];
const hasSession = !!document.cookie.match(/wl_session=([^;]+)/);
return {
step: hasSession ? "form" : "loading",
lang: (savedLang as Language) || "en",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
headgateLocked: false,
measurement: "",
unit: "CFS",
notes: "",
loading: false,
error: "",
success: false,
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_STEP":
return { ...state, step: action.value };
case "SET_LANG":
return { ...state, lang: action.value };
case "SET_PIN":
return { ...state, pin: action.value };
case "SET_IRRIGATOR_NAME":
return { ...state, irrigatorName: action.value };
case "SET_HEADGATES":
return { ...state, headgates: action.value };
case "SET_SELECTED_HEADGATE":
return { ...state, selectedHeadgate: action.value };
case "SET_HEADGATE_LOCKED":
return { ...state, headgateLocked: action.value };
case "SET_MEASUREMENT":
return { ...state, measurement: action.value };
case "SET_UNIT":
return { ...state, unit: action.value };
case "SET_NOTES":
return { ...state, notes: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SUCCESS":
return { ...state, success: action.value };
case "SET_PHOTO_PREVIEW":
return { ...state, photoPreview: action.value };
case "SET_LATITUDE":
return { ...state, latitude: action.value };
case "SET_LONGITUDE":
return { ...state, longitude: action.value };
case "SET_GPS_STATUS":
return { ...state, gpsStatus: action.value };
case "RESET_FORM_FIELDS":
return {
...state,
measurement: "",
notes: "",
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
case "RESET_FULL":
return {
...state,
step: "lang",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
measurement: "",
notes: "",
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
headgateLocked: false,
};
}
}
function ThresholdBadge({ high, low, t }: { high?: number | null; low?: number | null; t: typeof LABELS.en }) {
if (!high && !low) return null;
return (
@@ -121,40 +282,10 @@ function WaterFieldInner() {
const searchParams = useSearchParams();
const qrHeadgateToken = searchParams.get("h");
// Read cookie preferences on first render. Component is client-only via
// <Suspense>, so accessing document.cookie in the lazy initializer is safe.
const [lang, setLang] = useState<Language>(() => {
if (typeof document === "undefined") return "en";
const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1];
return (saved as Language) || "en";
});
const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">(() => {
if (typeof document === "undefined") return "loading";
const hasSession = document.cookie.match(/wl_session=([^;]+)/);
return hasSession ? "form" : "lang";
});
const [pin, setPin] = useState("");
const [irrigatorName, setIrrigatorName] = useState("");
const [headgates, setHeadgates] = useState<Headgate[]>([]);
const [selectedHeadgate, setSelectedHeadgate] = useState("");
const [headgateLocked, setHeadgateLocked] = useState(false);
const [measurement, setMeasurement] = useState("");
const [unit, setUnit] = useState("CFS");
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
// Photo state
const [state, dispatch] = useReducer(reducer, undefined, makeInitialState);
const photoFileRef = useRef<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
// GPS state
const [latitude, setLatitude] = useState<number | null>(null);
const [longitude, setLongitude] = useState<number | null>(null);
const [gpsStatus, setGpsStatus] = useState<"idle" | "capturing" | "success" | "error" | "denied">("idle");
const t = LABELS[lang];
const t = LABELS[state.lang];
// On mount, if wl_session cookie is present, load headgates so the
// form is populated by the time the user lands. The step itself is
@@ -172,42 +303,42 @@ function WaterFieldInner() {
// selection and lock the dropdown. Otherwise fall back to whatever the
// user picked (or none).
const qrMatchedHeadgate = useMemo(() => {
if (!qrHeadgateToken || headgates.length === 0) return null;
if (!qrHeadgateToken || state.headgates.length === 0) return null;
return (
headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null
state.headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null
);
}, [qrHeadgateToken, headgates]);
}, [qrHeadgateToken, state.headgates]);
const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? selectedHeadgate;
const isHeadgateLocked = qrMatchedHeadgate ? true : headgateLocked;
const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? state.selectedHeadgate;
const isHeadgateLocked = qrMatchedHeadgate ? true : state.headgateLocked;
async function loadHeadgates() {
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
setHeadgates(hgs.filter((h) => h.active));
dispatch({ type: "SET_HEADGATES", value: hgs.filter((h) => h.active) });
}
// Show loading spinner next to headgate dropdown
const isLoadingHeadgates = step === "form" && headgates.length === 0;
const isLoadingHeadgates = state.step === "form" && state.headgates.length === 0;
async function handleLangSelect(lang: Language) {
setLang(lang);
dispatch({ type: "SET_LANG", value: lang });
await setWaterLang(lang);
setStep("role");
setError("");
dispatch({ type: "SET_STEP", value: "role" });
dispatch({ type: "SET_ERROR", value: "" });
}
async function handlePinSubmit(e: React.FormEvent) {
e.preventDefault();
if (pin.length !== 4) return;
setLoading(true);
setError("");
if (state.pin.length !== 4) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", value: "" });
const result = await verifyWaterPin(TUXEDO_BRAND_ID, pin);
const result = await verifyWaterPin(TUXEDO_BRAND_ID, state.pin);
if (!result.success) {
setError(result.error === "Invalid PIN" ? LABELS[lang].invalidPin : result.error);
setPin("");
setLoading(false);
dispatch({ type: "SET_ERROR", value: result.error === "Invalid PIN" ? LABELS[state.lang].invalidPin : result.error });
dispatch({ type: "SET_PIN", value: "" });
dispatch({ type: "SET_LOADING", value: false });
return;
}
@@ -216,29 +347,29 @@ function WaterFieldInner() {
return;
}
setIrrigatorName(result.name ?? "");
dispatch({ type: "SET_IRRIGATOR_NAME", value: result.name ?? "" });
await loadHeadgates();
setStep("form");
setLoading(false);
dispatch({ type: "SET_STEP", value: "form" });
dispatch({ type: "SET_LOADING", value: false });
}
async function handleCaptureGps() {
if (!navigator.geolocation) {
setGpsStatus("error");
dispatch({ type: "SET_GPS_STATUS", value: "error" });
return;
}
setGpsStatus("capturing");
dispatch({ type: "SET_GPS_STATUS", value: "capturing" });
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude);
setLongitude(pos.coords.longitude);
setGpsStatus("success");
dispatch({ type: "SET_LATITUDE", value: pos.coords.latitude });
dispatch({ type: "SET_LONGITUDE", value: pos.coords.longitude });
dispatch({ type: "SET_GPS_STATUS", value: "success" });
},
(err) => {
if (err.code === err.PERMISSION_DENIED) {
setGpsStatus("denied");
dispatch({ type: "SET_GPS_STATUS", value: "denied" });
} else {
setGpsStatus("error");
dispatch({ type: "SET_GPS_STATUS", value: "error" });
}
},
{ enableHighAccuracy: true, timeout: 10000 }
@@ -249,29 +380,29 @@ function WaterFieldInner() {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
setError("Photo must be under 5MB");
dispatch({ type: "SET_ERROR", value: "Photo must be under 5MB" });
return;
}
if (!["image/jpeg", "image/png"].includes(file.type)) {
setError("Only JPG or PNG photos allowed");
dispatch({ type: "SET_ERROR", value: "Only JPG or PNG photos allowed" });
return;
}
photoFileRef.current = file;
const reader = new FileReader();
reader.onload = (ev) => setPhotoPreview(ev.target?.result as string);
reader.onload = (ev) => dispatch({ type: "SET_PHOTO_PREVIEW", value: ev.target?.result as string });
reader.readAsDataURL(file);
}
function removePhoto() {
photoFileRef.current = null;
setPhotoPreview(null);
dispatch({ type: "SET_PHOTO_PREVIEW", value: null });
}
async function handleSubmitEntry(e: React.FormEvent) {
e.preventDefault();
if (!effectiveSelectedHeadgate || !measurement) return;
setLoading(true);
setError("");
if (!effectiveSelectedHeadgate || !state.measurement) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", value: "" });
let photoUrl: string | undefined;
if (photoFileRef.current) {
@@ -294,196 +425,337 @@ function WaterFieldInner() {
const result = await submitWaterEntry(
effectiveSelectedHeadgate,
parseFloat(measurement),
unit,
notes,
parseFloat(state.measurement),
state.unit,
state.notes,
photoUrl,
latitude ?? undefined,
longitude ?? undefined,
state.latitude ?? undefined,
state.longitude ?? undefined,
isHeadgateLocked
);
if (!result.success) {
if (result.error === "Session expired or invalid" || result.error === "Not logged in") {
setStep("lang");
setPin("");
setError(LABELS[lang].sessionExpired);
dispatch({ type: "SET_STEP", value: "lang" });
dispatch({ type: "SET_PIN", value: "" });
dispatch({ type: "SET_ERROR", value: LABELS[state.lang].sessionExpired });
} else {
setError(result.error);
dispatch({ type: "SET_ERROR", value: result.error });
}
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
return;
}
setSuccess(true);
setMeasurement("");
setNotes("");
dispatch({ type: "SET_SUCCESS", value: true });
dispatch({ type: "RESET_FORM_FIELDS" });
photoFileRef.current = null;
setPhotoPreview(null);
setLatitude(null);
setLongitude(null);
setGpsStatus("idle");
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
setTimeout(() => setSuccess(false), 3000);
setTimeout(() => dispatch({ type: "SET_SUCCESS", value: false }), 3000);
}
async function handleLogout() {
await logoutWater();
setStep("lang");
setPin("");
setIrrigatorName("");
setHeadgates([]);
setSelectedHeadgate("");
setMeasurement("");
setNotes("");
photoFileRef.current = null;
setPhotoPreview(null);
setLatitude(null);
setLongitude(null);
setGpsStatus("idle");
setHeadgateLocked(false);
dispatch({ type: "RESET_FULL" });
}
// Language selection screen
if (step === "loading") {
if (state.step === "loading") {
return <LoadingScreen />;
}
if (state.step === "lang") {
return <LanguageSelectionScreen t={t} onSelect={handleLangSelect} />;
}
if (state.step === "role") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
<RoleSelectionScreen
t={t}
onSelectRole={() => dispatch({ type: "SET_STEP", value: "pin" })}
onBack={() => dispatch({ type: "SET_STEP", value: "lang" })}
/>
);
}
// Language selection screen
if (step === "lang") {
if (state.step === "pin") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => handleLangSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button type="button"
onClick={() => handleLangSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
</div>
</div>
</div>
);
}
// Role selection screen
if (step === "role") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={() => setStep("lang")}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button type="button"
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
</div>
</div>
</div>
);
}
// PIN entry screen
if (step === "pin") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={() => setStep("role")}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={handlePinSubmit}>
<input aria-label="Password"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
/>
{error && (
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
</form>
</div>
</div>
<PinEntryScreen
t={t}
pin={state.pin}
loading={state.loading}
error={state.error}
onSetPin={(v) => dispatch({ type: "SET_PIN", value: v })}
onBack={() => dispatch({ type: "SET_STEP", value: "role" })}
onSubmit={handlePinSubmit}
/>
);
}
// Main log form
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
const selectedHg = state.headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
return (
<WaterLogForm
t={t}
step={state.step}
loading={state.loading}
error={state.error}
success={state.success}
irrigatorName={state.irrigatorName}
measurement={state.measurement}
unit={state.unit}
notes={state.notes}
latitude={state.latitude}
longitude={state.longitude}
gpsStatus={state.gpsStatus}
photoPreview={state.photoPreview}
headgates={state.headgates}
selectedHeadgate={effectiveSelectedHeadgate}
isHeadgateLocked={isHeadgateLocked}
isLoadingHeadgates={isLoadingHeadgates}
selectedHg={selectedHg ?? null}
onSetMeasurement={(v) => dispatch({ type: "SET_MEASUREMENT", value: v })}
onSetUnit={(v) => dispatch({ type: "SET_UNIT", value: v })}
onSetNotes={(v) => dispatch({ type: "SET_NOTES", value: v })}
onSetSelectedHeadgate={(v) => dispatch({ type: "SET_SELECTED_HEADGATE", value: v })}
onCaptureGps={handleCaptureGps}
onPhotoChange={handlePhotoChange}
onRemovePhoto={removePhoto}
onSubmit={handleSubmitEntry}
onLogout={handleLogout}
/>
);
}
// ── LoadingScreen subcomponent ──────────────────────────────────────────────
function LoadingScreen() {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
);
}
// ── LanguageSelectionScreen subcomponent ────────────────────────────────────
function LanguageSelectionScreen({
t,
onSelect,
}: {
t: typeof LABELS.en;
onSelect: (lang: Language) => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => onSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button type="button"
onClick={() => onSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
</div>
</div>
</div>
);
}
// ── RoleSelectionScreen subcomponent ────────────────────────────────────────
function RoleSelectionScreen({
t,
onSelectRole,
onBack,
}: {
t: typeof LABELS.en;
onSelectRole: () => void;
onBack: () => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={onBack}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={onSelectRole}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button type="button"
onClick={onSelectRole}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
</div>
</div>
</div>
);
}
// ── PinEntryScreen subcomponent ─────────────────────────────────────────────
function PinEntryScreen({
t,
pin,
loading,
error,
onSetPin,
onBack,
onSubmit,
}: {
t: typeof LABELS.en;
pin: string;
loading: boolean;
error: string;
onSetPin: (v: string) => void;
onBack: () => void;
onSubmit: (e: React.FormEvent) => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={onBack}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={onSubmit}>
<input aria-label="Password"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => onSetPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
/>
{error && (
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
</form>
</div>
</div>
);
}
// ── WaterLogForm subcomponent ───────────────────────────────────────────────
function WaterLogForm({
t,
step,
loading,
error,
success,
irrigatorName,
measurement,
unit,
notes,
latitude,
longitude,
gpsStatus,
photoPreview,
headgates,
selectedHeadgate,
isHeadgateLocked,
isLoadingHeadgates,
selectedHg,
onSetMeasurement,
onSetUnit,
onSetNotes,
onSetSelectedHeadgate,
onCaptureGps,
onPhotoChange,
onRemovePhoto,
onSubmit,
onLogout,
}: {
t: typeof LABELS.en;
step: Step;
loading: boolean;
error: string;
success: boolean;
irrigatorName: string;
measurement: string;
unit: string;
notes: string;
latitude: number | null;
longitude: number | null;
gpsStatus: GpsStatus;
photoPreview: string | null;
headgates: Headgate[];
selectedHeadgate: string;
isHeadgateLocked: boolean;
isLoadingHeadgates: boolean;
selectedHg: Headgate | null;
onSetMeasurement: (v: string) => void;
onSetUnit: (v: string) => void;
onSetNotes: (v: string) => void;
onSetSelectedHeadgate: (v: string) => void;
onCaptureGps: () => void;
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemovePhoto: () => void;
onSubmit: (e: React.FormEvent) => void;
onLogout: () => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2]">
{/* Header */}
@@ -504,7 +776,7 @@ function WaterFieldInner() {
</div>
</div>
<button type="button"
onClick={handleLogout}
onClick={onLogout}
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
>
{t.logout}
@@ -532,7 +804,7 @@ function WaterFieldInner() {
</div>
<form
onSubmit={handleSubmitEntry}
onSubmit={onSubmit}
className="mx-auto max-w-lg px-4 py-6 space-y-5"
>
{/* Success banner */}
@@ -560,7 +832,7 @@ function WaterFieldInner() {
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{selectedHg?.name ?? effectiveSelectedHeadgate}
{selectedHg?.name ?? selectedHeadgate}
<span className="ml-1 inline-flex items-center gap-0.5 rounded bg-amber-200 px-2 py-0.5 text-xs font-bold text-amber-800">
🔒 {t.locked}
</span>
@@ -576,8 +848,8 @@ function WaterFieldInner() {
</div>
) : (
<select id="fld-tselectheadgate" aria-label="Select"
value={effectiveSelectedHeadgate}
onChange={(e) => setSelectedHeadgate(e.target.value)}
value={selectedHeadgate}
onChange={(e) => onSetSelectedHeadgate(e.target.value)}
required
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg outline-none focus:border-stone-900 min-h-[56px] appearance-none"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 16px center", backgroundSize: "20px" }}
@@ -606,7 +878,7 @@ function WaterFieldInner() {
type="number"
step="any"
value={measurement}
onChange={(e) => setMeasurement(e.target.value)}
onChange={(e) => onSetMeasurement(e.target.value)}
required
placeholder="0.00"
className="w-full rounded-xl border-2 border-stone-300 px-4 py-4 text-2xl font-bold text-stone-900 outline-none focus:border-stone-900 min-h-[60px]"
@@ -617,7 +889,7 @@ function WaterFieldInner() {
<label htmlFor="fld-2-tunit" className="block text-base font-semibold text-stone-700 mb-2">{t.unit}</label>
<select id="fld-2-tunit" aria-label="Select"
value={unit}
onChange={(e) => setUnit(e.target.value)}
onChange={(e) => onSetUnit(e.target.value)}
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg font-semibold outline-none focus:border-stone-900 min-h-[60px]"
>
{UNITS.map((u) => (
@@ -634,7 +906,7 @@ function WaterFieldInner() {
</label>
<button
type="button"
onClick={handleCaptureGps}
onClick={onCaptureGps}
disabled={gpsStatus === "capturing"}
className={`w-full rounded-xl border-2 px-4 py-4 text-base font-semibold transition-colors flex items-center justify-center gap-2 min-h-[56px] ${
gpsStatus === "success"
@@ -666,7 +938,7 @@ function WaterFieldInner() {
<Image src={photoPreview} alt="Preview" fill sizes="(max-width: 640px) 100vw, 480px" style={{ objectFit: "cover" }} />
<button
type="button"
onClick={removePhoto}
onClick={onRemovePhoto}
className="absolute top-2 right-2 rounded-full bg-black/70 text-white text-xs px-3 py-1.5 font-semibold hover:bg-black/90 min-h-[36px]"
>
{t.removePhoto}
@@ -678,7 +950,7 @@ function WaterFieldInner() {
<input
type="file"
accept="image/jpeg,image/png"
onChange={handlePhotoChange}
onChange={onPhotoChange}
className="hidden"
/>
</label>
@@ -690,7 +962,7 @@ function WaterFieldInner() {
<label htmlFor="fld-3-tnotes" className="block text-base font-semibold text-stone-700 mb-2">{t.notes}</label>
<textarea id="fld-3-tnotes" aria-label="Textarea"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e) => onSetNotes(e.target.value)}
placeholder={t.notesPlaceholder}
rows={2}
className="w-full rounded-xl border-2 border-stone-300 px-4 py-3 text-base outline-none focus:border-stone-900 resize-none min-h-[80px]"
@@ -700,7 +972,7 @@ function WaterFieldInner() {
{/* Submit */}
<button
type="submit"
disabled={!effectiveSelectedHeadgate || !measurement || loading}
disabled={!selectedHeadgate || !measurement || loading}
className="w-full rounded-xl bg-green-600 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 active:bg-green-700 min-h-[64px] shadow-lg shadow-green-900/20 flex items-center justify-center gap-2"
>
{loading ? (
@@ -731,4 +1003,4 @@ export default function WaterFieldClient() {
<WaterFieldInner />
</Suspense>
);
}
}