Files
LynkedUpPro_CRM/src/pages/CreateLeadPage.jsx
T

597 lines
26 KiB
React

import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { toast } from 'sonner';
import { useRegisterAddress } from '../hooks/useHailRecon';
import {
User, MapPin, Briefcase, Shield, Users,
ChevronLeft, Zap, FileText, Save, AlertCircle, CloudLightning, X,
} from 'lucide-react';
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
import LeadJobSection from '../components/leads/LeadJobSection';
import LeadContactSection from '../components/leads/LeadContactSection';
import LeadPropertySection from '../components/leads/LeadPropertySection';
import LeadInsuranceSection from '../components/leads/LeadInsuranceSection';
import LeadAssignmentSection from '../components/leads/LeadAssignmentSection';
import gsap from 'gsap';
// ---------------------------------------------------------------------------
// Section definitions
// ---------------------------------------------------------------------------
const SECTIONS = [
{
id: 'contact',
title: 'Contact',
icon: User,
accentColor: '#3B82F6',
quickCapture: true,
description: 'Name, phone numbers, and email addresses',
},
{
id: 'property',
title: 'Property',
icon: MapPin,
accentColor: '#10B981',
quickCapture: true,
description: 'Property address, type, and site photos',
},
{
id: 'job',
title: 'Job Details',
icon: Briefcase,
accentColor: '#F59E0B',
quickCapture: true,
description: 'Lead source, work type, urgency, and notes',
},
{
id: 'insurance',
title: 'Insurance',
icon: Shield,
accentColor: '#8B5CF6',
quickCapture: false,
description: 'Carrier, claim number, adjuster, and policy details',
},
{
id: 'assignment',
title: 'Assignment',
icon: Users,
accentColor: '#06B6D4',
quickCapture: false,
description: 'Assign a rep, set priority, and schedule follow-up',
},
];
// ---------------------------------------------------------------------------
// Initial form state
// ---------------------------------------------------------------------------
const INITIAL_FORM = {
// Job Details
leadSource: '',
canvasserId: '',
canvasserName: '',
referralNote: '',
leadType: '',
workType: '',
tradeType: '',
urgency: 'standard',
notes: '',
// Contact
firstName: '',
lastName: '',
phones: [{ id: '1', number: '', type: 'Mobile', isPrimary: true }],
emails: [],
// Property
address: '',
city: '',
state: 'TX',
zip: '',
propertyType: '',
propertyPhotos: [],
// Insurance (Phase 5)
insuranceCompany: '',
claimNumber: '',
claimStatus: '',
adjusterName: '',
adjusterPhone: '',
policyNumber: '',
// Assignment (Phase 6)
assignedTo: '',
priority: 'medium',
followUpDate: '',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CreateLeadPage() {
const { theme } = useTheme();
const { user } = useAuth();
const { addLead } = useMockStore();
const registerAddress = useRegisterAddress();
const isDark = theme === 'dark';
const navigate = useNavigate();
const location = useLocation();
const [validationError, setValidationError] = useState('');
// Storm zone context — passed from StormIntelPage via router state
const stormSource = location.state?.stormSource ?? null;
const [stormBannerDismissed, setStormBannerDismissed] = useState(false);
const [isQuickCapture, setIsQuickCapture] = useState(true);
const [openSections, setOpenSections] = useState({
contact: true,
property: false,
job: false,
insurance: false,
assignment: false,
});
// Pre-fill form when arriving from Storm Intel
const [formData, setFormData] = useState(() => {
if (!stormSource) return INITIAL_FORM;
const urgency = (stormSource.severity === 'severe' || stormSource.severity === 'significant') ? 'high' : 'standard';
const stormDate = stormSource.date
? new Date(stormSource.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '';
return {
...INITIAL_FORM,
city: 'Plano',
state: 'TX',
leadSource: 'Canvassers',
urgency,
notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`,
stormSource,
};
});
const progressBarRef = useRef(null);
// Section completion map
const sectionCompletion = {
contact: !!(
formData.firstName &&
formData.lastName &&
formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)
),
property: !!(formData.address && formData.city),
job: isQuickCapture
? !!formData.leadSource
: !!(formData.leadSource && formData.leadType && formData.workType),
insurance: !!(formData.insuranceCompany && formData.claimStatus),
assignment: !!(formData.assignedTo && formData.followUpDate),
};
// Progress = % of visible sections that are complete
const visibleSectionIds = (isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS).map(s => s.id);
const completedCount = visibleSectionIds.filter(id => sectionCompletion[id]).length;
const progress = visibleSectionIds.length > 0
? Math.round((completedCount / visibleSectionIds.length) * 100)
: 0;
// GSAP animates the progress bar whenever progress changes
useEffect(() => {
if (progressBarRef.current) {
gsap.to(progressBarRef.current, {
width: `${progress}%`,
duration: 0.45,
ease: 'power2.out',
});
}
}, [progress]);
const updateField = (field, value) => {
setFormData(prev => {
const next = { ...prev, [field]: value };
if (field === 'leadSource') {
if (value !== 'Canvassers') { next.canvasserId = ''; next.canvasserName = ''; }
if (value !== 'Referral') { next.referralNote = ''; }
}
return next;
});
};
const toggleSection = (id) => {
setOpenSections(prev => {
const isOpening = !prev[id];
const next = Object.keys(prev).reduce((acc, key) => {
acc[key] = false;
return acc;
}, {});
next[id] = isOpening;
return next;
});
};
const handleSave = () => {
// Required field validation
const missingFields = [];
if (!formData.firstName) missingFields.push('First Name');
if (!formData.lastName) missingFields.push('Last Name');
if (!formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10))
missingFields.push('Phone Number');
if (!formData.address) missingFields.push('Street Address');
if (!formData.city) missingFields.push('City');
if (!formData.leadSource) missingFields.push('Lead Source');
if (formData.leadSource === 'Canvassers' && !formData.canvasserId) missingFields.push('Canvasser');
if (missingFields.length > 0) {
setValidationError(`Required: ${missingFields.join(', ')}`);
// Open the first incomplete section (close others for accordion behavior)
const openOnly = (id) => setOpenSections(prev =>
Object.keys(prev).reduce((acc, key) => ({ ...acc, [key]: key === id }), {})
);
if (!formData.firstName || !formData.lastName || !formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)) {
openOnly('contact');
} else if (!formData.address || !formData.city) {
openOnly('property');
} else {
openOnly('job');
}
toast.error(`Missing required fields: ${missingFields.join(', ')}`);
return;
}
setValidationError('');
const savedLead = addLead({
...formData,
createdBy: user?.id,
createdByName: user?.name,
isQuickCapture,
});
// Silently register the address with Hail Recon for future monitoring
registerAddress({
id: savedLead?.id,
urgency: formData.urgency,
leadType: formData.jobType,
property: {
address: formData.address,
city: formData.city || 'Plano',
state: formData.state || 'TX',
zip: formData.zip || '',
lat: formData.lat || null,
lng: formData.lng || null,
},
customer: {
name: `${formData.firstName || ''} ${formData.lastName || ''}`.trim(),
phone: formData.phone || '',
email: formData.email || '',
},
});
toast.success(`Lead saved — ${formData.firstName} ${formData.lastName}`, {
description: formData.address || 'No address provided',
});
navigate('/emp/fa/leads');
};
const visibleSections = isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS;
const modeSwitchTo = (quick) => {
if (quick === isQuickCapture) return;
setIsQuickCapture(quick);
if (quick) {
setOpenSections(prev => ({ ...prev, insurance: false, assignment: false }));
}
};
// Render the content for a given section (placeholder until its phase is built)
const renderSectionContent = (section) => {
if (section.id === 'contact') {
return (
<LeadContactSection
formData={formData}
updateField={updateField}
isQuickCapture={isQuickCapture}
/>
);
}
if (section.id === 'property') {
return (
<LeadPropertySection
formData={formData}
updateField={updateField}
isQuickCapture={isQuickCapture}
/>
);
}
if (section.id === 'job') {
return (
<LeadJobSection
formData={formData}
updateField={updateField}
isQuickCapture={isQuickCapture}
/>
);
}
if (section.id === 'insurance') {
return (
<LeadInsuranceSection
formData={formData}
updateField={updateField}
/>
);
}
if (section.id === 'assignment') {
return (
<LeadAssignmentSection
formData={formData}
updateField={updateField}
currentUser={user}
/>
);
}
return null;
};
return (
<div className={`min-h-screen transition-colors duration-300
${isDark ? 'bg-[#09090b]' : 'bg-zinc-100/80'}
`}>
{/* --- Progress bar --- */}
<div className={`h-[3px] w-full ${isDark ? 'bg-zinc-800' : 'bg-zinc-200'}`}>
<div
ref={progressBarRef}
className="h-full bg-blue-500"
style={{ width: '0%' }}
/>
</div>
{/* ----------------------------------------------------------------
Page content
---------------------------------------------------------------- */}
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-44 md:pb-40">
{/* --- Page Header --- */}
<div className="mb-7">
<button
type="button"
onClick={() => navigate('/emp/fa/leads')}
className="flex items-center gap-1 text-xs uppercase tracking-widest font-bold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 mb-3 transition-colors"
>
<ChevronLeft size={13} strokeWidth={2.5} />
Leads
</button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
{/* Title */}
<div className="min-w-0">
<h1 className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-3xl sm:text-4xl leading-none text-zinc-900 dark:text-white">
New Lead
</h1>
<p className="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1.5">
{isQuickCapture
? 'Essential fields only — fastest capture at the door.'
: 'Full lead profile with insurance and assignment details.'}
</p>
</div>
{/* Quick / Full Form toggle */}
<div className={`self-start sm:self-auto flex items-center p-1 rounded-xl gap-0.5 shrink-0
${isDark ? 'bg-zinc-800' : 'bg-zinc-200/80'}
`}>
<button
type="button"
onClick={() => modeSwitchTo(true)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200
${isQuickCapture
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<Zap size={11} strokeWidth={2.5} />
Quick
</button>
<button
type="button"
onClick={() => modeSwitchTo(false)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200
${!isQuickCapture
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<FileText size={11} strokeWidth={2.5} />
Full Form
</button>
</div>
</div>
{/* Section indicator pills */}
<div className="flex items-center gap-2 mt-4 flex-wrap">
{SECTIONS.map(s => (
<div
key={s.id}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all duration-300
${(!s.quickCapture && isQuickCapture)
? 'opacity-30 bg-zinc-200 dark:bg-zinc-800 text-zinc-400'
: 'opacity-100'
}`}
style={(!s.quickCapture && isQuickCapture)
? {}
: { backgroundColor: s.accentColor + '22', color: s.accentColor }
}
>
<s.icon size={9} strokeWidth={2.5} />
{s.title}
</div>
))}
</div>
</div>
{/* --- Storm zone banner --- */}
<AnimatePresence>
{stormSource && !stormBannerDismissed && (
<motion.div
key="storm-banner"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.22 }}
className="mb-5 rounded-xl border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/8 overflow-hidden"
>
<div className="flex items-start gap-3 px-4 py-3">
<div className="w-8 h-8 rounded-lg bg-amber-500/15 dark:bg-amber-500/20 flex items-center justify-center shrink-0 mt-0.5">
<CloudLightning size={16} className="text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-black uppercase tracking-wider text-amber-700 dark:text-amber-400 mb-0.5">
Storm Zone Lead
</p>
<p className="text-[13px] font-semibold text-zinc-800 dark:text-zinc-200">
{stormSource.areaName}
{stormSource.maxHailSize && (
<span className="ml-2 text-[11px] font-bold text-amber-600 dark:text-amber-400">
{stormSource.maxHailSize}" hail
</span>
)}
</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
Lead source, urgency, and notes pre-filled from storm data.
{(stormSource.severity === 'severe' || stormSource.severity === 'significant') && (
<span className="ml-1 text-amber-600 dark:text-amber-400 font-semibold">Urgency set to High.</span>
)}
</p>
</div>
<button
type="button"
onClick={() => setStormBannerDismissed(true)}
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-amber-100 dark:hover:bg-amber-500/10 transition-colors shrink-0"
>
<X size={14} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* --- Sections --- */}
<div className="space-y-3 mb-8">
<AnimatePresence mode="popLayout">
{visibleSections.map(section => (
<motion.div
key={section.id}
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 280, damping: 28 }}
layout
exit={{
opacity: 0,
scale: 0.97,
y: -6,
transition: { duration: 0.18, ease: 'easeIn' },
}}
>
<LeadSectionWrapper
title={section.title}
icon={section.icon}
accentColor={section.accentColor}
isOpen={openSections[section.id]}
onToggle={() => toggleSection(section.id)}
isComplete={sectionCompletion[section.id]}
>
{renderSectionContent(section)}
</LeadSectionWrapper>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
{/* ----------------------------------------------------------------
Mobile sticky save footer
---------------------------------------------------------------- */}
<div className={`fixed bottom-0 left-0 right-0 md:hidden px-4 py-3
border-t backdrop-blur-md
${isDark
? 'bg-zinc-900/90 border-white/10'
: 'bg-white/90 border-zinc-200'
}
`}>
<div className="flex items-center gap-3 max-w-3xl mx-auto">
{/* Compact mode toggle */}
<div className={`flex items-center p-0.5 rounded-lg gap-0.5 shrink-0
${isDark ? 'bg-zinc-800' : 'bg-zinc-100'}
`}>
<button
type="button"
onClick={() => modeSwitchTo(true)}
className={`p-2 rounded-md transition-all duration-200
${isQuickCapture
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
: 'text-zinc-400'
}`}
>
<Zap size={14} />
</button>
<button
type="button"
onClick={() => modeSwitchTo(false)}
className={`p-2 rounded-md transition-all duration-200
${!isQuickCapture
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
: 'text-zinc-400'
}`}
>
<FileText size={14} />
</button>
</div>
{/* Save */}
<button
type="button"
onClick={handleSave}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97]"
style={{ backgroundColor: '#3B82F6' }}
>
<Save size={15} />
Save Lead
</button>
</div>
</div>
{/* ----------------------------------------------------------------
Desktop save bar (hidden on mobile — mobile uses sticky footer)
---------------------------------------------------------------- */}
<div className={`hidden md:block fixed bottom-0 left-0 right-0 px-6 py-4
border-t backdrop-blur-md
${isDark ? 'bg-zinc-900/90 border-white/10' : 'bg-white/90 border-zinc-200'}
`}>
<div className="max-w-3xl mx-auto flex items-center justify-between gap-4">
{/* Validation error */}
<AnimatePresence>
{validationError && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
className="flex items-center gap-2 text-sm text-red-500 dark:text-red-400"
>
<AlertCircle size={14} />
{validationError}
</motion.div>
)}
</AnimatePresence>
<div className="ml-auto flex items-center gap-3">
<span className="text-xs text-zinc-400 dark:text-zinc-500 uppercase tracking-widest font-bold">
{progress}% complete
</span>
<button
type="button"
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90"
style={{ backgroundColor: '#3B82F6' }}
>
<Save size={14} />
Save Lead
</button>
</div>
</div>
</div>
</div>
);
}