Two additions, both touching the form-config story: 1. scripts/sync-help-from-civi.mjs Diffs per-field help text in config/form.ts against CustomField rows in CiviCRM and (with --write) updates the file in place. Reads env from .env.local via Node's --env-file flag. Run as `npm run sync-help` or `npm run sync-help -- --write`. A --debug mode prints the parser's field list without calling Civi. Rationale: this form is low-traffic and help text doesn't change often once in production. A manual one-off sync is leaner than coupling every page load (or every build) to a Civi API call. 2. fieldGroups: visual clustering of related fields within a section New optional FieldGroupConfig overlay on StageSectionConfig — pure presentation, names existing fields by name so submit/visibility logic walks them unchanged. StageSection.tsx pulls grouped fields out of the standalone per-field grid and renders each group as its own bordered card with an optional heading. Stage 2 now clusters Market Study, Pro Forma, Business Plan, and Board Self Assessment (each a date + upload pair) into their own cards.
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
|
import type {
|
|
UseFormRegister,
|
|
FieldValues,
|
|
FieldErrors,
|
|
Control,
|
|
} from "react-hook-form";
|
|
import { FieldRenderer } from "./fields/FieldRenderer";
|
|
import { MatrixGroup } from "./MatrixGroup";
|
|
import { StageIcon } from "./StageIcon";
|
|
import { evaluate } from "@/lib/conditional";
|
|
|
|
interface StageSectionProps {
|
|
section: StageSectionConfig;
|
|
register: UseFormRegister<FieldValues>;
|
|
control: Control<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. */
|
|
isCurrent: boolean;
|
|
/**
|
|
* Whether this stage is ahead of the org's current stage. Locked sections
|
|
* still render so the user can see what's coming, but the fields inside
|
|
* are wrapped in a disabled fieldset and never written on submit.
|
|
*/
|
|
locked: boolean;
|
|
/** Whether the section starts open. */
|
|
defaultOpen: boolean;
|
|
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
|
options: Record<number, SelectOption[]>;
|
|
}
|
|
|
|
/**
|
|
* One accordion card per stage section. Header carries:
|
|
* - A hand-drawn stage icon (left)
|
|
* - The stage label (display serif)
|
|
* - A "Current stage" pill in terracotta if applicable
|
|
* - A field-count summary on the right
|
|
* - A chevron that rotates on open
|
|
*
|
|
* The card border thickens and tints leaf-green when current; otherwise it
|
|
* sits quietly on the cream paper.
|
|
*/
|
|
export function StageSection({
|
|
section,
|
|
register,
|
|
control,
|
|
errors,
|
|
formValues,
|
|
isCurrent,
|
|
locked,
|
|
defaultOpen,
|
|
options,
|
|
}: StageSectionProps) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
const headingId = useId();
|
|
const panelId = useId();
|
|
|
|
// Listen for programmatic expand requests (fired by EngagementForm when a
|
|
// submit fails on a required field inside a collapsed section).
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent<{ sectionId: string }>).detail;
|
|
if (detail?.sectionId === section.id) setOpen(true);
|
|
};
|
|
document.addEventListener("coop-checkin:expand-section", handler);
|
|
return () => document.removeEventListener("coop-checkin:expand-section", handler);
|
|
}, [section.id]);
|
|
|
|
const matrixFieldNames = useMemo(() => {
|
|
const names = new Set<string>();
|
|
for (const m of section.matrixGroups ?? []) {
|
|
for (const row of m.rows) for (const f of row.fields) names.add(f);
|
|
}
|
|
return names;
|
|
}, [section.matrixGroups]);
|
|
|
|
// Names of fields claimed by a fieldGroup. These get pulled out of the
|
|
// standalone per-field grid and rendered inside their group's card. If a
|
|
// name appears in multiple groups, the first wins.
|
|
const groupedFieldNames = useMemo(() => {
|
|
const names = new Set<string>();
|
|
for (const g of section.fieldGroups ?? []) {
|
|
for (const fname of g.fields) names.add(fname);
|
|
}
|
|
return names;
|
|
}, [section.fieldGroups]);
|
|
|
|
const fieldsByName = useMemo(() => {
|
|
const map = new Map<string, FieldConfig>();
|
|
for (const f of section.fields) map.set(f.name, f);
|
|
return map;
|
|
}, [section.fields]);
|
|
|
|
// Materialize each group as its list of *visible* fields (with the group's
|
|
// declared order preserved). A group with zero visible fields renders
|
|
// nothing.
|
|
const visibleGroups = useMemo(
|
|
() =>
|
|
(section.fieldGroups ?? [])
|
|
.map((g) => ({
|
|
group: g,
|
|
fields: g.fields
|
|
.map((name) => fieldsByName.get(name))
|
|
.filter((f): f is FieldConfig => !!f)
|
|
.filter((f) => evaluate(f.visibleWhen, formValues))
|
|
.filter((f) => !matrixFieldNames.has(f.name)),
|
|
}))
|
|
.filter((g) => g.fields.length > 0),
|
|
[section.fieldGroups, fieldsByName, formValues, matrixFieldNames],
|
|
);
|
|
|
|
const visibleFields = section.fields.filter(
|
|
(f) =>
|
|
evaluate(f.visibleWhen, formValues) &&
|
|
!matrixFieldNames.has(f.name) &&
|
|
!groupedFieldNames.has(f.name),
|
|
);
|
|
|
|
const fieldCount =
|
|
visibleFields.length +
|
|
visibleGroups.reduce((n, g) => n + g.fields.length, 0) +
|
|
(section.matrixGroups ?? []).reduce(
|
|
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
|
|
0,
|
|
);
|
|
|
|
const cardClass = isCurrent
|
|
? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95"
|
|
: locked
|
|
? "border border-dashed border-rule bg-paper-2/30 shadow-none"
|
|
: "border border-rule bg-white/95 shadow-sm";
|
|
|
|
const buttonClass = isCurrent
|
|
? "bg-leaf-50/60"
|
|
: locked
|
|
? "hover:bg-paper-2/50"
|
|
: "hover:bg-paper-2/60";
|
|
|
|
return (
|
|
<section
|
|
aria-labelledby={headingId}
|
|
className={"overflow-hidden rounded-lg transition " + cardClass}
|
|
>
|
|
<h2 id={headingId} className="m-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-expanded={open}
|
|
aria-controls={panelId}
|
|
className={
|
|
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
|
buttonClass
|
|
}
|
|
>
|
|
<StageRankMark rank={section.rank} isCurrent={isCurrent} locked={locked} />
|
|
<span className="flex-1 min-w-0">
|
|
<span
|
|
className={
|
|
"block font-display text-lg font-medium leading-tight tracking-tight sm:text-xl " +
|
|
(locked ? "text-ink-soft" : "text-ink")
|
|
}
|
|
>
|
|
{section.label}
|
|
</span>
|
|
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
|
{isCurrent && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
|
|
Current stage
|
|
</span>
|
|
)}
|
|
{locked && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-rule bg-paper px-2 py-0.5 font-medium text-ink-mute uppercase tracking-[0.08em]">
|
|
<LockGlyph />
|
|
Upcoming
|
|
</span>
|
|
)}
|
|
<span className="tabular-nums">
|
|
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<Chevron open={open} />
|
|
</button>
|
|
</h2>
|
|
|
|
<div
|
|
id={panelId}
|
|
role="region"
|
|
aria-labelledby={headingId}
|
|
hidden={!open}
|
|
className={"border-t " + (locked ? "border-rule" : "border-rule-soft")}
|
|
>
|
|
<fieldset
|
|
disabled={locked}
|
|
aria-disabled={locked || undefined}
|
|
className={
|
|
"px-5 py-6 sm:px-7 sm:py-7 " +
|
|
(locked ? "opacity-80" : "")
|
|
}
|
|
>
|
|
{locked && <LockedBanner />}
|
|
{section.intro && (
|
|
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
|
{section.intro}
|
|
</p>
|
|
)}
|
|
{(section.matrixGroups ?? []).length > 0 && (
|
|
<div className="mb-7 space-y-5">
|
|
{section.matrixGroups!.map((g) => (
|
|
<MatrixGroup key={g.id} group={g} register={register} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{visibleGroups.length > 0 && (
|
|
<div className="mb-7 space-y-5">
|
|
{visibleGroups.map(({ group, fields }) => (
|
|
<FieldGroupCard
|
|
key={group.id}
|
|
label={group.label}
|
|
intro={group.intro}
|
|
fields={fields}
|
|
formValues={formValues}
|
|
options={options}
|
|
register={register}
|
|
control={control}
|
|
errors={errors}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{visibleFields.length === 0 &&
|
|
visibleGroups.length === 0 &&
|
|
(section.matrixGroups ?? []).length === 0 ? (
|
|
<p className="text-sm italic text-ink-mute">No fields are visible at this stage.</p>
|
|
) : visibleFields.length === 0 ? null : (
|
|
<div className="grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2">
|
|
{visibleFields.map((f) => {
|
|
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
|
|
const wide =
|
|
f.type === "textarea" ||
|
|
f.type === "boolean" ||
|
|
f.type === "multiselect";
|
|
return (
|
|
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
|
|
<FieldRenderer
|
|
field={f}
|
|
register={register}
|
|
control={control}
|
|
errors={errors}
|
|
readonlyValue={formValues[f.name]}
|
|
resolvedOptions={resolvedOptions}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</fieldset>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stage rank mark: the number sits in a small circle, with the hand-drawn
|
|
* icon offset behind it. When current, the ring is leaf-green and the
|
|
* number is white-on-green; otherwise it's quiet.
|
|
*/
|
|
function StageRankMark({
|
|
rank,
|
|
isCurrent,
|
|
locked,
|
|
}: {
|
|
rank: number;
|
|
isCurrent: boolean;
|
|
locked: boolean;
|
|
}) {
|
|
const iconClass = isCurrent
|
|
? "text-leaf-700 opacity-100"
|
|
: locked
|
|
? "text-ink-mute opacity-30"
|
|
: "text-leaf-600 opacity-50";
|
|
const badgeClass = isCurrent
|
|
? "bg-leaf-700 text-paper"
|
|
: locked
|
|
? "bg-paper text-ink-mute border border-dashed border-rule"
|
|
: "bg-paper text-ink-soft border border-rule";
|
|
return (
|
|
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
|
<StageIcon
|
|
rank={rank}
|
|
className={"absolute inset-0 h-12 w-12 transition-opacity " + iconClass}
|
|
/>
|
|
<span
|
|
className={
|
|
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
|
|
badgeClass
|
|
}
|
|
style={{ marginLeft: 28, marginTop: 28 }}
|
|
>
|
|
{rank}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function LockGlyph() {
|
|
return (
|
|
<svg
|
|
aria-hidden
|
|
viewBox="0 0 12 12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.25"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="h-3 w-3"
|
|
>
|
|
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
|
|
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LockedBanner() {
|
|
return (
|
|
<div
|
|
role="note"
|
|
className="mb-5 flex items-start gap-3 rounded-md border border-dashed border-rule bg-paper px-4 py-3"
|
|
>
|
|
<span aria-hidden className="mt-0.5 text-ink-mute">
|
|
<LockGlyph />
|
|
</span>
|
|
<p className="text-xs leading-relaxed text-ink-soft">
|
|
<span className="font-medium text-ink-soft">A look ahead.</span>{" "}
|
|
These fields will become editable when your co-op reaches this stage. They're visible
|
|
now so you can preview the Framework you'll be working through.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Visual cluster of related fields inside a section (e.g. "Market Study"
|
|
* grouping its date + upload fields). Renders as a quiet bordered card
|
|
* with an optional heading; the fields inside use the same two-column
|
|
* grid rules as the standalone per-field grid above/below.
|
|
*/
|
|
function FieldGroupCard({
|
|
label,
|
|
intro,
|
|
fields,
|
|
formValues,
|
|
options,
|
|
register,
|
|
control,
|
|
errors,
|
|
}: {
|
|
label?: string;
|
|
intro?: string;
|
|
fields: FieldConfig[];
|
|
formValues: Record<string, unknown>;
|
|
options: Record<number, SelectOption[]>;
|
|
register: UseFormRegister<FieldValues>;
|
|
control: Control<FieldValues>;
|
|
errors: FieldErrors;
|
|
}) {
|
|
return (
|
|
<div className="rounded-md border border-rule-soft bg-paper-2/30 px-4 py-4 sm:px-5 sm:py-5">
|
|
{label && (
|
|
<h3 className="font-display text-sm font-medium uppercase tracking-[0.08em] text-ink-soft">
|
|
{label}
|
|
</h3>
|
|
)}
|
|
{intro && (
|
|
<p className={"max-w-prose text-xs leading-relaxed text-ink-mute " + (label ? "mt-1" : "")}>
|
|
{intro}
|
|
</p>
|
|
)}
|
|
<div
|
|
className={
|
|
"grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " +
|
|
(label || intro ? "mt-3" : "")
|
|
}
|
|
>
|
|
{fields.map((f) => {
|
|
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
|
|
const wide =
|
|
f.type === "textarea" || f.type === "boolean" || f.type === "multiselect";
|
|
return (
|
|
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
|
|
<FieldRenderer
|
|
field={f}
|
|
register={register}
|
|
control={control}
|
|
errors={errors}
|
|
readonlyValue={formValues[f.name]}
|
|
resolvedOptions={resolvedOptions}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Chevron({ open }: { open: boolean }) {
|
|
return (
|
|
<svg
|
|
aria-hidden
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
|
|
>
|
|
<path d="M5 8 L10 13 L15 8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|