feat(water-log): redesign /water as Apple HIG mobile-first experience
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
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)
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MobileWaterSuccessScreen — confirmation after a successful submit.
|
||||
*
|
||||
* Visual structure:
|
||||
* - Centered success illustration: a green ring that draws on,
|
||||
* with a checkmark that draws on ~280ms later (the same staggered
|
||||
* reveal UIKit's success feedback uses).
|
||||
* - Headline: "Reading logged."
|
||||
* - Recap card: headgate name + measurement + unit + timestamp.
|
||||
* - Two large primary actions, stacked:
|
||||
* - "Log Another Entry" (green primary, refresh icon)
|
||||
* - "Back to Headgates" (white secondary with thin border)
|
||||
* - Subtle thank-you micro-copy at the bottom.
|
||||
*
|
||||
* The animations use the global `water-ring` + `water-draw` keyframes
|
||||
* from `src/app/globals.css`.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Grid, Refresh } from "./icons";
|
||||
import { formatClockTime, formatLongTimestamp, formatMeasurement } from "./format";
|
||||
|
||||
type Props = {
|
||||
headgateName: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
timestamp: Date;
|
||||
onLogAnother: () => void;
|
||||
onBackToHeadgates: () => void;
|
||||
};
|
||||
|
||||
export function MobileWaterSuccessScreen({
|
||||
headgateName,
|
||||
measurement,
|
||||
unit,
|
||||
timestamp,
|
||||
onLogAnother,
|
||||
onBackToHeadgates,
|
||||
}: Props) {
|
||||
// Reveal animation — staggered for ring → checkmark → content.
|
||||
const [ringDone, setRingDone] = useState(false);
|
||||
const [contentShown, setContentShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ringTimer = window.setTimeout(() => setRingDone(true), 380);
|
||||
const contentTimer = window.setTimeout(() => setContentShown(true), 480);
|
||||
return () => {
|
||||
window.clearTimeout(ringTimer);
|
||||
window.clearTimeout(contentTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col bg-[#F2F2F7]"
|
||||
style={{
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
{/* Brand strip — same as PIN screen for visual continuity. */}
|
||||
<header
|
||||
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
|
||||
<span
|
||||
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]"
|
||||
style={{
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||
}}
|
||||
>
|
||||
TUXEDO WATER LOG
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
{/* Success ring + checkmark */}
|
||||
<div className="relative mx-auto mb-8 h-24 w-24">
|
||||
<svg
|
||||
width="96"
|
||||
height="96"
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
>
|
||||
{/* Ring — circumference ~264px for r=42 */}
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="42"
|
||||
stroke="#34C759"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
strokeDasharray="264"
|
||||
strokeDashoffset="264"
|
||||
style={{
|
||||
animation:
|
||||
"water-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
|
||||
["--ring-from" as string]: "264",
|
||||
}}
|
||||
/>
|
||||
{/* Checkmark — ~62px long */}
|
||||
<path
|
||||
d="M30 50 L43 63 L66 38"
|
||||
stroke="#34C759"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
strokeDasharray="62"
|
||||
strokeDashoffset="62"
|
||||
style={{
|
||||
animation:
|
||||
"water-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
|
||||
["--draw-from" as string]: "62",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
{/* Soft glow behind the ring — sells the celebration. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(52,199,89,0.18) 0%, rgba(52,199,89,0) 70%)",
|
||||
opacity: ringDone ? 1 : 0,
|
||||
transition: "opacity 280ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className={[
|
||||
"mb-2 text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]",
|
||||
"transition-[opacity,transform] duration-300",
|
||||
contentShown
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-2 opacity-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||
}}
|
||||
>
|
||||
Reading logged
|
||||
</h1>
|
||||
|
||||
{/* Recap card */}
|
||||
<div
|
||||
className={[
|
||||
"mx-auto mt-3 mb-8 max-w-xs rounded-[14px] bg-white px-5 py-4 text-left",
|
||||
"shadow-[0_1px_2px_rgba(0,0,0,0.04),0_1px_0_rgba(0,0,0,0.02)]",
|
||||
"transition-[opacity,transform] duration-300",
|
||||
contentShown
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-2 opacity-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transitionDelay: contentShown ? "80ms" : "0ms",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[15px] font-medium text-[rgba(60,60,67,0.55)]">
|
||||
{headgateName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[13px] font-medium text-[rgba(60,60,67,0.45)]">
|
||||
{formatClockTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className="text-[36px] font-bold leading-[1.05] tracking-tight text-[#1d1d1f]"
|
||||
style={{
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||
}}
|
||||
>
|
||||
{formatMeasurement(measurement)}
|
||||
</span>
|
||||
<span className="pb-1 text-[16px] font-semibold text-[rgba(60,60,67,0.55)]">
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 border-t border-[rgba(60,60,67,0.10)] pt-2 text-[12px] text-[rgba(60,60,67,0.45)]">
|
||||
{formatLongTimestamp(timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={[
|
||||
"flex flex-col gap-3",
|
||||
"transition-[opacity,transform] duration-300",
|
||||
contentShown
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-3 opacity-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transitionDelay: contentShown ? "160ms" : "0ms",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogAnother}
|
||||
className="flex h-[56px] w-full items-center justify-center gap-2 rounded-[14px] bg-[#34C759] text-[17px] font-semibold text-white active:scale-[0.985] active:opacity-90"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 6px 18px rgba(52,199,89,0.32), inset 0 1px 0 rgba(255,255,255,0.18)",
|
||||
}}
|
||||
>
|
||||
<Refresh size={20} />
|
||||
Log Another Entry
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToHeadgates}
|
||||
className="flex h-[56px] w-full items-center justify-center gap-2 rounded-[14px] border border-[rgba(60,60,67,0.18)] bg-white text-[17px] font-semibold text-[#14532d] active:scale-[0.985] active:bg-[rgba(120,120,128,0.08)]"
|
||||
style={{
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<Grid size={20} />
|
||||
Back to Headgates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-7 text-[13px] text-[rgba(60,60,67,0.45)]">
|
||||
Thanks for keeping the records flowing.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileWaterSuccessScreen;
|
||||
Reference in New Issue
Block a user