266 lines
8.3 KiB
TypeScript
266 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, 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";
|
|
|
|
interface EngagementFormProps {
|
|
config: FormConfig;
|
|
cid: string;
|
|
cs: string;
|
|
}
|
|
|
|
type LoadState =
|
|
| { kind: "loading" }
|
|
| { kind: "error"; message: string }
|
|
| { kind: "ready"; data: FormDataPayload };
|
|
|
|
/**
|
|
* Top-level form orchestrator. Responsible for:
|
|
* - fetching prefill data + the org's current stage from /api/data
|
|
* - hydrating react-hook-form with prefill values
|
|
* - re-evaluating section visibility against live form values on every change
|
|
* - submitting to /api/submit and surfacing success / error feedback
|
|
*/
|
|
export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
|
const [submitState, setSubmitState] = useState<
|
|
| { kind: "idle" }
|
|
| { kind: "submitting" }
|
|
| { kind: "success" }
|
|
| { kind: "error"; message: string }
|
|
>({ kind: "idle" });
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
watch,
|
|
formState: { errors },
|
|
} = useForm({ mode: "onBlur" });
|
|
|
|
// Subscribe to all form values so section visibility re-evaluates live.
|
|
const formValues = watch();
|
|
|
|
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();
|
|
if (!cancelled) {
|
|
setLoad({ kind: "error", message: `Could not load form (${res.status}). ${text}` });
|
|
}
|
|
return;
|
|
}
|
|
const data: FormDataPayload = await res.json();
|
|
if (cancelled) return;
|
|
// Merge stage value + per-field prefill into a single defaults map.
|
|
const defaults = {
|
|
[config.stageField]: data.currentStage,
|
|
...data.prefill,
|
|
};
|
|
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]);
|
|
|
|
const sectionsToRender = useMemo(() => {
|
|
return config.sections.map((s) => ({
|
|
section: s,
|
|
sectionVisible: evaluate(s.visibleWhen, formValues),
|
|
}));
|
|
}, [config.sections, formValues]);
|
|
|
|
// The "current" rank: highest section rank whose visibility rule matches
|
|
// the current stage value. If nothing matches, fall back to 0 (Inquiry).
|
|
const currentRank = useMemo(() => {
|
|
const visibleRanks = sectionsToRender
|
|
.filter((s) => s.sectionVisible)
|
|
.map((s) => s.section.rank);
|
|
return visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0;
|
|
}, [sectionsToRender]);
|
|
|
|
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 fields whose section or field rule isn't currently visible —
|
|
// those aren't part of the user's intent in this submission.
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
setSubmitState({ kind: "error", message: `Submit failed (${res.status}). ${text}` });
|
|
return;
|
|
}
|
|
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}
|
|
/>
|
|
|
|
{sectionsToRender.map(({ section, sectionVisible }) => {
|
|
if (!sectionVisible) return null;
|
|
return (
|
|
<StageSection
|
|
key={section.id}
|
|
section={section}
|
|
register={register}
|
|
errors={errors}
|
|
formValues={formValues}
|
|
isCurrent={section.rank === currentRank}
|
|
defaultOpen={section.rank === currentRank || section.rank === 0}
|
|
options={load.data.options ?? {}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<div className="sticky bottom-4 z-10 flex flex-col-reverse gap-3 rounded-xl border border-stone-200 bg-white/95 px-5 py-4 shadow-lg backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between">
|
|
<SubmitFeedback state={submitState} />
|
|
<button
|
|
type="submit"
|
|
disabled={submitState.kind === "submitting"}
|
|
className={
|
|
"inline-flex items-center justify-center rounded-md px-5 py-2.5 font-medium text-white transition " +
|
|
"bg-leaf-700 hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
|
"disabled:cursor-not-allowed disabled:bg-stone-400"
|
|
}
|
|
>
|
|
{submitState.kind === "submitting" ? "Saving…" : "Submit check-in"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function SubmissionContextHeader({
|
|
orgName,
|
|
currentStage,
|
|
}: {
|
|
orgName: string;
|
|
currentStage: string;
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-stone-200 bg-white px-5 py-4 shadow-sm">
|
|
<p className="text-sm text-stone-600">Check-in for</p>
|
|
<h1 className="mt-0.5 text-xl font-semibold text-stone-900">{orgName}</h1>
|
|
<p className="mt-2 text-sm text-stone-700">
|
|
Framework Stage:{" "}
|
|
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-leaf-800 text-sm font-medium">
|
|
{currentStage || "—"}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitFeedback({
|
|
state,
|
|
}: {
|
|
state:
|
|
| { kind: "idle" }
|
|
| { kind: "submitting" }
|
|
| { kind: "success" }
|
|
| { kind: "error"; message: string };
|
|
}) {
|
|
if (state.kind === "idle") {
|
|
return <p className="text-sm text-stone-600">Your changes will be saved as a new check-in record.</p>;
|
|
}
|
|
if (state.kind === "submitting") {
|
|
return (
|
|
<p className="text-sm text-stone-700" 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>
|
|
);
|
|
}
|
|
return (
|
|
<p className="text-sm font-medium text-red-700" role="alert">
|
|
✕ {state.message}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function LoadingState() {
|
|
return (
|
|
<div className="rounded-xl border border-stone-200 bg-white px-6 py-12 text-center text-stone-600 shadow-sm">
|
|
<div className="mx-auto mb-3 h-6 w-6 animate-spin rounded-full border-2 border-leaf-200 border-t-leaf-700" />
|
|
<p>Loading your check-in form…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorState({ message }: { message: string }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="rounded-xl border border-red-200 bg-red-50 px-6 py-6 text-red-900"
|
|
>
|
|
<h2 className="text-lg font-semibold">We couldn't load your check-in form.</h2>
|
|
<p className="mt-2 text-sm leading-relaxed">{message}</p>
|
|
<p className="mt-3 text-sm">
|
|
If this problem persists, please contact your engagement coordinator and ask for a fresh
|
|
link.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|