"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 { FieldRenderer } from "./fields/FieldRenderer"; 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); // Section currently in view, updated as the user scrolls. Surfaces in // the sticky SubmitBar so the org name + viewed stage remain visible // even when the top-of-form header has scrolled out of sight. const [viewedSectionId, setViewedSectionId] = useState(null); const { register, handleSubmit, reset, control, watch, setFocus, setValue, formState: { errors, isDirty }, } = useForm({ mode: "onBlur" }); // Count of file uploads currently in flight. Each calls the // handler with +1 when it starts and -1 when it finishes; submit is // blocked while the count is > 0 so users can't ship a half-uploaded form. const [uploadsInFlight, setUploadsInFlight] = useState(0); const handleUploadStateChange = (delta: 1 | -1) => setUploadsInFlight((n) => Math.max(0, n + delta)); // 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. Includes: // - current_stage: gates every Stage 1–5 visibleWhen rule and renders // as a readonly field in Stage 0. // - contact_first_name / contact_last_name / contact_email: readonly // identity fields in Stage 0, sourced from /api/data (the cid contact). // Static after load, so we read straight from load.data.contact rather // than subscribing through react-hook-form. const readyContact = load.kind === "ready" ? load.data.contact : undefined; const evalState = useMemo( () => ({ current_stage: currentStageValue, contact_first_name: readyContact?.firstName ?? "", contact_last_name: readyContact?.lastName ?? "", contact_email: readyContact?.email ?? "", }), [currentStageValue, readyContact], ); // ── 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 + readonly contact // identity. Then check if a local draft exists newer than the server // data; if so, layer it on top. Contact-identity fields are never // user-editable, so a draft can't override them — they're force-set // again after the draft merge below. const contactDefaults: Record = { contact_first_name: data.contact?.firstName ?? "", contact_last_name: data.contact?.lastName ?? "", contact_email: data.contact?.email ?? "", }; const defaults: Record = { [config.stageField]: data.currentStage, ...contactDefaults, ...data.prefill, }; const draft = loadDraft(cid); if (draft && Object.keys(draft.values).length > 0) { Object.assign(defaults, draft.values); // Re-set the stage and contact identity from server (drafts can // never override the org's actual stage nor the cid's contact). defaults[config.stageField] = data.currentStage; Object.assign(defaults, contactDefaults); 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]); // ── Observe which section is currently being viewed ─────────────────── // The sticky submit bar mirrors this so the user always knows which org // they're on and which stage they're scrolled to, even after the top // header has left the viewport. We bias intersection toward the upper // 40% of the viewport — once a section's box enters that band, it // becomes "viewed"; the topmost intersecting section wins ties. useEffect(() => { if (load.kind !== "ready") return; const els = Array.from( document.querySelectorAll("[data-section-id]"), ); if (els.length === 0) return; const intersecting = new Set(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const id = entry.target.getAttribute("data-section-id"); if (!id) continue; if (entry.isIntersecting) intersecting.add(id); else intersecting.delete(id); } // Pick the topmost intersecting section (closest to viewport top). let bestId: string | null = null; let bestDist = Infinity; for (const el of els) { const id = el.getAttribute("data-section-id"); if (!id || !intersecting.has(id)) continue; const dist = Math.abs(el.getBoundingClientRect().top); if (dist < bestDist) { bestDist = dist; bestId = id; } } if (bestId) setViewedSectionId(bestId); }, { rootMargin: "0px 0px -60% 0px", threshold: [0, 0.1] }, ); for (const el of els) observer.observe(el); return () => observer.disconnect(); }, [load.kind, config.sections]); const viewedSection = useMemo( () => (viewedSectionId ? config.sections.find((x) => x.id === viewedSectionId) ?? null : null), [viewedSectionId, config.sections], ); const viewedSectionLabel = viewedSection?.label ?? null; const viewedRank = viewedSection?.rank ?? null; // ── 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. // The submitter-info section (rank: -1) lives outside the stage pathway — // it renders directly above the section list (see JSX below), not inside // the rail/marker accordion model used for Stage 0..5. Filter it out here // so its rank doesn't pollute the past/current/future calculation. const submitterSection = useMemo( () => config.sections.find((s) => s.id === "submitter_info"), [config.sections], ); const stageSections = useMemo( () => config.sections.filter((s) => s.id !== "submitter_info"), [config.sections], ); // 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( () => stageSections.map((s) => { const pathwayState: PathwayState = s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future"; return { section: s, pathwayState, locked: pathwayState === "future" }; }), [stageSections, 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) => { // Block while any file upload is in flight — submitting now would // ship the form without the pending {id, file_name} value for that // field, which would silently clear the prior attachment. if (uploadsInFlight > 0) { setSubmitState({ kind: "error", message: uploadsInFlight === 1 ? "A file is still uploading. Please wait a moment and try again." : `${uploadsInFlight} files are still uploading. Please wait a moment and try again.`, }); return; } const confirmed = window.confirm( "Submit your check-in?\n\n" + "Please confirm your responses are complete and ready. " + "After submitting, you can come back later and submit another check-in with updates.", ); if (!confirmed) return; 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(); }} /> )} {submitterSection && (

{submitterSection.label}

{submitterSection.intro && (

{submitterSection.intro}

)}
{submitterSection.fields.map((f) => ( ))}
)}
    {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 (

Survey 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. * * `pulse` adds a slow halo to the active pill — used in the floating * submit bar (where the active rank moves as the user scrolls) to draw * the eye to the changing indicator. Left off in the static header. */ function StageProgress({ currentRank, pulse = false }: { currentRank: number; pulse?: boolean }) { 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, orgName, viewedSectionLabel, viewedRank, }: { state: SubmitStatus; isDirty: boolean; draftSavedAt: string | null; orgName: string; viewedSectionLabel: string | null; viewedRank: number | null; }) { // Build the small secondary line under the org name. Priority: // 1. submit feedback (error or in-flight) — most urgent // 2. "Viewing: " when the user has scrolled to a section // 3. draft / edit status — falls back when there's no section in view // yet (e.g. immediately after load, before any scroll) let secondary: React.ReactNode = null; if (state.kind === "error") { secondary = (

✕ {state.message}

); } else if (state.kind === "submitting") { secondary = (

Saving your survey…

); } else if (viewedSectionLabel && viewedRank != null) { secondary = (

{viewedSectionLabel} {isDirty && draftSavedAt && ( · saved {formatRelative(draftSavedAt)} )}

); } else if (isDirty && draftSavedAt) { secondary = (

Draft saved {formatRelative(draftSavedAt)} (locally on this device)

); } else if (isDirty) { secondary = (

Editing — your draft will save automatically

); } else { secondary = null; } return (

{orgName}

{secondary &&
{secondary}
}
); } function Spinner() { return ( ); } function LoadingState() { return (

Loading your survey…

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

Survey saved

Thank you. We've recorded this survey for{" "} {orgName}.

It's safe to close this window.

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

We couldn't open your survey.

{message}

If this keeps happening, please contact Chris @ FCI 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`; }