Future stages now render as preview-only "look ahead" cards instead of being hidden. A user at stage 2 can see headers and contents for stages 3, 4, 5, but those sections are visibly locked and uneditable. Locked-card treatment: - Dashed-rule border, paper-2 fill, no shadow — visually quieter than active cards - "Upcoming" pill in the header with a small lock glyph - Muted stage rank mark (dashed badge, low-opacity icon) - Panel content wrapped in fieldset[disabled] so every form control inside is natively non-interactive, with an opacity tweak for affordance - "A look ahead" banner explaining that fields will become editable when the co-op reaches this stage Section visibleWhen is still consulted on submit, so locked-stage values never get written back to CiviCRM even if data is prefilled. Journey rail: - Vertical rail (md+) in a new left gutter; each card carries an aligned marker. Past stages = filled leaf circle with check; current = filled leaf disc with rank number, leaf-100 halo ring, and a subtle rail-pulse box-shadow animation (motion-safe). A "Now" pill sits beneath the current marker. Future stages = dashed hollow ring with lock glyph. - Connector segments between markers are solid leaf when the next stage is past-or-current, dashed muted when future — so the transition from "traveled" to "ahead" reads at the right place in the journey. - Mobile fallback: a small vertical stem in the gap between adjacent cards, styled the same way (solid vs dashed) so the progression cue still reads on narrow viewports.
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
import type { 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]);
|
|
|
|
const visibleFields = section.fields.filter(
|
|
(f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name),
|
|
);
|
|
|
|
const fieldCount =
|
|
visibleFields.length +
|
|
(section.matrixGroups ?? []).reduce(
|
|
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
|
|
0,
|
|
);
|
|
|
|
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>
|
|
)}
|
|
{visibleFields.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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|