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:
23
components/FieldSet.js
Normal file
23
components/FieldSet.js
Normal 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
126
components/StageForm.js
Normal 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
38
components/StageHeader.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user