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.
This commit is contained in:
Joel Brock
2026-05-11 09:48:03 -07:00
parent 0d84b9654b
commit a804650f65
4 changed files with 144 additions and 14 deletions

View File

@@ -182,6 +182,21 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
if (load.kind === "loading") return <LoadingState />; if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />; if (load.kind === "error") return <ErrorState message={load.message} />;
// 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 (
<SuccessDestination
orgName={load.data.orgName}
onAnother={() => {
setSubmitState({ kind: "idle" });
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
);
}
const onSubmit = async (values: Record<string, unknown>) => { const onSubmit = async (values: Record<string, unknown>) => {
setSubmitState({ kind: "submitting" }); setSubmitState({ kind: "submitting" });
try { try {
@@ -283,6 +298,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
<StageSection <StageSection
section={section} section={section}
register={register} register={register}
control={control}
errors={errors} errors={errors}
formValues={evalState} formValues={evalState}
isCurrent={section.rank === currentRank} isCurrent={section.rank === currentRank}
@@ -392,7 +408,10 @@ function SubmitBar({
draftSavedAt: string | null; draftSavedAt: string | null;
}) { }) {
return ( return (
<div className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"> <div
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<SubmitFeedback state={state} /> <SubmitFeedback state={state} />
{state.kind === "idle" && ( {state.kind === "idle" && (
@@ -429,12 +448,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) {
Saving your check-in Saving your check-in
</p> </p>
); );
if (state.kind === "success")
return (
<p className="text-sm font-medium text-leaf-800" role="status">
Check-in saved. Thank you.
</p>
);
if (state.kind === "error") if (state.kind === "error")
return ( return (
<p className="text-sm font-medium text-clay-700" role="alert"> <p className="text-sm font-medium text-clay-700" role="alert">
@@ -471,6 +484,49 @@ function LoadingState() {
); );
} }
function SuccessDestination({
orgName,
onAnother,
}: {
orgName: string;
onAnother: () => void;
}) {
return (
<section
role="status"
aria-live="polite"
className="rounded-lg border-2 border-leaf-600 bg-leaf-50/40 px-6 py-10 text-center shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(60,80,40,0.22)] sm:px-10 sm:py-12"
>
<div
aria-hidden
className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm"
>
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12l5 5 9-11" />
</svg>
</div>
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
Check-in saved
</h2>
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
Thank you. We&apos;ve recorded this check-in for{" "}
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
it on their next review.
</p>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
>
Submit another check-in
</button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div>
</section>
);
}
function ErrorState({ message }: { message: string }) { function ErrorState({ message }: { message: string }) {
return ( return (
<div <div

View File

@@ -2,7 +2,12 @@
import { useEffect, useId, useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import type { StageSectionConfig, SelectOption } from "@/types/form"; import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; import type {
UseFormRegister,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer"; import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup"; import { MatrixGroup } from "./MatrixGroup";
import { StageIcon } from "./StageIcon"; import { StageIcon } from "./StageIcon";
@@ -11,6 +16,7 @@ import { evaluate } from "@/lib/conditional";
interface StageSectionProps { interface StageSectionProps {
section: StageSectionConfig; section: StageSectionConfig;
register: UseFormRegister<FieldValues>; register: UseFormRegister<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors; errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */ /** Live form values, used to evaluate per-field visibility rules. */
formValues: Record<string, unknown>; formValues: Record<string, unknown>;
@@ -36,6 +42,7 @@ interface StageSectionProps {
export function StageSection({ export function StageSection({
section, section,
register, register,
control,
errors, errors,
formValues, formValues,
isCurrent, isCurrent,
@@ -153,6 +160,7 @@ export function StageSection({
<FieldRenderer <FieldRenderer
field={f} field={f}
register={register} register={register}
control={control}
errors={errors} errors={errors}
readonlyValue={formValues[f.name]} readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions} resolvedOptions={resolvedOptions}

View File

@@ -1,12 +1,20 @@
"use client"; "use client";
import { useWatch } from "react-hook-form";
import type { FieldConfig, SelectOption } from "@/types/form"; import type { FieldConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; import type {
UseFormRegister,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
interface FieldRendererProps { interface FieldRendererProps {
field: FieldConfig; field: FieldConfig;
register: UseFormRegister<FieldValues>; register: UseFormRegister<FieldValues>;
errors: FieldErrors; errors: FieldErrors;
/** Form control — required for currency live-preview formatting. */
control: Control<FieldValues>;
/** For readonly display fields, the value to render. */ /** For readonly display fields, the value to render. */
readonlyValue?: unknown; readonlyValue?: unknown;
/** /**
@@ -17,6 +25,9 @@ interface FieldRendererProps {
resolvedOptions?: SelectOption[]; 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 * Renders a single field appropriate to its `type`. All inputs share a common
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing * accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
@@ -34,6 +45,7 @@ export function FieldRenderer({
field, field,
register, register,
errors, errors,
control,
readonlyValue, readonlyValue,
resolvedOptions, resolvedOptions,
}: FieldRendererProps) { }: FieldRendererProps) {
@@ -263,17 +275,43 @@ export function FieldRenderer({
</span> </span>
)} )}
</div> </div>
{field.type === "currency" && (
<CurrencyPreview control={control} name={field.name} />
)}
{field.help && <Help id={helpId!}>{field.help}</Help>} {field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>} {errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div> </div>
); );
} }
// ── 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 (
<div className="space-y-1">
<Label id={id} field={field} />
<input
id={id}
type="date"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
min={dateMin}
max={dateMax}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass}
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Default: text-like (text / email / phone) ───────────────────────────
const inputType = const inputType =
field.type === "email" ? "email" : field.type === "email" ? "email" :
field.type === "phone" ? "tel" : field.type === "phone" ? "tel" :
field.type === "date" ? "date" :
"text"; "text";
return ( 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<FieldValues>;
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 (
<p className="text-xs tabular-nums text-ink-mute" aria-live="polite">
{currencyFmt.format(n)}
</p>
);
}
function Label({ id, field }: { id: string; field: FieldConfig }) { function Label({ id, field }: { id: string; field: FieldConfig }) {
return ( return (
<label htmlFor={id} className="block text-sm font-medium text-ink"> <label htmlFor={id} className="block text-sm font-medium text-ink">

View File

@@ -69,9 +69,13 @@ export interface FieldConfig {
placeholder?: string; placeholder?: string;
/** For select / multiselect. */ /** For select / multiselect. */
options?: SelectOption[]; options?: SelectOption[];
/** For number / currency / percent — min/max/step constraints. */ /**
min?: number; * Min/max bound. For numeric fields, a number; for date fields, an ISO
max?: number; * date string (YYYY-MM-DD). Date fields default to a wide sanity window
* (1900-01-01 to 2100-12-31) when not set.
*/
min?: number | string;
max?: number | string;
step?: number; step?: number;
/** For text / textarea — max length. */ /** For text / textarea — max length. */
maxLength?: number; maxLength?: number;