feat(field): cycle 11 — visual cohesion for time-tracking + water-log
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
- Lift the PIN screen out of water-specific code into a shared src/components/field/FieldPinScreen.tsx used by both apps. Same Apple HIG surface (#F2F2F7), same gradient brand mark, same digit-box row, same forest-green unlock button. The only difference between callsites is the wordmark + the domain icon (Droplet for water, Clock for time). - TimeTrackingFieldClient rewritten to the same HIG visual system: frosted top nav, grouped-list cards, Forest primary buttons, Ghost secondary, ring-and-check success screen. Behavior (reducer/handlers/lang toggle) unchanged. - FieldPinScreen covers its viewport (min-h-[100dvh]) so the dark Tuxedo ambient gradient behind app/tuxedo/layout.tsx no longer shows through on /tuxedo/time-clock. - Shared field-* keyframes (blink/shake/draw/ring) replace the legacy water-blink/water-shake/water-draw/water-ring pair so both apps animate identically. SuccessScreen updated to use the new field-* names. - Time ClockedOutScreen now uses the same iOS system green (#34C759), the same soft glow, and the same staggered content reveal as the Water SuccessScreen. - No new lint errors or warnings (335 problems = baseline). tsc clean.
This commit is contained in:
+25
-20
@@ -302,16 +302,34 @@ select:-webkit-autofill:focus {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Water Log — PIN caret blink. The `to { opacity: 0 }` +
|
/* The water success screen used `water-draw`/`water-ring`; the Time
|
||||||
* `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl
|
* Tracking Clocked Out screen used inline `strokeDashoffset` transitions
|
||||||
* / UITextField, not a gentle fade. */
|
* + a single `field-draw`. Cycle 11 collapses everything into shared
|
||||||
@keyframes water-blink {
|
* `field-*` keyframes so a worker who clocks out sees the same
|
||||||
|
* ring-and-check reveal choreography as a worker who submits a water
|
||||||
|
* reading. */
|
||||||
|
@keyframes field-draw {
|
||||||
|
from { stroke-dashoffset: var(--draw-from, 100); }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes field-ring {
|
||||||
|
from { stroke-dashoffset: var(--ring-from, 360); }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Field — PIN caret blink. The `to { opacity: 0 }` +
|
||||||
|
* `steps(2, start)` combo gives a hard on/off blink like
|
||||||
|
* UISegmentedControl / UITextField, not a gentle fade. */
|
||||||
|
@keyframes field-blink {
|
||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Water Log — PIN-entry error shake. Three diminishing
|
/* Mobile Field — PIN-entry error shake. Three diminishing oscillations
|
||||||
* oscillations for a "no, that wasn't right" feel. */
|
* for a "no, that wasn't right" feel. The shake is applied directly
|
||||||
@keyframes water-shake {
|
* to the digit-box row in `FieldPinScreen.tsx`, not to an empty
|
||||||
|
* overlay, so the user actually sees the row jerk back and forth. */
|
||||||
|
@keyframes field-shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%, 100% { transform: translateX(0); }
|
||||||
20% { transform: translateX(-10px); }
|
20% { transform: translateX(-10px); }
|
||||||
40% { transform: translateX(10px); }
|
40% { transform: translateX(10px); }
|
||||||
@@ -319,19 +337,6 @@ select:-webkit-autofill:focus {
|
|||||||
80% { transform: translateX(7px); }
|
80% { transform: translateX(7px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Water Log — checkmark stroke draw for the success screen.
|
|
||||||
* The element's `stroke-dasharray` is set inline so it works for any
|
|
||||||
* length; the keyframe just controls the timing. */
|
|
||||||
@keyframes water-draw {
|
|
||||||
from { stroke-dashoffset: var(--draw-from, 100); }
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Water Log — circular progress for the success ring. */
|
|
||||||
@keyframes water-ring {
|
|
||||||
from { stroke-dashoffset: var(--ring-from, 360); }
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce motion: respect the user's OS-level preference. */
|
/* Reduce motion: respect the user's OS-level preference. */
|
||||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FieldPinScreen — shared Apple HIG PIN entry, used by both the
|
||||||
|
* Water Log field app and the Time Tracking field app.
|
||||||
|
*
|
||||||
|
* Visual system (cycle 11 — visual cohesion):
|
||||||
|
* - Surface: `#F2F2F7` Apple system grey (matches iOS Keyboard,
|
||||||
|
* secondarySystemBackground). Same surface for both apps so a
|
||||||
|
* worker stepping from `/water` into `/tuxedo/time-clock` (or
|
||||||
|
* vice versa) feels they switched modes, not apps.
|
||||||
|
* - Brand mark: a 22px-rounded square with a subtle green→white
|
||||||
|
* gradient, holding the brand icon. The icon is the only thing
|
||||||
|
* that differs between apps — Droplet for water, Clock for
|
||||||
|
* time. The square + gradient + ring shadow + tracked wordmark
|
||||||
|
* strap are the recognizable signature.
|
||||||
|
* - Four large digit boxes (68×58) that mirror the value of a
|
||||||
|
* hidden <input>. The hidden input is the source of truth so
|
||||||
|
* iOS's QuickType / password-manager suggestions fire
|
||||||
|
* correctly. Boxes never echo a digit back; they show a filled
|
||||||
|
* dot, with a blinking caret in the active slot.
|
||||||
|
* - Full-width forest-green primary with an inner highlight; on
|
||||||
|
* idle: low-contrast grey placeholder.
|
||||||
|
* - Inline error replaces the caption. Failure = 0.4s shake of
|
||||||
|
* the digit-box row (`field-shake` keyframe, mirrors
|
||||||
|
* `water-shake` semantically — three diminishing oscillations
|
||||||
|
* so it reads as "no, that wasn't right").
|
||||||
|
*
|
||||||
|
* Props are intentionally a small surface (length, onSubmit, app
|
||||||
|
* identity, translations, brand color) so both call sites stay
|
||||||
|
* thin. The `shakeEventName` lets the parent fire the same shake
|
||||||
|
* when the verify-result returned from the server is "PIN wrong".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Droplet,
|
||||||
|
Clock,
|
||||||
|
Spinner,
|
||||||
|
type IconProps,
|
||||||
|
} from "@/components/field/icons";
|
||||||
|
|
||||||
|
// ── Domain types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FieldPinIcon = "droplet" | "clock";
|
||||||
|
|
||||||
|
export type FieldPinTranslations = {
|
||||||
|
/** Headline above the digit boxes. */
|
||||||
|
title: string;
|
||||||
|
/** Caption below the headline (and the in-line error slot). */
|
||||||
|
caption: string;
|
||||||
|
/** Unlock button label at idle. */
|
||||||
|
unlock: string;
|
||||||
|
/** Unlock button label while verifying. */
|
||||||
|
verifying: string;
|
||||||
|
/** Footer hint (e.g. "Forgot your PIN? Ask the office"). */
|
||||||
|
forgot: string;
|
||||||
|
/** Fallback error when `onSubmit` throws. */
|
||||||
|
genericError: string;
|
||||||
|
/** a11y label for the hidden PIN input. */
|
||||||
|
a11yPin: string;
|
||||||
|
/** a11y label for the digit-box tap target. */
|
||||||
|
a11yPinEntry: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldPinBrand = {
|
||||||
|
/** Wordmark strap (already tracked-uppercase in the rendered DOM). */
|
||||||
|
wordmark: string;
|
||||||
|
/** Brand color used for the primary button, active digit ring, etc. */
|
||||||
|
primary?: string;
|
||||||
|
/** Domain icon shown inside the brand mark. */
|
||||||
|
icon: FieldPinIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Called when the user submits a `length`-digit PIN. May throw. */
|
||||||
|
onSubmit: (pin: string) => Promise<void>;
|
||||||
|
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
|
||||||
|
length?: 4 | 5 | 6 | 7 | 8;
|
||||||
|
brand: FieldPinBrand;
|
||||||
|
translations: FieldPinTranslations;
|
||||||
|
/**
|
||||||
|
* If a parent has a separate "PIN wrong" result path (e.g. server
|
||||||
|
* returned `{ ok: false }` instead of throwing), it can dispatch
|
||||||
|
* a DOM CustomEvent of this name to trigger the same shake + reset
|
||||||
|
* the input gets on a thrown submit error.
|
||||||
|
*/
|
||||||
|
shakeEventName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Brand mark ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ICONS: Record<FieldPinIcon, (p: IconProps) => React.JSX.Element> = {
|
||||||
|
droplet: Droplet,
|
||||||
|
clock: Clock,
|
||||||
|
};
|
||||||
|
|
||||||
|
function BrandMark({ icon, primary = "#14532D" }: { icon: FieldPinIcon; primary?: string }) {
|
||||||
|
const Icon = ICONS[icon];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mb-7 flex justify-center"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={32} className="" style={{ color: primary }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FieldPinScreen({
|
||||||
|
onSubmit,
|
||||||
|
length = 4,
|
||||||
|
brand,
|
||||||
|
translations: t,
|
||||||
|
shakeEventName = "field-pin-shake",
|
||||||
|
}: Props) {
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [shake, setShake] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(
|
||||||
|
() => inputRef.current?.focus(),
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const next = e.target.value.replace(/\D/g, "").slice(0, length);
|
||||||
|
setPin(next);
|
||||||
|
if (error) setError(null);
|
||||||
|
},
|
||||||
|
[error, length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetForFailure = useCallback(() => {
|
||||||
|
setShake(true);
|
||||||
|
window.setTimeout(() => setShake(false), 420);
|
||||||
|
setPin("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (pin.length !== length || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSubmit(pin);
|
||||||
|
// Don't reset here — the parent will navigate away.
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t.genericError);
|
||||||
|
resetForFailure();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pin, length, submitting, onSubmit, t.genericError, resetForFailure],
|
||||||
|
);
|
||||||
|
|
||||||
|
// External shake trigger (parent fires this when verify returns false).
|
||||||
|
useEffect(() => {
|
||||||
|
function onShake() {
|
||||||
|
resetForFailure();
|
||||||
|
}
|
||||||
|
window.addEventListener(shakeEventName, onShake);
|
||||||
|
return () => window.removeEventListener(shakeEventName, onShake);
|
||||||
|
}, [resetForFailure, shakeEventName]);
|
||||||
|
|
||||||
|
const ready = pin.length === length && !submitting;
|
||||||
|
const primary = brand.primary ?? "#14532D";
|
||||||
|
|
||||||
|
return (
|
||||||
|
// `min-h-[100dvh]` covers the viewport regardless of parent layout
|
||||||
|
// — the parent wraps us in a fixed-inset-0 container on the
|
||||||
|
// Time Tracking route, but we don't depend on that to fill.
|
||||||
|
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy
|
||||||
|
// `100vh` fallback keeps Android/older browsers full-bleed too.
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
|
||||||
|
{/* ── Branded header ───────────────────────────────────────── */}
|
||||||
|
<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">
|
||||||
|
{(() => {
|
||||||
|
const HeaderIcon = ICONS[brand.icon];
|
||||||
|
return <HeaderIcon size={18} style={{ color: primary }} />;
|
||||||
|
})()}
|
||||||
|
<span
|
||||||
|
className="text-[12px] font-bold tracking-[0.22em]"
|
||||||
|
style={{
|
||||||
|
color: primary,
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.wordmark}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Centered content ─────────────────────────────────────── */}
|
||||||
|
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<BrandMark icon={brand.icon} primary={primary} />
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={[
|
||||||
|
"mb-9 min-h-[20px] text-center text-[15px]",
|
||||||
|
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
|
||||||
|
].join(" ")}
|
||||||
|
role={error ? "alert" : undefined}
|
||||||
|
aria-live={error ? "assertive" : undefined}
|
||||||
|
>
|
||||||
|
{error ?? t.caption}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── Digit boxes ────────────────────────────────────── */}
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={length}
|
||||||
|
value={pin}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label={t.a11yPin}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
aria-label={t.a11yPinEntry}
|
||||||
|
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
|
||||||
|
style={
|
||||||
|
shake
|
||||||
|
? {
|
||||||
|
animation:
|
||||||
|
"field-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Array.from({ length }).map((_, i) => {
|
||||||
|
const filled = i < pin.length;
|
||||||
|
const active = i === pin.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
|
||||||
|
style={{
|
||||||
|
boxShadow: active
|
||||||
|
? `0 0 0 2px ${primary}, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)`
|
||||||
|
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
|
||||||
|
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filled ? (
|
||||||
|
<span className="relative" aria-hidden>
|
||||||
|
<span
|
||||||
|
className="block h-[14px] w-[14px] rounded-full"
|
||||||
|
style={{ background: "#1d1d1f" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{active && !filled && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="block h-[28px] w-[2px] rounded-full"
|
||||||
|
style={{
|
||||||
|
background: primary,
|
||||||
|
animation: "field-blink 1.05s steps(2, start) infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" className="sr-only" tabIndex={-1}>
|
||||||
|
{t.unlock}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Unlock button ─────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={!ready}
|
||||||
|
className={[
|
||||||
|
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
|
||||||
|
"transition-[transform,background-color,opacity] duration-200",
|
||||||
|
"active:scale-[0.985]",
|
||||||
|
ready
|
||||||
|
? "text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
|
||||||
|
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
|
||||||
|
].join(" ")}
|
||||||
|
style={ready ? { background: primary } : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
size={20}
|
||||||
|
className="text-white"
|
||||||
|
style={{ animation: "spin 0.8s linear infinite" }}
|
||||||
|
/>
|
||||||
|
{t.verifying}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{t.unlock}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
|
||||||
|
{t.forgot}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FieldPinScreen;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Field-domain icons (re-exports the SF-Symbol-style primitives from
|
||||||
|
* the water icon set, plus the shared `IconProps` type). This module
|
||||||
|
* is the canonical import path for shared field app icons so that
|
||||||
|
* `FieldPinScreen` and any future shared field components don't have
|
||||||
|
* to reach into `@/components/water/mobile/icons`.
|
||||||
|
*
|
||||||
|
* The actual icon definitions still live in
|
||||||
|
* `@/components/water/mobile/icons` because they're shared by both
|
||||||
|
* the water and time-tracking field apps, and moving them would
|
||||||
|
* touch too many callers in one go. When the next field app shows
|
||||||
|
* up, it should import from this barrel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { IconProps } from "@/components/water/mobile/icons";
|
||||||
|
export { Droplet, Clock, Spinner } from "@/components/water/mobile/icons";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MobileWaterPinScreen — PIN entry screen.
|
* MobileWaterPinScreen — water-side wrapper around the shared
|
||||||
|
* `FieldPinScreen`. Same brand color, water-specific icon and
|
||||||
|
* wordmark, and translations lifted from the existing Lang context.
|
||||||
*
|
*
|
||||||
* Visual structure (Apple HIG):
|
* Cycle 11 (visual cohesion) lifted the actual rendering code into
|
||||||
* - Minimal nav bar with "TUXEDO WATER LOG" branding (system
|
* `src/components/field/FieldPinScreen.tsx` so Time Tracking can use
|
||||||
* small-caps via tracking).
|
* the same component with a clock icon + a different wordmark.
|
||||||
* - Centered droplet + lock icon pair as a brand anchor.
|
* Behavior is byte-identical to the previous implementation; only
|
||||||
* - Headline ("Enter PIN") + supporting caption.
|
* the rendering primitive changed.
|
||||||
* - Four large digit boxes (64pt square) that mirror the value of
|
|
||||||
* a hidden <input>. Tapping the row focuses the hidden input —
|
|
||||||
* the native numeric keyboard rises from the bottom.
|
|
||||||
* - Full-width primary "Unlock" button (HIG success blue).
|
|
||||||
* - Inline error pill replaces the caption on failure, with a
|
|
||||||
* gentle shake.
|
|
||||||
*
|
|
||||||
* The hidden input is the source of truth so iOS's QuickType /
|
|
||||||
* password-manager suggestions work correctly — we never rebuild
|
|
||||||
* the digit boxes from a synthetic onChange.
|
|
||||||
*/
|
*/
|
||||||
import {
|
|
||||||
useCallback,
|
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type FormEvent,
|
|
||||||
} from "react";
|
|
||||||
import { Droplet, Lock, Spinner } from "./icons";
|
|
||||||
import { useLang } from "@/lib/water-log/lang-context";
|
import { useLang } from "@/lib/water-log/lang-context";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Called when the user submits a 4-8 digit PIN. */
|
|
||||||
onSubmit: (pin: string) => Promise<void>;
|
onSubmit: (pin: string) => Promise<void>;
|
||||||
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
|
|
||||||
length?: 4 | 5 | 6 | 7 | 8;
|
length?: 4 | 5 | 6 | 7 | 8;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
|
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const [pin, setPin] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [shake, setShake] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Auto-focus the hidden input when the screen mounts so the
|
|
||||||
// keyboard rises immediately. iOS sometimes needs a tiny delay
|
|
||||||
// for the focus to take after the page transition.
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setTimeout(() => inputRef.current?.focus(), 80);
|
|
||||||
return () => window.clearTimeout(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
// Strip non-digits, clamp to `length`.
|
|
||||||
const next = e.target.value.replace(/\D/g, "").slice(0, length);
|
|
||||||
setPin(next);
|
|
||||||
if (error) setError(null);
|
|
||||||
},
|
|
||||||
[error, length],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submit = useCallback(
|
|
||||||
async (e?: FormEvent) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (pin.length !== length || submitting) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await onSubmit(pin);
|
|
||||||
// Don't reset here — the parent will navigate away.
|
|
||||||
} catch (err) {
|
|
||||||
// Network / unexpected error
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : t.pin.genericError,
|
|
||||||
);
|
|
||||||
setShake(true);
|
|
||||||
window.setTimeout(() => setShake(false), 420);
|
|
||||||
setPin("");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[pin, length, submitting, onSubmit, t.pin.genericError],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFailureShake = useCallback(() => {
|
|
||||||
setShake(true);
|
|
||||||
window.setTimeout(() => setShake(false), 420);
|
|
||||||
setPin("");
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Expose the shake trigger via a custom event so the parent can
|
|
||||||
// fire it after a failed verify (parent owns the verify call).
|
|
||||||
useEffect(() => {
|
|
||||||
function onShake() {
|
|
||||||
onFailureShake();
|
|
||||||
}
|
|
||||||
window.addEventListener("water-pin-shake", onShake);
|
|
||||||
return () => window.removeEventListener("water-pin-shake", onShake);
|
|
||||||
}, [onFailureShake]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-[#F2F2F7]">
|
<FieldPinScreen
|
||||||
{/* ── Branded header ───────────────────────────────────────── */}
|
onSubmit={onSubmit}
|
||||||
<header
|
length={length}
|
||||||
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
|
brand={{
|
||||||
style={{
|
wordmark: t.brand.waterLog,
|
||||||
background:
|
icon: "droplet",
|
||||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
|
primary: "#14532D",
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
WebkitBackdropFilter: "blur(10px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
translations={{
|
||||||
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
|
title: t.pin.title,
|
||||||
<Droplet size={18} className="text-[#14532d]" />
|
caption: t.pin.caption,
|
||||||
<span
|
unlock: t.pin.unlock,
|
||||||
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]"
|
verifying: t.pin.verifying,
|
||||||
style={{
|
forgot: t.pin.forgot,
|
||||||
fontFamily:
|
genericError: t.pin.genericError,
|
||||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
a11yPin: t.pin.a11yPin,
|
||||||
|
a11yPinEntry: t.pin.a11yPinEntry,
|
||||||
}}
|
}}
|
||||||
>
|
shakeEventName="water-pin-shake"
|
||||||
{t.brand.waterLog}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Centered content ─────────────────────────────────────── */}
|
|
||||||
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
{/* Brand mark */}
|
|
||||||
<div className="mb-7 flex justify-center">
|
|
||||||
<div
|
|
||||||
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
|
|
||||||
boxShadow:
|
|
||||||
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Lock size={32} className="text-[#14532d]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1
|
|
||||||
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.pin.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Caption OR inline error */}
|
|
||||||
<p
|
|
||||||
className={[
|
|
||||||
"mb-9 min-h-[20px] text-center text-[15px]",
|
|
||||||
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
|
|
||||||
].join(" ")}
|
|
||||||
role={error ? "alert" : undefined}
|
|
||||||
aria-live={error ? "assertive" : undefined}
|
|
||||||
>
|
|
||||||
{error ?? t.pin.caption}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* ── Digit boxes ────────────────────────────────────── */}
|
|
||||||
{/* The hidden input is the source of truth and gives us
|
|
||||||
* native iOS numeric keyboard + autofill. The visible
|
|
||||||
* boxes mirror its value. */}
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
maxLength={length}
|
|
||||||
value={pin}
|
|
||||||
onChange={handleChange}
|
|
||||||
// Keep the hidden input in the layout flow so its
|
|
||||||
// keyboard rises correctly. Visually hidden via the
|
|
||||||
// standard "sr-only" technique.
|
|
||||||
className="sr-only"
|
|
||||||
aria-label={t.pin.a11yPin}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => inputRef.current?.focus()}
|
|
||||||
aria-label={t.pin.a11yPinEntry}
|
|
||||||
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
|
|
||||||
>
|
|
||||||
{Array.from({ length }).map((_, i) => {
|
|
||||||
const filled = i < pin.length;
|
|
||||||
const active = i === pin.length;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
|
|
||||||
style={{
|
|
||||||
boxShadow: active
|
|
||||||
? "0 0 0 2px #14532d, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)"
|
|
||||||
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
|
|
||||||
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filled ? (
|
|
||||||
<span className="relative">
|
|
||||||
{/* Solid dot — never reveals the digit back */}
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="block h-[14px] w-[14px] rounded-full bg-[#1d1d1f]"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{/* Caret blink on the active box (iOS-style). */}
|
|
||||||
{active && !filled && (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="block h-[28px] w-[2px] rounded-full bg-[#14532d]"
|
|
||||||
style={{
|
|
||||||
animation: "water-blink 1.05s steps(2, start) infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Hidden submit so Enter from the keyboard triggers Unlock */}
|
|
||||||
<button type="submit" className="sr-only" tabIndex={-1}>
|
|
||||||
{t.pin.unlock}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* ── Unlock button ─────────────────────────────────── */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={pin.length !== length || submitting}
|
|
||||||
className={[
|
|
||||||
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
|
|
||||||
"transition-[transform,background-color,opacity] duration-200",
|
|
||||||
"active:scale-[0.985]",
|
|
||||||
pin.length === length && !submitting
|
|
||||||
? "bg-[#14532d] text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
|
|
||||||
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
|
|
||||||
].join(" ")}
|
|
||||||
style={{
|
|
||||||
// Pressable feedback — springy scale.
|
|
||||||
transform: shake ? "translateX(0)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<>
|
|
||||||
<Spinner
|
|
||||||
size={20}
|
|
||||||
className="text-white"
|
|
||||||
style={{ animation: "spin 0.8s linear infinite" }}
|
|
||||||
/>
|
|
||||||
{t.pin.verifying}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>{t.pin.unlock}</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Footer hint */}
|
|
||||||
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
|
|
||||||
{t.pin.forgot}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Apply the shake via inline animation when `shake` is true.
|
|
||||||
* The `water-shake` keyframe lives in `src/app/globals.css`
|
|
||||||
* alongside the rest of the Water Log animation set. */}
|
|
||||||
{shake && (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none fixed inset-x-0 top-1/2 z-50 mx-auto max-w-sm"
|
|
||||||
style={{
|
|
||||||
animation: "water-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
* - "Back to Headgates" (white secondary with thin border)
|
* - "Back to Headgates" (white secondary with thin border)
|
||||||
* - Subtle thank-you micro-copy at the bottom.
|
* - Subtle thank-you micro-copy at the bottom.
|
||||||
*
|
*
|
||||||
* The animations use the global `water-ring` + `water-draw` keyframes
|
* The animations use the shared `field-ring` + `field-draw` keyframes
|
||||||
* from `src/app/globals.css`.
|
* (declared in `src/app/globals.css`). Cycle 11 collapsed the legacy
|
||||||
|
* `water-*` names so this screen and the Time Tracking clocked-out
|
||||||
|
* screen share the same reveal choreography.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Grid, Refresh } from "./icons";
|
import { Grid, Refresh } from "./icons";
|
||||||
@@ -119,7 +121,7 @@ export function MobileWaterSuccessScreen({
|
|||||||
strokeDashoffset="264"
|
strokeDashoffset="264"
|
||||||
style={{
|
style={{
|
||||||
animation:
|
animation:
|
||||||
"water-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
|
"field-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
|
||||||
["--ring-from" as string]: "264",
|
["--ring-from" as string]: "264",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -135,7 +137,7 @@ export function MobileWaterSuccessScreen({
|
|||||||
strokeDashoffset="62"
|
strokeDashoffset="62"
|
||||||
style={{
|
style={{
|
||||||
animation:
|
animation:
|
||||||
"water-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
|
"field-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
|
||||||
["--draw-from" as string]: "62",
|
["--draw-from" as string]: "62",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,10 +7,17 @@
|
|||||||
* mobile bundle. All icons use 1.75px strokes (close to SF's
|
* mobile bundle. All icons use 1.75px strokes (close to SF's
|
||||||
* "medium" weight at this size) and rounded line caps for the soft,
|
* "medium" weight at this size) and rounded line caps for the soft,
|
||||||
* pen-friendly feel HIG calls for.
|
* pen-friendly feel HIG calls for.
|
||||||
|
*
|
||||||
|
* Note: the `IconProps` type is re-exported from
|
||||||
|
* `@/components/field/icons` (cycle 11). The type itself is defined
|
||||||
|
* here for backward compat with older call sites.
|
||||||
*/
|
*/
|
||||||
import type { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
|
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
|
||||||
|
// Exported for FieldPinScreen etc. — same SF-Symbol-style primitives
|
||||||
|
// are reused across both the water and the time-tracking field apps.
|
||||||
|
export type { IconProps };
|
||||||
|
|
||||||
function base({ size = 22, strokeWidth = 1.75, ...rest }: IconProps) {
|
function base({ size = 22, strokeWidth = 1.75, ...rest }: IconProps) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user