Visual identity: Field Almanac — Fraunces+DM Sans, OKLCH cream/leaf/clay palette, paper texture, hand-drawn stage icons, draft auto-save, stage progress dots
This commit is contained in:
@@ -5,6 +5,7 @@ import type { StageSectionConfig, SelectOption } from "@/types/form";
|
||||
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
||||
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||
import { MatrixGroup } from "./MatrixGroup";
|
||||
import { StageIcon } from "./StageIcon";
|
||||
import { evaluate } from "@/lib/conditional";
|
||||
|
||||
interface StageSectionProps {
|
||||
@@ -13,18 +14,26 @@ interface StageSectionProps {
|
||||
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). */
|
||||
/** Whether this is the section that matches the current org stage. */
|
||||
isCurrent: boolean;
|
||||
/** Whether the section starts open. */
|
||||
defaultOpen: boolean;
|
||||
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
||||
options: Record<number, SelectOption[]>;
|
||||
/** Optional last-checked-in timestamp for this stage (ISO date). */
|
||||
lastTouched?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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,
|
||||
@@ -34,13 +43,12 @@ export function StageSection({
|
||||
isCurrent,
|
||||
defaultOpen,
|
||||
options,
|
||||
lastTouched,
|
||||
}: StageSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const headingId = useId();
|
||||
const panelId = useId();
|
||||
|
||||
// Names of fields that appear inside any matrix group; excluded from the
|
||||
// standalone per-field grid so they don't render twice.
|
||||
const matrixFieldNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const m of section.matrixGroups ?? []) {
|
||||
@@ -53,14 +61,21 @@ export function StageSection({
|
||||
(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,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className={
|
||||
"overflow-hidden rounded-xl border bg-white shadow-sm transition " +
|
||||
"overflow-hidden rounded-lg bg-white/95 transition " +
|
||||
(isCurrent
|
||||
? "border-leaf-300 ring-1 ring-leaf-200"
|
||||
: "border-stone-200")
|
||||
? "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")
|
||||
}
|
||||
>
|
||||
<h2 id={headingId} className="m-0">
|
||||
@@ -70,20 +85,31 @@ export function StageSection({
|
||||
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")
|
||||
"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")
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
|
||||
<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">
|
||||
{section.label}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
||||
{isCurrent && (
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-leaf-700">
|
||||
<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>
|
||||
)}
|
||||
<span className="tabular-nums">
|
||||
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
||||
</span>
|
||||
{lastTouched && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>Last touched {formatRelative(lastTouched)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron open={open} />
|
||||
@@ -95,23 +121,25 @@ export function StageSection({
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
hidden={!open}
|
||||
className="border-t border-stone-100"
|
||||
className="border-t border-rule-soft"
|
||||
>
|
||||
<div className="px-5 py-5 sm:px-6 sm:py-6">
|
||||
<div className="px-5 py-6 sm:px-7 sm:py-7">
|
||||
{section.intro && (
|
||||
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
|
||||
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
||||
{section.intro}
|
||||
</p>
|
||||
)}
|
||||
{(section.matrixGroups ?? []).length > 0 && (
|
||||
<div className="mb-6 space-y-5">
|
||||
<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-stone-500">No fields are visible at this stage.</p>
|
||||
<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-6 gap-y-5 md:grid-cols-2">
|
||||
<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 =
|
||||
@@ -138,18 +166,30 @@ export function StageSection({
|
||||
);
|
||||
}
|
||||
|
||||
function RankBadge({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||
/**
|
||||
* 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 }: { 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 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")
|
||||
}
|
||||
/>
|
||||
<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")
|
||||
}
|
||||
style={{ marginLeft: 28, marginTop: 28 }}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -159,14 +199,24 @@ function Chevron({ open }: { open: boolean }) {
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={"h-5 w-5 text-stone-500 transition-transform " + (open ? "rotate-180" : "")}
|
||||
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
|
||||
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"
|
||||
/>
|
||||
<path d="M5 8 L10 13 L15 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return iso;
|
||||
const days = Math.round((Date.now() - then) / (1000 * 60 * 60 * 24));
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 30) return `${days} days ago`;
|
||||
if (days < 365) return `${Math.round(days / 30)} months ago`;
|
||||
return `${Math.round(days / 365)} years ago`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user