"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, 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 { STAGE_OPTION_GROUP_ID } from "@/config/form"; 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 }; type PathwayState = "past" | "current" | "future"; 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, 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 — currently just current_stage, // which gates every Stage 1–5 visibleWhen rule and is shown as a readonly // field in the Stage 0 header. const evalState = useMemo( () => ({ current_stage: 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 = { [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 | 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); setDraftSavedAt(new Date().toISOString()); }, 1500); }); return () => { sub.unsubscribe(); if (saveTimer.current) clearTimeout(saveTimer.current); }; }, [load.kind, cid, watch]); const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; // Section pathway state — past / current / future is determined entirely // by stage rank vs the org's current rank. Sections never hide; future // stages render in a locked treatment so users can preview what's ahead. // // Section-level visibleWhen is still consulted on submit to strip // locked-stage values from the payload (see onSubmit below), so a future // stage's data never gets accidentally written. const sectionsToRender = useMemo( () => config.sections.map((s) => { const pathwayState: PathwayState = s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future"; return { section: s, pathwayState, locked: pathwayState === "future" }; }), [config.sections, currentRank], ); // 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 ; if (load.kind === "error") return ; // 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 ( { setSubmitState({ kind: "idle" }); window.scrollTo({ top: 0, behavior: "smooth" }); }} /> ); } 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.", }); } }; // ── 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) => { 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 (
{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((entry, i) => { const { section, pathwayState, locked } = entry; const nextState = i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined; return (
  1. 0} state={pathwayState} />
  2. ); })}
); } function SubmissionContextHeader({ orgName, currentStageLabel, currentRank, }: { orgName: string; currentStageLabel: string; currentRank: number; }) { return (

Check-in for

{orgName}

Current stage

{currentStageLabel || "—"}

); } /** * Resolve a Stage option-value (e.g. "Organizing") to its Civi option label * (e.g. "Stage 1 — Convene & Prepare"). Falls back to the raw value if the * option group hasn't loaded or the value isn't in the group. */ function resolveStageLabel( value: string, options: Record | undefined, ): string { if (!value) return ""; const group = options?.[STAGE_OPTION_GROUP_ID]; if (!group) return value; return group.find((o) => o.value === value)?.label ?? value; } /** * 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 === "error") return (

✕ {state.message}

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

Loading your check-in…

); } function SuccessDestination({ orgName, onAnother, }: { orgName: string; onAnother: () => void; }) { return (

Check-in saved

Thank you. We've recorded this check-in for{" "} {orgName}. Your engagement coordinator will see it on their next review.

It's safe to close this window.

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

); } /** * Vertical journey rail marker, visible on md+ only. Sits in the OL's left * gutter, aligned to the card header. Includes a downward connecting line * toward the next marker; the line's style is determined by whether the * NEXT stage is past/current (solid leaf) or future (dashed muted) — that * way the transition from "traveled" to "ahead" happens right after the * current marker, which reads correctly as "you've come this far." */ function RailMarker({ rank, state, nextState, }: { rank: number; state: PathwayState; nextState?: PathwayState; }) { return (
{nextState && ( )} {state === "current" && ( Now )}
); } function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) { if (state === "past") { return ( Stage {rank} (completed) ); } if (state === "current") { return ( {rank} Stage {rank} (current) ); } return ( Stage {rank} (upcoming) ); } /** * Mobile-only inter-card stem. Sits centered in the gap between adjacent * stage cards. Solid leaf when arriving at a past-or-current stage; dashed * muted when arriving at a future (locked) stage. */ function MobileStem({ show, state }: { show: boolean; state: PathwayState }) { if (!show) return null; return (
); } 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`; }