Build standalone CiviCRM check-in middleware
This commit is contained in:
264
components/EngagementForm.tsx
Normal file
264
components/EngagementForm.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form";
|
||||
import { evaluate } from "@/lib/conditional";
|
||||
import { StageSection } from "./StageSection";
|
||||
|
||||
interface EngagementFormProps {
|
||||
config: FormConfig;
|
||||
cid: string;
|
||||
cs: string;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; data: FormDataPayload };
|
||||
|
||||
/**
|
||||
* Top-level form orchestrator. Responsible for:
|
||||
* - fetching prefill data + the org's current stage from /api/data
|
||||
* - hydrating react-hook-form with prefill values
|
||||
* - re-evaluating section visibility against live form values on every change
|
||||
* - submitting to /api/submit and surfacing success / error feedback
|
||||
*/
|
||||
export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||||
const [submitState, setSubmitState] = useState<
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({ mode: "onBlur" });
|
||||
|
||||
// Subscribe to all form values so section visibility re-evaluates live.
|
||||
const formValues = watch();
|
||||
|
||||
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();
|
||||
if (!cancelled) {
|
||||
setLoad({ kind: "error", message: `Could not load form (${res.status}). ${text}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data: FormDataPayload = await res.json();
|
||||
if (cancelled) return;
|
||||
// Merge stage value + per-field prefill into a single defaults map.
|
||||
const defaults = {
|
||||
[config.stageField]: data.currentStage,
|
||||
...data.prefill,
|
||||
};
|
||||
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]);
|
||||
|
||||
const sectionsToRender = useMemo(() => {
|
||||
return config.sections.map((s) => ({
|
||||
section: s,
|
||||
sectionVisible: evaluate(s.visibleWhen, formValues),
|
||||
}));
|
||||
}, [config.sections, formValues]);
|
||||
|
||||
// The "current" rank: highest section rank whose visibility rule matches
|
||||
// the current stage value. If nothing matches, fall back to 0 (Inquiry).
|
||||
const currentRank = useMemo(() => {
|
||||
const visibleRanks = sectionsToRender
|
||||
.filter((s) => s.sectionVisible)
|
||||
.map((s) => s.section.rank);
|
||||
return visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0;
|
||||
}, [sectionsToRender]);
|
||||
|
||||
if (load.kind === "loading") {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (load.kind === "error") {
|
||||
return <ErrorState message={load.message} />;
|
||||
}
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
setSubmitState({ kind: "submitting" });
|
||||
try {
|
||||
// Strip values for fields whose section or field rule isn't currently visible —
|
||||
// those aren't part of the user's intent in this submission.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
setSubmitState({ kind: "error", message: `Submit failed (${res.status}). ${text}` });
|
||||
return;
|
||||
}
|
||||
setSubmitState({ kind: "success" });
|
||||
} catch (e) {
|
||||
setSubmitState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : "Unexpected submit error.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
|
||||
<SubmissionContextHeader
|
||||
orgName={load.data.orgName}
|
||||
currentStage={load.data.currentStage}
|
||||
/>
|
||||
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
return (
|
||||
<StageSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
register={register}
|
||||
errors={errors}
|
||||
formValues={formValues}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="sticky bottom-4 z-10 flex flex-col-reverse gap-3 rounded-xl border border-stone-200 bg-white/95 px-5 py-4 shadow-lg backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<SubmitFeedback state={submitState} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState.kind === "submitting"}
|
||||
className={
|
||||
"inline-flex items-center justify-center rounded-md px-5 py-2.5 font-medium text-white transition " +
|
||||
"bg-leaf-700 hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
||||
"disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
}
|
||||
>
|
||||
{submitState.kind === "submitting" ? "Saving…" : "Submit check-in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionContextHeader({
|
||||
orgName,
|
||||
currentStage,
|
||||
}: {
|
||||
orgName: string;
|
||||
currentStage: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white px-5 py-4 shadow-sm">
|
||||
<p className="text-sm text-stone-600">Check-in for</p>
|
||||
<h1 className="mt-0.5 text-xl font-semibold text-stone-900">{orgName}</h1>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
Framework Stage:{" "}
|
||||
<span className="rounded-full bg-leaf-100 px-2 py-0.5 text-leaf-800 text-sm font-medium">
|
||||
{currentStage || "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitFeedback({
|
||||
state,
|
||||
}: {
|
||||
state:
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting" }
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
}) {
|
||||
if (state.kind === "idle") {
|
||||
return <p className="text-sm text-stone-600">Your changes will be saved as a new check-in record.</p>;
|
||||
}
|
||||
if (state.kind === "submitting") {
|
||||
return (
|
||||
<p className="text-sm text-stone-700" role="status">
|
||||
Saving your check-in…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.kind === "success") {
|
||||
return (
|
||||
<p className="text-sm font-medium text-leaf-800" role="status">
|
||||
✓ Check-in saved. Thank you.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm font-medium text-red-700" role="alert">
|
||||
✕ {state.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white px-6 py-12 text-center text-stone-600 shadow-sm">
|
||||
<div className="mx-auto mb-3 h-6 w-6 animate-spin rounded-full border-2 border-leaf-200 border-t-leaf-700" />
|
||||
<p>Loading your check-in form…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-red-200 bg-red-50 px-6 py-6 text-red-900"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">We couldn't load your check-in form.</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed">{message}</p>
|
||||
<p className="mt-3 text-sm">
|
||||
If this problem persists, please contact your engagement coordinator and ask for a fresh
|
||||
link.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function FieldSet({ fields, register }) {
|
||||
return (
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4 bg-white border-x border-b rounded-b-lg -mt-4 mb-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="flex flex-col">
|
||||
<label htmlFor={field.name} className="text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
{...register(field.name)}
|
||||
className="border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition"
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/SiteChrome.tsx
Normal file
77
components/SiteChrome.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link
|
||||
href="https://fci.coop"
|
||||
className="flex items-center gap-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 rounded"
|
||||
>
|
||||
<Logo />
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-stone-900">Food Co-op Initiative</span>
|
||||
<span className="text-xs text-stone-600">Co-op Check-in</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav aria-label="Primary">
|
||||
<Link
|
||||
href="https://fci.coop"
|
||||
className="text-sm font-medium text-stone-700 hover:text-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 rounded px-2 py-1"
|
||||
>
|
||||
fci.coop ↗
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-stone-200 bg-stone-50">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 text-sm text-stone-600">
|
||||
<p>
|
||||
Securely connected to your co-op's record. Your data goes only to your CRM —
|
||||
nothing is shared with third parties.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Questions? Contact your engagement coordinator for a fresh link or to update your records.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG logo placeholder. Distinctive but neutral until the user supplies
|
||||
* the actual fci.coop logo asset to drop into /public.
|
||||
*
|
||||
* Geometry: a stylized "co-op leaf" — three overlapping leaf shapes forming a
|
||||
* loose cooperative motif. Pure CSS color so it adapts to dark/light themes.
|
||||
*/
|
||||
function Logo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
role="img"
|
||||
aria-label="Food Co-op Initiative"
|
||||
className="text-leaf-700"
|
||||
>
|
||||
<circle cx="20" cy="20" r="19" fill="currentColor" opacity="0.08" />
|
||||
<path
|
||||
d="M20 6 C 24 12, 30 14, 30 20 C 30 26, 24 30, 20 30 C 16 30, 10 26, 10 20 C 10 14, 16 12, 20 6 Z"
|
||||
fill="currentColor"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<path
|
||||
d="M20 11 C 22 15, 26 17, 26 20 C 26 23, 22 26, 20 26 C 18 26, 14 23, 14 20 C 14 17, 18 15, 20 11 Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<circle cx="20" cy="20" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import mapping from '@/config/mapping.json';
|
||||
import StageHeader from './StageHeader';
|
||||
import FieldSet from './FieldSet';
|
||||
|
||||
export default function StageForm({ contactId, orgId }) {
|
||||
const [currentOrgStage, setCurrentOrgStage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedStages, setExpandedStages] = useState([0]);
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
|
||||
const { register, handleSubmit, reset } = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const res = await fetch(`/api/data?orgId=${orgId}&contactId=${contactId}`);
|
||||
const data = await res.json();
|
||||
|
||||
setCurrentOrgStage(data.currentStage);
|
||||
|
||||
// Map CiviCRM prefill data to form field names
|
||||
const formValues = {};
|
||||
mapping.stages.forEach(stage => {
|
||||
stage.fields.forEach(field => {
|
||||
if (data.prefillData[field.crmField] !== undefined) {
|
||||
formValues[field.name] = data.prefillData[field.crmField];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize form with mapped values
|
||||
reset(formValues);
|
||||
|
||||
// Expand up to the current stage by default
|
||||
setExpandedStages(Array.from({ length: data.currentStage + 1 }, (_, i) => i));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [contactId, orgId, reset]);
|
||||
|
||||
const toggleStage = (stageId) => {
|
||||
if (stageId <= currentOrgStage) {
|
||||
setExpandedStages(prev =>
|
||||
prev.includes(stageId) ? prev.filter(id => id !== stageId) : [...prev, stageId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (formData) => {
|
||||
setSubmitStatus('submitting');
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contactId, orgId, formData }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setSubmitStatus('success');
|
||||
setTimeout(() => setSubmitStatus(null), 3000);
|
||||
} else {
|
||||
setSubmitStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Loading form...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 bg-white shadow-lg rounded-xl my-8">
|
||||
<header className="mb-8 border-b pb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800">CiviCRM Stage Progression</h1>
|
||||
<p className="text-gray-600">Update your details as you progress through stages.</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{mapping.stages.map((stage) => {
|
||||
const isVisible = stage.id <= currentOrgStage;
|
||||
const isExpanded = expandedStages.includes(stage.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={stage.id}>
|
||||
<StageHeader
|
||||
stage={stage}
|
||||
currentOrgStage={currentOrgStage}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleStage(stage.id)}
|
||||
/>
|
||||
{isVisible && isExpanded && (
|
||||
<FieldSet
|
||||
fields={stage.fields}
|
||||
register={register}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-6 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
{submitStatus === 'submitting' && <span className="text-blue-600">Saving...</span>}
|
||||
{submitStatus === 'success' && <span className="text-green-600">✓ Changes saved successfully!</span>}
|
||||
{submitStatus === 'error' && <span className="text-red-600">✗ Failed to save changes.</span>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitStatus === 'submitting'}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition disabled:bg-blue-300"
|
||||
>
|
||||
Submit Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function StageHeader({ stage, currentOrgStage, isExpanded, onToggle }) {
|
||||
const isLocked = stage.id > currentOrgStage;
|
||||
const isPast = stage.id < currentOrgStage;
|
||||
const isCurrent = stage.id === currentOrgStage;
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg mb-4 overflow-hidden ${isLocked ? 'opacity-50' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between p-4 text-left font-semibold ${
|
||||
isCurrent ? 'bg-blue-50 text-blue-700' : 'bg-gray-50'
|
||||
} ${isLocked ? 'cursor-not-allowed' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`w-8 h-8 flex items-center justify-center rounded-full text-sm ${
|
||||
isPast ? 'bg-green-100 text-green-700' :
|
||||
isCurrent ? 'bg-blue-600 text-white' :
|
||||
'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isPast ? '✓' : stage.id}
|
||||
</span>
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<span className="text-gray-400">
|
||||
{isExpanded ? '−' : '+'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
components/StageSection.tsx
Normal file
144
components/StageSection.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import type { StageSectionConfig } from "@/types/form";
|
||||
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
||||
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||
import { evaluate } from "@/lib/conditional";
|
||||
|
||||
interface StageSectionProps {
|
||||
section: StageSectionConfig;
|
||||
register: UseFormRegister<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 (for the "current" badge). */
|
||||
isCurrent: boolean;
|
||||
/** Whether the section starts open. */
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function StageSection({
|
||||
section,
|
||||
register,
|
||||
errors,
|
||||
formValues,
|
||||
isCurrent,
|
||||
defaultOpen,
|
||||
}: StageSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const headingId = useId();
|
||||
const panelId = useId();
|
||||
|
||||
const visibleFields = section.fields.filter((f) => evaluate(f.visibleWhen, formValues));
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className={
|
||||
"overflow-hidden rounded-xl border bg-white shadow-sm transition " +
|
||||
(isCurrent
|
||||
? "border-leaf-300 ring-1 ring-leaf-200"
|
||||
: "border-stone-200")
|
||||
}
|
||||
>
|
||||
<h2 id={headingId} className="m-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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")
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{isCurrent && (
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-leaf-700">
|
||||
Current stage
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
hidden={!open}
|
||||
className="border-t border-stone-100"
|
||||
>
|
||||
<div className="px-5 py-5 sm:px-6 sm:py-6">
|
||||
{section.intro && (
|
||||
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
|
||||
{visibleFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={f.type === "textarea" || f.type === "boolean" ? "md:col-span-2" : ""}
|
||||
>
|
||||
<FieldRenderer
|
||||
field={f}
|
||||
register={register}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RankBadge({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={"h-5 w-5 text-stone-500 transition-transform " + (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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
266
components/fields/FieldRenderer.tsx
Normal file
266
components/fields/FieldRenderer.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import type { FieldConfig } from "@/types/form";
|
||||
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** For readonly display fields, the value to render. */
|
||||
readonlyValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single field appropriate to its `type`. All inputs share a common
|
||||
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
||||
* to help/error text, aria-invalid set when in error, and visible focus.
|
||||
*
|
||||
* Currency, percent, and number all use input type="number" with appropriate
|
||||
* `step` and inputMode for mobile keyboards. We keep formatting light — the
|
||||
* server is the source of truth for normalization.
|
||||
*/
|
||||
export function FieldRenderer({ field, register, errors, readonlyValue }: FieldRendererProps) {
|
||||
const id = `field-${field.name}`;
|
||||
const helpId = field.help ? `${id}-help` : undefined;
|
||||
const errorId = errors[field.name] ? `${id}-error` : undefined;
|
||||
const describedBy = [helpId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
const errorMsg = errors[field.name]?.message as string | undefined;
|
||||
|
||||
const baseInputClass =
|
||||
"w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " +
|
||||
"placeholder:text-stone-400 shadow-sm transition " +
|
||||
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/40 " +
|
||||
"disabled:bg-stone-50 disabled:text-stone-500 " +
|
||||
"aria-invalid:border-red-500 aria-invalid:ring-red-500/30";
|
||||
|
||||
// ── Readonly display field ──────────────────────────────────────────────
|
||||
if (field.type === "readonly") {
|
||||
const display = readonlyValue == null || readonlyValue === "" ? "—" : String(readonlyValue);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<div
|
||||
id={id}
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
className="rounded-md border border-stone-200 bg-stone-50 px-3 py-2 text-stone-700"
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Boolean (checkbox) ──────────────────────────────────────────────────
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
{...register(field.name)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-400 text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={id} className="font-medium text-stone-800 cursor-pointer">
|
||||
{field.label}
|
||||
{field.required && <RequiredMark />}
|
||||
</label>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Textarea ────────────────────────────────────────────────────────────
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<textarea
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
rows={4}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass + " min-h-[7rem] leading-6"}
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select ──────────────────────────────────────────────────────────────
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<select
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass + " bg-white"}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select…
|
||||
</option>
|
||||
{(field.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File ────────────────────────────────────────────────────────────────
|
||||
if (field.type === "file") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className="block w-full text-sm text-stone-700 file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer"
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Numeric family: number / currency / percent ─────────────────────────
|
||||
if (field.type === "number" || field.type === "currency" || field.type === "percent") {
|
||||
const prefix = field.type === "currency" ? "$" : null;
|
||||
const suffix = field.type === "percent" ? "%" : null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<div className="relative">
|
||||
{prefix && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-stone-500"
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step ?? (field.type === "number" ? 1 : 0.01)}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
{...register(field.name, {
|
||||
required: field.required,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
className={
|
||||
baseInputClass +
|
||||
(prefix ? " pl-7" : "") +
|
||||
(suffix ? " pr-8" : "")
|
||||
}
|
||||
/>
|
||||
{suffix && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-stone-500"
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Default: text-like (text / email / phone / date) ────────────────────
|
||||
const inputType =
|
||||
field.type === "email" ? "email" :
|
||||
field.type === "phone" ? "tel" :
|
||||
field.type === "date" ? "date" :
|
||||
"text";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<input
|
||||
id={id}
|
||||
type={inputType}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
autoComplete={
|
||||
field.type === "email" ? "email" :
|
||||
field.type === "phone" ? "tel" :
|
||||
undefined
|
||||
}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className={baseInputClass}
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||
return (
|
||||
<label htmlFor={id} className="block text-sm font-medium text-stone-800">
|
||||
{field.label}
|
||||
{field.required && <RequiredMark />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return (
|
||||
<span aria-label="required" className="ml-1 text-red-600">
|
||||
*
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Help({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} className="text-xs text-stone-600">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} role="alert" className="text-xs font-medium text-red-700">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user