Files
route-commerce/src/components/water/mobile/HeadgateList.tsx
T
Tyler ac88584b8b
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
feat(water-log): redesign /water as Apple HIG mobile-first experience
Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:

  PIN  ->  Headgates  ->  Log  ->  Success

Design (Apple Human Interface Guidelines):
  - System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
    instead of the admin's Fraunces/Manrope for native iOS feel
  - iOS systemGreen (#34C759) primary success, forest-900 brand accent
  - iOS systemGroupedBackground (#F2F2F7) surface, white cards,
    rgba(60,60,67,0.x) text greys
  - 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
  - env(safe-area-inset-top/bottom) respected on every screen
  - 100dvh shell so iOS Safari chrome collapse doesn't reflow

Screens:
  - PinScreen: 4 iOS-style digit boxes with blinking caret, big
    'Unlock' button (disabled until 4 digits entered), branded
    'TUXEDO WATER LOG' header, error shake on bad PIN
  - HeadgateList: grouped list of large tappable cards showing
    name + geocode (notes) + unit + last-logged relative time,
    pull-to-refresh, empty state with refresh CTA
  - LogForm: sticky headgate card + 44pt numeric input +
    SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
    + live 'Logged now' timestamp + optional 500-char notes +
    green Submit Log button with inline measurement/unit preview
  - SuccessScreen: animated ring + checkmark draw-on, recap card,
    'Log Another Entry' (green) and 'Back to Headgates' (secondary)

Reusable primitives (mobile/):
  - SegmentedControl: iOS-style sliding pill with spring timing
  - PullToRefresh: callback-based (no router.refresh) with resistance
  - icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)

Backend:
  - getWaterHeadgates extended to include notes (the 'geocode') and
    last_used_at for richer card display
  - No new routes, no DB migration - reuses verifyWaterPin,
    submitWaterEntry, logoutWater server actions unchanged

Quality gates:
  - tsc --noEmit clean
  - eslint clean on all new files
  - npm run build succeeds (/water rendered as static)
  - prefers-reduced-motion respected (existing global guard)
2026-07-01 19:27:10 -06:00

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* MobileWaterHeadgateList — headgate selection screen.
*
* Visual structure (Apple HIG grouped list):
* - Sticky top nav: small back chevron + "Headgates" title +
* logout icon on the right.
* - Section header ("ACTIVE HEADGATES") with a count chip.
* - One tappable card per headgate. Each card has:
* - Leading gauge icon (status-tinted)
* - Bold headgate name
* - Subtitle = "geocode" (notes) OR a derived status line
* - Meta row = unit + last-logged relative time
* - Trailing chevron
* - Subtle threshold badge if high/low thresholds are set
* - Pull-to-refresh at the top.
* - "No active headgates" empty state with an icon and CTA back to
* refresh when the operator has just added a gate.
*/
import { useCallback } from "react";
import type { FieldHeadgate } from "./types";
import {
ChevronRight,
Droplet,
Gauge,
Logout,
Pin,
Spinner,
} from "./icons";
import { MobilePullToRefresh } from "./PullToRefresh";
import { formatRelativeTime, headgateSubtitle } from "./format";
type Props = {
/** Optional display name of the logged-in irrigator (for the header). */
irrigatorName?: string;
headgates: FieldHeadgate[];
loading: boolean;
error: string | null;
onSelect: (headgate: FieldHeadgate) => void;
onRefresh: () => Promise<void> | void;
onLogout: () => void;
};
export function MobileWaterHeadgateList({
irrigatorName,
headgates,
loading,
error,
onSelect,
onRefresh,
onLogout,
}: Props) {
const active = headgates.filter((h) => h.active);
return (
<div
className="flex h-full flex-col bg-[#F2F2F7]"
style={{
paddingTop: "env(safe-area-inset-top)",
}}
>
{/* ── Top nav ──────────────────────────────────────────────── */}
<header
className="sticky top-0 z-30 flex h-11 items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<div className="flex items-center gap-1">
{/* Placeholder to keep the title centered — the back
* chevron would lead to the PIN screen, which the user
* got here from. Leaving it as just the title to keep
* the nav minimal per HIG. */}
<span className="w-9" />
</div>
<h1
className="absolute left-1/2 -translate-x-1/2 text-[17px] font-semibold text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
Headgates
</h1>
<button
type="button"
onClick={onLogout}
aria-label="Sign out"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
<Logout size={20} />
</button>
</header>
{/* ── Scrollable list ─────────────────────────────────────── */}
<MobilePullToRefresh onRefresh={onRefresh} disabled={loading}>
<div className="px-4 pb-[max(env(safe-area-inset-bottom),1.25rem)] pt-3">
{irrigatorName && (
<p className="mb-4 px-1 text-[13px] font-medium uppercase tracking-[0.08em] text-[rgba(60,60,67,0.5)]">
Signed in as {irrigatorName}
</p>
)}
{/* Error banner */}
{error && (
<div
role="alert"
className="mb-3 flex items-start gap-2 rounded-[12px] bg-[rgba(255,59,48,0.10)] px-4 py-3 text-[15px] text-[#7f1d1d]"
>
<span className="mt-0.5"></span>
<span>{error}</span>
</div>
)}
{/* Section header */}
<div className="mb-2 flex items-end justify-between px-3 pt-1">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.06em] text-[rgba(60,60,67,0.55)]">
Active headgates
</h2>
<span className="text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
{active.length}
</span>
</div>
{loading && active.length === 0 ? (
<ListSkeleton />
) : active.length === 0 ? (
<EmptyState onRefresh={onRefresh} refreshing={loading} />
) : (
<ul className="overflow-hidden rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
{active.map((hg, i) => (
<li key={hg.id}>
<HeadgateCard
headgate={hg}
isLast={i === active.length - 1}
onClick={() => onSelect(hg)}
/>
</li>
))}
</ul>
)}
{/* Footer note */}
{active.length > 0 && (
<p className="mt-4 px-3 text-center text-[12px] text-[rgba(60,60,67,0.4)]">
Pull down to refresh the list
</p>
)}
</div>
</MobilePullToRefresh>
</div>
);
}
// ── Headgate card ────────────────────────────────────────────────────────
function HeadgateCard({
headgate,
isLast,
onClick,
}: {
headgate: FieldHeadgate;
isLast: boolean;
onClick: () => void;
}) {
const subtitle = headgateSubtitle(headgate);
const lastUsed = formatRelativeTime(headgate.last_used_at);
const isClosed = headgate.status === "closed";
const isMaintenance = headgate.status === "maintenance";
return (
<button
type="button"
onClick={onClick}
disabled={isClosed || isMaintenance}
className={[
"group flex w-full items-center gap-3 px-4 py-4 text-left transition-colors",
"active:bg-[rgba(120,120,128,0.12)]",
isClosed || isMaintenance ? "opacity-55" : "",
].join(" ")}
style={{
borderBottom: isLast ? "none" : "1px solid rgba(60,60,67,0.10)",
minHeight: 76,
}}
>
{/* Leading icon — colored by status */}
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
style={{
background: isMaintenance
? "rgba(255,149,0,0.14)"
: isClosed
? "rgba(120,120,128,0.14)"
: "rgba(20,83,45,0.10)",
color: isMaintenance
? "#a16207"
: isClosed
? "#86868b"
: "#14532d",
}}
>
<Gauge size={22} />
</div>
{/* Body — name + subtitle + meta */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="truncate text-[17px] font-semibold leading-[1.2] text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{headgate.name}
</span>
{(headgate.high_threshold != null ||
headgate.low_threshold != null) && (
<ThresholdDot
high={headgate.high_threshold}
low={headgate.low_threshold}
/>
)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[13px] text-[rgba(60,60,67,0.6)]">
<Pin size={13} className="shrink-0 opacity-60" />
<span className="truncate">{subtitle}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[12px] text-[rgba(60,60,67,0.45)]">
<span className="rounded-full bg-[rgba(120,120,128,0.12)] px-2 py-0.5 font-medium">
{headgate.unit}
</span>
<span>·</span>
<span>{lastUsed}</span>
</div>
</div>
{/* Trailing chevron */}
<ChevronRight size={18} className="shrink-0 text-[rgba(60,60,67,0.3)]" />
</button>
);
}
// ── Threshold dot (small indicator on the card row) ────────────────────
function ThresholdDot({
high,
}: {
high: number | null;
low: number | null;
}) {
return (
<span
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide"
style={{
background: high != null ? "rgba(255,59,48,0.12)" : "rgba(0,122,255,0.12)",
color: high != null ? "#7f1d1d" : "#1e3a8a",
}}
>
{high != null ? "Hi" : "Lo"}
</span>
);
}
// ── Skeleton ────────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<ul className="overflow-hidden rounded-[14px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex animate-pulse items-center gap-3 px-4 py-4"
style={{
borderBottom: i === 3 ? "none" : "1px solid rgba(60,60,67,0.10)",
}}
>
<div className="h-11 w-11 rounded-[10px] bg-[rgba(120,120,128,0.16)]" />
<div className="flex-1 space-y-2">
<div className="h-4 w-2/3 rounded bg-[rgba(120,120,128,0.16)]" />
<div className="h-3 w-1/2 rounded bg-[rgba(120,120,128,0.12)]" />
</div>
</li>
))}
</ul>
);
}
// ── Empty state ─────────────────────────────────────────────────────────
function EmptyState({
onRefresh,
refreshing,
}: {
onRefresh: () => Promise<void> | void;
refreshing: boolean;
}) {
const handleRefresh = useCallback(async () => {
if (!refreshing) await onRefresh();
}, [onRefresh, refreshing]);
return (
<div className="rounded-[14px] bg-white px-6 py-12 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[rgba(20,83,45,0.10)]">
<Droplet size={28} className="text-[#14532d]" />
</div>
<h3 className="mb-1 text-[17px] font-semibold text-[#1d1d1f]">
No active headgates
</h3>
<p className="mx-auto mb-5 max-w-xs text-[15px] leading-[1.4] text-[rgba(60,60,67,0.6)]">
Once your supervisor activates a headgate, it&apos;ll show up here.
Pull down to check for updates.
</p>
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="inline-flex h-11 items-center justify-center gap-2 rounded-[10px] bg-[#14532d] px-5 text-[15px] font-semibold text-white active:opacity-80 disabled:opacity-60"
style={{
boxShadow: "0 4px 12px rgba(20,83,45,0.25)",
}}
>
{refreshing ? (
<>
<Spinner size={16} className="text-white" style={{ animation: "spin 0.8s linear infinite" }} />
Checking
</>
) : (
"Refresh"
)}
</button>
</div>
);
}
export default MobileWaterHeadgateList;