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.
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useForm, useWatch, type FieldErrors, type FieldValues } from "react-hook-form";
|
|
import type { FormConfig, FormDataPayload, SubmitPayload, StageSectionConfig } from "@/types/form";
|
|
|
|
function sectionForField(
|
|
sections: ReadonlyArray<StageSectionConfig>,
|
|
fieldName: string,
|
|
): StageSectionConfig | undefined {
|
|
for (const s of sections) {
|
|
if (s.fields.some((f) => f.name === fieldName)) return s;
|
|
for (const m of s.matrixGroups ?? []) {
|
|
for (const row of m.rows) if (row.fields.includes(fieldName)) return s;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
import { evaluate } from "@/lib/conditional";
|
|
import { StageSection } from "./StageSection";
|
|
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
|
|
|
interface EngagementFormProps {
|
|
config: FormConfig;
|
|
cid: string;
|
|
cs: string;
|
|
}
|
|
|
|
type LoadState =
|
|
| { kind: "loading" }
|
|
| { kind: "error"; message: string }
|
|
| { kind: "ready"; data: FormDataPayload };
|
|
|
|
type SubmitStatus =
|
|
| { kind: "idle" }
|
|
| { kind: "submitting" }
|
|
| { kind: "success" }
|
|
| { kind: "error"; message: string };
|
|
|
|
type PathwayState = "past" | "current" | "future";
|
|
|
|
const STAGE_RANK: Record<string, number> = {
|
|
Inquiry: 0,
|
|
Organizing: 1,
|
|
Feasibility: 2,
|
|
"Business feasibility": 3,
|
|
"Store Implementation": 4,
|
|
"Stabilize newly opened co-op": 5,
|
|
};
|
|
|
|
export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
|
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
|
|
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
|
|
const [draftRestored, setDraftRestored] = useState(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
control,
|
|
watch,
|
|
setFocus,
|
|
formState: { errors, isDirty },
|
|
} = useForm({ mode: "onBlur" });
|
|
|
|
// Subscribe ONLY to current_stage. That's the single field that affects
|
|
// section visibility, so re-rendering the whole form on every keystroke
|
|
// (which `watch()` with no args would do) is wasteful — particularly with
|
|
// 120 fields and a 39-cell matrix in Stage 5.
|
|
//
|
|
// The auto-save effect uses watch's subscription API for side-effect-only
|
|
// change tracking (no re-render).
|
|
const currentStageValue =
|
|
(useWatch({ control, name: "current_stage" }) as string | undefined) ?? "";
|
|
|
|
// State map passed to the conditional engine and to FieldRenderer for
|
|
// readonly display values. Only fields referenced by visibility rules or
|
|
// by readonly displays need to be here. Both readonly fields in this form
|
|
// (current_stage, stage_at_submission) display the org's current stage.
|
|
const evalState = useMemo(
|
|
() => ({
|
|
current_stage: currentStageValue,
|
|
stage_at_submission: currentStageValue,
|
|
}),
|
|
[currentStageValue],
|
|
);
|
|
|
|
// ── Initial load ───────────────────────────────────────────────────────
|
|
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();
|
|
let message = `Could not load form (HTTP ${res.status}).`;
|
|
try {
|
|
const j = JSON.parse(text) as { error?: string };
|
|
if (j.error) message = j.error;
|
|
} catch { /* keep default */ }
|
|
if (!cancelled) setLoad({ kind: "error", message });
|
|
return;
|
|
}
|
|
const data: FormDataPayload = await res.json();
|
|
if (cancelled) return;
|
|
|
|
// Build defaults: server prefill + current stage. Then check if a
|
|
// local draft exists newer than the server data; if so, layer it on top.
|
|
const defaults: Record<string, unknown> = {
|
|
[config.stageField]: data.currentStage,
|
|
...data.prefill,
|
|
};
|
|
const draft = loadDraft(cid);
|
|
if (draft && Object.keys(draft.values).length > 0) {
|
|
Object.assign(defaults, draft.values);
|
|
// Re-set the stage from server (draft can never override the org's actual stage).
|
|
defaults[config.stageField] = data.currentStage;
|
|
setDraftRestored(true);
|
|
setDraftSavedAt(draft.savedAt);
|
|
}
|
|
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]);
|
|
|
|
// ── Auto-save draft on idle ────────────────────────────────────────────
|
|
// Debounce — save 1.5s after the user stops editing. Uses watch's
|
|
// subscription API so we get notified on every change WITHOUT re-rendering
|
|
// the form on each keystroke.
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (load.kind !== "ready") return;
|
|
const sub = watch((values) => {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
saveTimer.current = setTimeout(() => {
|
|
saveDraft(cid, values as Record<string, unknown>);
|
|
setDraftSavedAt(new Date().toISOString());
|
|
}, 1500);
|
|
});
|
|
return () => {
|
|
sub.unsubscribe();
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
};
|
|
}, [load.kind, cid, watch]);
|
|
|
|
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) => {
|
|
const pathwayState: PathwayState =
|
|
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
|
return { section: s, pathwayState, locked: pathwayState === "future" };
|
|
}),
|
|
[config.sections, currentRank],
|
|
);
|
|
|
|
// 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.
|
|
const expandSection = useCallback((sectionId: string) => {
|
|
document.dispatchEvent(
|
|
new CustomEvent("coop-checkin:expand-section", { detail: { sectionId } }),
|
|
);
|
|
}, []);
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────
|
|
if (load.kind === "loading") return <LoadingState />;
|
|
if (load.kind === "error") return <ErrorState message={load.message} />;
|
|
|
|
// After a successful submit, replace the form entirely with a destination
|
|
// screen. This prevents accidental duplicate submits (back-button, double-
|
|
// click, browser autofill replay) and gives the user a clear endpoint.
|
|
if (submitState.kind === "success") {
|
|
return (
|
|
<SuccessDestination
|
|
orgName={load.data.orgName}
|
|
onAnother={() => {
|
|
setSubmitState({ kind: "idle" });
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const onSubmit = async (values: Record<string, unknown>) => {
|
|
setSubmitState({ kind: "submitting" });
|
|
try {
|
|
// Strip values for hidden fields — never write data the user couldn't see.
|
|
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);
|
|
}
|
|
for (const m of s.matrixGroups ?? []) {
|
|
for (const row of m.rows) {
|
|
for (const fname of row.fields) visibleFieldNames.add(fname);
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
let message = `Submit failed (HTTP ${res.status}).`;
|
|
try {
|
|
const j = JSON.parse(text) as { error?: string };
|
|
if (j.error) message = j.error;
|
|
} catch { /* keep default */ }
|
|
setSubmitState({ kind: "error", message });
|
|
return;
|
|
}
|
|
// Successful submit — clear draft and show confirmation.
|
|
clearDraft(cid);
|
|
setDraftSavedAt(null);
|
|
setSubmitState({ kind: "success" });
|
|
} catch (e) {
|
|
setSubmitState({
|
|
kind: "error",
|
|
message: e instanceof Error ? e.message : "Unexpected submit error.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// ── Invalid-submit handling ────────────────────────────────────────────
|
|
// When required-field validation blocks submit, RHF calls onInvalid with
|
|
// the errors map. Find the first error's section, expand it, scroll into
|
|
// view, focus the field. Saves users from chasing a silent failure.
|
|
const onInvalid = (errs: FieldErrors<FieldValues>) => {
|
|
const firstErrorField = Object.keys(errs)[0];
|
|
if (!firstErrorField) return;
|
|
const section = sectionForField(config.sections, firstErrorField);
|
|
if (section) {
|
|
expandSection(section.id);
|
|
// Allow the section to expand before focusing.
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
setFocus(firstErrorField);
|
|
} catch {
|
|
// setFocus throws if the field isn't registered; safe to ignore.
|
|
}
|
|
const el = document.getElementById(`field-${firstErrorField}`);
|
|
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
});
|
|
}
|
|
setSubmitState({
|
|
kind: "error",
|
|
message: "Please fill the required fields highlighted below before submitting.",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
|
|
<SubmissionContextHeader
|
|
orgName={load.data.orgName}
|
|
currentStage={load.data.currentStage}
|
|
currentRank={currentRank}
|
|
/>
|
|
|
|
{draftRestored && draftSavedAt && (
|
|
<DraftRestoredNotice savedAt={draftSavedAt} onDiscard={() => {
|
|
clearDraft(cid);
|
|
setDraftRestored(false);
|
|
setDraftSavedAt(null);
|
|
// Force re-fetch by bumping a key — simplest is full page reload.
|
|
window.location.reload();
|
|
}} />
|
|
)}
|
|
|
|
<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="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={pathwayState === "current"}
|
|
locked={locked}
|
|
defaultOpen={pathwayState === "current" || section.rank === 0}
|
|
options={load.data.options ?? {}}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
|
|
<SubmitBar
|
|
state={submitState}
|
|
isDirty={isDirty}
|
|
draftSavedAt={draftSavedAt}
|
|
/>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function SubmissionContextHeader({
|
|
orgName,
|
|
currentStage,
|
|
currentRank,
|
|
}: {
|
|
orgName: string;
|
|
currentStage: string;
|
|
currentRank: number;
|
|
}) {
|
|
return (
|
|
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
|
|
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Check-in for</p>
|
|
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
|
{orgName}
|
|
</h1>
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<StageProgress currentRank={currentRank} />
|
|
<span className="text-sm text-ink-soft">
|
|
<span className="text-ink-mute">Stage</span>{" "}
|
|
<span className="font-medium text-leaf-800">{currentStage || "—"}</span>
|
|
</span>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Six small dots representing the six stages, with the current rank filled
|
|
* and earlier ranks marked as visited. Quietly conveys progression without
|
|
* pretending to be a "100% complete" progress bar.
|
|
*/
|
|
function StageProgress({ currentRank }: { currentRank: number }) {
|
|
return (
|
|
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
|
{[0, 1, 2, 3, 4, 5].map((r) => (
|
|
<span
|
|
key={r}
|
|
className={
|
|
"h-1.5 rounded-full transition-all " +
|
|
(r < currentRank
|
|
? "w-2.5 bg-leaf-300"
|
|
: r === currentRank
|
|
? "w-6 bg-leaf-700"
|
|
: "w-2.5 bg-rule-soft")
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraftRestoredNotice({
|
|
savedAt,
|
|
onDiscard,
|
|
}: {
|
|
savedAt: string;
|
|
onDiscard: () => void;
|
|
}) {
|
|
const ago = formatRelative(savedAt);
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="flex flex-col gap-2 rounded-lg border border-clay-200 bg-clay-100/40 px-5 py-3 text-sm text-clay-700 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<p>
|
|
<span className="font-medium">Draft restored</span> from {ago}. Your in-progress edits were
|
|
saved locally and have been re-applied.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onDiscard}
|
|
className="self-start rounded border border-clay-400/40 bg-paper px-3 py-1 text-xs font-medium text-clay-700 transition hover:bg-clay-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-clay-500/40 sm:self-auto"
|
|
>
|
|
Discard draft
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitBar({
|
|
state,
|
|
isDirty,
|
|
draftSavedAt,
|
|
}: {
|
|
state: SubmitStatus;
|
|
isDirty: boolean;
|
|
draftSavedAt: string | null;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
|
|
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<SubmitFeedback state={state} />
|
|
{state.kind === "idle" && (
|
|
<p className="text-xs text-ink-mute">
|
|
{isDirty
|
|
? draftSavedAt
|
|
? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)`
|
|
: "Editing — your draft will save automatically"
|
|
: "Submitting saves a new check-in record on your co-op."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={state.kind === "submitting"}
|
|
className="inline-flex items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
|
|
>
|
|
{state.kind === "submitting" ? (
|
|
<>
|
|
<Spinner /> Saving check-in…
|
|
</>
|
|
) : (
|
|
<>Submit check-in</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmitFeedback({ state }: { state: SubmitStatus }) {
|
|
if (state.kind === "submitting")
|
|
return (
|
|
<p className="text-sm text-ink-soft" role="status">
|
|
Saving your check-in…
|
|
</p>
|
|
);
|
|
if (state.kind === "error")
|
|
return (
|
|
<p className="text-sm font-medium text-clay-700" role="alert">
|
|
✕ {state.message}
|
|
</p>
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg
|
|
aria-hidden
|
|
viewBox="0 0 16 16"
|
|
className="h-4 w-4 animate-spin"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M14 8a6 6 0 11-6-6" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LoadingState() {
|
|
return (
|
|
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
|
|
<div
|
|
aria-hidden
|
|
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
|
|
/>
|
|
<p className="font-display text-base text-ink-soft italic">Loading your check-in…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuccessDestination({
|
|
orgName,
|
|
onAnother,
|
|
}: {
|
|
orgName: string;
|
|
onAnother: () => void;
|
|
}) {
|
|
return (
|
|
<section
|
|
role="status"
|
|
aria-live="polite"
|
|
className="rounded-lg border-2 border-leaf-600 bg-leaf-50/40 px-6 py-10 text-center shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(60,80,40,0.22)] sm:px-10 sm:py-12"
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm"
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5 12l5 5 9-11" />
|
|
</svg>
|
|
</div>
|
|
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
|
|
Check-in saved
|
|
</h2>
|
|
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
|
Thank you. We've recorded this check-in for{" "}
|
|
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
|
|
it on their next review.
|
|
</p>
|
|
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
<button
|
|
type="button"
|
|
onClick={onAnother}
|
|
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
|
|
>
|
|
Submit another check-in
|
|
</button>
|
|
<p className="text-xs text-ink-mute">It's safe to close this window.</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ErrorState({ message }: { message: string }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7"
|
|
>
|
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
|
We couldn't open your check-in.
|
|
</h2>
|
|
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
|
|
<p className="mt-4 text-sm text-ink-soft">
|
|
If this keeps happening, please contact your engagement coordinator and ask for a fresh
|
|
link.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const seconds = Math.round((Date.now() - then) / 1000);
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes} min ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.round(hours / 24);
|
|
if (days < 30) return `${days} days ago`;
|
|
if (days < 365) return `${Math.round(days / 30)} months ago`;
|
|
return `${Math.round(days / 365)} years ago`;
|
|
}
|