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 (