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); }
|
||||
}
|
||||
|
||||
/* Mobile Water Log — 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 water-blink {
|
||||
/* The water success screen used `water-draw`/`water-ring`; the Time
|
||||
* Tracking Clocked Out screen used inline `strokeDashoffset` transitions
|
||||
* + a single `field-draw`. Cycle 11 collapses everything into shared
|
||||
* `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; }
|
||||
}
|
||||
|
||||
/* Mobile Water Log — PIN-entry error shake. Three diminishing
|
||||
* oscillations for a "no, that wasn't right" feel. */
|
||||
@keyframes water-shake {
|
||||
/* Mobile Field — PIN-entry error shake. Three diminishing oscillations
|
||||
* for a "no, that wasn't right" feel. The shake is applied directly
|
||||
* 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); }
|
||||
20% { transform: translateX(-10px); }
|
||||
40% { transform: translateX(10px); }
|
||||
@@ -319,19 +337,6 @@ select:-webkit-autofill:focus {
|
||||
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. */
|
||||
/* 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,298 +1,49 @@
|
||||
"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):
|
||||
* - Minimal nav bar with "TUXEDO WATER LOG" branding (system
|
||||
* small-caps via tracking).
|
||||
* - Centered droplet + lock icon pair as a brand anchor.
|
||||
* - Headline ("Enter PIN") + supporting caption.
|
||||
* - 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.
|
||||
* Cycle 11 (visual cohesion) lifted the actual rendering code into
|
||||
* `src/components/field/FieldPinScreen.tsx` so Time Tracking can use
|
||||
* the same component with a clock icon + a different wordmark.
|
||||
* Behavior is byte-identical to the previous implementation; only
|
||||
* the rendering primitive changed.
|
||||
*/
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Droplet, Lock, Spinner } from "./icons";
|
||||
|
||||
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
|
||||
import { useLang } from "@/lib/water-log/lang-context";
|
||||
|
||||
type Props = {
|
||||
/** Called when the user submits a 4-8 digit PIN. */
|
||||
onSubmit: (pin: string) => Promise<void>;
|
||||
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
|
||||
length?: 4 | 5 | 6 | 7 | 8;
|
||||
};
|
||||
|
||||
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
|
||||
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 (
|
||||
<div className="flex h-full 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">
|
||||
<Droplet size={18} className="text-[#14532d]" />
|
||||
<span
|
||||
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]"
|
||||
style={{
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<FieldPinScreen
|
||||
onSubmit={onSubmit}
|
||||
length={length}
|
||||
brand={{
|
||||
wordmark: t.brand.waterLog,
|
||||
icon: "droplet",
|
||||
primary: "#14532D",
|
||||
}}
|
||||
translations={{
|
||||
title: t.pin.title,
|
||||
caption: t.pin.caption,
|
||||
unlock: t.pin.unlock,
|
||||
verifying: t.pin.verifying,
|
||||
forgot: t.pin.forgot,
|
||||
genericError: t.pin.genericError,
|
||||
a11yPin: t.pin.a11yPin,
|
||||
a11yPinEntry: t.pin.a11yPinEntry,
|
||||
}}
|
||||
shakeEventName="water-pin-shake"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileWaterPinScreen;
|
||||
export default MobileWaterPinScreen;
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
* - "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`.
|
||||
* The animations use the shared `field-ring` + `field-draw` keyframes
|
||||
* (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 { Grid, Refresh } from "./icons";
|
||||
@@ -119,7 +121,7 @@ export function MobileWaterSuccessScreen({
|
||||
strokeDashoffset="264"
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
@@ -135,7 +137,7 @@ export function MobileWaterSuccessScreen({
|
||||
strokeDashoffset="62"
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
* mobile bundle. All icons use 1.75px strokes (close to SF's
|
||||
* "medium" weight at this size) and rounded line caps for the soft,
|
||||
* 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";
|
||||
|
||||
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) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user