feat(leads): Lead Creation Phase 3 — Contact section with dynamic phone and email rows
- LeadContactSection: First/Last name grid, dynamic phone list, dynamic email list (Full Form only), all with spring height animations and LayoutGroup for coordinated layout shifts - PhoneEntryRow: phone input with (555) 000-0000 mask, type selector (Mobile/Home/Work/Other), amber star primary toggle, red X remove — type+star hidden in Quick Capture mode - EmailEntryRow: email input, type selector, star primary toggle, remove button - Add Phone / Add Email buttons animate in when switching to Full Form; rows slide in/out with spring height collapse - CreateLeadPage: sectionCompletion.contact now live (firstName + lastName + 10-digit phone), progress bar switched to section-completion-based percentage (completed sections / visible sections)
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Mail, Star, X } from 'lucide-react';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
export default function EmailEntryRow({ email, isOnly, onUpdate, onSetPrimary, onRemove }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const inputBase = `w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
|
{/* Email input */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Mail
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email.address}
|
||||||
|
onChange={e => onUpdate('address', e.target.value)}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
className={`${inputBase} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Type selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={email.type}
|
||||||
|
onChange={e => onUpdate('type', e.target.value)}
|
||||||
|
className={`appearance-none rounded-xl px-3 py-3 pr-8 text-xs font-bold uppercase tracking-wide cursor-pointer outline-none transition-all duration-150
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-800/60 border border-zinc-700 text-zinc-300 focus:border-blue-500'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-600 focus:border-blue-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{LEAD_FORM_OPTIONS.emailTypes.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-400"
|
||||||
|
width="10" height="10" viewBox="0 0 10 10"
|
||||||
|
>
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary star */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSetPrimary}
|
||||||
|
title={email.isPrimary ? 'Primary email' : 'Set as primary'}
|
||||||
|
className={`p-2 rounded-lg transition-all duration-150 shrink-0
|
||||||
|
${email.isPrimary
|
||||||
|
? 'text-amber-400 bg-amber-50 dark:bg-amber-400/10'
|
||||||
|
: 'text-zinc-300 dark:text-zinc-600 hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-400/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={15} fill={email.isPrimary ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Remove */}
|
||||||
|
{!isOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-lg text-zinc-300 dark:text-zinc-600 hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10 transition-all duration-150 shrink-0"
|
||||||
|
>
|
||||||
|
<X size={15} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||||
|
import { PlusCircle } from 'lucide-react';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import PhoneEntryRow from './PhoneEntryRow';
|
||||||
|
import EmailEntryRow from './EmailEntryRow';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared field primitives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function FieldLabel({ children }) {
|
||||||
|
return (
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-zinc-400 dark:text-zinc-500 mb-1.5">
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
className={`w-full rounded-xl px-4 py-3 text-sm font-medium
|
||||||
|
outline-none transition-all duration-150
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row slide animation config
|
||||||
|
const rowTransition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 32,
|
||||||
|
opacity: { duration: 0.15 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contact Section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function LeadContactSection({ formData, updateField, isQuickCapture }) {
|
||||||
|
|
||||||
|
// ---- Phone helpers ----
|
||||||
|
const addPhone = () => {
|
||||||
|
const next = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
number: '',
|
||||||
|
type: 'Mobile',
|
||||||
|
isPrimary: false,
|
||||||
|
};
|
||||||
|
updateField('phones', [...formData.phones, next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhone = (id) => {
|
||||||
|
const filtered = formData.phones.filter(p => p.id !== id);
|
||||||
|
// Ensure at least one primary
|
||||||
|
if (filtered.length > 0 && !filtered.some(p => p.isPrimary)) {
|
||||||
|
filtered[0] = { ...filtered[0], isPrimary: true };
|
||||||
|
}
|
||||||
|
updateField('phones', filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePhone = (id, field, value) => {
|
||||||
|
updateField('phones', formData.phones.map(p =>
|
||||||
|
p.id === id ? { ...p, [field]: value } : p
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPrimaryPhone = (id) => {
|
||||||
|
updateField('phones', formData.phones.map(p => ({ ...p, isPrimary: p.id === id })));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Email helpers ----
|
||||||
|
const addEmail = () => {
|
||||||
|
const next = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
address: '',
|
||||||
|
type: 'Personal',
|
||||||
|
isPrimary: formData.emails.length === 0,
|
||||||
|
};
|
||||||
|
updateField('emails', [...formData.emails, next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEmail = (id) => {
|
||||||
|
const filtered = formData.emails.filter(e => e.id !== id);
|
||||||
|
if (filtered.length > 0 && !filtered.some(e => e.isPrimary)) {
|
||||||
|
filtered[0] = { ...filtered[0], isPrimary: true };
|
||||||
|
}
|
||||||
|
updateField('emails', filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEmail = (id, field, value) => {
|
||||||
|
updateField('emails', formData.emails.map(e =>
|
||||||
|
e.id === id ? { ...e, [field]: value } : e
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPrimaryEmail = (id) => {
|
||||||
|
updateField('emails', formData.emails.map(e => ({ ...e, isPrimary: e.id === id })));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ---- Name row ---- */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<LeadInput
|
||||||
|
label="First Name"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={v => updateField('firstName', v)}
|
||||||
|
placeholder="John"
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
<LeadInput
|
||||||
|
label="Last Name"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={v => updateField('lastName', v)}
|
||||||
|
placeholder="Smith"
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Phone(s) ---- */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<FieldLabel>{isQuickCapture ? 'Phone' : 'Phone Numbers'}</FieldLabel>
|
||||||
|
<LayoutGroup id="phones">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{formData.phones.map((phone, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={phone.id}
|
||||||
|
layout
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={rowTransition}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={index > 0 ? 'pt-2' : ''}>
|
||||||
|
<PhoneEntryRow
|
||||||
|
phone={phone}
|
||||||
|
isOnly={formData.phones.length === 1}
|
||||||
|
showFullControls={!isQuickCapture}
|
||||||
|
onUpdate={(field, value) => updatePhone(phone.id, field, value)}
|
||||||
|
onSetPrimary={() => setPrimaryPhone(phone.id)}
|
||||||
|
onRemove={() => removePhone(phone.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</LayoutGroup>
|
||||||
|
|
||||||
|
{/* Add Phone button — Full Form only */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!isQuickCapture && (
|
||||||
|
<motion.div
|
||||||
|
key="add-phone-btn"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={rowTransition}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPhone}
|
||||||
|
className="mt-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusCircle size={13} strokeWidth={2.5} />
|
||||||
|
Add Phone
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Email(s) — Full Form only ---- */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!isQuickCapture && (
|
||||||
|
<motion.div
|
||||||
|
key="email-section"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={rowTransition}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="pt-4">
|
||||||
|
<FieldLabel>Email Addresses</FieldLabel>
|
||||||
|
|
||||||
|
<LayoutGroup id="emails">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{formData.emails.map((email, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={email.id}
|
||||||
|
layout
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={rowTransition}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={index > 0 ? 'pt-2' : ''}>
|
||||||
|
<EmailEntryRow
|
||||||
|
email={email}
|
||||||
|
isOnly={formData.emails.length === 1}
|
||||||
|
onUpdate={(field, value) => updateEmail(email.id, field, value)}
|
||||||
|
onSetPrimary={() => setPrimaryEmail(email.id)}
|
||||||
|
onRemove={() => removeEmail(email.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</LayoutGroup>
|
||||||
|
|
||||||
|
{/* Empty state when no emails added yet */}
|
||||||
|
{formData.emails.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-400 dark:text-zinc-500 mb-2">
|
||||||
|
No emails added yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEmail}
|
||||||
|
className="mt-1 flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusCircle size={13} strokeWidth={2.5} />
|
||||||
|
Add Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Phone, Star, X } from 'lucide-react';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
const formatPhone = (raw) => {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 10);
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PhoneEntryRow({ phone, isOnly, showFullControls, onUpdate, onSetPrimary, onRemove }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const inputBase = `w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
|
{/* Phone input */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Phone
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone.number}
|
||||||
|
onChange={e => onUpdate('number', formatPhone(e.target.value))}
|
||||||
|
placeholder="(555) 000-0000"
|
||||||
|
className={`${inputBase} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls row (type + star + remove) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Type selector — shown in Full Form, hidden in Quick Capture */}
|
||||||
|
{showFullControls && (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={phone.type}
|
||||||
|
onChange={e => onUpdate('type', e.target.value)}
|
||||||
|
className={`appearance-none rounded-xl px-3 py-3 pr-8 text-xs font-bold uppercase tracking-wide cursor-pointer outline-none transition-all duration-150
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-800/60 border border-zinc-700 text-zinc-300 focus:border-blue-500'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-600 focus:border-blue-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{LEAD_FORM_OPTIONS.phoneTypes.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-400"
|
||||||
|
width="10" height="10" viewBox="0 0 10 10"
|
||||||
|
>
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary star — Full Form only */}
|
||||||
|
{showFullControls && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSetPrimary}
|
||||||
|
title={phone.isPrimary ? 'Primary contact' : 'Set as primary'}
|
||||||
|
className={`p-2 rounded-lg transition-all duration-150 shrink-0
|
||||||
|
${phone.isPrimary
|
||||||
|
? 'text-amber-400 bg-amber-50 dark:bg-amber-400/10'
|
||||||
|
: 'text-zinc-300 dark:text-zinc-600 hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-400/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={15} fill={phone.isPrimary ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove — shown when more than one row */}
|
||||||
|
{!isOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-lg text-zinc-300 dark:text-zinc-600 hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10 transition-all duration-150 shrink-0"
|
||||||
|
>
|
||||||
|
<X size={15} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
|
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
|
||||||
import LeadJobSection from '../components/leads/LeadJobSection';
|
import LeadJobSection from '../components/leads/LeadJobSection';
|
||||||
|
import LeadContactSection from '../components/leads/LeadContactSection';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -85,10 +86,10 @@ const INITIAL_FORM = {
|
|||||||
tradeType: '',
|
tradeType: '',
|
||||||
urgency: 'standard',
|
urgency: 'standard',
|
||||||
notes: '',
|
notes: '',
|
||||||
// Contact (Phase 3)
|
// Contact
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
phones: [],
|
phones: [{ id: '1', number: '', type: 'Mobile', isPrimary: true }],
|
||||||
emails: [],
|
emails: [],
|
||||||
// Property (Phase 4)
|
// Property (Phase 4)
|
||||||
address: '',
|
address: '',
|
||||||
@@ -130,14 +131,26 @@ export default function CreateLeadPage() {
|
|||||||
|
|
||||||
const progressBarRef = useRef(null);
|
const progressBarRef = useRef(null);
|
||||||
|
|
||||||
// Derived: which fields must be filled for this mode
|
// Section completion map
|
||||||
const requiredFields = isQuickCapture
|
const sectionCompletion = {
|
||||||
? ['leadSource']
|
contact: !!(
|
||||||
: ['leadSource', 'leadType', 'workType'];
|
formData.firstName &&
|
||||||
|
formData.lastName &&
|
||||||
|
formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)
|
||||||
|
),
|
||||||
|
property: false, // Phase 4
|
||||||
|
job: isQuickCapture
|
||||||
|
? !!formData.leadSource
|
||||||
|
: !!(formData.leadSource && formData.leadType && formData.workType),
|
||||||
|
insurance: false, // Phase 5
|
||||||
|
assignment: false, // Phase 6
|
||||||
|
};
|
||||||
|
|
||||||
const filledCount = requiredFields.filter(f => formData[f]).length;
|
// Progress = % of visible sections that are complete
|
||||||
const progress = requiredFields.length > 0
|
const visibleSectionIds = (isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS).map(s => s.id);
|
||||||
? Math.round((filledCount / requiredFields.length) * 100)
|
const completedCount = visibleSectionIds.filter(id => sectionCompletion[id]).length;
|
||||||
|
const progress = visibleSectionIds.length > 0
|
||||||
|
? Math.round((completedCount / visibleSectionIds.length) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// GSAP animates the progress bar whenever progress changes
|
// GSAP animates the progress bar whenever progress changes
|
||||||
@@ -151,17 +164,6 @@ export default function CreateLeadPage() {
|
|||||||
}
|
}
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
// Section completion map — grows as phases add fields
|
|
||||||
const sectionCompletion = {
|
|
||||||
contact: false, // Phase 3
|
|
||||||
property: false, // Phase 4
|
|
||||||
job: isQuickCapture
|
|
||||||
? !!formData.leadSource
|
|
||||||
: !!(formData.leadSource && formData.leadType && formData.workType),
|
|
||||||
insurance: false, // Phase 5
|
|
||||||
assignment: false, // Phase 6
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
const updateField = (field, value) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
@@ -170,9 +172,7 @@ export default function CreateLeadPage() {
|
|||||||
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
|
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleSections = isQuickCapture
|
const visibleSections = isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS;
|
||||||
? SECTIONS.filter(s => s.quickCapture)
|
|
||||||
: SECTIONS;
|
|
||||||
|
|
||||||
const modeSwitchTo = (quick) => {
|
const modeSwitchTo = (quick) => {
|
||||||
if (quick === isQuickCapture) return;
|
if (quick === isQuickCapture) return;
|
||||||
@@ -184,6 +184,15 @@ export default function CreateLeadPage() {
|
|||||||
|
|
||||||
// Render the content for a given section (placeholder until its phase is built)
|
// Render the content for a given section (placeholder until its phase is built)
|
||||||
const renderSectionContent = (section) => {
|
const renderSectionContent = (section) => {
|
||||||
|
if (section.id === 'contact') {
|
||||||
|
return (
|
||||||
|
<LeadContactSection
|
||||||
|
formData={formData}
|
||||||
|
updateField={updateField}
|
||||||
|
isQuickCapture={isQuickCapture}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (section.id === 'job') {
|
if (section.id === 'job') {
|
||||||
return (
|
return (
|
||||||
<LeadJobSection
|
<LeadJobSection
|
||||||
|
|||||||
Reference in New Issue
Block a user