Visual identity: Field Almanac — Fraunces+DM Sans, OKLCH cream/leaf/clay palette, paper texture, hand-drawn stage icons, draft auto-save, stage progress dots
This commit is contained in:
@@ -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<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<
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
|
||||
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(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<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) {
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 <LoadingState />;
|
||||
}
|
||||
if (load.kind === "error") {
|
||||
return <ErrorState message={load.message} />;
|
||||
}
|
||||
const currentStageValue = formValues[config.stageField] as string | undefined;
|
||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
if (load.kind === "loading") return <LoadingState />;
|
||||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
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<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);
|
||||
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) {
|
||||
<SubmissionContextHeader
|
||||
orgName={load.data.orgName}
|
||||
currentStage={load.data.currentStage}
|
||||
currentRank={currentRank}
|
||||
/>
|
||||
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
return (
|
||||
<StageSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
register={register}
|
||||
errors={errors}
|
||||
formValues={formValues}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
options={load.data.options ?? {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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();
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-4 z-10 flex flex-col-reverse gap-3 rounded-xl border border-stone-200 bg-white/95 px-5 py-4 shadow-lg backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<SubmitFeedback state={submitState} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState.kind === "submitting"}
|
||||
className={
|
||||
"inline-flex items-center justify-center rounded-md px-5 py-2.5 font-medium text-white transition " +
|
||||
"bg-leaf-700 hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
||||
"disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
}
|
||||
>
|
||||
{submitState.kind === "submitting" ? "Saving…" : "Submit check-in"}
|
||||
</button>
|
||||
</div>
|
||||
<ol className="space-y-5">
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
return (
|
||||
<li key={section.id} className="list-none">
|
||||
<StageSection
|
||||
section={section}
|
||||
register={register}
|
||||
errors={errors}
|
||||
formValues={formValues}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
options={load.data.options ?? {}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<SubmitBar
|
||||
state={submitState}
|
||||
isDirty={isDirty}
|
||||
draftSavedAt={draftSavedAt}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -188,62 +232,167 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
function SubmissionContextHeader({
|
||||
orgName,
|
||||
currentStage,
|
||||
currentRank,
|
||||
}: {
|
||||
orgName: string;
|
||||
currentStage: string;
|
||||
currentRank: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white px-5 py-4 shadow-sm">
|
||||
<p className="text-sm text-stone-600">Check-in for</p>
|
||||
<h1 className="mt-0.5 text-xl font-semibold text-stone-900">{orgName}</h1>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
Framework Stage:{" "}
|
||||
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-leaf-800 text-sm font-medium">
|
||||
{currentStage || "—"}
|
||||
<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-3 flex items-center gap-3">
|
||||
<StageProgress currentRank={currentRank} />
|
||||
<span className="text-sm text-ink-soft">
|
||||
<span className="text-ink-mute">Stage</span>{" "}
|
||||
<span className="font-medium text-leaf-800">{currentStage || "—"}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 SubmitFeedback({
|
||||
state,
|
||||
function DraftRestoredNotice({
|
||||
savedAt,
|
||||
onDiscard,
|
||||
}: {
|
||||
state:
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
savedAt: string;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
if (state.kind === "idle") {
|
||||
return <p className="text-sm text-stone-600">Your changes will be saved as a new check-in record.</p>;
|
||||
}
|
||||
if (state.kind === "submitting") {
|
||||
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">
|
||||
<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-stone-700" role="status">
|
||||
<p className="text-sm text-ink-soft" role="status">
|
||||
Saving your check-in…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.kind === "success") {
|
||||
if (state.kind === "success")
|
||||
return (
|
||||
<p className="text-sm font-medium text-leaf-800" role="status">
|
||||
✓ Check-in saved. Thank you.
|
||||
</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 (
|
||||
<p className="text-sm font-medium text-red-700" role="alert">
|
||||
✕ {state.message}
|
||||
</p>
|
||||
<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-xl border border-stone-200 bg-white px-6 py-12 text-center text-stone-600 shadow-sm">
|
||||
<div className="mx-auto mb-3 h-6 w-6 animate-spin rounded-full border-2 border-leaf-200 border-t-leaf-700" />
|
||||
<p>Loading your check-in form…</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -252,14 +401,31 @@ function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-red-200 bg-red-50 px-6 py-6 text-red-900"
|
||||
className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">We couldn't load your check-in form.</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed">{message}</p>
|
||||
<p className="mt-3 text-sm">
|
||||
If this problem persists, please contact your engagement coordinator and ask for a fresh
|
||||
<h2 className="font-display text-xl font-medium text-clay-700">
|
||||
We couldn'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>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user