Files
WebForm-mw/components/EngagementForm.tsx
T
Joel Brock bd859ce906 Form: required submitter info section above the stage pathway
Captures the form-filler's name and email on every check-in. Both fields
are required; values write back to Check_in_data__organizing_.Survey_completed_by
and Survey_completed_by_email on the activity, giving us a per-submission
record of who filled out which check-in.

Implementation:
- config/form.ts: new submitterInfo section (rank -1) at the head of the
  sections array. rank -1 keeps it out of the past/current/future stage
  pathway computation.
- components/EngagementForm.tsx: filter the submitter section out of
  sectionsToRender and render it directly with FieldRenderer inside a
  bordered card above the stage list. The fields still flow through RHF
  registration, onInvalid scroll-to-error, and the onSubmit visibility
  filter the same as any other field.

The staff report auto-discovers these fields via CustomField.get since
they live in Check_in_data__organizing_, so the field history shows up
in the report with no extra wiring.
2026-06-10 09:03:19 -07:00

895 lines
33 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 { 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<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);
// 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<string | null>(null);
const {
register,
handleSubmit,
reset,
control,
watch,
setFocus,
setValue,
formState: { errors, isDirty },
} = useForm({ mode: "onBlur" });
// Count of file uploads currently in flight. Each <FileField> 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 15 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<string, unknown> = {
contact_first_name: data.contact?.firstName ?? "",
contact_last_name: data.contact?.lastName ?? "",
contact_email: data.contact?.email ?? "",
};
const defaults: Record<string, unknown> = {
[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<HTMLElement>("[data-section-id]"),
);
if (els.length === 0) return;
const intersecting = new Set<string>();
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<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.
// 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 <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>) => {
// 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<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();
}} />
)}
{submitterSection && (
<section
aria-labelledby="submitter-info-heading"
className="rounded-2xl border border-rule bg-paper p-4 sm:p-5"
>
<h2
id="submitter-info-heading"
className="font-display text-lg font-medium text-ink"
>
{submitterSection.label}
</h2>
{submitterSection.intro && (
<p className="mt-1 text-sm text-ink-soft">{submitterSection.intro}</p>
)}
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2">
{submitterSection.fields.map((f) => (
<FieldRenderer
key={f.name}
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
cid={cid}
cs={cs}
onUploadStateChange={handleUploadStateChange}
/>
))}
</div>
</section>
)}
<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}
data-section-id={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}
setValue={setValue}
control={control}
errors={errors}
formValues={evalState}
isCurrent={pathwayState === "current"}
locked={locked}
defaultOpen={pathwayState === "current" || section.rank === 0}
options={load.data.options ?? {}}
cid={cid}
cs={cs}
onUploadStateChange={handleUploadStateChange}
/>
</li>
);
})}
</ol>
<SubmitBar
state={submitState}
isDirty={isDirty}
draftSavedAt={draftSavedAt}
orgName={load.data.orgName}
viewedSectionLabel={viewedSectionLabel}
viewedRank={viewedRank}
/>
</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">Survey 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.
*
* `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 (
<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 duration-500 ease-out " +
(r === currentRank && pulse
? "motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite] "
: "") +
(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,
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: <stage>" 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 = (
<p className="truncate text-xs font-medium text-clay-700" role="alert">
{state.message}
</p>
);
} else if (state.kind === "submitting") {
secondary = (
<p className="truncate text-xs text-ink-soft" role="status">
Saving your survey
</p>
);
} else if (viewedSectionLabel && viewedRank != null) {
secondary = (
<div
className="flex min-w-0 items-center gap-2.5"
aria-live="polite"
aria-label={`Viewing ${viewedSectionLabel}`}
>
<StageProgress currentRank={viewedRank} pulse />
<p className="min-w-0 truncate text-xs text-ink-mute">
<span className="text-ink-soft">{viewedSectionLabel}</span>
{isDirty && draftSavedAt && (
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
)}
</p>
</div>
);
} else if (isDirty && draftSavedAt) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Draft saved {formatRelative(draftSavedAt)} (locally on this device)
</p>
);
} else if (isDirty) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Editing your draft will save automatically
</p>
);
} else {
secondary = 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:gap-6 sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-medium leading-tight text-ink sm:text-lg">
{orgName}
</p>
{secondary && <div className="mt-0.5">{secondary}</div>}
</div>
<button
type="submit"
disabled={state.kind === "submitting"}
className="inline-flex shrink-0 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 survey
</>
) : (
<>Submit survey</>
)}
</button>
</div>
);
}
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 survey</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">
Survey 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 survey for{" "}
<span className="font-medium text-ink">{orgName}</span>.
</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 survey
</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 survey.
</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 <a href="mailto:chris@fci.coop">Chris @ FCI</a> 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`;
}