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:
@@ -182,6 +182,21 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
if (load.kind === "loading") return <LoadingState />;
|
||||
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>) => {
|
||||
setSubmitState({ kind: "submitting" });
|
||||
try {
|
||||
@@ -283,6 +298,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
<StageSection
|
||||
section={section}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
formValues={evalState}
|
||||
isCurrent={section.rank === currentRank}
|
||||
@@ -392,7 +408,10 @@ function SubmitBar({
|
||||
draftSavedAt: string | null;
|
||||
}) {
|
||||
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">
|
||||
<SubmitFeedback state={state} />
|
||||
{state.kind === "idle" && (
|
||||
@@ -429,12 +448,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) {
|
||||
Saving your check-in…
|
||||
</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")
|
||||
return (
|
||||
<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'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's safe to close this window.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
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 { MatrixGroup } from "./MatrixGroup";
|
||||
import { StageIcon } from "./StageIcon";
|
||||
@@ -11,6 +16,7 @@ import { evaluate } from "@/lib/conditional";
|
||||
interface StageSectionProps {
|
||||
section: StageSectionConfig;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
control: Control<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** Live form values, used to evaluate per-field visibility rules. */
|
||||
formValues: Record<string, unknown>;
|
||||
@@ -36,6 +42,7 @@ interface StageSectionProps {
|
||||
export function StageSection({
|
||||
section,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
formValues,
|
||||
isCurrent,
|
||||
@@ -153,6 +160,7 @@ export function StageSection({
|
||||
<FieldRenderer
|
||||
field={f}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
resolvedOptions={resolvedOptions}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useWatch } from "react-hook-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 {
|
||||
field: FieldConfig;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** Form control — required for currency live-preview formatting. */
|
||||
control: Control<FieldValues>;
|
||||
/** 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 <label htmlFor>, aria-describedby pointing
|
||||
@@ -34,6 +45,7 @@ export function FieldRenderer({
|
||||
field,
|
||||
register,
|
||||
errors,
|
||||
control,
|
||||
readonlyValue,
|
||||
resolvedOptions,
|
||||
}: FieldRendererProps) {
|
||||
@@ -263,17 +275,43 @@ export function FieldRenderer({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{field.type === "currency" && (
|
||||
<CurrencyPreview control={control} name={field.name} />
|
||||
)}
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</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 =
|
||||
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<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 }) {
|
||||
return (
|
||||
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
||||
|
||||
Reference in New Issue
Block a user