"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 = { 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({ kind: "loading" }); const [submitState, setSubmitState] = useState({ kind: "idle" }); const [draftSavedAt, setDraftSavedAt] = useState(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 = { [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 | 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 ; if (load.kind === "error") return ; const onSubmit = async (values: Record) => { setSubmitState({ kind: "submitting" }); try { // Strip values for hidden fields — never write data the user couldn't see. const visibleFieldNames = new Set(); 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 = {}; 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 (
{draftRestored && draftSavedAt && ( { clearDraft(cid); setDraftRestored(false); setDraftSavedAt(null); // Force re-fetch by bumping a key — simplest is full page reload. window.location.reload(); }} /> )}
    {sectionsToRender.map(({ section, sectionVisible }) => { if (!sectionVisible) return null; return (
  1. ); })}
); } function SubmissionContextHeader({ orgName, currentStage, currentRank, }: { orgName: string; currentStage: string; currentRank: number; }) { return (

Check-in for

{orgName}

Stage{" "} {currentStage || "—"}
); } /** * 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 (
{[0, 1, 2, 3, 4, 5].map((r) => ( ))}
); } function DraftRestoredNotice({ savedAt, onDiscard, }: { savedAt: string; onDiscard: () => void; }) { const ago = formatRelative(savedAt); return (

Draft restored from {ago}. Your in-progress edits were saved locally and have been re-applied.

); } function SubmitBar({ state, isDirty, draftSavedAt, }: { state: SubmitStatus; isDirty: boolean; draftSavedAt: string | null; }) { return (
{state.kind === "idle" && (

{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."}

)}
); } function SubmitFeedback({ state }: { state: SubmitStatus }) { if (state.kind === "submitting") return (

Saving your check-in…

); if (state.kind === "success") return (

✓ Check-in saved. Thank you.

); if (state.kind === "error") return (

✕ {state.message}

); return null; } function Spinner() { return ( ); } function LoadingState() { return (

Loading your check-in…

); } function ErrorState({ message }: { message: string }) { return (

We couldn't open your check-in.

{message}

If this keeps happening, please contact your engagement coordinator and ask for a fresh link.

); } 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`; }