Subtitle now tells respondents they can skip fields they do not know. Post-submit CTA says Submit an update since the same form is the ongoing update channel, not a one-off survey.
895 lines
33 KiB
TypeScript
895 lines
33 KiB
TypeScript
"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 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<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'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 an update
|
||
</button>
|
||
<p className="text-xs text-ink-mute">It'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'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`;
|
||
}
|