feat(leads): Lead Creation Phase 4 — Property section with address, state select, and photo upload
- LeadPropertySection: Street Address (with pin icon), City/State/ZIP row (flex layout, ZIP auto-strips non-digits to 5 chars, state defaults to TX), Property Type dropdown and Site Photos (Full Form only, animate in/out) - Photo upload: dashed zone with Camera + Image icons, hover highlight in emerald, hidden file input with capture=environment (opens camera on mobile), multiple selection allowed - Thumbnails: 4-col grid, spring scale-in animation, hover overlay with X to remove individual photos - Focus ring accent changed to emerald to match section color identity (blue=contact, emerald=property) - CreateLeadPage: propertyPhotos added to INITIAL_FORM, sectionCompletion.property live (address + city required), LeadPropertySection wired into renderSectionContent
This commit is contained in:
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { MapPin, Camera, X, ChevronDown, Image } from 'lucide-react';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared field primitives (local)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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, inputMode }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
inputMode={inputMode}
|
||||||
|
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-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadSelect({ label, value, onChange, options, placeholder }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <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-9 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-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100'
|
||||||
|
}
|
||||||
|
${!value ? (isDark ? 'text-zinc-500' : 'text-zinc-400') : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{placeholder && <option value="" disabled>{placeholder}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse transition shared config
|
||||||
|
const collapseTransition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 32,
|
||||||
|
opacity: { duration: 0.15 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property Section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function LeadPropertySection({ formData, updateField, isQuickCapture }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handlePhotoAdd = (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
const newPhotos = files.map(file => ({
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
name: file.name,
|
||||||
|
}));
|
||||||
|
updateField('propertyPhotos', [...(formData.propertyPhotos || []), ...newPhotos]);
|
||||||
|
// Reset input so the same file can be re-added after removal
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhoto = (id) => {
|
||||||
|
updateField('propertyPhotos', (formData.propertyPhotos || []).filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ---- Street Address ---- */}
|
||||||
|
<div className="relative">
|
||||||
|
<FieldLabel>Street Address</FieldLabel>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={e => updateField('address', e.target.value)}
|
||||||
|
placeholder="123 Main St"
|
||||||
|
autoComplete="street-address"
|
||||||
|
className={`w-full rounded-xl pl-9 pr-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-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
|
||||||
|
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- City / State / ZIP ---- */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
{/* City — flex-1 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<LeadInput
|
||||||
|
label="City"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={v => updateField('city', v)}
|
||||||
|
placeholder="Plano"
|
||||||
|
autoComplete="address-level2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State — fixed narrow */}
|
||||||
|
<div className="w-[5.5rem] shrink-0">
|
||||||
|
<LeadSelect
|
||||||
|
label="State"
|
||||||
|
value={formData.state}
|
||||||
|
onChange={v => updateField('state', v)}
|
||||||
|
options={LEAD_FORM_OPTIONS.usStates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ZIP — fixed narrow */}
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
|
<LeadInput
|
||||||
|
label="ZIP"
|
||||||
|
value={formData.zip}
|
||||||
|
onChange={v => updateField('zip', v.replace(/\D/g, '').slice(0, 5))}
|
||||||
|
placeholder="75023"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="postal-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Property Type + Photo Upload — Full Form only ---- */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!isQuickCapture && (
|
||||||
|
<motion.div
|
||||||
|
key="fullFormFields"
|
||||||
|
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="Property Type"
|
||||||
|
value={formData.propertyType}
|
||||||
|
onChange={v => updateField('propertyType', v)}
|
||||||
|
options={LEAD_FORM_OPTIONS.leadTypes}
|
||||||
|
placeholder="Residential, Commercial…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Photo Upload ---- */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<FieldLabel>Site Photos</FieldLabel>
|
||||||
|
|
||||||
|
{/* Upload zone */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`w-full rounded-xl border-2 border-dashed px-4 py-5 flex flex-col items-center gap-2
|
||||||
|
transition-all duration-150 cursor-pointer group
|
||||||
|
${isDark
|
||||||
|
? 'border-zinc-700 hover:border-emerald-600/50 hover:bg-emerald-500/5'
|
||||||
|
: 'border-zinc-200 hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-zinc-400 group-hover:text-emerald-500 transition-colors duration-150">
|
||||||
|
<Camera size={22} />
|
||||||
|
<Image size={20} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-emerald-500 transition-colors duration-150">
|
||||||
|
Tap to add photos
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-zinc-400/60">
|
||||||
|
Camera · Gallery · Multiple allowed
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handlePhotoAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Photo thumbnails */}
|
||||||
|
{(formData.propertyPhotos || []).length > 0 && (
|
||||||
|
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||||
|
<AnimatePresence>
|
||||||
|
{(formData.propertyPhotos || []).map(photo => (
|
||||||
|
<motion.div
|
||||||
|
key={photo.id}
|
||||||
|
initial={{ scale: 0.7, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.7, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
|
className="relative group aspect-square rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Remove overlay */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePhoto(photo.id)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-all duration-150"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
className="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 drop-shadow"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
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 LeadContactSection from '../components/leads/LeadContactSection';
|
||||||
|
import LeadPropertySection from '../components/leads/LeadPropertySection';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -91,12 +92,13 @@ const INITIAL_FORM = {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
phones: [{ id: '1', number: '', type: 'Mobile', isPrimary: true }],
|
phones: [{ id: '1', number: '', type: 'Mobile', isPrimary: true }],
|
||||||
emails: [],
|
emails: [],
|
||||||
// Property (Phase 4)
|
// Property
|
||||||
address: '',
|
address: '',
|
||||||
city: '',
|
city: '',
|
||||||
state: 'TX',
|
state: 'TX',
|
||||||
zip: '',
|
zip: '',
|
||||||
propertyType: '',
|
propertyType: '',
|
||||||
|
propertyPhotos: [],
|
||||||
// Insurance (Phase 5)
|
// Insurance (Phase 5)
|
||||||
insuranceCompany: '',
|
insuranceCompany: '',
|
||||||
claimNumber: '',
|
claimNumber: '',
|
||||||
@@ -138,7 +140,7 @@ export default function CreateLeadPage() {
|
|||||||
formData.lastName &&
|
formData.lastName &&
|
||||||
formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)
|
formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)
|
||||||
),
|
),
|
||||||
property: false, // Phase 4
|
property: !!(formData.address && formData.city),
|
||||||
job: isQuickCapture
|
job: isQuickCapture
|
||||||
? !!formData.leadSource
|
? !!formData.leadSource
|
||||||
: !!(formData.leadSource && formData.leadType && formData.workType),
|
: !!(formData.leadSource && formData.leadType && formData.workType),
|
||||||
@@ -193,6 +195,15 @@ export default function CreateLeadPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (section.id === 'property') {
|
||||||
|
return (
|
||||||
|
<LeadPropertySection
|
||||||
|
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