- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
507 lines
17 KiB
TypeScript
507 lines
17 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
OnboardingDataLegacy,
|
|
GoalPrimary,
|
|
LeadType,
|
|
Channel,
|
|
ExperienceLevel,
|
|
GCIRange,
|
|
CRMPainPoint,
|
|
ExternalSystem
|
|
} from '../types';
|
|
import { Button } from './Button';
|
|
import { ClayCard } from './ClayCard';
|
|
import { ArrowRight, Check } from 'lucide-react';
|
|
|
|
// Use legacy interface which has snake_case properties
|
|
type OnboardingData = OnboardingDataLegacy;
|
|
|
|
interface Props {
|
|
onComplete: (data: OnboardingData) => void;
|
|
}
|
|
|
|
const INITIAL_DATA: OnboardingData = {
|
|
// Experience questions
|
|
years_in_business: null,
|
|
gci_last_12_months: null,
|
|
|
|
// CRM questions
|
|
using_other_crm: null,
|
|
current_crm_name: '',
|
|
crm_pain_points: [],
|
|
|
|
// Goals
|
|
goals_selected: [],
|
|
|
|
// Lead questions
|
|
has_lead_source: null,
|
|
lead_source_name: '',
|
|
wants_more_leads: null,
|
|
lead_type_desired: [],
|
|
leads_per_month_target: '',
|
|
|
|
// Channels
|
|
channels_selected: [],
|
|
|
|
// Systems to connect
|
|
systems_to_connect: [],
|
|
|
|
// Custom values for personalization
|
|
user_first_name: '',
|
|
user_last_name: '',
|
|
user_email: '',
|
|
brokerage_name: '',
|
|
};
|
|
|
|
const TOTAL_STEPS = 7;
|
|
|
|
export const OnboardingFlow: React.FC<Props> = ({ onComplete }) => {
|
|
const [step, setStep] = useState(1);
|
|
const [data, setData] = useState<OnboardingData>(INITIAL_DATA);
|
|
|
|
const updateData = (key: keyof OnboardingData, value: any) => {
|
|
setData(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const toggleArrayItem = <T,>(key: keyof OnboardingData, item: T) => {
|
|
setData(prev => {
|
|
const arr = prev[key] as T[];
|
|
if (arr.includes(item)) {
|
|
return { ...prev, [key]: arr.filter(i => i !== item) };
|
|
}
|
|
return { ...prev, [key]: [...arr, item] };
|
|
});
|
|
};
|
|
|
|
const nextStep = () => setStep(prev => prev + 1);
|
|
|
|
// Step 1: Experience Questions
|
|
const renderStep1 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Tell us about your experience</h2>
|
|
<p className="text-gray-500 mt-2">This helps us personalize your setup</p>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="space-y-3">
|
|
<label className="block text-gray-700 font-medium">How long have you been in the business?</label>
|
|
<div className="grid gap-3">
|
|
{Object.values(ExperienceLevel).map((level) => (
|
|
<ClayCard
|
|
key={level}
|
|
selected={data.years_in_business === level}
|
|
onClick={() => updateData('years_in_business', level)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="font-medium text-gray-700">{level}</span>
|
|
{data.years_in_business === level && <Check className="text-indigo-600 w-6 h-6" />}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-gray-700 font-medium">How much GCI have you done in the last 12 months?</label>
|
|
<div className="grid gap-3">
|
|
{Object.values(GCIRange).map((range) => (
|
|
<ClayCard
|
|
key={range}
|
|
selected={data.gci_last_12_months === range}
|
|
onClick={() => updateData('gci_last_12_months', range)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="font-medium text-gray-700">{range}</span>
|
|
{data.gci_last_12_months === range && <Check className="text-indigo-600 w-6 h-6" />}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={!data.years_in_business || !data.gci_last_12_months}
|
|
onClick={nextStep}
|
|
className="mt-8"
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 2: CRM Questions
|
|
const renderStep2 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Your Current CRM</h2>
|
|
<p className="text-gray-500 mt-2">Tell us about your current setup</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<label className="block text-gray-700 font-medium">Are you currently using a CRM?</label>
|
|
<div className="flex gap-4">
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.using_other_crm === true}
|
|
onClick={() => updateData('using_other_crm', true)}
|
|
>
|
|
Yes
|
|
</ClayCard>
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.using_other_crm === false}
|
|
onClick={() => updateData('using_other_crm', false)}
|
|
>
|
|
No
|
|
</ClayCard>
|
|
</div>
|
|
</div>
|
|
|
|
{data.using_other_crm === true && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">Which CRM are you using?</label>
|
|
<input
|
|
type="text"
|
|
value={data.current_crm_name}
|
|
onChange={(e) => updateData('current_crm_name', e.target.value)}
|
|
placeholder="Enter your CRM name..."
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
<div className="flex gap-2 flex-wrap mt-2">
|
|
{['Follow Up Boss', 'KvCore', 'Salesforce', 'HubSpot', 'Other'].map(crm => (
|
|
<button
|
|
key={crm}
|
|
onClick={() => updateData('current_crm_name', crm)}
|
|
className="text-xs px-3 py-1 bg-gray-200 rounded-full text-gray-600 hover:bg-gray-300"
|
|
>
|
|
{crm}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">What are you looking for that it doesn't do?</label>
|
|
<p className="text-sm text-gray-500">Select all that apply</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{Object.values(CRMPainPoint).map((painPoint) => (
|
|
<ClayCard
|
|
key={painPoint}
|
|
selected={data.crm_pain_points.includes(painPoint)}
|
|
onClick={() => toggleArrayItem('crm_pain_points', painPoint)}
|
|
className="py-3 px-4 text-center text-sm"
|
|
>
|
|
{painPoint}
|
|
{data.crm_pain_points.includes(painPoint) && (
|
|
<Check className="inline-block ml-1 text-indigo-600 w-4 h-4" />
|
|
)}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={data.using_other_crm === null || (data.using_other_crm && !data.current_crm_name)}
|
|
onClick={nextStep}
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 3: Goals
|
|
const renderStep3 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">What are you looking to accomplish?</h2>
|
|
<p className="text-gray-500 mt-2">Select all that apply</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
{Object.values(GoalPrimary).map((goal) => (
|
|
<ClayCard
|
|
key={goal}
|
|
selected={data.goals_selected.includes(goal)}
|
|
onClick={() => toggleArrayItem('goals_selected', goal)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="font-medium text-gray-700">{goal}</span>
|
|
{data.goals_selected.includes(goal) && <Check className="text-indigo-600 w-6 h-6" />}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={data.goals_selected.length === 0}
|
|
onClick={nextStep}
|
|
className="mt-8"
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 4: Lead Source
|
|
const renderStep4 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Lead Sources</h2>
|
|
<p className="text-gray-500 mt-2">Tell us about your lead generation</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<label className="block text-gray-700 font-medium">Do you currently have a lead source?</label>
|
|
<div className="flex gap-4">
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.has_lead_source === true}
|
|
onClick={() => updateData('has_lead_source', true)}
|
|
>
|
|
Yes
|
|
</ClayCard>
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.has_lead_source === false}
|
|
onClick={() => updateData('has_lead_source', false)}
|
|
>
|
|
No
|
|
</ClayCard>
|
|
</div>
|
|
</div>
|
|
|
|
{data.has_lead_source === true && (
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">Where are your leads coming from?</label>
|
|
<input
|
|
type="text"
|
|
value={data.lead_source_name}
|
|
onChange={(e) => updateData('lead_source_name', e.target.value)}
|
|
placeholder="e.g. Zillow, Referrals, Meta..."
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<label className="block text-gray-700 font-medium">Do you want more leads?</label>
|
|
<div className="flex gap-4">
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.wants_more_leads === true}
|
|
onClick={() => updateData('wants_more_leads', true)}
|
|
>
|
|
Yes
|
|
</ClayCard>
|
|
<ClayCard
|
|
className="flex-1 text-center"
|
|
selected={data.wants_more_leads === false}
|
|
onClick={() => updateData('wants_more_leads', false)}
|
|
>
|
|
No
|
|
</ClayCard>
|
|
</div>
|
|
</div>
|
|
|
|
{data.wants_more_leads === true && (
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">What type of leads?</label>
|
|
<p className="text-sm text-gray-500">Select all that apply</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{[LeadType.SELLER, LeadType.BUYER, LeadType.COMMERCIAL].map((type) => (
|
|
<ClayCard
|
|
key={type}
|
|
selected={data.lead_type_desired.includes(type)}
|
|
onClick={() => toggleArrayItem('lead_type_desired', type)}
|
|
className="py-3 px-4 text-center text-sm"
|
|
>
|
|
{type}
|
|
{data.lead_type_desired.includes(type) && (
|
|
<Check className="inline-block ml-1 text-indigo-600 w-4 h-4" />
|
|
)}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={
|
|
data.has_lead_source === null ||
|
|
data.wants_more_leads === null ||
|
|
(data.has_lead_source && !data.lead_source_name) ||
|
|
(data.wants_more_leads && data.lead_type_desired.length === 0)
|
|
}
|
|
onClick={nextStep}
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 5: Systems to Connect
|
|
const renderStep5 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Systems to Connect</h2>
|
|
<p className="text-gray-500 mt-2">What other systems would you like to connect?</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
{Object.values(ExternalSystem).map((system) => (
|
|
<ClayCard
|
|
key={system}
|
|
selected={data.systems_to_connect.includes(system)}
|
|
onClick={() => toggleArrayItem('systems_to_connect', system)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="font-medium text-gray-700">{system}</span>
|
|
{data.systems_to_connect.includes(system) && <Check className="text-indigo-600 w-6 h-6" />}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
|
|
<Button
|
|
fullWidth
|
|
onClick={nextStep}
|
|
className="mt-8"
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 6: Contact Channels
|
|
const renderStep6 = () => (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Contact Channels</h2>
|
|
<p className="text-gray-500 mt-2">How do you want to reach leads?</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
{Object.values(Channel).map((channel) => (
|
|
<ClayCard
|
|
key={channel}
|
|
selected={data.channels_selected.includes(channel)}
|
|
onClick={() => toggleArrayItem('channels_selected', channel)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="font-medium text-gray-700">{channel}</span>
|
|
{data.channels_selected.includes(channel) && <Check className="text-indigo-600 w-6 h-6" />}
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={data.channels_selected.length === 0}
|
|
onClick={nextStep}
|
|
className="mt-8"
|
|
>
|
|
Continue <ArrowRight size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Step 7: Custom Values (Profile Info)
|
|
const renderStep7 = () => {
|
|
const isStep7Valid =
|
|
data.user_first_name.trim() !== '' &&
|
|
data.user_last_name.trim() !== '' &&
|
|
data.user_email.trim() !== '' &&
|
|
data.brokerage_name.trim() !== '';
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-gray-800">Let's personalize your experience</h2>
|
|
<p className="text-gray-500 mt-2">Tell us a bit about yourself</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">First Name *</label>
|
|
<input
|
|
type="text"
|
|
value={data.user_first_name}
|
|
onChange={(e) => updateData('user_first_name', e.target.value)}
|
|
placeholder="Enter your first name"
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">Last Name *</label>
|
|
<input
|
|
type="text"
|
|
value={data.user_last_name}
|
|
onChange={(e) => updateData('user_last_name', e.target.value)}
|
|
placeholder="Enter your last name"
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">Email *</label>
|
|
<input
|
|
type="email"
|
|
value={data.user_email}
|
|
onChange={(e) => updateData('user_email', e.target.value)}
|
|
placeholder="Enter your email address"
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-gray-700 font-medium">Brokerage Name *</label>
|
|
<input
|
|
type="text"
|
|
value={data.brokerage_name}
|
|
onChange={(e) => updateData('brokerage_name', e.target.value)}
|
|
placeholder="Enter your brokerage name"
|
|
className="w-full p-4 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
fullWidth
|
|
disabled={!isStep7Valid}
|
|
onClick={() => onComplete(data)}
|
|
className="mt-8"
|
|
>
|
|
Finish Setup <Check size={20} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-md mx-auto py-10 px-4">
|
|
<div className="mb-8 flex justify-center space-x-2">
|
|
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map(s => (
|
|
<div
|
|
key={s}
|
|
className={`h-2 rounded-full transition-all duration-500 ${s <= step ? 'w-8 bg-indigo-600' : 'w-2 bg-gray-300'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{step === 1 && renderStep1()}
|
|
{step === 2 && renderStep2()}
|
|
{step === 3 && renderStep3()}
|
|
{step === 4 && renderStep4()}
|
|
{step === 5 && renderStep5()}
|
|
{step === 6 && renderStep6()}
|
|
{step === 7 && renderStep7()}
|
|
</div>
|
|
);
|
|
};
|