Build standalone CiviCRM check-in middleware
This commit is contained in:
176
README.md
176
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=<contactId>&cs=<checksum>
|
||||
```
|
||||
|
||||
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=<a Civi user's API key>
|
||||
CIVI_SITE_KEY=<the site_key from civicrm.settings.php>
|
||||
```
|
||||
|
||||
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 `<group_name>.<field_name>` format. For now,
|
||||
`config/form.ts` uses placeholder `custom_<form_field_name>` 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 `<label htmlFor>` associations.
|
||||
- Help text and error messages are linked via `aria-describedby`.
|
||||
- Errors set `aria-invalid` and use `role="alert"` for assistive tech.
|
||||
- Required fields use `aria-required` plus a visible `*` (with
|
||||
`aria-label="required"`).
|
||||
- Sections use semantic `<section>` + `<h2>` + `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.
|
||||
|
||||
124
app/api/data/route.ts
Normal file
124
app/api/data/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* GET /api/data?cid=<cid>&cs=<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);
|
||||
}
|
||||
109
app/api/submit/route.ts
Normal file
109
app/api/submit/route.ts
Normal file
@@ -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<string, unknown> = {
|
||||
"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 });
|
||||
}
|
||||
@@ -1,26 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* Base / reset */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: var(--color-stone-50);
|
||||
--foreground: var(--color-stone-900);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${inter.variable} ${serif.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col font-sans">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
67
app/page.tsx
67
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 <StageForm contactId={contactId} orgId={orgId} />;
|
||||
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 (
|
||||
<main className="min-h-screen bg-gray-100 py-12">
|
||||
<Suspense fallback={<div className="text-center">Loading...</div>}>
|
||||
<FormContainer />
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="main" className="flex-1 bg-stone-50">
|
||||
<a
|
||||
href="#main"
|
||||
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 ? (
|
||||
<MissingLinkParams />
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<EngagementForm config={formConfig} cid={cid} cs={cs} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingLinkParams() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-amber-200 bg-amber-50 px-6 py-6 text-amber-900"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">This link is missing required information.</h2>
|
||||
<p className="mt-2 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
264
components/EngagementForm.tsx
Normal file
264
components/EngagementForm.tsx
Normal file
@@ -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<LoadState>({ 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 <LoadingState />;
|
||||
}
|
||||
if (load.kind === "error") {
|
||||
return <ErrorState message={load.message} />;
|
||||
}
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
setSubmitState({ kind: "submitting" });
|
||||
try {
|
||||
// Strip values for fields whose section or field rule isn't currently visible —
|
||||
// those aren't part of the user's intent in this submission.
|
||||
const visibleFieldNames = new Set<string>();
|
||||
for (const s of config.sections) {
|
||||
if (!evaluate(s.visibleWhen, values)) continue;
|
||||
for (const f of s.fields) {
|
||||
if (evaluate(f.visibleWhen, values)) {
|
||||
visibleFieldNames.add(f.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (visibleFieldNames.has(k)) filtered[k] = v;
|
||||
}
|
||||
const payload: SubmitPayload = { cid, cs, values: filtered };
|
||||
const res = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
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 (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
|
||||
<SubmissionContextHeader
|
||||
orgName={load.data.orgName}
|
||||
currentStage={load.data.currentStage}
|
||||
/>
|
||||
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
return (
|
||||
<StageSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
register={register}
|
||||
errors={errors}
|
||||
formValues={formValues}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="sticky bottom-4 z-10 flex flex-col-reverse gap-3 rounded-xl border border-stone-200 bg-white/95 px-5 py-4 shadow-lg backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<SubmitFeedback state={submitState} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState.kind === "submitting"}
|
||||
className={
|
||||
"inline-flex items-center justify-center rounded-md px-5 py-2.5 font-medium text-white transition " +
|
||||
"bg-leaf-700 hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
||||
"disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
}
|
||||
>
|
||||
{submitState.kind === "submitting" ? "Saving…" : "Submit check-in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionContextHeader({
|
||||
orgName,
|
||||
currentStage,
|
||||
}: {
|
||||
orgName: string;
|
||||
currentStage: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white px-5 py-4 shadow-sm">
|
||||
<p className="text-sm text-stone-600">Check-in for</p>
|
||||
<h1 className="mt-0.5 text-xl font-semibold text-stone-900">{orgName}</h1>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
Framework Stage:{" "}
|
||||
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-leaf-800 text-sm font-medium">
|
||||
{currentStage || "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitFeedback({
|
||||
state,
|
||||
}: {
|
||||
state:
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
}) {
|
||||
if (state.kind === "idle") {
|
||||
return <p className="text-sm text-stone-600">Your changes will be saved as a new check-in record.</p>;
|
||||
}
|
||||
if (state.kind === "submitting") {
|
||||
return (
|
||||
<p className="text-sm text-stone-700" role="status">
|
||||
Saving your check-in…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.kind === "success") {
|
||||
return (
|
||||
<p className="text-sm font-medium text-leaf-800" role="status">
|
||||
✓ Check-in saved. Thank you.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm font-medium text-red-700" role="alert">
|
||||
✕ {state.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white px-6 py-12 text-center text-stone-600 shadow-sm">
|
||||
<div className="mx-auto mb-3 h-6 w-6 animate-spin rounded-full border-2 border-leaf-200 border-t-leaf-700" />
|
||||
<p>Loading your check-in form…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-red-200 bg-red-50 px-6 py-6 text-red-900"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">We couldn't load your check-in form.</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed">{message}</p>
|
||||
<p className="mt-3 text-sm">
|
||||
If this problem persists, please contact your engagement coordinator and ask for a fresh
|
||||
link.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function FieldSet({ fields, register }) {
|
||||
return (
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4 bg-white border-x border-b rounded-b-lg -mt-4 mb-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="flex flex-col">
|
||||
<label htmlFor={field.name} className="text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
{...register(field.name)}
|
||||
className="border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition"
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/SiteChrome.tsx
Normal file
77
components/SiteChrome.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link
|
||||
href="https://fci.coop"
|
||||
className="flex items-center gap-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 rounded"
|
||||
>
|
||||
<Logo />
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-stone-900">Food Co-op Initiative</span>
|
||||
<span className="text-xs text-stone-600">Co-op Check-in</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav aria-label="Primary">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
fci.coop ↗
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-stone-200 bg-stone-50">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 text-sm text-stone-600">
|
||||
<p>
|
||||
Securely connected to your co-op's record. Your data goes only to your CRM —
|
||||
nothing is shared with third parties.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Questions? Contact your engagement coordinator for a fresh link or to update your records.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
role="img"
|
||||
aria-label="Food Co-op Initiative"
|
||||
className="text-leaf-700"
|
||||
>
|
||||
<circle cx="20" cy="20" r="19" fill="currentColor" opacity="0.08" />
|
||||
<path
|
||||
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"
|
||||
fill="currentColor"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<circle cx="20" cy="20" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="p-8 text-center">Loading form...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 bg-white shadow-lg rounded-xl my-8">
|
||||
<header className="mb-8 border-b pb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800">CiviCRM Stage Progression</h1>
|
||||
<p className="text-gray-600">Update your details as you progress through stages.</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{mapping.stages.map((stage) => {
|
||||
const isVisible = stage.id <= currentOrgStage;
|
||||
const isExpanded = expandedStages.includes(stage.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={stage.id}>
|
||||
<StageHeader
|
||||
stage={stage}
|
||||
currentOrgStage={currentOrgStage}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleStage(stage.id)}
|
||||
/>
|
||||
{isVisible && isExpanded && (
|
||||
<FieldSet
|
||||
fields={stage.fields}
|
||||
register={register}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-6 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
{submitStatus === 'submitting' && <span className="text-blue-600">Saving...</span>}
|
||||
{submitStatus === 'success' && <span className="text-green-600">✓ Changes saved successfully!</span>}
|
||||
{submitStatus === 'error' && <span className="text-red-600">✗ Failed to save changes.</span>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitStatus === 'submitting'}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition disabled:bg-blue-300"
|
||||
>
|
||||
Submit Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`border rounded-lg mb-4 overflow-hidden ${isLocked ? 'opacity-50' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between p-4 text-left font-semibold ${
|
||||
isCurrent ? 'bg-blue-50 text-blue-700' : 'bg-gray-50'
|
||||
} ${isLocked ? 'cursor-not-allowed' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`w-8 h-8 flex items-center justify-center rounded-full text-sm ${
|
||||
isPast ? 'bg-green-100 text-green-700' :
|
||||
isCurrent ? 'bg-blue-600 text-white' :
|
||||
'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isPast ? '✓' : stage.id}
|
||||
</span>
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<span className="text-gray-400">
|
||||
{isExpanded ? '−' : '+'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
components/StageSection.tsx
Normal file
144
components/StageSection.tsx
Normal file
@@ -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<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** Live form values, used to evaluate per-field visibility rules. */
|
||||
formValues: Record<string, unknown>;
|
||||
/** 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 (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className={
|
||||
"overflow-hidden rounded-xl border bg-white shadow-sm transition " +
|
||||
(isCurrent
|
||||
? "border-leaf-300 ring-1 ring-leaf-200"
|
||||
: "border-stone-200")
|
||||
}
|
||||
>
|
||||
<h2 id={headingId} className="m-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className={
|
||||
"group flex w-full items-center justify-between gap-4 px-5 py-4 text-left " +
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
||||
(isCurrent ? "bg-leaf-50" : "bg-stone-50 hover:bg-stone-100")
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<RankBadge rank={section.rank} isCurrent={isCurrent} />
|
||||
<span className="flex flex-col">
|
||||
<span className="text-base font-semibold text-stone-900">{section.label}</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-leaf-700">
|
||||
Current stage
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
hidden={!open}
|
||||
className="border-t border-stone-100"
|
||||
>
|
||||
<div className="px-5 py-5 sm:px-6 sm:py-6">
|
||||
{section.intro && (
|
||||
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
|
||||
{visibleFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={f.type === "textarea" || f.type === "boolean" ? "md:col-span-2" : ""}
|
||||
>
|
||||
<FieldRenderer
|
||||
field={f}
|
||||
register={register}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RankBadge({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold " +
|
||||
(isCurrent
|
||||
? "bg-leaf-600 text-white"
|
||||
: "bg-stone-200 text-stone-700")
|
||||
}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={"h-5 w-5 text-stone-500 transition-transform " + (open ? "rotate-180" : "")}
|
||||
>
|
||||
<path
|
||||
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>
|
||||
);
|
||||
}
|
||||
266
components/fields/FieldRenderer.tsx
Normal file
266
components/fields/FieldRenderer.tsx
Normal file
@@ -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<FieldValues>;
|
||||
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 <label htmlFor>, aria-describedby pointing
|
||||
* to help/error text, aria-invalid set when in error, and visible focus.
|
||||
*
|
||||
* Currency, percent, and number all use input type="number" with appropriate
|
||||
* `step` and inputMode for mobile keyboards. We keep formatting light — the
|
||||
* server is the source of truth for normalization.
|
||||
*/
|
||||
export function FieldRenderer({ field, register, errors, readonlyValue }: FieldRendererProps) {
|
||||
const id = `field-${field.name}`;
|
||||
const helpId = field.help ? `${id}-help` : undefined;
|
||||
const errorId = errors[field.name] ? `${id}-error` : undefined;
|
||||
const describedBy = [helpId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
const errorMsg = errors[field.name]?.message as string | undefined;
|
||||
|
||||
const baseInputClass =
|
||||
"w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " +
|
||||
"placeholder:text-stone-400 shadow-sm transition " +
|
||||
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/40 " +
|
||||
"disabled:bg-stone-50 disabled:text-stone-500 " +
|
||||
"aria-invalid:border-red-500 aria-invalid:ring-red-500/30";
|
||||
|
||||
// ── Readonly display field ──────────────────────────────────────────────
|
||||
if (field.type === "readonly") {
|
||||
const display = readonlyValue == null || readonlyValue === "" ? "—" : String(readonlyValue);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<div
|
||||
id={id}
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
className="rounded-md border border-stone-200 bg-stone-50 px-3 py-2 text-stone-700"
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Boolean (checkbox) ──────────────────────────────────────────────────
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
{...register(field.name)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-400 text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={id} className="font-medium text-stone-800 cursor-pointer">
|
||||
{field.label}
|
||||
{field.required && <RequiredMark />}
|
||||
</label>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Textarea ────────────────────────────────────────────────────────────
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<textarea
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
rows={4}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass + " min-h-[7rem] leading-6"}
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select ──────────────────────────────────────────────────────────────
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<select
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass + " bg-white"}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select…
|
||||
</option>
|
||||
{(field.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File ────────────────────────────────────────────────────────────────
|
||||
if (field.type === "file") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className="block w-full text-sm text-stone-700 file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer"
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Numeric family: number / currency / percent ─────────────────────────
|
||||
if (field.type === "number" || field.type === "currency" || field.type === "percent") {
|
||||
const prefix = field.type === "currency" ? "$" : null;
|
||||
const suffix = field.type === "percent" ? "%" : null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<div className="relative">
|
||||
{prefix && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-stone-500"
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step ?? (field.type === "number" ? 1 : 0.01)}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
{...register(field.name, {
|
||||
required: field.required,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
className={
|
||||
baseInputClass +
|
||||
(prefix ? " pl-7" : "") +
|
||||
(suffix ? " pr-8" : "")
|
||||
}
|
||||
/>
|
||||
{suffix && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-stone-500"
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Default: text-like (text / email / phone / date) ────────────────────
|
||||
const inputType =
|
||||
field.type === "email" ? "email" :
|
||||
field.type === "phone" ? "tel" :
|
||||
field.type === "date" ? "date" :
|
||||
"text";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<input
|
||||
id={id}
|
||||
type={inputType}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
autoComplete={
|
||||
field.type === "email" ? "email" :
|
||||
field.type === "phone" ? "tel" :
|
||||
undefined
|
||||
}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass}
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||
return (
|
||||
<label htmlFor={id} className="block text-sm font-medium text-stone-800">
|
||||
{field.label}
|
||||
{field.required && <RequiredMark />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return (
|
||||
<span aria-label="required" className="ml-1 text-red-600">
|
||||
*
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Help({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} className="text-xs text-stone-600">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} role="alert" className="text-xs font-medium text-red-700">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
220
config/form.ts
Normal file
220
config/form.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Form definition for the Org Engagement Check-in form.
|
||||
*
|
||||
* Fields and stages are sourced from the spec at
|
||||
* docs/superpowers/specs/2026-05-08-civi-webform-design.md and the
|
||||
* concrete field list provided 2026-05-09.
|
||||
*
|
||||
* The `civiField` property on each field is currently a placeholder
|
||||
* (`custom_<machine_name>`). Replace these with the actual CiviCRM custom
|
||||
* field references (`custom_<id>` or `<group_name>.<field_name>`) once the
|
||||
* field IDs are known. See README.md.
|
||||
*/
|
||||
|
||||
import type { FieldConfig, FormConfig, StageSectionConfig, VisibilityRule } from "@/types/form";
|
||||
|
||||
// Active stage values (stored values in CiviCRM's `Stage` option group).
|
||||
const S = {
|
||||
Inquiry: "Inquiry",
|
||||
Organizing: "Organizing",
|
||||
Feasibility: "Feasibility",
|
||||
BusinessFeasibility: "Business feasibility",
|
||||
StoreImplementation: "Store Implementation",
|
||||
Stabilize: "Stabilize newly opened co-op",
|
||||
} as const;
|
||||
|
||||
const visibleAtOrAfter = (...stages: string[]): VisibilityRule => ({
|
||||
field: "current_stage",
|
||||
op: "in",
|
||||
values: stages,
|
||||
});
|
||||
|
||||
const stage0: StageSectionConfig = {
|
||||
rank: 0,
|
||||
id: "stage_0",
|
||||
label: "Check-in (organizing)",
|
||||
intro:
|
||||
"Core check-in fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.",
|
||||
fields: [
|
||||
{ name: "current_stage", label: "Framework Stage", type: "readonly", civiField: "Food_Co_op_Organizing.Stage" },
|
||||
{ name: "stage_0_peer_group_participation", label: "Peer Group Participation", type: "text" },
|
||||
{ name: "stage_0_internal_startup_assessment", label: "Internal Startup Assessment", type: "textarea" },
|
||||
{ name: "stage_0_internal_startup_assessment_date", label: "Internal Startup Assessment Date", type: "date" },
|
||||
{ name: "stage_0_member_goal", label: "Member Goal for current Stage", type: "number" },
|
||||
{ name: "stage_0_members_current", label: "Members (current)", type: "number" },
|
||||
{ name: "stage_0_total_members_at_opening", label: "Total members at opening", type: "number" },
|
||||
{ name: "stage_0_volunteers_helping_in_store", label: "Volunteers Helping In Store", type: "number" },
|
||||
{ name: "stage_0_work_from_members", label: "Work from Members", type: "text", help: "Hours, dollars, or descriptive — your choice." },
|
||||
{ name: "stage_0_total_square_ft", label: "Total square ft", type: "number" },
|
||||
{ name: "stage_0_retail_sq_ft", label: "Retail sq ft", type: "number" },
|
||||
{ name: "stage_0_latest_sources_uses_doc", label: "Latest Sources and Uses doc", type: "file" },
|
||||
{ name: "stage_0_latest_pro_forma_doc", label: "Latest Pro Forma doc", type: "file" },
|
||||
{ name: "stage_0_projected_y1_sales", label: "Projected Year 1 Sales", type: "currency" },
|
||||
{ name: "stage_0_projected_y2_sales", label: "Projected Year 2 Sales", type: "currency" },
|
||||
{ name: "stage_0_projected_y3_sales", label: "Projected Year 3 Sales", type: "currency" },
|
||||
{ name: "stage_0_projected_sales_established", label: "Projected sales once established", type: "currency" },
|
||||
{ name: "stage_0_projected_ftes", label: "Projected FTEs", type: "number", step: 0.1 },
|
||||
{ name: "stage_0_actual_fte", label: "Actual FTE", type: "number", step: 0.1 },
|
||||
{ name: "stage_0_total_cost_of_project", label: "Total cost of project", type: "currency" },
|
||||
{ name: "stage_0_member_equity_total_needed", label: "Member equity total needed", type: "currency" },
|
||||
{ name: "stage_0_member_equity_raised", label: "Member equity raised", type: "currency" },
|
||||
{ name: "stage_0_member_loans_total_needed", label: "Member loans total needed", type: "currency" },
|
||||
{ name: "stage_0_member_loans_raised", label: "Member loans raised", type: "currency" },
|
||||
{ name: "stage_0_member_preferred_shares_total_needed", label: "Member preferred shares total needed", type: "currency" },
|
||||
{ name: "stage_0_member_preferred_shares_raised", label: "Member preferred shares raised", type: "currency" },
|
||||
{ name: "stage_0_bank_debt_total_needed", label: "Bank debt total needed", type: "currency" },
|
||||
{ name: "stage_0_bank_debt_raised", label: "Bank debt raised", type: "currency" },
|
||||
{ name: "stage_0_grant_donations_needed", label: "Grants / Donations Needed", type: "currency" },
|
||||
{ name: "stage_0_grants_donations_raised", label: "Grants / Donations Raised", type: "currency" },
|
||||
{ name: "stage_0_other_sources_total_needed", label: "Other sources total needed", type: "currency" },
|
||||
{ name: "stage_0_other_sources_raised", label: "Other sources raised", type: "currency" },
|
||||
{ name: "stage_0_date_closed_folded", label: "Date Closed / Folded", type: "date" },
|
||||
].map((f) => ({ ...f, civiField: f.civiField ?? `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
const stage1: StageSectionConfig = {
|
||||
rank: 1,
|
||||
id: "stage_1",
|
||||
label: "Stage 1 — Convene & Prepare",
|
||||
visibleWhen: visibleAtOrAfter(S.Organizing, S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||
fields: [
|
||||
{ name: "stage_1_preliminary_market_assessment", label: "Preliminary Market Assessment", type: "textarea" },
|
||||
{ name: "stage_1_preliminary_market_assessment_upload", label: "Preliminary Market Assessment — Upload", type: "file" },
|
||||
{ name: "stage_1_preliminary_sources_uses", label: "Preliminary Sources And Uses", type: "textarea" },
|
||||
{ name: "stage_1_preliminary_sources_uses_upload", label: "Preliminary Sources and Uses — Upload", type: "file" },
|
||||
{ name: "stage_1_vision", label: "Vision", type: "textarea" },
|
||||
{ name: "stage_1_vision_upload", label: "Vision — Upload", type: "file" },
|
||||
{ name: "stage_1_business_concept", label: "Business Concept", type: "textarea" },
|
||||
{ name: "stage_1_business_concept_upload", label: "Business Concept — Upload", type: "file" },
|
||||
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
const stage2: StageSectionConfig = {
|
||||
rank: 2,
|
||||
id: "stage_2",
|
||||
label: "Stage 2 — Feasibility",
|
||||
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||
fields: [
|
||||
{ name: "stage_2_market_study_date", label: "Market Study Date", type: "date" },
|
||||
{ name: "stage_2_market_study_upload", label: "Market Study — Upload", type: "file" },
|
||||
{ name: "stage_2_pro_forma_date", label: "Pro Forma — date completed", type: "date" },
|
||||
{ name: "stage_2_pro_forma_upload", label: "Pro Forma — Upload", type: "file" },
|
||||
{
|
||||
name: "stage_2_pro_forma_viability",
|
||||
label: "Pro Forma Viability",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "viable", label: "Viable" },
|
||||
{ value: "marginal", label: "Marginal" },
|
||||
{ value: "not_viable", label: "Not viable" },
|
||||
{ value: "tbd", label: "Not yet determined" },
|
||||
],
|
||||
},
|
||||
{ name: "stage_2_business_plan", label: "Business Plan", type: "textarea" },
|
||||
{ name: "stage_2_business_plan_upload", label: "Business Plan — Upload", type: "file" },
|
||||
{ name: "stage_2_board_self_assessment", label: "Board Self Assessment", type: "textarea" },
|
||||
{ name: "stage_2_board_self_assessment_upload", label: "Board Self Assessment — Upload", type: "file" },
|
||||
{ name: "stage_2_governance_system", label: "Governance System Used", type: "text" },
|
||||
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
const stage3: StageSectionConfig = {
|
||||
rank: 3,
|
||||
id: "stage_3",
|
||||
label: "Stage 3 — Connect & Gather",
|
||||
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||
fields: [
|
||||
{ name: "stage_3_site_loi_date", label: "Site: Letter of Intent Date", type: "date" },
|
||||
{ name: "stage_3_site_loi_upload", label: "Site: Letter of Intent — Upload", type: "file" },
|
||||
{ name: "stage_3_own_building_property", label: "Own the building / property", type: "boolean" },
|
||||
{ name: "stage_3_capital_campaign_owner_participation_pct", label: "Capital Campaign: Owner Participation %", type: "percent" },
|
||||
{ name: "stage_3_capital_campaign_avg_owner_investment", label: "Capital Campaign: Average Owner Investment", type: "currency" },
|
||||
{ name: "stage_3_capital_stack", label: "Capital Stack", type: "textarea" },
|
||||
{ name: "stage_3_project_manager_hire_date", label: "Project Manager — Date of Hire", type: "date" },
|
||||
{ name: "stage_3_store_design_plan_completion", label: "Store Design — Plan Completion", type: "date" },
|
||||
{ name: "stage_3_store_designer", label: "Store Designer", type: "text" },
|
||||
{ name: "stage_3_ncg_member", label: "NCG Member", type: "boolean" },
|
||||
{ name: "stage_3_ncg_corridor", label: "NCG Corridor", type: "text" },
|
||||
{ name: "stage_3_infra_member", label: "INFRA Member", type: "boolean" },
|
||||
{ name: "stage_3_other_distributors", label: "Other Distributors", type: "text" },
|
||||
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
const stage4: StageSectionConfig = {
|
||||
rank: 4,
|
||||
id: "stage_4",
|
||||
label: "Stage 4 — Excite & Build",
|
||||
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
|
||||
fields: [
|
||||
{ name: "stage_4_projected_opening_date", label: "Projected Opening Date", type: "date" },
|
||||
{ name: "stage_4_construction_completion_date", label: "Construction Completion Date", type: "date" },
|
||||
{ name: "stage_4_mission_transition_plan_date", label: "Mission Transition Plan — Date", type: "date" },
|
||||
{ name: "stage_4_mission_transition_plan_upload", label: "Mission Transition Plan — Upload", type: "file" },
|
||||
{ name: "stage_4_gm_name", label: "General Manager — Name", type: "text" },
|
||||
{ name: "stage_4_gm_phone", label: "General Manager Phone", type: "phone" },
|
||||
{ name: "stage_4_gm_email", label: "General Manager Email", type: "email" },
|
||||
{ name: "stage_4_gm_hire_date", label: "General Manager — Date of Hire", type: "date" },
|
||||
{ name: "stage_4_gm_background", label: "General Manager Background", type: "textarea" },
|
||||
{ name: "stage_4_gm_support_training", label: "GM Support and Training", type: "textarea" },
|
||||
{ name: "stage_4_gm_support_team", label: "GM Support Team", type: "textarea" },
|
||||
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
// Stage 5 has 12 monthly + 4 quarterly trackers across multiple metrics; build programmatically.
|
||||
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
const quarters = [1, 2, 3, 4];
|
||||
|
||||
const stage5: StageSectionConfig = {
|
||||
rank: 5,
|
||||
id: "stage_5",
|
||||
label: "Stage 5 — Fulfill & Stabilize",
|
||||
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||
fields: [
|
||||
{ name: "stage_5_date_opened", label: "Date Opened", type: "date" as const },
|
||||
{ name: "stage_5_y1_actual_sales", label: "Y1 Actual Sales", type: "currency" as const },
|
||||
...months.map((m) => ({
|
||||
name: `stage_5_y1_target_m${m}`,
|
||||
label: `Y1 Monthly Sales Target: M${m}`,
|
||||
type: "currency" as const,
|
||||
})),
|
||||
...months.map((m) => ({
|
||||
name: `stage_5_y1_actual_m${m}`,
|
||||
label: `Y1 M${m} Actual Sales`,
|
||||
type: "currency" as const,
|
||||
})),
|
||||
...months.map((m) => ({
|
||||
name: `stage_5_y1_transactions_m${m}`,
|
||||
label: `Y1 M${m} Transactions`,
|
||||
type: "number" as const,
|
||||
})),
|
||||
...quarters.map((q) => ({
|
||||
name: `stage_5_y1_q${q}_labor`,
|
||||
label: `Y1 Q${q} Labor`,
|
||||
type: "number" as const,
|
||||
step: 0.1,
|
||||
help: "FTEs",
|
||||
})),
|
||||
...quarters.map((q) => ({
|
||||
name: `stage_5_y1_q${q}_margin`,
|
||||
label: `Y1 Q${q} Margin`,
|
||||
type: "percent" as const,
|
||||
})),
|
||||
...quarters.map((q) => ({
|
||||
name: `stage_5_y1_q${q}_member_sales_pct`,
|
||||
label: `Y1 Q${q} Member Sales %`,
|
||||
type: "percent" as const,
|
||||
})),
|
||||
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
|
||||
};
|
||||
|
||||
export const formConfig: FormConfig = {
|
||||
id: "org_engagement_check_in",
|
||||
title: "Co-op Check-in",
|
||||
subtitle:
|
||||
"Update tracking data for your co-op as you progress through the organizing stages. The questions you'll see depend on where you are in the framework.",
|
||||
stageField: "current_stage",
|
||||
sections: [stage0, stage1, stage2, stage3, stage4, stage5],
|
||||
};
|
||||
|
||||
/** Convenience: flatten all field configs across all sections for lookup by name. */
|
||||
export const allFields = formConfig.sections.flatMap((s) => s.fields);
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"stageField": "custom_stage_field",
|
||||
"stages": [
|
||||
{
|
||||
"id": 0,
|
||||
"label": "Stage 0: Intake",
|
||||
"fields": [
|
||||
{ "name": "first_name", "label": "First Name", "entity": "Contact", "crmField": "first_name" },
|
||||
{ "name": "last_name", "label": "Last Name", "entity": "Contact", "crmField": "last_name" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"label": "Stage 1: Assessment",
|
||||
"fields": [
|
||||
{ "name": "email", "label": "Email", "entity": "Contact", "crmField": "email_primary.email" },
|
||||
{ "name": "phone", "label": "Phone", "entity": "Contact", "crmField": "phone_primary.phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "Stage 2: Planning",
|
||||
"fields": [
|
||||
{ "name": "custom_planning_date", "label": "Planning Date", "entity": "Contact", "crmField": "custom_123" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "Stage 3: Implementation",
|
||||
"fields": [
|
||||
{ "name": "custom_status", "label": "Implementation Status", "entity": "Contact", "crmField": "custom_124" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "Stage 4: Evaluation",
|
||||
"fields": [
|
||||
{ "name": "custom_results", "label": "Evaluation Results", "entity": "Contact", "crmField": "custom_125" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "Stage 5: Completion",
|
||||
"fields": [
|
||||
{ "name": "custom_completion_notes", "label": "Completion Notes", "entity": "Contact", "crmField": "custom_126" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const CIVICRM_SITE_URL = process.env.CIVICRM_SITE_URL;
|
||||
const CIVICRM_API_KEY = process.env.CIVICRM_API_KEY;
|
||||
const CIVICRM_SITE_KEY = process.env.CIVICRM_SITE_KEY;
|
||||
|
||||
const civicrmClient = axios.create({
|
||||
baseURL: `${CIVICRM_SITE_URL}/civicrm/ajax/api4`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Civi-Auth': `Bearer ${CIVICRM_API_KEY}`, // Note: Auth strategy might vary (AuthX, etc.)
|
||||
'X-Civi-Key': CIVICRM_SITE_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
// Using AuthX style if preferred by the user's environment,
|
||||
// but APIv4 REST usually uses these or similar headers.
|
||||
// We'll stick to a standard implementation that can be adjusted.
|
||||
|
||||
export async function civicrmApi(entity, action, params = {}) {
|
||||
try {
|
||||
const response = await civicrmClient.post(`/${entity}/${action}`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`CiviCRM API Error (${entity}.${action}):`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizationStage(orgId) {
|
||||
const result = await civicrmApi('Contact', 'get', {
|
||||
select: ['custom_stage_field'], // Replace with actual field name
|
||||
where: [['id', '=', orgId]],
|
||||
});
|
||||
return result.values[0]?.custom_stage_field || 0;
|
||||
}
|
||||
|
||||
export async function updateContact(contactId, values) {
|
||||
return await civicrmApi('Contact', 'save', {
|
||||
records: [{ id: contactId, ...values }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function createActivity(params) {
|
||||
return await civicrmApi('Activity', 'create', {
|
||||
values: params,
|
||||
});
|
||||
}
|
||||
105
lib/civicrm.ts
Normal file
105
lib/civicrm.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* CiviCRM APIv4 client.
|
||||
*
|
||||
* Reads credentials from environment variables. Falls back to STUB mode if
|
||||
* any required env var is missing — STUB mode returns mock data so the UI
|
||||
* can be developed without a live CiviCRM instance.
|
||||
*
|
||||
* Env vars:
|
||||
* CIVI_BASE_URL e.g. https://crm.fci.coop
|
||||
* CIVI_API_KEY per-user API key (Civi user "API Key" property)
|
||||
* CIVI_SITE_KEY site-wide key (from civicrm.settings.php)
|
||||
*
|
||||
* If you are seeing STUB MODE warnings, set those three. Auth strategy may
|
||||
* also need adjustment depending on your CiviCRM auth extension (AuthX vs
|
||||
* stock APIv3-style site_key/api_key). The header style here matches the
|
||||
* stock CiviCRM 5+ pattern; AuthX users may need Bearer tokens instead.
|
||||
*/
|
||||
|
||||
export interface CiviApiOptions {
|
||||
/** Override env CIVI_BASE_URL for one-off calls (e.g. tests). */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface CiviApiResponse<T = unknown> {
|
||||
values: T[];
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const STUB_LOG_PREFIX = "[civi:STUB]";
|
||||
|
||||
function isStubMode(): boolean {
|
||||
return !(
|
||||
process.env.CIVI_BASE_URL &&
|
||||
process.env.CIVI_API_KEY &&
|
||||
process.env.CIVI_SITE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic APIv4 call. `entity` is e.g. "Contact" / "Activity" / "Relationship".
|
||||
* `action` is the APIv4 action name. `params` is the JSON params object.
|
||||
*/
|
||||
export async function civi<T = unknown>(
|
||||
entity: string,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
opts: CiviApiOptions = {},
|
||||
): Promise<CiviApiResponse<T>> {
|
||||
if (isStubMode()) {
|
||||
console.warn(`${STUB_LOG_PREFIX} ${entity}.${action} — env not set, returning empty values`);
|
||||
return { values: [] };
|
||||
}
|
||||
|
||||
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
|
||||
const url = `${base}/civicrm/ajax/api4/${entity}/${action}`;
|
||||
const body = new URLSearchParams({
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
||||
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`CiviCRM ${entity}.${action} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as CiviApiResponse<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a contact checksum (cid + cs) against CiviCRM.
|
||||
*
|
||||
* APIv4 exposes Contact.validateChecksum in newer Civi versions. For older
|
||||
* versions you may need to call Contact.get with the cs param and verify
|
||||
* the contact resolves. We use validateChecksum here and fall back to a
|
||||
* Contact.get probe if it returns a "missing API" error.
|
||||
*/
|
||||
export async function verifyChecksum(cid: string, cs: string): Promise<boolean> {
|
||||
if (isStubMode()) {
|
||||
// STUB: any non-empty cs is "valid" so the UI can be exercised locally.
|
||||
return Boolean(cid && cs);
|
||||
}
|
||||
try {
|
||||
const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", {
|
||||
contactId: Number(cid),
|
||||
checksum: cs,
|
||||
});
|
||||
return Boolean(res.values?.[0]?.valid);
|
||||
} catch (e) {
|
||||
// Fallback: try Contact.get with the checksum as `cs` URL param. If the
|
||||
// contact resolves, the checksum is valid.
|
||||
const res = await civi<{ id: number }>("Contact", "get", {
|
||||
where: [["id", "=", Number(cid)]],
|
||||
select: ["id"],
|
||||
checksum: cs,
|
||||
});
|
||||
return Array.isArray(res.values) && res.values.length === 1;
|
||||
}
|
||||
}
|
||||
80
lib/conditional.ts
Normal file
80
lib/conditional.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Visibility engine for form sections and fields.
|
||||
*
|
||||
* Evaluates a VisibilityRule tree against the current form state.
|
||||
* Form state is a plain object {fieldName: value}. Missing fields are
|
||||
* treated as undefined.
|
||||
*
|
||||
* Rule semantics:
|
||||
* - SimpleCondition: tests a single field with an operator
|
||||
* - {all: [...]}: AND
|
||||
* - {any: [...]}: OR
|
||||
*
|
||||
* Operator semantics:
|
||||
* - equals : field value === rule.value
|
||||
* - notEquals : field value !== rule.value
|
||||
* - in : rule.values includes the field value
|
||||
* - notIn : rule.values does not include the field value
|
||||
* - isEmpty : field is undefined / null / "" / [] / {}
|
||||
* - isNotEmpty : negation of isEmpty
|
||||
*
|
||||
* If a rule is malformed, evaluate returns false (field stays hidden) rather
|
||||
* than throwing, so a config typo can never break the page.
|
||||
*/
|
||||
|
||||
import type {
|
||||
VisibilityRule,
|
||||
SimpleCondition,
|
||||
AllCondition,
|
||||
AnyCondition,
|
||||
} from "@/types/form";
|
||||
|
||||
type FormState = Record<string, unknown>;
|
||||
|
||||
function isSimple(r: VisibilityRule): r is SimpleCondition {
|
||||
return typeof (r as SimpleCondition).field === "string";
|
||||
}
|
||||
|
||||
function isAll(r: VisibilityRule): r is AllCondition {
|
||||
return Array.isArray((r as AllCondition).all);
|
||||
}
|
||||
|
||||
function isAny(r: VisibilityRule): r is AnyCondition {
|
||||
return Array.isArray((r as AnyCondition).any);
|
||||
}
|
||||
|
||||
function isEmpty(v: unknown): boolean {
|
||||
if (v === undefined || v === null) return true;
|
||||
if (typeof v === "string") return v.length === 0;
|
||||
if (Array.isArray(v)) return v.length === 0;
|
||||
if (typeof v === "object") return Object.keys(v as object).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function evalSimple(c: SimpleCondition, state: FormState): boolean {
|
||||
const v = state[c.field];
|
||||
switch (c.op) {
|
||||
case "equals":
|
||||
return v === c.value;
|
||||
case "notEquals":
|
||||
return v !== c.value;
|
||||
case "in":
|
||||
return Array.isArray(c.values) && c.values.includes(v as string | number | boolean);
|
||||
case "notIn":
|
||||
return Array.isArray(c.values) && !c.values.includes(v as string | number | boolean);
|
||||
case "isEmpty":
|
||||
return isEmpty(v);
|
||||
case "isNotEmpty":
|
||||
return !isEmpty(v);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluate(rule: VisibilityRule | undefined, state: FormState): boolean {
|
||||
if (!rule) return true;
|
||||
if (isSimple(rule)) return evalSimple(rule, state);
|
||||
if (isAll(rule)) return rule.all.every((r) => evaluate(r, state));
|
||||
if (isAny(rule)) return rule.any.some((r) => evaluate(r, state));
|
||||
return false;
|
||||
}
|
||||
67
lib/prefill.ts
Normal file
67
lib/prefill.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Per-field-most-recent prefill walk.
|
||||
*
|
||||
* Loads all `Org Engagement Submission` activities for an organization,
|
||||
* sorted DESC by activity_date_time. For each field name we care about,
|
||||
* walk the list and take the first non-null value found.
|
||||
*
|
||||
* This closes the v1 prefill gap that Webform CiviCRM cannot express in
|
||||
* its admin UI: load latest values per-field across multiple activities,
|
||||
* without coupling to update-mode.
|
||||
*/
|
||||
|
||||
import { civi } from "./civicrm";
|
||||
import type { FieldConfig } from "@/types/form";
|
||||
|
||||
interface ActivityRow {
|
||||
id: number;
|
||||
activity_date_time: string;
|
||||
// CiviCRM returns custom fields under their machine names like
|
||||
// `custom_42` or `Stage_0_Core.peer_group_participation`.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PrefillResult {
|
||||
/** Form-side keys → most-recent non-null value. */
|
||||
values: Record<string, unknown>;
|
||||
/** Number of submission activities walked. */
|
||||
activityCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk Org Engagement Submission activities for the org, returning per-field
|
||||
* most-recent values keyed by FieldConfig.name. Only fields with a `civiField`
|
||||
* are looked up; transient fields are ignored.
|
||||
*/
|
||||
export async function loadPrefill(
|
||||
orgId: number,
|
||||
fields: FieldConfig[],
|
||||
activityTypeName = "Org Engagement Submission",
|
||||
): Promise<PrefillResult> {
|
||||
const civiSelected = fields.filter((f) => f.civiField);
|
||||
const select = ["id", "activity_date_time", ...new Set(civiSelected.map((f) => f.civiField!))];
|
||||
|
||||
const res = await civi<ActivityRow>("Activity", "get", {
|
||||
select,
|
||||
where: [
|
||||
["activity_type_id:name", "=", activityTypeName],
|
||||
["target_contact_id", "=", orgId],
|
||||
],
|
||||
orderBy: { activity_date_time: "DESC" },
|
||||
limit: 200,
|
||||
});
|
||||
const rows = res.values ?? [];
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of civiSelected) {
|
||||
for (const row of rows) {
|
||||
const v = row[f.civiField!];
|
||||
if (v !== null && v !== undefined && v !== "") {
|
||||
out[f.name] = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { values: out, activityCount: rows.length };
|
||||
}
|
||||
108
package-lock.json
generated
108
package-lock.json
generated
@@ -614,9 +614,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -633,9 +630,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -652,9 +646,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,9 +662,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -690,9 +678,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -709,9 +694,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -728,9 +710,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,9 +726,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -766,9 +742,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -791,9 +764,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,9 +786,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -841,9 +808,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -866,9 +830,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -891,9 +852,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -916,9 +874,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -941,9 +896,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1153,9 +1105,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1172,9 +1121,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1191,9 +1137,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1210,9 +1153,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1451,9 +1391,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1471,9 +1408,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1491,9 +1425,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1511,9 +1442,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2064,9 +1992,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2081,9 +2006,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2098,9 +2020,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2115,9 +2034,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2132,9 +2048,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2149,9 +2062,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2166,9 +2076,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2183,9 +2090,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4853,9 +4757,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4877,9 +4778,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4901,9 +4799,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4925,9 +4820,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
146
types/form.ts
Normal file
146
types/form.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Form configuration types.
|
||||
*
|
||||
* A FormConfig declares an ordered list of stage sections. Each section has
|
||||
* its own visibility rule (so Stage 1..5 can be shown/hidden based on the
|
||||
* org's current Framework Stage), and each field within a section can also
|
||||
* carry its own visibility rule (so individual fields can be gated).
|
||||
*
|
||||
* The conditional language is intentionally narrow: a rule references one
|
||||
* field by name, an operator, and a value (or set of values). Composition
|
||||
* is via { all: [...] } / { any: [...] }. Anything else stays out of the
|
||||
* config — it goes in code.
|
||||
*/
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "percent"
|
||||
| "date"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "boolean"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "file"
|
||||
| "readonly"; // display-only; the value comes from API and is not editable
|
||||
|
||||
export type ConditionOp =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "isEmpty"
|
||||
| "isNotEmpty";
|
||||
|
||||
export interface SimpleCondition {
|
||||
field: string;
|
||||
op: ConditionOp;
|
||||
value?: string | number | boolean | null;
|
||||
values?: Array<string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface AllCondition {
|
||||
all: VisibilityRule[];
|
||||
}
|
||||
|
||||
export interface AnyCondition {
|
||||
any: VisibilityRule[];
|
||||
}
|
||||
|
||||
export type VisibilityRule = SimpleCondition | AllCondition | AnyCondition;
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
/** Form-side machine name. Use `stage_<N>_<short>` per the spec convention. */
|
||||
name: string;
|
||||
/** Human-readable label shown to the user. */
|
||||
label: string;
|
||||
type: FieldType;
|
||||
/** Optional helper text rendered below the label. */
|
||||
help?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
/** For select / multiselect. */
|
||||
options?: SelectOption[];
|
||||
/** For number / currency / percent — min/max/step constraints. */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** For text / textarea — max length. */
|
||||
maxLength?: number;
|
||||
/** Visibility rule. If absent, field is always visible (within its visible section). */
|
||||
visibleWhen?: VisibilityRule;
|
||||
/**
|
||||
* The CiviCRM custom-field reference, if this maps to a custom field on the
|
||||
* Org Engagement Submission activity. Format: `custom_<id>` or
|
||||
* `custom_<group_name>.<field_name>` depending on APIv4 conventions.
|
||||
* Leave undefined for fields that don't write back to Civi (e.g. transient
|
||||
* UI helpers).
|
||||
*/
|
||||
civiField?: string;
|
||||
}
|
||||
|
||||
export interface StageSectionConfig {
|
||||
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
|
||||
rank: number;
|
||||
/** Internal id, e.g. "stage_0", "stage_1", … */
|
||||
id: string;
|
||||
/** Human label, e.g. "Inquiry / Check-in", "Stage 1 — Convene & Prepare". */
|
||||
label: string;
|
||||
/** Optional intro text rendered at the top of the section. */
|
||||
intro?: string;
|
||||
/**
|
||||
* Visibility rule for the entire section. Stage 0 has no rule (always
|
||||
* visible). Stages 1–5 each declare a multi-value list match against the
|
||||
* current Framework Stage.
|
||||
*/
|
||||
visibleWhen?: VisibilityRule;
|
||||
fields: FieldConfig[];
|
||||
}
|
||||
|
||||
export interface FormConfig {
|
||||
/** Unique form identifier (e.g. "org_engagement_check_in"). */
|
||||
id: string;
|
||||
/** Public title shown in the form header. */
|
||||
title: string;
|
||||
/** Short subtitle / description. */
|
||||
subtitle?: string;
|
||||
/**
|
||||
* The form's stage-source field name. The conditional engine reads this
|
||||
* field's current value when evaluating rules that reference "current_stage".
|
||||
* Conventionally `current_stage`.
|
||||
*/
|
||||
stageField: string;
|
||||
sections: StageSectionConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape returned by /api/data. Used by the form on initial load.
|
||||
*/
|
||||
export interface FormDataPayload {
|
||||
/** The organization's display name. */
|
||||
orgName: string;
|
||||
/** Current Framework Stage (text value). */
|
||||
currentStage: string;
|
||||
/**
|
||||
* Per-field-most-recent prefill values, keyed by the FieldConfig.name.
|
||||
* Fields with no prior value are simply absent from this map.
|
||||
*/
|
||||
prefill: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape submitted to /api/submit.
|
||||
*/
|
||||
export interface SubmitPayload {
|
||||
cid: string;
|
||||
cs: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user