feat(leads): Lead Creation Phase 2 — Job Details section with live fields
- UrgencyPillSelector: 3-pill selector (Standard/High/Emergency) with Framer Motion layoutId sliding background, spring physics, color transition on text - LeadJobSection: full Job Details fields — Lead Source (always), Lead Type / Work Type / Trade Type (Full Form only, each row animates in/out with spring height collapse), Urgency pill (always), Notes textarea (Full Form only) - Full Form fields use overflow-hidden + height:auto spring animation — no phantom gaps since spacing is managed via pt-4 inside each animated block - CreateLeadPage: formData state tracking all sections (scaffold for Phase 3-6 fields), updateField handler, sectionCompletion map, GSAP progress bar now driven by real field completion percentage, Job Details section wired up with live data
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useTheme } from '../../context/ThemeContext';
|
||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||
import UrgencyPillSelector from './UrgencyPillSelector';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared field primitives (local to Job section for now)
|
||||
// ---------------------------------------------------------------------------
|
||||
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 LeadSelect({ label, value, onChange, options, placeholder }) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`w-full appearance-none rounded-xl px-4 py-3 pr-10 text-sm font-medium cursor-pointer
|
||||
outline-none transition-all duration-150
|
||||
${isDark
|
||||
? 'bg-zinc-800/60 border border-zinc-700 text-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20'
|
||||
: 'bg-white border border-zinc-200 text-zinc-800 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
||||
}
|
||||
${!value ? (isDark ? 'text-zinc-500' : 'text-zinc-400') : ''}
|
||||
`}
|
||||
>
|
||||
<option value="" disabled>{placeholder || 'Select…'}</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium resize-none
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared animation config for collapsing rows
|
||||
const collapseTransition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 32,
|
||||
opacity: { duration: 0.15 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job Details Section
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function LeadJobSection({ formData, updateField, isQuickCapture }) {
|
||||
return (
|
||||
// No gap/space-y — each block controls its own top padding to avoid
|
||||
// phantom gaps from collapsed motion.divs
|
||||
<div>
|
||||
{/* --- Lead Source — always visible --- */}
|
||||
<LeadSelect
|
||||
label="Lead Source"
|
||||
value={formData.leadSource}
|
||||
onChange={v => updateField('leadSource', v)}
|
||||
options={LEAD_FORM_OPTIONS.leadSources}
|
||||
placeholder="How did you find this lead?"
|
||||
/>
|
||||
|
||||
{/* --- Lead Type — Full Form only --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isQuickCapture && (
|
||||
<motion.div
|
||||
key="leadType"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={collapseTransition}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-4">
|
||||
<LeadSelect
|
||||
label="Lead Type"
|
||||
value={formData.leadType}
|
||||
onChange={v => updateField('leadType', v)}
|
||||
options={LEAD_FORM_OPTIONS.leadTypes}
|
||||
placeholder="Residential, Commercial…"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* --- Work Type + Trade Type — Full Form only --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isQuickCapture && (
|
||||
<motion.div
|
||||
key="workTrade"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={collapseTransition}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
|
||||
<LeadSelect
|
||||
label="Work Type"
|
||||
value={formData.workType}
|
||||
onChange={v => updateField('workType', v)}
|
||||
options={LEAD_FORM_OPTIONS.workTypes}
|
||||
placeholder="Roof Replacement, Repair…"
|
||||
/>
|
||||
<LeadSelect
|
||||
label="Trade Type"
|
||||
value={formData.tradeType}
|
||||
onChange={v => updateField('tradeType', v)}
|
||||
options={LEAD_FORM_OPTIONS.tradeTypes}
|
||||
placeholder="Roofing, Gutter, Siding…"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* --- Urgency — always visible --- */}
|
||||
<div className="pt-4">
|
||||
<FieldLabel>Urgency</FieldLabel>
|
||||
<UrgencyPillSelector
|
||||
value={formData.urgency}
|
||||
onChange={v => updateField('urgency', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- Notes — Full Form only --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isQuickCapture && (
|
||||
<motion.div
|
||||
key="notes"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={collapseTransition}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-4">
|
||||
<LeadTextarea
|
||||
label="Notes"
|
||||
value={formData.notes}
|
||||
onChange={v => updateField('notes', v)}
|
||||
placeholder="First impression, visible damage, special circumstances…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '../../context/ThemeContext';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'standard', label: 'Standard', color: '#6B7280' },
|
||||
{ value: 'high', label: 'High', color: '#F59E0B' },
|
||||
{ value: 'emergency', label: 'Emergency', color: '#EF4444' },
|
||||
];
|
||||
|
||||
export default function UrgencyPillSelector({ value, onChange }) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center p-1 rounded-xl ${isDark ? 'bg-zinc-800' : 'bg-zinc-100/80'}`}>
|
||||
{OPTIONS.map(opt => {
|
||||
const isSelected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className="relative flex-1 flex items-center justify-center py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors duration-150"
|
||||
>
|
||||
{/* Sliding background — Framer Motion layoutId handles the move */}
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="urgency-pill-bg"
|
||||
className={`absolute inset-0.5 rounded-[7px] shadow-sm ${isDark ? 'bg-zinc-700' : 'bg-white'}`}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="relative z-10"
|
||||
style={{
|
||||
color: isSelected
|
||||
? opt.color
|
||||
: isDark ? '#71717a' : '#a1a1aa',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user