- 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.
562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useForm, useWatch, type FieldErrors, type FieldValues } from "react-hook-form";
|
|
import type { FormConfig, FormDataPayload, SubmitPayload, StageSectionConfig } from "@/types/form";
|
|
|
|
function sectionForField(
|
|
sections: ReadonlyArray<StageSectionConfig>,
|
|
fieldName: string,
|
|
): StageSectionConfig | undefined {
|
|
for (const s of sections) {
|
|
if (s.fields.some((f) => f.name === fieldName)) return s;
|
|
for (const m of s.matrixGroups ?? []) {
|
|
for (const row of m.rows) if (row.fields.includes(fieldName)) return s;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
import { evaluate } from "@/lib/conditional";
|
|
import { StageSection } from "./StageSection";
|
|
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
|
|
|
interface EngagementFormProps {
|
|
config: FormConfig;
|
|
cid: string;
|
|
cs: string;
|
|
}
|
|
|
|
type LoadState =
|
|
| { kind: "loading" }
|
|
| { kind: "error"; message: string }
|
|
| { kind: "ready"; data: FormDataPayload };
|
|
|
|
type SubmitStatus =
|
|
| { kind: "idle" }
|
|
| { kind: "submitting" }
|
|
| { kind: "success" }
|
|
| { kind: "error"; message: string };
|
|
|
|
const STAGE_RANK: Record<string, number> = {
|
|
Inquiry: 0,
|
|
Organizing: 1,
|
|
Feasibility: 2,
|
|
"Business feasibility": 3,
|
|
"Store Implementation": 4,
|
|
"Stabilize newly opened co-op": 5,
|
|
};
|
|
|
|
export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
|
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
|
|
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
|
|
const [draftRestored, setDraftRestored] = useState(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
control,
|
|
watch,
|
|
setFocus,
|
|
formState: { errors, isDirty },
|
|
} = useForm({ mode: "onBlur" });
|
|
|
|
// Subscribe ONLY to current_stage. That's the single field that affects
|
|
// section visibility, so re-rendering the whole form on every keystroke
|
|
// (which `watch()` with no args would do) is wasteful — particularly with
|
|
// 120 fields and a 39-cell matrix in Stage 5.
|
|
//
|
|
// The auto-save effect uses watch's subscription API for side-effect-only
|
|
// change tracking (no re-render).
|
|
const currentStageValue =
|
|
(useWatch({ control, name: "current_stage" }) as string | undefined) ?? "";
|
|
|
|
// State map passed to the conditional engine and to FieldRenderer for
|
|
// readonly display values. Only fields referenced by visibility rules or
|
|
// by readonly displays need to be here. Both readonly fields in this form
|
|
// (current_stage, stage_at_submission) display the org's current stage.
|
|
const evalState = useMemo(
|
|
() => ({
|
|
current_stage: currentStageValue,
|
|
stage_at_submission: currentStageValue,
|
|
}),
|
|
[currentStageValue],
|
|
);
|
|
|
|
// ── Initial load ───────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function go() {
|
|
try {
|
|
const url = new URL("/api/data", window.location.origin);
|
|
url.searchParams.set("cid", cid);
|
|
url.searchParams.set("cs", cs);
|
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
let message = `Could not load form (HTTP ${res.status}).`;
|
|
try {
|
|
const j = JSON.parse(text) as { error?: string };
|
|
if (j.error) message = j.error;
|
|
} catch { /* keep default */ }
|
|
if (!cancelled) setLoad({ kind: "error", message });
|
|
return;
|
|
}
|
|
const data: FormDataPayload = await res.json();
|
|
if (cancelled) return;
|
|
|
|
// Build defaults: server prefill + current stage. Then check if a
|
|
// local draft exists newer than the server data; if so, layer it on top.
|
|
const defaults: Record<string, unknown> = {
|
|
[config.stageField]: data.currentStage,
|
|
...data.prefill,
|
|
};
|
|
const draft = loadDraft(cid);
|
|
if (draft && Object.keys(draft.values).length > 0) {
|
|
Object.assign(defaults, draft.values);
|
|
// Re-set the stage from server (draft can never override the org's actual stage).
|
|
defaults[config.stageField] = data.currentStage;
|
|
setDraftRestored(true);
|
|
setDraftSavedAt(draft.savedAt);
|
|
}
|
|
reset(defaults);
|
|
setLoad({ kind: "ready", data });
|
|
} catch (e) {
|
|
if (!cancelled) {
|
|
setLoad({
|
|
kind: "error",
|
|
message: e instanceof Error ? e.message : "Unexpected error loading form.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
void go();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [cid, cs, reset, config.stageField]);
|
|
|
|
// ── Auto-save draft on idle ────────────────────────────────────────────
|
|
// Debounce — save 1.5s after the user stops editing. Uses watch's
|
|
// subscription API so we get notified on every change WITHOUT re-rendering
|
|
// the form on each keystroke.
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (load.kind !== "ready") return;
|
|
const sub = watch((values) => {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
saveTimer.current = setTimeout(() => {
|
|
saveDraft(cid, values as Record<string, unknown>);
|
|
setDraftSavedAt(new Date().toISOString());
|
|
}, 1500);
|
|
});
|
|
return () => {
|
|
sub.unsubscribe();
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
};
|
|
}, [load.kind, cid, watch]);
|
|
|
|
// ── Section ordering ───────────────────────────────────────────────────
|
|
const sectionsToRender = useMemo(
|
|
() =>
|
|
config.sections.map((s) => ({
|
|
section: s,
|
|
sectionVisible: evaluate(s.visibleWhen, evalState),
|
|
})),
|
|
[config.sections, evalState],
|
|
);
|
|
|
|
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
|
|
|
// Track which sections the user has manually opened/closed so we can
|
|
// programmatically re-open them when validation surfaces an error within.
|
|
// Used by the onInvalid handler below.
|
|
const expandSection = useCallback((sectionId: string) => {
|
|
document.dispatchEvent(
|
|
new CustomEvent("coop-checkin:expand-section", { detail: { sectionId } }),
|
|
);
|
|
}, []);
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────
|
|
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 {
|
|
// Strip values for hidden fields — never write data the user couldn't see.
|
|
const visibleFieldNames = new Set<string>();
|
|
for (const s of config.sections) {
|
|
if (!evaluate(s.visibleWhen, values)) continue;
|
|
for (const f of s.fields) {
|
|
if (evaluate(f.visibleWhen, values)) visibleFieldNames.add(f.name);
|
|
}
|
|
for (const m of s.matrixGroups ?? []) {
|
|
for (const row of m.rows) {
|
|
for (const fname of row.fields) visibleFieldNames.add(fname);
|
|
}
|
|
}
|
|
}
|
|
const filtered: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(values)) {
|
|
if (visibleFieldNames.has(k)) filtered[k] = v;
|
|
}
|
|
const payload: SubmitPayload = { cid, cs, values: filtered };
|
|
const res = await fetch("/api/submit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
let message = `Submit failed (HTTP ${res.status}).`;
|
|
try {
|
|
const j = JSON.parse(text) as { error?: string };
|
|
if (j.error) message = j.error;
|
|
} catch { /* keep default */ }
|
|
setSubmitState({ kind: "error", message });
|
|
return;
|
|
}
|
|
// Successful submit — clear draft and show confirmation.
|
|
clearDraft(cid);
|
|
setDraftSavedAt(null);
|
|
setSubmitState({ kind: "success" });
|
|
} catch (e) {
|
|
setSubmitState({
|
|
kind: "error",
|
|
message: e instanceof Error ? e.message : "Unexpected submit error.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// ── Invalid-submit handling ────────────────────────────────────────────
|
|
// When required-field validation blocks submit, RHF calls onInvalid with
|
|
// the errors map. Find the first error's section, expand it, scroll into
|
|
// view, focus the field. Saves users from chasing a silent failure.
|
|
const onInvalid = (errs: FieldErrors<FieldValues>) => {
|
|
const firstErrorField = Object.keys(errs)[0];
|
|
if (!firstErrorField) return;
|
|
const section = sectionForField(config.sections, firstErrorField);
|
|
if (section) {
|
|
expandSection(section.id);
|
|
// Allow the section to expand before focusing.
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
setFocus(firstErrorField);
|
|
} catch {
|
|
// setFocus throws if the field isn't registered; safe to ignore.
|
|
}
|
|
const el = document.getElementById(`field-${firstErrorField}`);
|
|
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
});
|
|
}
|
|
setSubmitState({
|
|
kind: "error",
|
|
message: "Please fill the required fields highlighted below before submitting.",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
|
|
<SubmissionContextHeader
|
|
orgName={load.data.orgName}
|
|
currentStage={load.data.currentStage}
|
|
currentRank={currentRank}
|
|
/>
|
|
|
|
{draftRestored && draftSavedAt && (
|
|
<DraftRestoredNotice savedAt={draftSavedAt} onDiscard={() => {
|
|
clearDraft(cid);
|
|
setDraftRestored(false);
|
|
setDraftSavedAt(null);
|
|
// Force re-fetch by bumping a key — simplest is full page reload.
|
|
window.location.reload();
|
|
}} />
|
|
)}
|
|
|
|
<ol className="space-y-5">
|
|
{sectionsToRender.map(({ section, sectionVisible }) => {
|
|
if (!sectionVisible) return null;
|
|
return (
|
|
<li key={section.id} className="list-none">
|
|
<StageSection
|
|
section={section}
|
|
register={register}
|
|
control={control}
|
|
errors={errors}
|
|
formValues={evalState}
|
|
isCurrent={section.rank === currentRank}
|
|
defaultOpen={section.rank === currentRank || section.rank === 0}
|
|
options={load.data.options ?? {}}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
|
|
<SubmitBar
|
|
state={submitState}
|
|
isDirty={isDirty}
|
|
draftSavedAt={draftSavedAt}
|
|
/>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function SubmissionContextHeader({
|
|
orgName,
|
|
currentStage,
|
|
currentRank,
|
|
}: {
|
|
orgName: string;
|
|
currentStage: string;
|
|
currentRank: number;
|
|
}) {
|
|
return (
|
|
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
|
|
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Check-in for</p>
|
|
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
|
{orgName}
|
|
</h1>
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<StageProgress currentRank={currentRank} />
|
|
<span className="text-sm text-ink-soft">
|
|
<span className="text-ink-mute">Stage</span>{" "}
|
|
<span className="font-medium text-leaf-800">{currentStage || "—"}</span>
|
|
</span>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Six small dots representing the six stages, with the current rank filled
|
|
* and earlier ranks marked as visited. Quietly conveys progression without
|
|
* pretending to be a "100% complete" progress bar.
|
|
*/
|
|
function StageProgress({ currentRank }: { currentRank: number }) {
|
|
return (
|
|
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
|
{[0, 1, 2, 3, 4, 5].map((r) => (
|
|
<span
|
|
key={r}
|
|
className={
|
|
"h-1.5 rounded-full transition-all " +
|
|
(r < currentRank
|
|
? "w-2.5 bg-leaf-300"
|
|
: r === currentRank
|
|
? "w-6 bg-leaf-700"
|
|
: "w-2.5 bg-rule-soft")
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraftRestoredNotice({
|
|
savedAt,
|
|
onDiscard,
|
|
}: {
|
|
savedAt: string;
|
|
onDiscard: () => void;
|
|
}) {
|
|
const ago = formatRelative(savedAt);
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="flex flex-col gap-2 rounded-lg border border-clay-200 bg-clay-100/40 px-5 py-3 text-sm text-clay-700 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<p>
|
|
<span className="font-medium">Draft restored</span> from {ago}. Your in-progress edits were
|
|
saved locally and have been re-applied.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onDiscard}
|
|
className="self-start rounded border border-clay-400/40 bg-paper px-3 py-1 text-xs font-medium text-clay-700 transition hover:bg-clay-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-clay-500/40 sm:self-auto"
|
|
>
|
|
Discard draft
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitBar({
|
|
state,
|
|
isDirty,
|
|
draftSavedAt,
|
|
}: {
|
|
state: SubmitStatus;
|
|
isDirty: boolean;
|
|
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"
|
|
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<SubmitFeedback state={state} />
|
|
{state.kind === "idle" && (
|
|
<p className="text-xs text-ink-mute">
|
|
{isDirty
|
|
? draftSavedAt
|
|
? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)`
|
|
: "Editing — your draft will save automatically"
|
|
: "Submitting saves a new check-in record on your co-op."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={state.kind === "submitting"}
|
|
className="inline-flex items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
|
|
>
|
|
{state.kind === "submitting" ? (
|
|
<>
|
|
<Spinner /> Saving check-in…
|
|
</>
|
|
) : (
|
|
<>Submit check-in</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitFeedback({ state }: { state: SubmitStatus }) {
|
|
if (state.kind === "submitting")
|
|
return (
|
|
<p className="text-sm text-ink-soft" role="status">
|
|
Saving your check-in…
|
|
</p>
|
|
);
|
|
if (state.kind === "error")
|
|
return (
|
|
<p className="text-sm font-medium text-clay-700" role="alert">
|
|
✕ {state.message}
|
|
</p>
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg
|
|
aria-hidden
|
|
viewBox="0 0 16 16"
|
|
className="h-4 w-4 animate-spin"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M14 8a6 6 0 11-6-6" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LoadingState() {
|
|
return (
|
|
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
|
|
<div
|
|
aria-hidden
|
|
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
|
|
/>
|
|
<p className="font-display text-base text-ink-soft italic">Loading your check-in…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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
|
|
role="alert"
|
|
className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7"
|
|
>
|
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
|
We couldn't open your check-in.
|
|
</h2>
|
|
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
|
|
<p className="mt-4 text-sm text-ink-soft">
|
|
If this keeps happening, please contact your engagement coordinator and ask for a fresh
|
|
link.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatRelative(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return iso;
|
|
const seconds = Math.round((Date.now() - then) / 1000);
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes} min ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.round(hours / 24);
|
|
if (days < 30) return `${days} days ago`;
|
|
if (days < 365) return `${Math.round(days / 30)} months ago`;
|
|
return `${Math.round(days / 365)} years ago`;
|
|
}
|