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:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user