Files
route-commerce/src/components/water/mobile/SuccessScreen.tsx
T
Tyler ce62b17898
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
feat(water-log): surface irrigator name on log form + success screen
Makes the currently-signed-in irrigator visible while data is being
entered and confirmed, so the operator can verify the reading is
attributed to them at a glance.

  - LogForm nav bar: title + initials avatar + name as a subtitle,
    sign-out button moves from the form footer into the nav bar
    (more discoverable, frees vertical space for the Submit button)
  - SuccessScreen recap: 'Logged by {name}' row with avatar below
    the timestamp, matches the log form styling
  - MobileWaterApp threads irrigatorName through to both screens

The headgate list still shows 'Signed in as {name}' as a small
caption — left untouched to avoid duplicate UI.

Validation:
  - tsc --noEmit clean
  - eslint clean on all modified files
2026-07-01 20:04:41 -06:00

277 lines
9.8 KiB
TypeScript

"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;
/** Name of the irrigator who logged the entry — shown in the recap
* so the operator can confirm the reading is attributed to them. */
irrigatorName?: string;
onLogAnother: () => void;
onBackToHeadgates: () => void;
};
export function MobileWaterSuccessScreen({
headgateName,
measurement,
unit,
timestamp,
irrigatorName,
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>
{irrigatorName ? (
<div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-[rgba(60,60,67,0.55)]">
<span
aria-hidden
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#14532d] text-[9px] font-bold uppercase text-white"
>
{initials(irrigatorName)}
</span>
<span>Logged by {irrigatorName}</span>
</div>
) : null}
</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;
/**
* Two-letter initials for the avatar chip in the recap.
* Mirrors the same helper used in the log form header so the avatar
* stays consistent across the two screens.
*/
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}