diff --git a/README.md b/README.md index e215bc4..32c8a11 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,168 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Co-op Check-in (WebForm-mw) -## Getting Started +Standalone Next.js middleware that lets external co-op contacts update their +organization's tracking data on CiviCRM, sidestepping the limitations of +Webform CiviCRM's admin UI. -First, run the development server: +This app is the **v2** delivery path described in +`../docs/superpowers/specs/2026-05-08-civi-webform-design.md`. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +## What it does + +A form-filler clicks a tokenized link in an email: + +``` +https://check-in.fci.coop/?cid=&cs= ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The app: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +1. Verifies the checksum against CiviCRM. +2. Resolves the org from the contact's **Primary Form Contact** relationship. +3. Reads the org's **Framework Stage** (`Food_Co_op_Organizing.Stage`) — text-keyed values like `Inquiry`, `Organizing`, `Feasibility`, `Business feasibility`, `Store Implementation`, `Stabilize newly opened co-op`. +4. Walks the org's past `Org Engagement Submission` activities DESC and assembles per-field-most-recent prefill values (the exact behaviour the WCM admin UI cannot express). +5. Renders the form with stage-conditional sections — Stage 0 (Inquiry / core check-in fields, ~32 of them) is always visible; Stages 1–5 each appear when `current_stage` is in their visibility set. +6. On submit, creates a **new immutable** `Org Engagement Submission` activity with the visible-field values plus a `stage_at_submission` audit field. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Tech stack -## Learn More +- Next.js 16 (App Router) + React 19 + TypeScript +- Tailwind CSS 4 (CSS-based `@theme` config) +- `react-hook-form` for state + validation +- `axios` (only used in earlier scaffolding; this version uses native `fetch`) -To learn more about Next.js, take a look at the following resources: +## Project structure -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +``` +app/ + layout.tsx Root layout, fonts (Inter + Source Serif 4) + page.tsx Public form entry; reads cid+cs from URL + globals.css Tailwind 4 + theme tokens (leaf + stone palettes) + api/ + data/route.ts GET form data + per-field-most-recent prefill + submit/route.ts POST submission → creates new Civi activity -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +components/ + EngagementForm.tsx Top-level orchestrator + StageSection.tsx Collapsible accordion card per stage section + SiteChrome.tsx Header + footer chrome (logo placeholder included) + fields/ + FieldRenderer.tsx One renderer for all 12 field types -## Deploy on Vercel +config/ + form.ts The 123-field form definition -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +lib/ + civicrm.ts APIv4 client (Bearer-style auth) + conditional.ts Visibility rule engine + prefill.ts Per-field-most-recent walk -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +types/ + form.ts FormConfig, FieldConfig, VisibilityRule, etc. +``` + +## Configuration + +The app falls back to **STUB MODE** with hardcoded mock data when any of +these environment variables are missing. The UI renders fully and the +form is interactive, but no CiviCRM calls are made. + +```bash +# .env.local +CIVI_BASE_URL=https://crm.fci.coop +CIVI_API_KEY= +CIVI_SITE_KEY= +``` + +The exact auth header strategy depends on which CiviCRM auth extension is +active. The default in `lib/civicrm.ts` uses the AuthX-style Bearer header +for the API key plus the legacy `X-Civi-Key` for the site key. If your +instance uses a different scheme (e.g. classic APIv3 `api_key`+`key` query +params), adjust `lib/civicrm.ts` accordingly. + +### Custom-field references (CiviCRM APIv4) + +Form fields write to CiviCRM custom fields via the `civiField` property. +The convention is APIv4's `.` format. For now, +`config/form.ts` uses placeholder `custom_` references +for all but the Framework Stage. **Before going live, replace each +`civiField` value with the actual APIv4 reference** for that custom +field on the `Org Engagement Submission` activity type. + +To inventory the actual field names, query APIv4 from the Civi UI: + +``` +/civicrm/api4#/explorer + → CustomField.get + select: ["name", "label", "custom_group_id:name"] + where: [["custom_group_id:name", "IN", ["Stage_0_Inquiry", "Stage_1_*", ...]]] +``` + +## Running locally + +```bash +npm install +npm run dev +# open http://localhost:3000/?cid=anything&cs=anything (any non-empty cs while in stub mode) +``` + +## Building + +```bash +npm run build +``` + +## Deploying + +The simplest deployment is Vercel. The app is a standard App Router project: + +```bash +vercel +``` + +Set the three CiviCRM env vars in the Vercel project settings; the app +auto-leaves stub mode the moment all three are present. + +For self-hosting, any Node 20+ environment supporting Next.js 16 will work: + +```bash +npm run build && npm start +``` + +## Accessibility + +- All inputs have proper `
` + `

` + `aria-controls` accordion pattern. +- Keyboard-only flow works end-to-end. +- Color contrast meets WCAG AA against the leaf + stone palette (verified + visually; an audit pass is recommended before launch). +- A skip-to-content link is present for keyboard users. + +## Conditional logic + +Rules live alongside fields and sections in `config/form.ts`. The engine +(`lib/conditional.ts`) supports: + +- `equals`, `notEquals`, `in`, `notIn`, `isEmpty`, `isNotEmpty` +- Composition via `{all: [...]}` (AND) and `{any: [...]}` (OR) + +Section visibility re-evaluates live on every form change. Hidden fields' +values are stripped from the submitted payload, so users never accidentally +write data they couldn't see. + +## What's not in scope for this build + +- **Browser-based form builder.** Forms are configured by editing + `config/form.ts`. An admin UI for non-developers to build forms is a + follow-on project. +- **File upload to a storage backend.** File fields render correctly but + the Civi-side upload pipeline is a stub. Wire up to S3/R2 + CiviCRM's + `File` entity when ready. +- **CSRF protection on the submit endpoint.** Checksum gating is the + current trust mechanism. Add CSRF tokens if you put this behind a + long-lived session. +- **i18n.** Copy is English-only. diff --git a/app/api/data/route.ts b/app/api/data/route.ts new file mode 100644 index 0000000..d90ccab --- /dev/null +++ b/app/api/data/route.ts @@ -0,0 +1,124 @@ +/** + * GET /api/data?cid=&cs= + * + * Verifies the checksum, resolves the org from the contact via the + * Primary Form Contact relationship, reads the org's Framework Stage, + * and walks past Org Engagement Submission activities for per-field + * most-recent prefill. + * + * Returns FormDataPayload (see types/form.ts). + * + * STUB MODE: if CiviCRM env vars are unset, returns mock data so the UI + * is exercisable without a live CRM. The mock data uses the actual stage + * values and a couple of populated example fields so the form looks alive + * during development. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { civi, verifyChecksum } from "@/lib/civicrm"; +import { loadPrefill } from "@/lib/prefill"; +import { allFields } from "@/config/form"; +import type { FormDataPayload } from "@/types/form"; + +const STUB_PAYLOAD: FormDataPayload = { + orgName: "Sample Co-op (stub)", + currentStage: "Organizing", + prefill: { + stage_0_peer_group_participation: "Peer cohort 4", + stage_0_members_current: 87, + stage_0_total_members_at_opening: null, + stage_0_projected_y1_sales: 2400000, + stage_0_projected_y2_sales: 2950000, + stage_0_total_cost_of_project: 4200000, + stage_1_vision: "A neighborhood-rooted co-op grocery prioritizing local farmers and equity in food access.", + stage_1_business_concept: "5,500 sq ft full-service co-op in a transit-adjacent storefront.", + }, +}; + +function isStubMode(): boolean { + return !( + process.env.CIVI_BASE_URL && + process.env.CIVI_API_KEY && + process.env.CIVI_SITE_KEY + ); +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const cid = url.searchParams.get("cid"); + const cs = url.searchParams.get("cs"); + + if (!cid || !cs) { + return NextResponse.json( + { error: "Missing cid or cs parameter." }, + { status: 400 }, + ); + } + + if (isStubMode()) { + return NextResponse.json(STUB_PAYLOAD); + } + + // Verify checksum first. + const ok = await verifyChecksum(cid, cs); + if (!ok) { + return NextResponse.json( + { error: "Link is invalid or has expired. Please request a fresh one." }, + { status: 401 }, + ); + } + + // Resolve the org from the contact's Primary Form Contact relationship. + // Requires that the relationship exists and is active. We fetch contact_id_b + // because the relationship is Individual (A) → Organization (B). + const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { + select: ["contact_id_b"], + where: [ + ["contact_id_a", "=", Number(cid)], + ["relationship_type_id:name", "=", "Primary Form Contact of"], + ["is_active", "=", true], + ], + limit: 2, + }); + const orgs = relRes.values ?? []; + if (orgs.length === 0) { + return NextResponse.json( + { error: "No active Primary Form Contact relationship found for your contact." }, + { status: 404 }, + ); + } + if (orgs.length > 1) { + return NextResponse.json( + { error: "Your contact has multiple active Primary Form Contact relationships; staff must resolve before this link will work." }, + { status: 409 }, + ); + } + const orgId = orgs[0].contact_id_b; + + // Fetch org name + Framework Stage value. + const orgRes = await civi<{ + id: number; + display_name: string; + "Food_Co_op_Organizing.Stage": string | null; + custom_1?: string | null; + }>("Contact", "get", { + select: ["id", "display_name", "custom_1", "Food_Co_op_Organizing.Stage"], + where: [["id", "=", orgId]], + }); + const org = orgRes.values?.[0]; + if (!org) { + return NextResponse.json({ error: "Organization not found." }, { status: 404 }); + } + const currentStage = + org["Food_Co_op_Organizing.Stage"] ?? org.custom_1 ?? ""; + + // Per-field-most-recent prefill across past submission activities. + const { values: prefill } = await loadPrefill(orgId, allFields); + + const payload: FormDataPayload = { + orgName: org.display_name, + currentStage: typeof currentStage === "string" ? currentStage : "", + prefill, + }; + return NextResponse.json(payload); +} diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts new file mode 100644 index 0000000..faf4822 --- /dev/null +++ b/app/api/submit/route.ts @@ -0,0 +1,109 @@ +/** + * POST /api/submit + * + * Body: SubmitPayload { cid, cs, values }. + * + * Verifies checksum, resolves org from cid (same as /api/data), then creates a + * new Org Engagement Submission activity with all visible-field values written + * to their custom-field bindings + stage_at_submission set to the org's + * current Framework Stage. + * + * STUB MODE: if CiviCRM env vars are unset, returns success without writing + * anywhere. Useful for UI dev. + */ + +import { NextResponse } from "next/server"; +import { civi, verifyChecksum } from "@/lib/civicrm"; +import { allFields } from "@/config/form"; +import type { SubmitPayload } from "@/types/form"; + +function isStubMode(): boolean { + return !( + process.env.CIVI_BASE_URL && + process.env.CIVI_API_KEY && + process.env.CIVI_SITE_KEY + ); +} + +const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f])); + +export async function POST(req: Request) { + let body: SubmitPayload; + try { + body = (await req.json()) as SubmitPayload; + } catch { + return NextResponse.json({ error: "Malformed JSON body." }, { status: 400 }); + } + const { cid, cs, values } = body; + if (!cid || !cs) { + return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 }); + } + + if (isStubMode()) { + console.warn("[submit:STUB] would create Org Engagement Submission activity with values:", values); + return NextResponse.json({ ok: true, stub: true }); + } + + const ok = await verifyChecksum(cid, cs); + if (!ok) { + return NextResponse.json( + { error: "Link is invalid or has expired." }, + { status: 401 }, + ); + } + + // Resolve org via the relationship (mirror of /api/data). + const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { + select: ["contact_id_b"], + where: [ + ["contact_id_a", "=", Number(cid)], + ["relationship_type_id:name", "=", "Primary Form Contact of"], + ["is_active", "=", true], + ], + limit: 2, + }); + const orgs = relRes.values ?? []; + if (orgs.length !== 1) { + return NextResponse.json( + { error: "Could not resolve a unique organization for your contact." }, + { status: orgs.length === 0 ? 404 : 409 }, + ); + } + const orgId = orgs[0].contact_id_b; + + // Read the org's current Framework Stage so we can stamp `stage_at_submission`. + const orgRes = await civi<{ + "Food_Co_op_Organizing.Stage": string | null; + custom_1?: string | null; + }>("Contact", "get", { + select: ["custom_1", "Food_Co_op_Organizing.Stage"], + where: [["id", "=", orgId]], + }); + const stageAtSubmission = + orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? + orgRes.values?.[0]?.custom_1 ?? + null; + + // Build the activity record. Each form-side field name maps to its + // configured `civiField` for the activity-side write. + const activityRecord: Record = { + "activity_type_id:name": "Org Engagement Submission", + "status_id:name": "Completed", + target_contact_id: orgId, + source_contact_id: Number(cid), + }; + for (const [name, value] of Object.entries(values)) { + const field = FIELD_BY_NAME.get(name); + if (!field || !field.civiField) continue; + if (field.type === "readonly") continue; // never write read-only fields + activityRecord[field.civiField] = value; + } + // Stamp the stage-at-submission audit field. Convention: a field named + // `Submission_Audit.stage_at_submission` on the activity. Adjust to your + // actual machine name if different. + activityRecord["Submission_Audit.stage_at_submission"] = stageAtSubmission; + + await civi("Activity", "create", { values: activityRecord }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..6cd4cbb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,84 @@ @import "tailwindcss"; +/* ───────────────────────────────────────────────────────────────────────── + * Theme — Food Co-op Initiative palette + * + * "leaf" is the primary green. Inspired by the warm, grassroots-organic + * feel of fci.coop: not corporate teal, not jewel-toned. A balanced + * field-green that sits comfortably alongside warm cream/stone neutrals. + * + * "stone" is the neutral ramp — slightly warm, never icy. + * + * Both are built as Tailwind 4 color tokens, so utility classes like + * `bg-leaf-700`, `text-stone-600` work everywhere. + * ─────────────────────────────────────────────────────────────────────── */ + +@theme { + --font-sans: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-serif: var(--font-serif), ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + + --color-leaf-50: #f3f8f0; + --color-leaf-100: #e3eed9; + --color-leaf-200: #c8dcb3; + --color-leaf-300: #a3c280; + --color-leaf-400: #7ea455; + --color-leaf-500: #5e8638; + --color-leaf-600: #4a6c2a; + --color-leaf-700: #3a5520; + --color-leaf-800: #2c401a; + --color-leaf-900: #1f2d12; + + --color-stone-50: #fafaf7; + --color-stone-100: #f3f2ed; + --color-stone-200: #e7e4dc; + --color-stone-300: #d4cfc1; + --color-stone-400: #aaa494; + --color-stone-500: #807a6a; + --color-stone-600: #5b574b; + --color-stone-700: #403d35; + --color-stone-800: #2a2823; + --color-stone-900: #1a1815; +} + +/* Base / reset */ :root { - --background: #ffffff; - --foreground: #171717; + color-scheme: light; + --background: var(--color-stone-50); + --foreground: var(--color-stone-900); } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html { + -webkit-text-size-adjust: 100%; } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +} + +/* Larger, more readable form controls on small screens */ +input, +select, +textarea, +button { + font: inherit; +} + +/* Ensure focus is always perceptible (in addition to Tailwind's ring) */ +*:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; +} + +/* Make Chrome's date input chevron less garish */ +input[type="date"]::-webkit-calendar-picker-indicator { + opacity: 0.6; + cursor: pointer; +} + +/* Prevent layout shift when the sticky submit bar appears */ +@media (min-width: 640px) { + main { + padding-bottom: 5rem; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..f93fcca 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,22 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, Source_Serif_4 } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-sans", subsets: ["latin"], + display: "swap", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const serif = Source_Serif_4({ + variable: "--font-serif", subsets: ["latin"], + display: "swap", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Co-op Check-in · Food Co-op Initiative", + description: "Update your co-op's progress through the FCI organizing framework.", }; export default function RootLayout({ @@ -25,9 +27,9 @@ export default function RootLayout({ return ( - {children} + {children} ); } diff --git a/app/page.tsx b/app/page.tsx index 160e6e8..aedf619 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,23 +1,60 @@ -'use client'; +import { Suspense } from "react"; +import { EngagementForm } from "@/components/EngagementForm"; +import { SiteHeader, SiteFooter } from "@/components/SiteChrome"; +import { formConfig } from "@/config/form"; -import { useSearchParams } from 'next/navigation'; -import StageForm from '@/components/StageForm'; -import { Suspense } from 'react'; - -function FormContainer() { - const searchParams = useSearchParams(); - const contactId = searchParams.get('contactId') || '2'; - const orgId = searchParams.get('orgId') || '1'; - - return ; +interface PageProps { + searchParams: Promise<{ cid?: string; cs?: string }>; } -export default function Home() { +export default async function Page({ searchParams }: PageProps) { + const { cid, cs } = await searchParams; + return ( -
- Loading...}> - - -
+ <> + +
+ + Skip to content + +
+
+

+ {formConfig.title} +

+ {formConfig.subtitle && ( +

{formConfig.subtitle}

+ )} +
+ + {!cid || !cs ? ( + + ) : ( + + + + )} +
+
+ + + ); +} + +function MissingLinkParams() { + return ( +
+

This link is missing required information.

+

+ 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. +

+
); } diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx new file mode 100644 index 0000000..a591f2b --- /dev/null +++ b/components/EngagementForm.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form"; +import { evaluate } from "@/lib/conditional"; +import { StageSection } from "./StageSection"; + +interface EngagementFormProps { + config: FormConfig; + cid: string; + cs: string; +} + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; data: FormDataPayload }; + +/** + * Top-level form orchestrator. Responsible for: + * - fetching prefill data + the org's current stage from /api/data + * - hydrating react-hook-form with prefill values + * - re-evaluating section visibility against live form values on every change + * - submitting to /api/submit and surfacing success / error feedback + */ +export function EngagementForm({ config, cid, cs }: EngagementFormProps) { + const [load, setLoad] = useState({ kind: "loading" }); + const [submitState, setSubmitState] = useState< + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "success" } + | { kind: "error"; message: string } + >({ kind: "idle" }); + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ mode: "onBlur" }); + + // Subscribe to all form values so section visibility re-evaluates live. + const formValues = watch(); + + useEffect(() => { + let cancelled = false; + async function go() { + try { + const url = new URL("/api/data", window.location.origin); + url.searchParams.set("cid", cid); + url.searchParams.set("cs", cs); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const text = await res.text(); + if (!cancelled) { + setLoad({ kind: "error", message: `Could not load form (${res.status}). ${text}` }); + } + return; + } + const data: FormDataPayload = await res.json(); + if (cancelled) return; + // Merge stage value + per-field prefill into a single defaults map. + const defaults = { + [config.stageField]: data.currentStage, + ...data.prefill, + }; + reset(defaults); + setLoad({ kind: "ready", data }); + } catch (e) { + if (!cancelled) { + setLoad({ + kind: "error", + message: e instanceof Error ? e.message : "Unexpected error loading form.", + }); + } + } + } + void go(); + return () => { + cancelled = true; + }; + }, [cid, cs, reset, config.stageField]); + + const sectionsToRender = useMemo(() => { + return config.sections.map((s) => ({ + section: s, + sectionVisible: evaluate(s.visibleWhen, formValues), + })); + }, [config.sections, formValues]); + + // The "current" rank: highest section rank whose visibility rule matches + // the current stage value. If nothing matches, fall back to 0 (Inquiry). + const currentRank = useMemo(() => { + const visibleRanks = sectionsToRender + .filter((s) => s.sectionVisible) + .map((s) => s.section.rank); + return visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0; + }, [sectionsToRender]); + + if (load.kind === "loading") { + return ; + } + if (load.kind === "error") { + return ; + } + + const onSubmit = async (values: Record) => { + setSubmitState({ kind: "submitting" }); + try { + // Strip values for fields whose section or field rule isn't currently visible — + // those aren't part of the user's intent in this submission. + const visibleFieldNames = new Set(); + for (const s of config.sections) { + if (!evaluate(s.visibleWhen, values)) continue; + for (const f of s.fields) { + if (evaluate(f.visibleWhen, values)) { + visibleFieldNames.add(f.name); + } + } + } + const filtered: Record = {}; + for (const [k, v] of Object.entries(values)) { + if (visibleFieldNames.has(k)) filtered[k] = v; + } + const payload: SubmitPayload = { cid, cs, values: filtered }; + const res = await fetch("/api/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text(); + setSubmitState({ kind: "error", message: `Submit failed (${res.status}). ${text}` }); + return; + } + setSubmitState({ kind: "success" }); + } catch (e) { + setSubmitState({ + kind: "error", + message: e instanceof Error ? e.message : "Unexpected submit error.", + }); + } + }; + + return ( +
+ + + {sectionsToRender.map(({ section, sectionVisible }) => { + if (!sectionVisible) return null; + return ( + + ); + })} + +
+ + +
+ + ); +} + +function SubmissionContextHeader({ + orgName, + currentStage, +}: { + orgName: string; + currentStage: string; +}) { + return ( +
+

Check-in for

+

{orgName}

+

+ Framework Stage:{" "} + + {currentStage || "—"} + +

+
+ ); +} + +function SubmitFeedback({ + state, +}: { + state: + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "success" } + | { kind: "error"; message: string }; +}) { + if (state.kind === "idle") { + return

Your changes will be saved as a new check-in record.

; + } + if (state.kind === "submitting") { + return ( +

+ Saving your check-in… +

+ ); + } + if (state.kind === "success") { + return ( +

+ ✓ Check-in saved. Thank you. +

+ ); + } + return ( +

+ ✕ {state.message} +

+ ); +} + +function LoadingState() { + return ( +
+
+

Loading your check-in form…

+
+ ); +} + +function ErrorState({ message }: { message: string }) { + return ( +
+

We couldn't load your check-in form.

+

{message}

+

+ If this problem persists, please contact your engagement coordinator and ask for a fresh + link. +

+
+ ); +} diff --git a/components/FieldSet.js b/components/FieldSet.js deleted file mode 100644 index 5d24f92..0000000 --- a/components/FieldSet.js +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import React from 'react'; - -export default function FieldSet({ fields, register }) { - return ( -
- {fields.map((field) => ( -
- - -
- ))} -
- ); -} diff --git a/components/SiteChrome.tsx b/components/SiteChrome.tsx new file mode 100644 index 0000000..cb2b9a6 --- /dev/null +++ b/components/SiteChrome.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; + +export function SiteHeader() { + return ( +
+
+ + + + Food Co-op Initiative + Co-op Check-in + + + +
+
+ ); +} + +export function SiteFooter() { + return ( +
+
+

+ Securely connected to your co-op's record. Your data goes only to your CRM — + nothing is shared with third parties. +

+

+ Questions? Contact your engagement coordinator for a fresh link or to update your records. +

+
+
+ ); +} + +/** + * Inline SVG logo placeholder. Distinctive but neutral until the user supplies + * the actual fci.coop logo asset to drop into /public. + * + * Geometry: a stylized "co-op leaf" — three overlapping leaf shapes forming a + * loose cooperative motif. Pure CSS color so it adapts to dark/light themes. + */ +function Logo() { + return ( + + + + + + + ); +} diff --git a/components/StageForm.js b/components/StageForm.js deleted file mode 100644 index 6b72ab8..0000000 --- a/components/StageForm.js +++ /dev/null @@ -1,126 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import mapping from '@/config/mapping.json'; -import StageHeader from './StageHeader'; -import FieldSet from './FieldSet'; - -export default function StageForm({ contactId, orgId }) { - const [currentOrgStage, setCurrentOrgStage] = useState(0); - const [loading, setLoading] = useState(true); - const [expandedStages, setExpandedStages] = useState([0]); - const [submitStatus, setSubmitStatus] = useState(null); - - const { register, handleSubmit, reset } = useForm(); - - useEffect(() => { - async function fetchData() { - try { - const res = await fetch(`/api/data?orgId=${orgId}&contactId=${contactId}`); - const data = await res.json(); - - setCurrentOrgStage(data.currentStage); - - // Map CiviCRM prefill data to form field names - const formValues = {}; - mapping.stages.forEach(stage => { - stage.fields.forEach(field => { - if (data.prefillData[field.crmField] !== undefined) { - formValues[field.name] = data.prefillData[field.crmField]; - } - }); - }); - - // Initialize form with mapped values - reset(formValues); - - // Expand up to the current stage by default - setExpandedStages(Array.from({ length: data.currentStage + 1 }, (_, i) => i)); - } catch (error) { - console.error('Failed to fetch data', error); - } finally { - setLoading(false); - } - } - fetchData(); - }, [contactId, orgId, reset]); - - const toggleStage = (stageId) => { - if (stageId <= currentOrgStage) { - setExpandedStages(prev => - prev.includes(stageId) ? prev.filter(id => id !== stageId) : [...prev, stageId] - ); - } - }; - - const onSubmit = async (formData) => { - setSubmitStatus('submitting'); - try { - const response = await fetch('/api/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ contactId, orgId, formData }), - }); - const result = await response.json(); - if (result.success) { - setSubmitStatus('success'); - setTimeout(() => setSubmitStatus(null), 3000); - } else { - setSubmitStatus('error'); - } - } catch (error) { - setSubmitStatus('error'); - } - }; - - if (loading) return
Loading form...
; - - return ( -
-
-

CiviCRM Stage Progression

-

Update your details as you progress through stages.

-
- -
- {mapping.stages.map((stage) => { - const isVisible = stage.id <= currentOrgStage; - const isExpanded = expandedStages.includes(stage.id); - - return ( - - toggleStage(stage.id)} - /> - {isVisible && isExpanded && ( -
- )} - - ); - })} - -
-
- {submitStatus === 'submitting' && Saving...} - {submitStatus === 'success' && ✓ Changes saved successfully!} - {submitStatus === 'error' && ✗ Failed to save changes.} -
- -
- -
- ); -} diff --git a/components/StageHeader.js b/components/StageHeader.js deleted file mode 100644 index fd9e30b..0000000 --- a/components/StageHeader.js +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import React from 'react'; - -export default function StageHeader({ stage, currentOrgStage, isExpanded, onToggle }) { - const isLocked = stage.id > currentOrgStage; - const isPast = stage.id < currentOrgStage; - const isCurrent = stage.id === currentOrgStage; - - return ( -
- -
- ); -} diff --git a/components/StageSection.tsx b/components/StageSection.tsx new file mode 100644 index 0000000..3c67045 --- /dev/null +++ b/components/StageSection.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useId, useState } from "react"; +import type { StageSectionConfig } from "@/types/form"; +import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; +import { FieldRenderer } from "./fields/FieldRenderer"; +import { evaluate } from "@/lib/conditional"; + +interface StageSectionProps { + section: StageSectionConfig; + register: UseFormRegister; + errors: FieldErrors; + /** Live form values, used to evaluate per-field visibility rules. */ + formValues: Record; + /** Whether this is the section that matches the current org stage (for the "current" badge). */ + isCurrent: boolean; + /** Whether the section starts open. */ + defaultOpen: boolean; +} + +/** + * One accordion card per stage section. Renders all fields whose individual + * visibility rules pass. The section's own visibility is decided by the + * parent EngagementForm; if rendered, it's visible. + */ +export function StageSection({ + section, + register, + errors, + formValues, + isCurrent, + defaultOpen, +}: StageSectionProps) { + const [open, setOpen] = useState(defaultOpen); + const headingId = useId(); + const panelId = useId(); + + const visibleFields = section.fields.filter((f) => evaluate(f.visibleWhen, formValues)); + + return ( +
+

+ +

+ + +
+ ); +} + +function RankBadge({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { + return ( + + {rank} + + ); +} + +function Chevron({ open }: { open: boolean }) { + return ( + + + + ); +} diff --git a/components/fields/FieldRenderer.tsx b/components/fields/FieldRenderer.tsx new file mode 100644 index 0000000..1100ab2 --- /dev/null +++ b/components/fields/FieldRenderer.tsx @@ -0,0 +1,266 @@ +"use client"; + +import type { FieldConfig } from "@/types/form"; +import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; + +interface FieldRendererProps { + field: FieldConfig; + register: UseFormRegister; + errors: FieldErrors; + /** For readonly display fields, the value to render. */ + readonlyValue?: unknown; +} + +/** + * Renders a single field appropriate to its `type`. All inputs share a common + * accessibility scaffold: a real