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:
Joel Brock
2026-05-09 21:48:45 -07:00
parent dcdf315244
commit 656bf7fd0a
9 changed files with 776 additions and 272 deletions

View File

@@ -1,84 +1,172 @@
@import "tailwindcss"; @import "tailwindcss";
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
* Theme — Food Co-op Initiative palette * Theme — "Field Almanac"
* *
* "leaf" is the primary green. Inspired by the warm, grassroots-organic * Inspiration: 1970s ecological-movement print, agricultural cooperative
* feel of fci.coop: not corporate teal, not jewel-toned. A balanced * publications, hand-bound field journals. Warm cream paper, deep
* field-green that sits comfortably alongside warm cream/stone neutrals. * botanical green ink, sparing terracotta accent for emphasis.
* *
* "stone" is the neutral ramp — slightly warm, never icy. * The palette is built in OKLCH for perceptual uniformity. Every neutral
* * is tinted toward the leaf hue (h≈130) so cream and green feel like they
* Both are built as Tailwind 4 color tokens, so utility classes like * came from the same press run.
* `bg-leaf-700`, `text-stone-600` work everywhere.
* ─────────────────────────────────────────────────────────────────────── */ * ─────────────────────────────────────────────────────────────────────── */
@theme { @theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; /* Typography */
--font-serif: var(--font-serif), ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; --font-display: var(--font-display), ui-serif, Georgia, "Times New Roman", serif;
--font-body: var(--font-body), ui-sans-serif, system-ui, -apple-system, sans-serif;
--color-leaf-50: #f3f8f0; /* Custom utility names */
--color-leaf-100: #e3eed9; --color-paper: oklch(97.5% 0.012 95); /* warm cream */
--color-leaf-200: #c8dcb3; --color-paper-2: oklch(95.5% 0.018 90); /* deeper cream — section bands */
--color-leaf-300: #a3c280; --color-ink: oklch(20% 0.02 130); /* near-black, slight green tint */
--color-leaf-400: #7ea455; --color-ink-soft: oklch(35% 0.018 130);
--color-leaf-500: #5e8638; --color-ink-mute: oklch(55% 0.014 120);
--color-leaf-600: #4a6c2a; --color-rule: oklch(82% 0.025 100); /* hairline rules — like ink on cream */
--color-leaf-700: #3a5520; --color-rule-soft: oklch(89% 0.02 100);
--color-leaf-800: #2c401a;
--color-leaf-900: #1f2d12;
--color-stone-50: #fafaf7; /* Botanical green — primary */
--color-stone-100: #f3f2ed; --color-leaf-50: oklch(97% 0.025 135);
--color-stone-200: #e7e4dc; --color-leaf-100: oklch(93% 0.05 135);
--color-stone-300: #d4cfc1; --color-leaf-200: oklch(87% 0.085 135);
--color-stone-400: #aaa494; --color-leaf-300: oklch(78% 0.115 135);
--color-stone-500: #807a6a; --color-leaf-400: oklch(68% 0.13 135);
--color-stone-600: #5b574b; --color-leaf-500: oklch(57% 0.135 135);
--color-stone-700: #403d35; --color-leaf-600: oklch(47% 0.13 135);
--color-stone-800: #2a2823; --color-leaf-700: oklch(38% 0.115 135);
--color-stone-900: #1a1815; --color-leaf-800: oklch(30% 0.09 135);
--color-leaf-900: oklch(22% 0.06 135);
/* Terracotta — accent. Used sparingly: current-stage marker, key CTAs. */
--color-clay-100: oklch(94% 0.04 50);
--color-clay-200: oklch(86% 0.075 50);
--color-clay-400: oklch(70% 0.13 45);
--color-clay-500: oklch(63% 0.15 42);
--color-clay-600: oklch(54% 0.155 40);
--color-clay-700: oklch(45% 0.135 38);
/* Stone — kept as a familiar alias mapping to our warm neutrals so any
* earlier `text-stone-*` / `border-stone-*` references stay readable. */
--color-stone-50: var(--color-paper);
--color-stone-100: var(--color-paper-2);
--color-stone-200: var(--color-rule-soft);
--color-stone-300: var(--color-rule);
--color-stone-400: oklch(70% 0.018 110);
--color-stone-500: var(--color-ink-mute);
--color-stone-600: oklch(48% 0.015 120);
--color-stone-700: var(--color-ink-soft);
--color-stone-800: oklch(28% 0.018 130);
--color-stone-900: var(--color-ink);
} }
/* Base / reset */ /* Base — paper texture and ink defaults */
:root { :root {
color-scheme: light; color-scheme: light;
--background: var(--color-stone-50);
--foreground: var(--color-stone-900);
} }
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
font-feature-settings: "kern", "liga", "calt", "ss01";
} }
body { body {
background: var(--background); background-color: var(--color-paper);
color: var(--foreground); color: var(--color-ink);
/* Subtle paper grain — two diagonal repeating linear gradients combined
* with a soft radial overlay. No external image. Survives dark mode if
* we ever add one. */
background-image:
radial-gradient(ellipse 90% 60% at 50% 0%, oklch(99% 0.01 95 / 0.7), transparent 60%),
repeating-linear-gradient(
105deg,
oklch(96% 0.02 95 / 0.4) 0,
oklch(96% 0.02 95 / 0.4) 1px,
transparent 1px,
transparent 6px
),
repeating-linear-gradient(
15deg,
oklch(94% 0.02 95 / 0.25) 0,
oklch(94% 0.02 95 / 0.25) 1px,
transparent 1px,
transparent 9px
);
} }
/* Larger, more readable form controls on small screens */ /* Typography defaults */
input, .font-display { font-family: var(--font-display); font-feature-settings: "ss01", "cv01"; }
select, .font-body { font-family: var(--font-body); }
textarea,
button { /* Long-form headings get optical-size tuning for richer letterforms */
h1.font-display, h2.font-display, h3.font-display {
font-variation-settings: "opsz" 144, "SOFT" 50;
letter-spacing: -0.015em;
}
/* Forms: shared input rhythm */
input, select, textarea, button {
font: inherit; font: inherit;
} }
/* Ensure focus is always perceptible (in addition to Tailwind's ring) */ /* A more deliberate focus ring — uses the leaf token */
*:focus-visible { *:focus-visible {
outline: 2px solid transparent; outline: 2px solid var(--color-leaf-500);
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px;
} }
/* Make Chrome's date input chevron less garish */ /* Date input chevron styled to match */
input[type="date"]::-webkit-calendar-picker-indicator { input[type="date"]::-webkit-calendar-picker-indicator {
opacity: 0.6; opacity: 0.5;
cursor: pointer; cursor: pointer;
filter: hue-rotate(70deg);
} }
/* Prevent layout shift when the sticky submit bar appears */ /* Print: lay it out like a real field-journal page when a user hits Cmd+P */
@media (min-width: 640px) { @media print {
main { body { background: white; color: black; }
padding-bottom: 5rem; body { background-image: none; }
details { display: block !important; }
details > div[role="region"] { display: block !important; }
.no-print { display: none !important; }
}
/* Reduced motion: defer to user preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
} }
} }
/* A small cohort of utility classes — rule lines, rough underlines, etc. */
.rule-soft {
background: linear-gradient(
90deg,
transparent 0%,
var(--color-rule) 12%,
var(--color-rule) 88%,
transparent 100%
);
}
/* Ink-bloom focus — used on text inputs to feel handwritten */
.input-ink {
background-image: linear-gradient(
to top,
var(--color-leaf-200) 0%,
var(--color-leaf-200) 1px,
transparent 1px,
transparent 100%
);
background-size: 0% 100%;
background-repeat: no-repeat;
background-position: 0 100%;
transition: background-size 280ms cubic-bezier(0.4, 0, 0.2, 1);
}
.input-ink:focus {
background-size: 100% 100%;
}

View File

@@ -1,22 +1,36 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, Source_Serif_4 } from "next/font/google"; import { Fraunces, DM_Sans } from "next/font/google";
import "./globals.css"; import "./globals.css";
const inter = Inter({ /**
variable: "--font-sans", * Display face: Fraunces. A variable serif with strong optical interest —
* elegant in long descenders and bracketed serifs, reads like a field guide
* or almanac. We use the SOFT axis to round the inktraps slightly so it
* doesn't read as "old print" but as "warm modern editorial."
*/
const display = Fraunces({
variable: "--font-display",
subsets: ["latin"], subsets: ["latin"],
axes: ["SOFT", "opsz"],
display: "swap", display: "swap",
}); });
const serif = Source_Serif_4({ /**
variable: "--font-serif", * Body face: DM Sans. A humanist geometric sans with a slightly soft feel —
* legible at small sizes for forms, doesn't read as cold/corporate the way
* Inter or Helvetica does.
*/
const body = DM_Sans({
variable: "--font-body",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Co-op Check-in · Food Co-op Initiative", title: "Co-op Check-in · Food Co-op Initiative",
description: "Update your co-op's progress through the FCI organizing framework.", description:
"Update your co-op's progress through the FCI organizing framework. A monthly check-in for food co-ops in development.",
robots: { index: false, follow: false }, // Form pages are tokenized; not for crawlers.
}; };
export default function RootLayout({ export default function RootLayout({
@@ -27,9 +41,11 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${inter.variable} ${serif.variable} h-full antialiased`} className={`${display.variable} ${body.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col font-sans">{children}</body> <body className="min-h-full flex flex-col font-body bg-paper text-ink">
{children}
</body>
</html> </html>
); );
} }

View File

@@ -12,24 +12,16 @@ export default async function Page({ searchParams }: PageProps) {
return ( return (
<> <>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
>
Skip to content
</a>
<SiteHeader /> <SiteHeader />
<main id="main" className="flex-1 bg-stone-50"> <main id="main" className="flex-1">
<a <div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
href="#main" <PageIntro />
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:rounded focus:bg-white focus:px-3 focus:py-2 focus:shadow"
>
Skip to content
</a>
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12">
<header className="mb-8">
<h1 className="text-2xl font-semibold text-stone-900 sm:text-3xl">
{formConfig.title}
</h1>
{formConfig.subtitle && (
<p className="mt-2 text-stone-700 leading-relaxed">{formConfig.subtitle}</p>
)}
</header>
{!cid || !cs ? ( {!cid || !cs ? (
<MissingLinkParams /> <MissingLinkParams />
) : ( ) : (
@@ -44,14 +36,30 @@ export default async function Page({ searchParams }: PageProps) {
); );
} }
function PageIntro() {
return (
<header className="mb-10 max-w-2xl">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Monthly check-in · Co-op organizing
</p>
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
{formConfig.title}
</h1>
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
{formConfig.subtitle}
</p>
<div className="mt-6 h-px bg-rule" />
</header>
);
}
function MissingLinkParams() { function MissingLinkParams() {
return ( return (
<div <div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
role="alert" <h2 className="font-display text-xl font-medium text-clay-700">
className="rounded-xl border border-amber-200 bg-amber-50 px-6 py-6 text-amber-900" This link is missing required information.
> </h2>
<h2 className="text-lg font-semibold">This link is missing required information.</h2> <p className="mt-3 leading-relaxed text-ink-soft">
<p className="mt-2 leading-relaxed">
Open the form using the personalized link from your email. If you no longer have it, Open the form using the personalized link from your email. If you no longer have it,
please contact your engagement coordinator and ask for a fresh link. please contact your engagement coordinator and ask for a fresh link.
</p> </p>

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form"; import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form";
import { evaluate } from "@/lib/conditional"; import { evaluate } from "@/lib/conditional";
import { StageSection } from "./StageSection"; import { StageSection } from "./StageSection";
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
interface EngagementFormProps { interface EngagementFormProps {
config: FormConfig; config: FormConfig;
@@ -17,33 +18,38 @@ type LoadState =
| { kind: "error"; message: string } | { kind: "error"; message: string }
| { kind: "ready"; data: FormDataPayload }; | { kind: "ready"; data: FormDataPayload };
/** type SubmitStatus =
* Top-level form orchestrator. Responsible for: | { kind: "idle" }
* - fetching prefill data + the org's current stage from /api/data | { kind: "submitting" }
* - hydrating react-hook-form with prefill values | { kind: "success" }
* - re-evaluating section visibility against live form values on every change | { kind: "error"; message: string };
* - submitting to /api/submit and surfacing success / error feedback
*/ 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) { export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" }); const [load, setLoad] = useState<LoadState>({ kind: "loading" });
const [submitState, setSubmitState] = useState< const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
| { kind: "idle" } const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
| { kind: "submitting" } const [draftRestored, setDraftRestored] = useState(false);
| { kind: "success" }
| { kind: "error"; message: string }
>({ kind: "idle" });
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ mode: "onBlur" }); } = useForm({ mode: "onBlur" });
// Subscribe to all form values so section visibility re-evaluates live.
const formValues = watch(); const formValues = watch();
// ── Initial load ───────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function go() { async function go() {
@@ -54,18 +60,31 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const res = await fetch(url.toString(), { cache: "no-store" }); const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
if (!cancelled) { let message = `Could not load form (HTTP ${res.status}).`;
setLoad({ kind: "error", message: `Could not load form (${res.status}). ${text}` }); 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; return;
} }
const data: FormDataPayload = await res.json(); const data: FormDataPayload = await res.json();
if (cancelled) return; 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, [config.stageField]: data.currentStage,
...data.prefill, ...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); reset(defaults);
setLoad({ kind: "ready", data }); setLoad({ kind: "ready", data });
} catch (e) { } catch (e) {
@@ -83,40 +102,52 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}; };
}, [cid, cs, reset, config.stageField]); }, [cid, cs, reset, config.stageField]);
const sectionsToRender = useMemo(() => { // ── Auto-save draft on idle ────────────────────────────────────────────
return config.sections.map((s) => ({ // Debounce — save 1.5s after the user stops editing.
section: s, const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
sectionVisible: evaluate(s.visibleWhen, formValues), useEffect(() => {
})); if (load.kind !== "ready") return;
}, [config.sections, formValues]); 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 // ── Section ordering ───────────────────────────────────────────────────
// the current stage value. If nothing matches, fall back to 0 (Inquiry). const sectionsToRender = useMemo(
const currentRank = useMemo(() => { () =>
const visibleRanks = sectionsToRender config.sections.map((s) => ({
.filter((s) => s.sectionVisible) section: s,
.map((s) => s.section.rank); sectionVisible: evaluate(s.visibleWhen, formValues),
return visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0; })),
}, [sectionsToRender]); [config.sections, formValues],
);
if (load.kind === "loading") { const currentStageValue = formValues[config.stageField] as string | undefined;
return <LoadingState />; const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
}
if (load.kind === "error") { // ── Render ─────────────────────────────────────────────────────────────
return <ErrorState message={load.message} />; if (load.kind === "loading") return <LoadingState />;
} if (load.kind === "error") return <ErrorState message={load.message} />;
const onSubmit = async (values: Record<string, unknown>) => { const onSubmit = async (values: Record<string, unknown>) => {
setSubmitState({ kind: "submitting" }); setSubmitState({ kind: "submitting" });
try { try {
// Strip values for fields whose section or field rule isn't currently visible — // Strip values for hidden fields — never write data the user couldn't see.
// those aren't part of the user's intent in this submission.
const visibleFieldNames = new Set<string>(); const visibleFieldNames = new Set<string>();
for (const s of config.sections) { for (const s of config.sections) {
if (!evaluate(s.visibleWhen, values)) continue; if (!evaluate(s.visibleWhen, values)) continue;
for (const f of s.fields) { for (const f of s.fields) {
if (evaluate(f.visibleWhen, values)) { if (evaluate(f.visibleWhen, values)) visibleFieldNames.add(f.name);
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) { if (!res.ok) {
const text = await res.text(); 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; return;
} }
// Successful submit — clear draft and show confirmation.
clearDraft(cid);
setDraftSavedAt(null);
setSubmitState({ kind: "success" }); setSubmitState({ kind: "success" });
} catch (e) { } catch (e) {
setSubmitState({ setSubmitState({
@@ -149,38 +188,43 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
<SubmissionContextHeader <SubmissionContextHeader
orgName={load.data.orgName} orgName={load.data.orgName}
currentStage={load.data.currentStage} currentStage={load.data.currentStage}
currentRank={currentRank}
/> />
{sectionsToRender.map(({ section, sectionVisible }) => { {draftRestored && draftSavedAt && (
if (!sectionVisible) return null; <DraftRestoredNotice savedAt={draftSavedAt} onDiscard={() => {
return ( clearDraft(cid);
<StageSection setDraftRestored(false);
key={section.id} setDraftSavedAt(null);
section={section} // Force re-fetch by bumping a key — simplest is full page reload.
register={register} window.location.reload();
errors={errors} }} />
formValues={formValues} )}
isCurrent={section.rank === currentRank}
defaultOpen={section.rank === currentRank || section.rank === 0}
options={load.data.options ?? {}}
/>
);
})}
<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"> <ol className="space-y-5">
<SubmitFeedback state={submitState} /> {sectionsToRender.map(({ section, sectionVisible }) => {
<button if (!sectionVisible) return null;
type="submit" return (
disabled={submitState.kind === "submitting"} <li key={section.id} className="list-none">
className={ <StageSection
"inline-flex items-center justify-center rounded-md px-5 py-2.5 font-medium text-white transition " + section={section}
"bg-leaf-700 hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " + register={register}
"disabled:cursor-not-allowed disabled:bg-stone-400" errors={errors}
} formValues={formValues}
> isCurrent={section.rank === currentRank}
{submitState.kind === "submitting" ? "Saving…" : "Submit check-in"} defaultOpen={section.rank === currentRank || section.rank === 0}
</button> options={load.data.options ?? {}}
</div> />
</li>
);
})}
</ol>
<SubmitBar
state={submitState}
isDirty={isDirty}
draftSavedAt={draftSavedAt}
/>
</form> </form>
); );
} }
@@ -188,62 +232,167 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
function SubmissionContextHeader({ function SubmissionContextHeader({
orgName, orgName,
currentStage, currentStage,
currentRank,
}: { }: {
orgName: string; orgName: string;
currentStage: string; currentStage: string;
currentRank: number;
}) { }) {
return ( return (
<div className="rounded-xl border border-stone-200 bg-white px-5 py-4 shadow-sm"> <header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
<p className="text-sm text-stone-600">Check-in for</p> <p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Check-in for</p>
<h1 className="mt-0.5 text-xl font-semibold text-stone-900">{orgName}</h1> <h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
<p className="mt-2 text-sm text-stone-700"> {orgName}
Framework Stage:{" "} </h1>
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-leaf-800 text-sm font-medium"> <div className="mt-3 flex items-center gap-3">
{currentStage || "—"} <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> </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> </div>
); );
} }
function SubmitFeedback({ function DraftRestoredNotice({
state, savedAt,
onDiscard,
}: { }: {
state: savedAt: string;
| { kind: "idle" } onDiscard: () => void;
| { kind: "submitting" }
| { kind: "success" }
| { kind: "error"; message: string };
}) { }) {
if (state.kind === "idle") { const ago = formatRelative(savedAt);
return <p className="text-sm text-stone-600">Your changes will be saved as a new check-in record.</p>; return (
} <div
if (state.kind === "submitting") { 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 ( return (
<p className="text-sm text-stone-700" role="status"> <p className="text-sm text-ink-soft" role="status">
Saving your check-in Saving your check-in
</p> </p>
); );
} if (state.kind === "success")
if (state.kind === "success") {
return ( return (
<p className="text-sm font-medium text-leaf-800" role="status"> <p className="text-sm font-medium text-leaf-800" role="status">
Check-in saved. Thank you. Check-in saved. Thank you.
</p> </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 ( return (
<p className="text-sm font-medium text-red-700" role="alert"> <svg
{state.message} aria-hidden
</p> 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() { function LoadingState() {
return ( return (
<div className="rounded-xl border border-stone-200 bg-white px-6 py-12 text-center text-stone-600 shadow-sm"> <div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
<div className="mx-auto mb-3 h-6 w-6 animate-spin rounded-full border-2 border-leaf-200 border-t-leaf-700" /> <div
<p>Loading your check-in form</p> 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> </div>
); );
} }
@@ -252,14 +401,31 @@ function ErrorState({ message }: { message: string }) {
return ( return (
<div <div
role="alert" 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> <h2 className="font-display text-xl font-medium text-clay-700">
<p className="mt-2 text-sm leading-relaxed">{message}</p> We couldn&apos;t open your check-in.
<p className="mt-3 text-sm"> </h2>
If this problem persists, please contact your engagement coordinator and ask for a fresh <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. link.
</p> </p>
</div> </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`;
}

View File

@@ -2,22 +2,27 @@ import Link from "next/link";
export function SiteHeader() { export function SiteHeader() {
return ( return (
<header className="border-b border-stone-200 bg-white"> <header className="border-b border-rule bg-paper/80 backdrop-blur-sm">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4 sm:px-6"> <div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-5 sm:px-6">
<Link <Link
href="https://fci.coop" href="https://fci.coop"
className="flex items-center gap-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 rounded" className="group flex items-center gap-3.5 focus:outline-none"
aria-label="Food Co-op Initiative — fci.coop"
> >
<Logo /> <Logo />
<span className="flex flex-col leading-tight"> <div className="flex flex-col leading-none">
<span className="font-semibold text-stone-900">Food Co-op Initiative</span> <span className="font-display text-base font-medium text-ink tracking-tight">
<span className="text-xs text-stone-600">Co-op Check-in</span> Food Co-op Initiative
</span> </span>
<span className="mt-1 text-[11px] uppercase tracking-[0.18em] text-ink-mute">
Co-op Check-in
</span>
</div>
</Link> </Link>
<nav aria-label="Primary"> <nav aria-label="Primary">
<Link <Link
href="https://fci.coop" href="https://fci.coop"
className="text-sm font-medium text-stone-700 hover:text-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 rounded px-2 py-1" className="font-body text-sm text-ink-soft hover:text-leaf-700 transition-colors px-2 py-1"
> >
fci.coop fci.coop
</Link> </Link>
@@ -29,14 +34,19 @@ export function SiteHeader() {
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="mt-auto border-t border-stone-200 bg-stone-50"> <footer className="mt-auto border-t border-rule bg-paper-2/60">
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 text-sm text-stone-600"> <div className="mx-auto max-w-3xl px-4 py-7 sm:px-6">
<p> <div className="flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between">
Securely connected to your co-op&apos;s record. Your data goes only to your CRM <p className="font-display text-sm italic text-ink-soft">
nothing is shared with third parties. &ldquo;A grocery co-op begins with people who decide to feed each other well.&rdquo;
</p> </p>
<p className="mt-1"> <p className="text-xs text-ink-mute">
Questions? Contact your engagement coordinator for a fresh link or to update your records. Need a fresh link? Contact your engagement coordinator.
</p>
</div>
<p className="mt-4 text-xs text-ink-mute leading-relaxed">
Your responses go directly to your co-op&apos;s record. Nothing is shared with third
parties. This page is only reachable through a personalized link issued to you.
</p> </p>
</div> </div>
</footer> </footer>
@@ -44,34 +54,50 @@ export function SiteFooter() {
} }
/** /**
* Inline SVG logo placeholder. Distinctive but neutral until the user supplies * "Sown" — a stylized geometric mark: a center point (the co-op) with six
* the actual fci.coop logo asset to drop into /public. * radiating sprouts (the six framework stages). Inscribed in a soft circle
* (the cooperative principle). Pure SVG, scales cleanly, no external asset.
* *
* Geometry: a stylized "co-op leaf" — three overlapping leaf shapes forming a * Drawn at 40px; can be sized via className.
* loose cooperative motif. Pure CSS color so it adapts to dark/light themes.
*/ */
function Logo() { function Logo({ className = "" }: { className?: string }) {
return ( return (
<svg <svg
width="40" width="42"
height="40" height="42"
viewBox="0 0 40 40" viewBox="0 0 42 42"
role="img" role="img"
aria-label="Food Co-op Initiative" aria-label="Food Co-op Initiative logo"
className="text-leaf-700" className={"text-leaf-700 " + className}
> >
<circle cx="20" cy="20" r="19" fill="currentColor" opacity="0.08" /> {/* Outer cooperative ring */}
<path <circle cx="21" cy="21" r="19" stroke="currentColor" strokeWidth="1.2" fill="none" />
d="M20 6 C 24 12, 30 14, 30 20 C 30 26, 24 30, 20 30 C 16 30, 10 26, 10 20 C 10 14, 16 12, 20 6 Z" <circle cx="21" cy="21" r="19" fill="currentColor" opacity="0.06" />
fill="currentColor"
opacity="0.85" {/* Six sprouts radiating from center, one per stage */}
/> <g stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" fill="none">
<path {/* North */}
d="M20 11 C 22 15, 26 17, 26 20 C 26 23, 22 26, 20 26 C 18 26, 14 23, 14 20 C 14 17, 18 15, 20 11 Z" <path d="M21 21 V8" />
fill="white" <path d="M21 12 q-2.5 -1 -3.5 -3 q3 -.5 3.5 3 z" fill="currentColor" opacity="0.7" />
opacity="0.9" {/* NE */}
/> <path d="M21 21 L30 12" />
<circle cx="20" cy="20" r="2" fill="currentColor" /> <path d="M27.3 14.7 q.7 -2.6 -.5 -4.7 q2.5 1.5 .5 4.7 z" fill="currentColor" opacity="0.6" />
{/* SE */}
<path d="M21 21 L30 30" />
<path d="M27.3 27.3 q2.6 .7 4.7 -.5 q-1.5 2.5 -4.7 .5 z" fill="currentColor" opacity="0.5" />
{/* South */}
<path d="M21 21 V34" />
<path d="M21 30 q2.5 1 3.5 3 q-3 .5 -3.5 -3 z" fill="currentColor" opacity="0.4" />
{/* SW */}
<path d="M21 21 L12 30" />
<path d="M14.7 27.3 q-2.6 .7 -4.7 -.5 q1.5 2.5 4.7 .5 z" fill="currentColor" opacity="0.5" />
{/* NW */}
<path d="M21 21 L12 12" />
<path d="M14.7 14.7 q-.7 -2.6 .5 -4.7 q-2.5 1.5 -.5 4.7 z" fill="currentColor" opacity="0.6" />
</g>
{/* Center seed */}
<circle cx="21" cy="21" r="2.2" fill="currentColor" />
</svg> </svg>
); );
} }

93
components/StageIcon.tsx Normal file
View File

@@ -0,0 +1,93 @@
/**
* Hand-drawn SVG icons for each stage. Single 1.5px stroke, slight
* imperfection, no fills — meant to read as field-journal sketches.
*
* Each icon's metaphor traces the lifecycle of a co-op:
* Stage 0 — Inquiry: a seed, just starting
* Stage 1 — Convene: a sprout, two leaves
* Stage 2 — Feasibility: a measuring scale (weighing options)
* Stage 3 — Connect: linked hands / chain of cooperation
* Stage 4 — Build: a hammer over a foundation
* Stage 5 — Stabilize: a basket of harvested produce / open store
*/
interface StageIconProps {
rank: number;
className?: string;
}
export function StageIcon({ rank, className }: StageIconProps) {
const Icon = ICONS[rank] ?? ICONS[0];
return <Icon className={className} />;
}
const baseProps = {
viewBox: "0 0 32 32",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
const ICONS: Record<number, React.FC<{ className?: string }>> = {
// 0 — Seed in earth
0: ({ className }) => (
<svg {...baseProps} className={className}>
<path d="M16 22c-3-1-4-3-4-5.5 0-2.2 1.8-4.5 4-5.5 2.2 1 4 3.3 4 5.5 0 2.5-1 4.5-4 5.5Z" />
<path d="M16 11.5v6" strokeDasharray="0.5 1.5" />
<path d="M6 24h20" />
<path d="M9 26.5h2M14 27h1.5M19 26.5h2M23 26h1" />
</svg>
),
// 1 — Sprout with two leaves
1: ({ className }) => (
<svg {...baseProps} className={className}>
<path d="M16 26V14" />
<path d="M16 18c-3-2-5-1.5-6-3.5C9 13 10 11 13 11.5c2 .3 3 2.5 3 6.5Z" />
<path d="M16 16c2.5-1.5 4.5-1 5.5-3 .8-1.6-.5-3-3-2.5-1.7.4-2.5 2.5-2.5 5.5Z" />
<path d="M9 26h14" />
</svg>
),
// 2 — Balance scale (weighing feasibility)
2: ({ className }) => (
<svg {...baseProps} className={className}>
<path d="M16 6v18" />
<path d="M11 26h10" />
<path d="M8 11h16" />
<path d="M8 11l-3 6h6l-3-6Z" />
<path d="M24 11l-3 6h6l-3-6Z" />
</svg>
),
// 3 — Linked rings
3: ({ className }) => (
<svg {...baseProps} className={className}>
<circle cx="11" cy="16" r="5" />
<circle cx="21" cy="16" r="5" />
<path d="M14 12.5q1 .5 2 1.5" opacity="0.6" />
<path d="M18 18q1 1.5 2 2" opacity="0.6" />
</svg>
),
// 4 — Hammer over foundation
4: ({ className }) => (
<svg {...baseProps} className={className}>
<path d="M14 6l8 4-2 3-8-4 2-3Z" />
<path d="M13.5 8.5L6 22l3 1.5 7.5-13" />
<path d="M5 26h22" />
<path d="M8 26v-2M14 26v-2M20 26v-2M26 26v-2" opacity="0.5" />
</svg>
),
// 5 — Storefront with awning
5: ({ className }) => (
<svg {...baseProps} className={className}>
<path d="M5 11h22l-2-3H7l-2 3Z" />
<path d="M5 11l2 4M9 11l1 4M13 11l.5 4M17 11l.5 4M21 11l1 4M25 11l2 4" opacity="0.5" />
<path d="M6 26V15" />
<path d="M26 26V15" />
<path d="M6 26h20" />
<rect x="13" y="18" width="6" height="8" />
<path d="M16 22.5v.5" />
</svg>
),
};

View File

@@ -5,6 +5,7 @@ import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer"; import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup"; import { MatrixGroup } from "./MatrixGroup";
import { StageIcon } from "./StageIcon";
import { evaluate } from "@/lib/conditional"; import { evaluate } from "@/lib/conditional";
interface StageSectionProps { interface StageSectionProps {
@@ -13,18 +14,26 @@ interface StageSectionProps {
errors: FieldErrors; errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */ /** Live form values, used to evaluate per-field visibility rules. */
formValues: Record<string, unknown>; formValues: Record<string, unknown>;
/** Whether this is the section that matches the current org stage (for the "current" badge). */ /** Whether this is the section that matches the current org stage. */
isCurrent: boolean; isCurrent: boolean;
/** Whether the section starts open. */ /** Whether the section starts open. */
defaultOpen: boolean; defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */ /** Option groups fetched from CiviCRM, keyed by option_group_id. */
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
/** Optional last-checked-in timestamp for this stage (ISO date). */
lastTouched?: string;
} }
/** /**
* One accordion card per stage section. Renders all fields whose individual * One accordion card per stage section. Header carries:
* visibility rules pass. The section's own visibility is decided by the * - A hand-drawn stage icon (left)
* parent EngagementForm; if rendered, it's visible. * - The stage label (display serif)
* - A "Current stage" pill in terracotta if applicable
* - A field-count summary on the right
* - A chevron that rotates on open
*
* The card border thickens and tints leaf-green when current; otherwise it
* sits quietly on the cream paper.
*/ */
export function StageSection({ export function StageSection({
section, section,
@@ -34,13 +43,12 @@ export function StageSection({
isCurrent, isCurrent,
defaultOpen, defaultOpen,
options, options,
lastTouched,
}: StageSectionProps) { }: StageSectionProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const headingId = useId(); const headingId = useId();
const panelId = useId(); const panelId = useId();
// Names of fields that appear inside any matrix group; excluded from the
// standalone per-field grid so they don't render twice.
const matrixFieldNames = useMemo(() => { const matrixFieldNames = useMemo(() => {
const names = new Set<string>(); const names = new Set<string>();
for (const m of section.matrixGroups ?? []) { for (const m of section.matrixGroups ?? []) {
@@ -53,14 +61,21 @@ export function StageSection({
(f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name), (f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name),
); );
const fieldCount =
visibleFields.length +
(section.matrixGroups ?? []).reduce(
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
0,
);
return ( return (
<section <section
aria-labelledby={headingId} aria-labelledby={headingId}
className={ className={
"overflow-hidden rounded-xl border bg-white shadow-sm transition " + "overflow-hidden rounded-lg bg-white/95 transition " +
(isCurrent (isCurrent
? "border-leaf-300 ring-1 ring-leaf-200" ? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)]"
: "border-stone-200") : "border border-rule shadow-sm")
} }
> >
<h2 id={headingId} className="m-0"> <h2 id={headingId} className="m-0">
@@ -70,20 +85,31 @@ export function StageSection({
aria-expanded={open} aria-expanded={open}
aria-controls={panelId} aria-controls={panelId}
className={ className={
"group flex w-full items-center justify-between gap-4 px-5 py-4 text-left " + "group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
"focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " + (isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
(isCurrent ? "bg-leaf-50" : "bg-stone-50 hover:bg-stone-100")
} }
> >
<span className="flex items-center gap-3"> <StageRankMark rank={section.rank} isCurrent={isCurrent} />
<RankBadge rank={section.rank} isCurrent={isCurrent} /> <span className="flex-1 min-w-0">
<span className="flex flex-col"> <span className="block font-display text-lg font-medium text-ink leading-tight tracking-tight sm:text-xl">
<span className="text-base font-semibold text-stone-900">{section.label}</span> {section.label}
</span>
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
{isCurrent && ( {isCurrent && (
<span className="text-xs font-medium uppercase tracking-wide text-leaf-700"> <span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
Current stage Current stage
</span> </span>
)} )}
<span className="tabular-nums">
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
</span>
{lastTouched && (
<>
<span aria-hidden>·</span>
<span>Last touched {formatRelative(lastTouched)}</span>
</>
)}
</span> </span>
</span> </span>
<Chevron open={open} /> <Chevron open={open} />
@@ -95,23 +121,25 @@ export function StageSection({
role="region" role="region"
aria-labelledby={headingId} aria-labelledby={headingId}
hidden={!open} hidden={!open}
className="border-t border-stone-100" className="border-t border-rule-soft"
> >
<div className="px-5 py-5 sm:px-6 sm:py-6"> <div className="px-5 py-6 sm:px-7 sm:py-7">
{section.intro && ( {section.intro && (
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p> <p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
{section.intro}
</p>
)} )}
{(section.matrixGroups ?? []).length > 0 && ( {(section.matrixGroups ?? []).length > 0 && (
<div className="mb-6 space-y-5"> <div className="mb-7 space-y-5">
{section.matrixGroups!.map((g) => ( {section.matrixGroups!.map((g) => (
<MatrixGroup key={g.id} group={g} register={register} /> <MatrixGroup key={g.id} group={g} register={register} />
))} ))}
</div> </div>
)} )}
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? ( {visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p> <p className="text-sm italic text-ink-mute">No fields are visible at this stage.</p>
) : visibleFields.length === 0 ? null : ( ) : visibleFields.length === 0 ? null : (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2"> <div className="grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => { {visibleFields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined; const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide = const wide =
@@ -138,18 +166,30 @@ export function StageSection({
); );
} }
function RankBadge({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { /**
* Stage rank mark: the number sits in a small circle, with the hand-drawn
* icon offset behind it. When current, the ring is leaf-green and the
* number is white-on-green; otherwise it's quiet.
*/
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return ( return (
<span <span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
aria-hidden <StageIcon
className={ rank={rank}
"inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold " + className={
(isCurrent "absolute inset-0 h-12 w-12 transition-opacity " +
? "bg-leaf-600 text-white" (isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
: "bg-stone-200 text-stone-700") }
} />
> <span
{rank} className={
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
}
style={{ marginLeft: 28, marginTop: 28 }}
>
{rank}
</span>
</span> </span>
); );
} }
@@ -159,14 +199,24 @@ function Chevron({ open }: { open: boolean }) {
<svg <svg
aria-hidden aria-hidden
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="none"
className={"h-5 w-5 text-stone-500 transition-transform " + (open ? "rotate-180" : "")} stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
> >
<path <path d="M5 8 L10 13 L15 8" />
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg> </svg>
); );
} }
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return iso;
const days = Math.round((Date.now() - then) / (1000 * 60 * 60 * 24));
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 30) return `${days} days ago`;
if (days < 365) return `${Math.round(days / 30)} months ago`;
return `${Math.round(days / 365)} years ago`;
}

View File

@@ -41,11 +41,11 @@ export function FieldRenderer({
const effectiveOptions = resolvedOptions ?? field.options ?? []; const effectiveOptions = resolvedOptions ?? field.options ?? [];
const baseInputClass = const baseInputClass =
"w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " + "w-full rounded-md border border-rule bg-paper px-3 py-2 text-ink " +
"placeholder:text-stone-400 shadow-sm transition " + "placeholder:text-ink-mute/70 shadow-sm transition " +
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/40 " + "focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/30 " +
"disabled:bg-stone-50 disabled:text-stone-500 " + "disabled:bg-paper-2 disabled:text-ink-mute " +
"aria-invalid:border-red-500 aria-invalid:ring-red-500/30"; "aria-invalid:border-clay-500 aria-invalid:ring-clay-500/25";
// ── Readonly display field ────────────────────────────────────────────── // ── Readonly display field ──────────────────────────────────────────────
if (field.type === "readonly") { if (field.type === "readonly") {

57
lib/draft.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Local-storage draft persistence.
*
* Drafts are keyed by the form-filler's contact id so a single browser used
* for multiple co-ops (rare but possible) keeps each draft separate.
*
* We persist values + a timestamp. On load, if a draft is younger than
* MAX_DRAFT_AGE_MS, it overrides the server-side prefill. Otherwise it's
* discarded (stale drafts probably represent abandoned sessions, and the
* server-side prefill is fresher anyway).
*/
const MAX_DRAFT_AGE_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
interface Draft {
values: Record<string, unknown>;
savedAt: string;
}
function key(cid: string): string {
return `coop-checkin:draft:${cid}`;
}
export function loadDraft(cid: string): Draft | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(key(cid));
if (!raw) return null;
const parsed = JSON.parse(raw) as Draft;
if (Date.now() - new Date(parsed.savedAt).getTime() > MAX_DRAFT_AGE_MS) {
window.localStorage.removeItem(key(cid));
return null;
}
return parsed;
} catch {
return null;
}
}
export function saveDraft(cid: string, values: Record<string, unknown>): void {
if (typeof window === "undefined") return;
try {
const draft: Draft = { values, savedAt: new Date().toISOString() };
window.localStorage.setItem(key(cid), JSON.stringify(draft));
} catch {
// localStorage might be unavailable (private mode, quota). Silent ok.
}
}
export function clearDraft(cid: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key(cid));
} catch {
// ignore
}
}