Locked future-stage cards + journey rail
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.
This commit is contained in:
@@ -37,6 +37,8 @@ type SubmitStatus =
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
type PathwayState = "past" | "current" | "future";
|
||||
|
||||
const STAGE_RANK: Record<string, number> = {
|
||||
Inquiry: 0,
|
||||
Organizing: 1,
|
||||
@@ -157,18 +159,25 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
};
|
||||
}, [load.kind, cid, watch]);
|
||||
|
||||
// ── Section ordering ───────────────────────────────────────────────────
|
||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
||||
|
||||
// Section pathway state — past / current / future is determined entirely
|
||||
// by stage rank vs the org's current rank. Sections never hide; future
|
||||
// stages render in a locked treatment so users can preview what's ahead.
|
||||
//
|
||||
// Section-level visibleWhen is still consulted on submit to strip
|
||||
// locked-stage values from the payload (see onSubmit below), so a future
|
||||
// stage's data never gets accidentally written.
|
||||
const sectionsToRender = useMemo(
|
||||
() =>
|
||||
config.sections.map((s) => ({
|
||||
section: s,
|
||||
sectionVisible: evaluate(s.visibleWhen, evalState),
|
||||
})),
|
||||
[config.sections, evalState],
|
||||
config.sections.map((s) => {
|
||||
const pathwayState: PathwayState =
|
||||
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
||||
return { section: s, pathwayState, locked: pathwayState === "future" };
|
||||
}),
|
||||
[config.sections, currentRank],
|
||||
);
|
||||
|
||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
||||
|
||||
// Track which sections the user has manually opened/closed so we can
|
||||
// programmatically re-open them when validation surfaces an error within.
|
||||
// Used by the onInvalid handler below.
|
||||
@@ -290,19 +299,30 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
}} />
|
||||
)}
|
||||
|
||||
<ol className="space-y-5">
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
<ol className="relative space-y-5 md:pl-20">
|
||||
{sectionsToRender.map((entry, i) => {
|
||||
const { section, pathwayState, locked } = entry;
|
||||
const nextState =
|
||||
i < sectionsToRender.length - 1
|
||||
? sectionsToRender[i + 1].pathwayState
|
||||
: undefined;
|
||||
return (
|
||||
<li key={section.id} className="list-none">
|
||||
<li key={section.id} className="relative list-none">
|
||||
<MobileStem show={i > 0} state={pathwayState} />
|
||||
<RailMarker
|
||||
rank={section.rank}
|
||||
state={pathwayState}
|
||||
nextState={nextState}
|
||||
/>
|
||||
<StageSection
|
||||
section={section}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
formValues={evalState}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
isCurrent={pathwayState === "current"}
|
||||
locked={locked}
|
||||
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||
options={load.data.options ?? {}}
|
||||
/>
|
||||
</li>
|
||||
@@ -545,6 +565,99 @@ function ErrorState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical journey rail marker, visible on md+ only. Sits in the OL's left
|
||||
* gutter, aligned to the card header. Includes a downward connecting line
|
||||
* toward the next marker; the line's style is determined by whether the
|
||||
* NEXT stage is past/current (solid leaf) or future (dashed muted) — that
|
||||
* way the transition from "traveled" to "ahead" happens right after the
|
||||
* current marker, which reads correctly as "you've come this far."
|
||||
*/
|
||||
function RailMarker({
|
||||
rank,
|
||||
state,
|
||||
nextState,
|
||||
}: {
|
||||
rank: number;
|
||||
state: PathwayState;
|
||||
nextState?: PathwayState;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none hidden md:block absolute -left-14 top-0 bottom-0 w-12"
|
||||
>
|
||||
{nextState && (
|
||||
<span
|
||||
className={
|
||||
"absolute left-1/2 -translate-x-1/2 top-12 -bottom-11 " +
|
||||
(nextState === "future"
|
||||
? "w-0 border-l border-dashed border-rule"
|
||||
: "w-px bg-leaf-500/70")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MarkerCircle rank={rank} state={state} />
|
||||
{state === "current" && (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-[60px] whitespace-nowrap rounded-full bg-leaf-700 px-1.5 py-[1px] text-[9px] font-medium uppercase tracking-[0.12em] text-paper shadow-sm">
|
||||
Now
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
|
||||
if (state === "past") {
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
|
||||
<path d="M3 6.5l2 2 4-5" />
|
||||
</svg>
|
||||
<span className="sr-only">Stage {rank} (completed)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === "current") {
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-7 w-7 items-center justify-center rounded-full bg-leaf-700 text-paper text-[11px] font-semibold shadow-md ring-4 ring-leaf-100 motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite]">
|
||||
{rank}
|
||||
<span className="sr-only">Stage {rank} (current)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
|
||||
<svg 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>
|
||||
<span className="sr-only">Stage {rank} (upcoming)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-only inter-card stem. Sits centered in the gap between adjacent
|
||||
* stage cards. Solid leaf when arriving at a past-or-current stage; dashed
|
||||
* muted when arriving at a future (locked) stage.
|
||||
*/
|
||||
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
|
||||
<span
|
||||
className={
|
||||
"block h-4 " +
|
||||
(state === "future"
|
||||
? "w-0 border-l border-dashed border-rule"
|
||||
: "w-px bg-leaf-500/70")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return iso;
|
||||
|
||||
@@ -22,6 +22,12 @@ interface StageSectionProps {
|
||||
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. */
|
||||
@@ -46,6 +52,7 @@ export function StageSection({
|
||||
errors,
|
||||
formValues,
|
||||
isCurrent,
|
||||
locked,
|
||||
defaultOpen,
|
||||
options,
|
||||
}: StageSectionProps) {
|
||||
@@ -83,15 +90,22 @@ export function StageSection({
|
||||
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 bg-white/95 transition " +
|
||||
(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)]"
|
||||
: "border border-rule shadow-sm")
|
||||
}
|
||||
className={"overflow-hidden rounded-lg transition " + cardClass}
|
||||
>
|
||||
<h2 id={headingId} className="m-0">
|
||||
<button
|
||||
@@ -101,12 +115,17 @@ export function StageSection({
|
||||
aria-controls={panelId}
|
||||
className={
|
||||
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
||||
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
|
||||
buttonClass
|
||||
}
|
||||
>
|
||||
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
|
||||
<StageRankMark rank={section.rank} isCurrent={isCurrent} locked={locked} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block font-display text-lg font-medium text-ink leading-tight tracking-tight sm:text-xl">
|
||||
<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">
|
||||
@@ -116,6 +135,12 @@ export function StageSection({
|
||||
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>
|
||||
@@ -130,9 +155,17 @@ export function StageSection({
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
hidden={!open}
|
||||
className="border-t border-rule-soft"
|
||||
className={"border-t " + (locked ? "border-rule" : "border-rule-soft")}
|
||||
>
|
||||
<div className="px-5 py-6 sm:px-7 sm:py-7">
|
||||
<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}
|
||||
@@ -170,7 +203,7 @@ export function StageSection({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -181,20 +214,35 @@ export function StageSection({
|
||||
* icon offset behind it. When current, the ring is leaf-green and the
|
||||
* number is white-on-green; otherwise it's quiet.
|
||||
*/
|
||||
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||
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 " +
|
||||
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
|
||||
}
|
||||
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 " +
|
||||
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
|
||||
badgeClass
|
||||
}
|
||||
style={{ marginLeft: 28, marginTop: 28 }}
|
||||
>
|
||||
@@ -204,6 +252,42 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user