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:
184
app/globals.css
184
app/globals.css
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/page.tsx
54
app/page.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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'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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'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.
|
“A grocery co-op begins with people who decide to feed each other well.”
|
||||||
</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'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
93
components/StageIcon.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
57
lib/draft.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user