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

60
app/api/data/route.js Normal file
View File

@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { civicrmApi } from '@/lib/civicrm';
import mapping from '@/config/mapping.json';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const orgId = searchParams.get('orgId');
const contactId = searchParams.get('contactId');
if (!orgId || !contactId) {
return NextResponse.json({ error: 'Missing orgId or contactId' }, { status: 400 });
}
try {
// 1. Fetch current stage from Organization (Contact record with type Organization)
const orgResult = await civicrmApi('Contact', 'get', {
select: [mapping.stageField],
where: [['id', '=', parseInt(orgId)]],
});
const currentStage = orgResult.values[0]?.[mapping.stageField] || 0;
// 2. Prepare select fields for pre-filling contact data
const selectFields = ['id'];
mapping.stages.forEach(stage => {
stage.fields.forEach(field => {
if (field.entity === 'Contact') {
selectFields.push(field.crmField);
}
});
});
// 3. Fetch contact data for pre-filling
const contactResult = await civicrmApi('Contact', 'get', {
select: selectFields,
where: [['id', '=', parseInt(contactId)]],
});
const contactData = contactResult.values[0] || {};
return NextResponse.json({
currentStage: parseInt(currentStage),
prefillData: contactData,
});
} catch (error) {
console.error('API Error:', error);
// Return mock data if CiviCRM is not reachable (for development/demo)
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({
currentStage: 2,
prefillData: {
first_name: 'John',
last_name: 'Doe',
'email_primary.email': 'john@example.com'
}
});
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

55
app/api/submit/route.js Normal file
View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { civicrmApi } from '@/lib/civicrm';
import mapping from '@/config/mapping.json';
export async function POST(request) {
try {
const body = await request.json();
const { contactId, orgId, formData } = body;
if (!contactId || !orgId || !formData) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// 1. Separate fields by entity for updating
const contactUpdates = {};
mapping.stages.forEach(stage => {
stage.fields.forEach(field => {
if (formData[field.name] !== undefined) {
if (field.entity === 'Contact') {
contactUpdates[field.crmField] = formData[field.name];
}
}
});
});
// 2. Update Contact in CiviCRM
if (Object.keys(contactUpdates).length > 0) {
await civicrmApi('Contact', 'save', {
records: [{ id: parseInt(contactId), ...contactUpdates }],
});
}
// 3. Create Activity in CiviCRM to record the submission
await civicrmApi('Activity', 'create', {
values: {
activity_type_id: 'Meeting', // Or a custom "Form Submission" activity type
subject: `Stage Progression Form Submission (Org ID: ${orgId})`,
target_contact_id: [parseInt(contactId)],
source_contact_id: parseInt(contactId), // Assuming contact submits for themselves or adjust
status_id: 'Completed',
details: JSON.stringify(formData, null, 2),
},
});
return NextResponse.json({ success: true, message: 'Data saved and activity created' });
} catch (error) {
console.error('Submission Error:', error);
// Mock success for development
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ success: true, message: 'Mock Success' });
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

26
app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

33
app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

23
app/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client';
import { useSearchParams } from 'next/navigation';
import StageForm from '@/components/StageForm';
import { Suspense } from 'react';
function FormContainer() {
const searchParams = useSearchParams();
const contactId = searchParams.get('contactId') || '2';
const orgId = searchParams.get('orgId') || '1';
return <StageForm contactId={contactId} orgId={orgId} />;
}
export default function Home() {
return (
<main className="min-h-screen bg-gray-100 py-12">
<Suspense fallback={<div className="text-center">Loading...</div>}>
<FormContainer />
</Suspense>
</main>
);
}