From a804650f65259d21d3012bd7c42ba6700aa51a81 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Mon, 11 May 2026 09:48:03 -0700 Subject: [PATCH] Audit short-term: currency preview, date bounds, success destination, safe-area - H3: Live currency preview below currency inputs shows the value formatted with thousands separators (en-US, USD) using Intl.NumberFormat. Skipped inside matrix cells to keep the Y1 monthly table compact. - M1: Date inputs now apply min/max bounds. Default window is 1900-01-01 to 2100-12-31; per-field override via FieldConfig.min/max as ISO strings. - H6: On successful submit, replace the form with a SuccessDestination card (large checkmark, org name, "Submit another" + "safe to close" affordance). Prevents accidental duplicate submits from back-button / autofill replay. - M6: Sticky submit bar respects iOS safe-area-inset-bottom. FieldRenderer now takes a control prop so the currency preview can subscribe to its single field via useWatch without re-rendering the whole form. --- components/EngagementForm.tsx | 70 ++++++++++++++++++++++++++--- components/StageSection.tsx | 10 ++++- components/fields/FieldRenderer.tsx | 68 ++++++++++++++++++++++++++-- types/form.ts | 10 +++-- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index d5bbcf9..e5d0637 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -182,6 +182,21 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { if (load.kind === "loading") return ; if (load.kind === "error") return ; + // After a successful submit, replace the form entirely with a destination + // screen. This prevents accidental duplicate submits (back-button, double- + // click, browser autofill replay) and gives the user a clear endpoint. + if (submitState.kind === "success") { + return ( + { + setSubmitState({ kind: "idle" }); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + ); + } + const onSubmit = async (values: Record) => { setSubmitState({ kind: "submitting" }); try { @@ -283,6 +298,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { +
{state.kind === "idle" && ( @@ -429,12 +448,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) { Saving your check-in…

); - if (state.kind === "success") - return ( -

- ✓ Check-in saved. Thank you. -

- ); if (state.kind === "error") return (

@@ -471,6 +484,49 @@ function LoadingState() { ); } +function SuccessDestination({ + orgName, + onAnother, +}: { + orgName: string; + onAnother: () => void; +}) { + return ( +

+
+ + + +
+

+ Check-in saved +

+

+ Thank you. We've recorded this check-in for{" "} + {orgName}. Your engagement coordinator will see + it on their next review. +

+
+ +

It's safe to close this window.

+
+
+ ); +} + function ErrorState({ message }: { message: string }) { return (
; + control: Control; errors: FieldErrors; /** Live form values, used to evaluate per-field visibility rules. */ formValues: Record; @@ -36,6 +42,7 @@ interface StageSectionProps { export function StageSection({ section, register, + control, errors, formValues, isCurrent, @@ -153,6 +160,7 @@ export function StageSection({ ; errors: FieldErrors; + /** Form control — required for currency live-preview formatting. */ + control: Control; /** For readonly display fields, the value to render. */ readonlyValue?: unknown; /** @@ -17,6 +25,9 @@ interface FieldRendererProps { resolvedOptions?: SelectOption[]; } +const DATE_MIN_DEFAULT = "1900-01-01"; +const DATE_MAX_DEFAULT = "2100-12-31"; + /** * Renders a single field appropriate to its `type`. All inputs share a common * accessibility scaffold: a real
+ {field.type === "currency" && ( + + )} {field.help && {field.help}} {errorMsg && {errorMsg}}
); } - // ── Default: text-like (text / email / phone / date) ──────────────────── + // ── Date ──────────────────────────────────────────────────────────────── + if (field.type === "date") { + const dateMin = typeof field.min === "string" ? field.min : DATE_MIN_DEFAULT; + const dateMax = typeof field.max === "string" ? field.max : DATE_MAX_DEFAULT; + return ( +
+
+ ); + } + + // ── Default: text-like (text / email / phone) ─────────────────────────── const inputType = field.type === "email" ? "email" : field.type === "phone" ? "tel" : - field.type === "date" ? "date" : "text"; return ( @@ -301,6 +339,30 @@ export function FieldRenderer({ ); } +const currencyFmt = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +function CurrencyPreview({ + control, + name, +}: { + control: Control; + name: string; +}) { + const raw = useWatch({ control, name }); + if (raw === undefined || raw === null || raw === "") return null; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) return null; + return ( +

+ {currencyFmt.format(n)} +

+ ); +} + function Label({ id, field }: { id: string; field: FieldConfig }) { return (