Open the form using the personalized link from your email. If you no longer have it,
please contact your engagement coordinator and ask for a fresh link.
diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx
index 7ef5b6e..49396e8 100644
--- a/components/EngagementForm.tsx
+++ b/components/EngagementForm.tsx
@@ -1,10 +1,11 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+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;
@@ -17,33 +18,38 @@ type LoadState =
| { 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
- */
+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" }
- | { kind: "submitting" }
- | { kind: "success" }
- | { kind: "error"; message: string }
- >({ kind: "idle" });
+ const [submitState, setSubmitState] = useState({ kind: "idle" });
+ const [draftSavedAt, setDraftSavedAt] = useState(null);
+ const [draftRestored, setDraftRestored] = useState(false);
const {
register,
handleSubmit,
reset,
watch,
- formState: { errors },
+ formState: { errors, isDirty },
} = useForm({ mode: "onBlur" });
- // Subscribe to all form values so section visibility re-evaluates live.
const formValues = watch();
+ // ── Initial load ───────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
async function go() {
@@ -54,18 +60,31 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
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}` });
- }
+ 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;
- // Merge stage value + per-field prefill into a single defaults map.
- const defaults = {
+
+ // 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) {
@@ -83,40 +102,52 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
};
}, [cid, cs, reset, config.stageField]);
- const sectionsToRender = useMemo(() => {
- return config.sections.map((s) => ({
- section: s,
- sectionVisible: evaluate(s.visibleWhen, formValues),
- }));
- }, [config.sections, formValues]);
+ // ── 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]);
- // 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]);
+ // ── Section ordering ───────────────────────────────────────────────────
+ const sectionsToRender = useMemo(
+ () =>
+ config.sections.map((s) => ({
+ section: s,
+ sectionVisible: evaluate(s.visibleWhen, formValues),
+ })),
+ [config.sections, formValues],
+ );
- if (load.kind === "loading") {
- return ;
- }
- if (load.kind === "error") {
- return ;
- }
+ 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 fields whose section or field rule isn't currently visible —
- // those aren't part of the user's intent in this submission.
+ // 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);
+ 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);
}
}
}
@@ -132,9 +163,17 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
});
if (!res.ok) {
const text = await res.text();
- setSubmitState({ kind: "error", message: `Submit failed (${res.status}). ${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({
@@ -149,38 +188,43 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
- {sectionsToRender.map(({ section, sectionVisible }) => {
- if (!sectionVisible) return null;
- return (
-
- );
- })}
+ {draftRestored && draftSavedAt && (
+ {
+ clearDraft(cid);
+ setDraftRestored(false);
+ setDraftSavedAt(null);
+ // Force re-fetch by bumping a key — simplest is full page reload.
+ window.location.reload();
+ }} />
+ )}
-
+
+ );
+}
+
+/**
+ * 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 (
+
+ {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."}
+