Files
WebForm-mw/components/EngagementForm.tsx

432 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form";
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,
watch,
formState: { errors, isDirty },
} = useForm({ mode: "onBlur" });
const formValues = watch();
// ── 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.
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (load.kind !== "ready") return;
if (!isDirty) return;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveDraft(cid, formValues);
setDraftSavedAt(new Date().toISOString());
}, 1500);
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [formValues, isDirty, load.kind, cid]);
// ── Section ordering ───────────────────────────────────────────────────
const sectionsToRender = useMemo(
() =>
config.sections.map((s) => ({
section: s,
sectionVisible: evaluate(s.visibleWhen, formValues),
})),
[config.sections, formValues],
);
const currentStageValue = formValues[config.stageField] as string | undefined;
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// ── Render ─────────────────────────────────────────────────────────────
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
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.",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} 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}
errors={errors}
formValues={formValues}
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">
<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 === "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">
{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 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&apos;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`;
}