Files
WebForm-mw/components/EngagementForm.tsx
Joel Brock 04e69ca04c Header: show Civi stage label prominently; restore current_stage readonly in Stage 0
- New STAGE_OPTION_GROUP_ID constant (=75) added to config and consumed
  by both the in-card readonly and the header. Because the field carries
  optionGroupId, /api/data auto-includes the Stage option group in its
  parallel fetch (via the existing optionGroupIds derivation).
- SubmissionContextHeader: dropped the small inline 'Stage Organizing'
  line. Replaced with an uppercase 'Current stage' eyebrow next to the
  progress dots, followed by a display-font 20/24px leaf-toned label
  underneath. Resolves the stored option value ('Organizing') to its
  Civi option label ('Stage 1 — Convene & Prepare') via the new
  resolveStageLabel helper; falls back to the raw value if the option
  group hasn't loaded.
- Stage 0 'Check-in (organizing)' section: re-added a current_stage
  readonly field at the top. With optionGroupId set, FieldRenderer's
  readonly branch resolves the value to the Civi label for display.
2026-05-13 12:09:24 -07:00

691 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<StageSectionConfig>,
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<string, number> = {
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<LoadState>({ kind: "loading" });
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(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 15 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<string, unknown> = {
[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<ReturnType<typeof setTimeout> | 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<string, unknown>);
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 <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
// 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 (
<SuccessDestination
orgName={load.data.orgName}
onAnother={() => {
setSubmitState({ kind: "idle" });
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
);
}
const onSubmit = async (values: Record<string, unknown>) => {
setSubmitState({ kind: "submitting" });
try {
// Strip values for hidden fields — never write data the user couldn't see.
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);
}
for (const m of s.matrixGroups ?? []) {
for (const row of m.rows) {
for (const fname of row.fields) visibleFieldNames.add(fname);
}
}
}
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();
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<FieldValues>) => {
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 (
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
<SubmissionContextHeader
orgName={load.data.orgName}
currentStageLabel={resolveStageLabel(load.data.currentStage, load.data.options)}
currentRank={currentRank}
/>
{draftRestored && draftSavedAt && (
<DraftRestoredNotice savedAt={draftSavedAt} onDiscard={() => {
clearDraft(cid);
setDraftRestored(false);
setDraftSavedAt(null);
// Force re-fetch by bumping a key — simplest is full page reload.
window.location.reload();
}} />
)}
<ol className="relative space-y-5 md:pl-12">
{sectionsToRender.map((entry, i) => {
const { section, pathwayState, locked } = entry;
const nextState =
i < sectionsToRender.length - 1
? sectionsToRender[i + 1].pathwayState
: undefined;
return (
<li key={section.id} className="relative list-none">
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker
rank={section.rank}
state={pathwayState}
nextState={nextState}
/>
<StageSection
section={section}
register={register}
control={control}
errors={errors}
formValues={evalState}
isCurrent={pathwayState === "current"}
locked={locked}
defaultOpen={pathwayState === "current" || section.rank === 0}
options={load.data.options ?? {}}
/>
</li>
);
})}
</ol>
<SubmitBar
state={submitState}
isDirty={isDirty}
draftSavedAt={draftSavedAt}
/>
</form>
);
}
function SubmissionContextHeader({
orgName,
currentStageLabel,
currentRank,
}: {
orgName: string;
currentStageLabel: string;
currentRank: number;
}) {
return (
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Check-in for</p>
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
{orgName}
</h1>
<div className="mt-4 flex items-center gap-3">
<StageProgress currentRank={currentRank} />
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
Current stage
</span>
</div>
<p className="mt-1.5 font-display text-xl sm:text-2xl font-medium leading-tight tracking-tight text-leaf-800">
{currentStageLabel || "—"}
</p>
</header>
);
}
/**
* 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<number, { value: string; label: string }[]> | 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 (
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
{[0, 1, 2, 3, 4, 5].map((r) => (
<span
key={r}
className={
"h-1.5 rounded-full transition-all " +
(r < currentRank
? "w-2.5 bg-leaf-300"
: r === currentRank
? "w-6 bg-leaf-700"
: "w-2.5 bg-rule-soft")
}
/>
))}
</div>
);
}
function DraftRestoredNotice({
savedAt,
onDiscard,
}: {
savedAt: string;
onDiscard: () => void;
}) {
const ago = formatRelative(savedAt);
return (
<div
role="status"
className="flex flex-col gap-2 rounded-lg border border-clay-200 bg-clay-100/40 px-5 py-3 text-sm text-clay-700 sm:flex-row sm:items-center sm:justify-between"
>
<p>
<span className="font-medium">Draft restored</span> from {ago}. Your in-progress edits were
saved locally and have been re-applied.
</p>
<button
type="button"
onClick={onDiscard}
className="self-start rounded border border-clay-400/40 bg-paper px-3 py-1 text-xs font-medium text-clay-700 transition hover:bg-clay-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-clay-500/40 sm:self-auto"
>
Discard draft
</button>
</div>
);
}
function SubmitBar({
state,
isDirty,
draftSavedAt,
}: {
state: SubmitStatus;
isDirty: boolean;
draftSavedAt: string | null;
}) {
return (
<div
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex flex-col gap-0.5">
<SubmitFeedback state={state} />
{state.kind === "idle" && (
<p className="text-xs text-ink-mute">
{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."}
</p>
)}
</div>
<button
type="submit"
disabled={state.kind === "submitting"}
className="inline-flex items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
>
{state.kind === "submitting" ? (
<>
<Spinner /> Saving check-in
</>
) : (
<>Submit check-in</>
)}
</button>
</div>
);
}
function SubmitFeedback({ state }: { state: SubmitStatus }) {
if (state.kind === "submitting")
return (
<p className="text-sm text-ink-soft" role="status">
Saving your check-in
</p>
);
if (state.kind === "error")
return (
<p className="text-sm font-medium text-clay-700" role="alert">
{state.message}
</p>
);
return null;
}
function Spinner() {
return (
<svg
aria-hidden
viewBox="0 0 16 16"
className="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 8a6 6 0 11-6-6" strokeLinecap="round" />
</svg>
);
}
function LoadingState() {
return (
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
<div
aria-hidden
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
/>
<p className="font-display text-base text-ink-soft italic">Loading your check-in</p>
</div>
);
}
function SuccessDestination({
orgName,
onAnother,
}: {
orgName: string;
onAnother: () => void;
}) {
return (
<section
role="status"
aria-live="polite"
className="rounded-lg border-2 border-leaf-600 bg-leaf-50/40 px-6 py-10 text-center shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(60,80,40,0.22)] sm:px-10 sm:py-12"
>
<div
aria-hidden
className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm"
>
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12l5 5 9-11" />
</svg>
</div>
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
Check-in saved
</h2>
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
Thank you. We&apos;ve recorded this check-in for{" "}
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
it on their next review.
</p>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
>
Submit another check-in
</button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div>
</section>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div
role="alert"
className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7"
>
<h2 className="font-display text-xl font-medium text-clay-700">
We couldn&apos;t open your check-in.
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
<p className="mt-4 text-sm text-ink-soft">
If this keeps happening, please contact your engagement coordinator and ask for a fresh
link.
</p>
</div>
);
}
/**
* 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 (
<div
aria-hidden
className="pointer-events-none hidden md:block absolute -left-9 top-0 bottom-0 w-7"
>
{nextState && (
<span
className={
"absolute left-1/2 -translate-x-1/2 top-11 -bottom-11 " +
(nextState === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
)}
<MarkerCircle rank={rank} state={state} />
{state === "current" && (
<span className="absolute left-1/2 -translate-x-1/2 top-[52px] whitespace-nowrap rounded-full bg-leaf-700 px-1.5 py-[1px] text-[8px] font-medium uppercase tracking-[0.1em] text-paper shadow-sm">
Now
</span>
)}
</div>
);
}
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
if (state === "past") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
<path d="M3 6.5l2 2 4-5" />
</svg>
<span className="sr-only">Stage {rank} (completed)</span>
</span>
);
}
if (state === "current") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper text-[10px] font-semibold shadow-md ring-2 ring-leaf-100 motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite]">
{rank}
<span className="sr-only">Stage {rank} (current)</span>
</span>
);
}
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
<span className="sr-only">Stage {rank} (upcoming)</span>
</span>
);
}
/**
* 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 (
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
<span
className={
"block h-4 " +
(state === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
</div>
);
}
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`;
}