- 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>
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { ClayCard } from './ClayCard';
|
|
import { Button } from './Button';
|
|
import { ArrowRight, ArrowLeft, TrendingUp, Users, BookOpen } from 'lucide-react';
|
|
|
|
export interface QuizResult {
|
|
yearsInBusiness: string;
|
|
dealsLast12Months: number;
|
|
leadsPerMonth: number;
|
|
hoursProspecting: number;
|
|
performanceLevel: 'below' | 'at' | 'above';
|
|
recommendedAction: 'coaching' | 'team' | 'none';
|
|
}
|
|
|
|
interface QuizProps {
|
|
onComplete: (result: QuizResult) => void;
|
|
calendlyCoachingLink?: string;
|
|
calendlyTeamLink?: string;
|
|
}
|
|
|
|
interface Question {
|
|
id: keyof Pick<QuizResult, 'yearsInBusiness' | 'dealsLast12Months' | 'leadsPerMonth' | 'hoursProspecting'>;
|
|
question: string;
|
|
type: 'select' | 'number';
|
|
options?: { value: string; label: string }[];
|
|
placeholder?: string;
|
|
}
|
|
|
|
const questions: Question[] = [
|
|
{
|
|
id: 'yearsInBusiness',
|
|
question: 'How long have you been in real estate?',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '0-1', label: 'Less than 1 year' },
|
|
{ value: '1-3', label: '1-3 years' },
|
|
{ value: '3-5', label: '3-5 years' },
|
|
{ value: '5-10', label: '5-10 years' },
|
|
{ value: '10+', label: '10+ years' },
|
|
],
|
|
},
|
|
{
|
|
id: 'dealsLast12Months',
|
|
question: 'How many deals did you close in the last 12 months?',
|
|
type: 'number',
|
|
placeholder: 'Enter number of deals',
|
|
},
|
|
{
|
|
id: 'leadsPerMonth',
|
|
question: 'How many leads do you generate per month?',
|
|
type: 'number',
|
|
placeholder: 'Enter number of leads',
|
|
},
|
|
{
|
|
id: 'hoursProspecting',
|
|
question: 'How many hours per week do you spend prospecting?',
|
|
type: 'number',
|
|
placeholder: 'Enter hours per week',
|
|
},
|
|
];
|
|
|
|
// Peer benchmarks based on years of experience
|
|
const getPeerBenchmarks = (years: string) => {
|
|
const benchmarks: Record<string, { deals: number; leads: number; hours: number }> = {
|
|
'0-1': { deals: 3, leads: 10, hours: 15 },
|
|
'1-3': { deals: 8, leads: 20, hours: 12 },
|
|
'3-5': { deals: 12, leads: 30, hours: 10 },
|
|
'5-10': { deals: 18, leads: 40, hours: 10 },
|
|
'10+': { deals: 24, leads: 50, hours: 8 },
|
|
};
|
|
return benchmarks[years] || benchmarks['1-3'];
|
|
};
|
|
|
|
const calculatePerformanceLevel = (
|
|
answers: Partial<QuizResult>
|
|
): { level: 'below' | 'at' | 'above'; action: 'coaching' | 'team' | 'none' } => {
|
|
const benchmarks = getPeerBenchmarks(answers.yearsInBusiness || '1-3');
|
|
|
|
let score = 0;
|
|
|
|
// Compare deals (weight: 40%)
|
|
const dealsRatio = (answers.dealsLast12Months || 0) / benchmarks.deals;
|
|
if (dealsRatio >= 1.2) score += 40;
|
|
else if (dealsRatio >= 0.8) score += 25;
|
|
else score += 10;
|
|
|
|
// Compare leads (weight: 35%)
|
|
const leadsRatio = (answers.leadsPerMonth || 0) / benchmarks.leads;
|
|
if (leadsRatio >= 1.2) score += 35;
|
|
else if (leadsRatio >= 0.8) score += 22;
|
|
else score += 8;
|
|
|
|
// Compare prospecting hours (weight: 25%)
|
|
const hoursRatio = (answers.hoursProspecting || 0) / benchmarks.hours;
|
|
if (hoursRatio >= 1.2) score += 25;
|
|
else if (hoursRatio >= 0.8) score += 16;
|
|
else score += 6;
|
|
|
|
// Determine performance level
|
|
let level: 'below' | 'at' | 'above';
|
|
let action: 'coaching' | 'team' | 'none';
|
|
|
|
if (score >= 80) {
|
|
level = 'above';
|
|
action = 'none';
|
|
} else if (score >= 55) {
|
|
level = 'at';
|
|
action = 'coaching';
|
|
} else {
|
|
level = 'below';
|
|
action = 'team';
|
|
}
|
|
|
|
return { level, action };
|
|
};
|
|
|
|
export const Quiz: React.FC<QuizProps> = ({
|
|
onComplete,
|
|
calendlyCoachingLink = 'https://calendly.com/coaching',
|
|
calendlyTeamLink = 'https://calendly.com/team',
|
|
}) => {
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [answers, setAnswers] = useState<Partial<QuizResult>>({});
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [result, setResult] = useState<QuizResult | null>(null);
|
|
|
|
const currentQuestion = questions[currentStep];
|
|
const isLastQuestion = currentStep === questions.length - 1;
|
|
const progress = ((currentStep + 1) / questions.length) * 100;
|
|
|
|
const handleAnswer = (value: string | number) => {
|
|
setAnswers((prev) => ({
|
|
...prev,
|
|
[currentQuestion.id]: value,
|
|
}));
|
|
};
|
|
|
|
const getCurrentAnswer = () => {
|
|
return answers[currentQuestion.id];
|
|
};
|
|
|
|
const canProceed = () => {
|
|
const answer = getCurrentAnswer();
|
|
return answer !== undefined && answer !== '';
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (!canProceed()) return;
|
|
|
|
if (isLastQuestion) {
|
|
const { level, action } = calculatePerformanceLevel(answers);
|
|
const finalResult: QuizResult = {
|
|
yearsInBusiness: answers.yearsInBusiness || '',
|
|
dealsLast12Months: Number(answers.dealsLast12Months) || 0,
|
|
leadsPerMonth: Number(answers.leadsPerMonth) || 0,
|
|
hoursProspecting: Number(answers.hoursProspecting) || 0,
|
|
performanceLevel: level,
|
|
recommendedAction: action,
|
|
};
|
|
setResult(finalResult);
|
|
setShowResults(true);
|
|
onComplete(finalResult);
|
|
} else {
|
|
setCurrentStep((prev) => prev + 1);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (currentStep > 0) {
|
|
setCurrentStep((prev) => prev - 1);
|
|
}
|
|
};
|
|
|
|
const handleCalendlyClick = (link: string) => {
|
|
window.open(link, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
if (showResults && result) {
|
|
return (
|
|
<div className="max-w-xl mx-auto py-10 px-4 animate-fadeIn">
|
|
<ClayCard>
|
|
<div className="text-center">
|
|
<div
|
|
className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 shadow-inner ${
|
|
result.performanceLevel === 'above'
|
|
? 'bg-green-100 text-green-600'
|
|
: result.performanceLevel === 'at'
|
|
? 'bg-yellow-100 text-yellow-600'
|
|
: 'bg-indigo-100 text-indigo-600'
|
|
}`}
|
|
>
|
|
<TrendingUp size={40} />
|
|
</div>
|
|
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-4">Your Results</h2>
|
|
|
|
{result.performanceLevel === 'above' && (
|
|
<div className="mb-6">
|
|
<p className="text-gray-600 leading-relaxed">
|
|
Great work! Based on your responses, you're performing above your peers.
|
|
Keep up the excellent momentum!
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{result.performanceLevel === 'at' && (
|
|
<div className="mb-6">
|
|
<p className="text-gray-600 leading-relaxed">
|
|
You're performing at peer level. With some additional coaching,
|
|
you could take your business to the next level.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{result.performanceLevel === 'below' && (
|
|
<div className="mb-6">
|
|
<p className="text-gray-600 leading-relaxed">
|
|
Based on your peers, you may benefit from additional support.
|
|
Would you like to learn more about coaching or joining our team?
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{result.performanceLevel !== 'above' && (
|
|
<div className="space-y-3 mt-8">
|
|
<Button
|
|
fullWidth
|
|
variant="primary"
|
|
onClick={() => handleCalendlyClick(calendlyCoachingLink)}
|
|
className="gap-3"
|
|
>
|
|
<BookOpen size={20} />
|
|
Learn about coaching
|
|
</Button>
|
|
<Button
|
|
fullWidth
|
|
variant="secondary"
|
|
onClick={() => handleCalendlyClick(calendlyTeamLink)}
|
|
className="gap-3"
|
|
>
|
|
<Users size={20} />
|
|
Learn about joining the team
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">
|
|
Your Responses
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-left">
|
|
<div className="bg-gray-50 rounded-xl p-3">
|
|
<p className="text-xs text-gray-500">Experience</p>
|
|
<p className="font-semibold text-gray-800">{result.yearsInBusiness} years</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-3">
|
|
<p className="text-xs text-gray-500">Deals (12 mo)</p>
|
|
<p className="font-semibold text-gray-800">{result.dealsLast12Months}</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-3">
|
|
<p className="text-xs text-gray-500">Leads/Month</p>
|
|
<p className="font-semibold text-gray-800">{result.leadsPerMonth}</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-3">
|
|
<p className="text-xs text-gray-500">Prospecting Hrs/Wk</p>
|
|
<p className="font-semibold text-gray-800">{result.hoursProspecting}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ClayCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-xl mx-auto py-10 px-4">
|
|
{/* Progress Bar */}
|
|
<div className="mb-8">
|
|
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
|
<span>Question {currentStep + 1} of {questions.length}</span>
|
|
<span>{Math.round(progress)}% complete</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden shadow-inner">
|
|
<div
|
|
className="h-full bg-indigo-600 rounded-full transition-all duration-500 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ClayCard>
|
|
<div className="mb-6">
|
|
<h2 className="text-xl font-bold text-gray-800 mb-2">
|
|
{currentQuestion.question}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-8">
|
|
{currentQuestion.type === 'select' && currentQuestion.options && (
|
|
<div className="space-y-3">
|
|
{currentQuestion.options.map((option) => (
|
|
<ClayCard
|
|
key={option.value}
|
|
onClick={() => handleAnswer(option.value)}
|
|
selected={getCurrentAnswer() === option.value}
|
|
className="!p-4 !rounded-xl"
|
|
>
|
|
<span className="text-gray-700 font-medium">{option.label}</span>
|
|
</ClayCard>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{currentQuestion.type === 'number' && (
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="input-field"
|
|
placeholder={currentQuestion.placeholder}
|
|
value={getCurrentAnswer() ?? ''}
|
|
onChange={(e) => handleAnswer(e.target.value ? Number(e.target.value) : '')}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
{currentStep > 0 && (
|
|
<Button variant="secondary" onClick={handleBack}>
|
|
<ArrowLeft size={18} />
|
|
Back
|
|
</Button>
|
|
)}
|
|
<Button
|
|
fullWidth
|
|
variant="primary"
|
|
onClick={handleNext}
|
|
disabled={!canProceed()}
|
|
>
|
|
{isLastQuestion ? 'See Results' : 'Continue'}
|
|
<ArrowRight size={18} />
|
|
</Button>
|
|
</div>
|
|
</ClayCard>
|
|
|
|
{/* Input field styles */}
|
|
<style>{`
|
|
.input-field {
|
|
width: 100%;
|
|
padding: 16px 20px;
|
|
border-radius: 16px;
|
|
background-color: #F9FAFB;
|
|
border: 1px solid transparent;
|
|
box-shadow: inset 2px 2px 5px rgba(0,0,0,0.05);
|
|
outline: none;
|
|
transition: all 0.2s;
|
|
font-size: 16px;
|
|
}
|
|
.input-field:focus {
|
|
background-color: #fff;
|
|
box-shadow: 0 0 0 2px #6366f1;
|
|
}
|
|
.input-field::placeholder {
|
|
color: #9CA3AF;
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.animate-fadeIn {
|
|
animation: fadeIn 0.4s ease-out;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|