Implement CiviCRM middleware form with stage-based visibility

- Initialize Next.js project with Tailwind CSS
- Create CiviCRM APIv4 integration layer
- Implement stage-based form visibility (stages 0-5)
- Add field mapping configuration for CRM-to-form linking
- Create API routes for data retrieval and submission
- Record form submissions as CiviCRM activities
- Support dynamic contactId and orgId via URL parameters
- Ensure robust form state management with react-hook-form

Co-authored-by: joelbrock <52835+joelbrock@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-05-09 08:12:39 +00:00
commit 0899e6ae9a
25 changed files with 7466 additions and 0 deletions

23
components/FieldSet.js Normal file
View File

@@ -0,0 +1,23 @@
'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>
);
}

126
components/StageForm.js Normal file
View File

@@ -0,0 +1,126 @@
'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>
);
}

38
components/StageHeader.js Normal file
View File

@@ -0,0 +1,38 @@
'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>
);
}