fix(leads): portaled dropdowns, flex-wrapper icons, responsive layout, and UI polish

- Replace all native <select> with LeadCustomSelect (ReactDOM.createPortal into body,
  getBoundingClientRect positioning, e.composedPath() scroll detection)
- Add LeadCombobox: fuzzy-search state picker (searches abbr + full name, portal, onMouseDown)
- Convert Phone/MapPin icons from absolute-positioned to flex-wrapper siblings of bg-transparent input
- Fix dark mode invisible text: all inputs converted from isDark JS conditionals to dark: variants
- Fix urgency Standard pill visibility: container bg-zinc-900, selected bg-zinc-700 ring-1 shadow-lg
- Fix City/State/ZIP mobile overlap: flex -> grid-cols-2 (mobile) / grid-cols-[1fr_5.5rem_6rem] (sm+)
- Fix urgency section hidden behind mobile sticky footer: pb-28 -> pb-44 md:pb-10
- Replace all compact type pickers (phone/email) with LeadCustomSelect compact mode
- Update README.md with full lead creation architecture docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Satyam
2026-03-13 17:22:58 +05:30
parent 205ada99e9
commit d3e2944fa3
10 changed files with 606 additions and 270 deletions
+20 -40
View File
@@ -1,62 +1,43 @@
import React from 'react';
import { Mail, Star, X } from 'lucide-react';
import { useTheme } from '../../context/ThemeContext';
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
import LeadCustomSelect from './LeadCustomSelect';
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"
/>
{/* Email input — flex wrapper owns border/bg */}
<div className="flex items-center flex-1 rounded-xl
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
focus-within:border-blue-400 dark:focus-within:border-blue-500
focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-500/20
transition-all duration-150"
>
<Mail size={14} className="ml-4 shrink-0 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`}
className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500"
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{/* Type selector */}
<div className="relative">
<select
<div className="w-24">
<LeadCustomSelect
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>
onChange={v => onUpdate('type', v)}
options={LEAD_FORM_OPTIONS.emailTypes}
accent="blue"
compact
/>
</div>
{/* Primary star */}
<button
type="button"
onClick={onSetPrimary}
@@ -70,7 +51,6 @@ export default function EmailEntryRow({ email, isOnly, onUpdate, onSetPrimary, o
<Star size={15} fill={email.isPrimary ? 'currentColor' : 'none'} strokeWidth={1.5} />
</button>
{/* Remove */}
{!isOnly && (
<button
type="button"
+230
View File
@@ -0,0 +1,230 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, ChevronDown } from 'lucide-react';
// Full name map so users can search "Texas" and land on TX
const US_STATE_NAMES = {
AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California',
CO:'Colorado', CT:'Connecticut', DE:'Delaware', FL:'Florida', GA:'Georgia',
HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa',
KS:'Kansas', KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland',
MA:'Massachusetts', MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri',
MT:'Montana', NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey',
NM:'New Mexico', NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio',
OK:'Oklahoma', OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina',
SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont',
VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming',
};
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>
);
}
/**
* Combobox for state selection.
* - Input shows the abbreviation when idle, allows free-text search when focused
* - Dropdown filters on abbreviation OR full state name (fuzzy substring)
* - Portal rendering so it escapes overflow:hidden wrappers
* - accent: 'blue' | 'emerald'
*/
export default function LeadCombobox({ label, value, onChange, options, accent = 'emerald' }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); // what user is typing
const [pos, setPos] = useState(null);
const inputRef = useRef(null);
const dropdownRef = useRef(null);
// Filter options: match abbr or full name, case-insensitive
const filtered = useMemo(() => {
if (!query.trim()) return options;
const q = query.toLowerCase();
return options.filter(abbr => {
const fullName = (US_STATE_NAMES[abbr] || '').toLowerCase();
return abbr.toLowerCase().startsWith(q) || fullName.includes(q);
});
}, [query, options]);
const focusRing = accent === 'emerald'
? 'border-emerald-400 dark:border-emerald-500 ring-2 ring-emerald-100 dark:ring-emerald-500/20'
: 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-100 dark:ring-blue-500/20';
const selectedAccent = accent === 'emerald'
? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10';
const computePos = () => {
if (inputRef.current) {
const r = inputRef.current.getBoundingClientRect();
setPos({
top: r.bottom + window.scrollY + 4,
left: r.left + window.scrollX,
width: Math.max(r.width, 220), // at least 220px so full names fit
});
}
};
const handleFocus = () => {
computePos();
setQuery(''); // clear so user can type fresh
setOpen(true);
};
const handleChange = (e) => {
setQuery(e.target.value);
if (!open) {
computePos();
setOpen(true);
}
};
const handleSelect = (abbr) => {
onChange(abbr);
setQuery('');
setOpen(false);
inputRef.current?.blur();
};
const handleBlur = (e) => {
// Give dropdown clicks a chance to fire before closing
if (dropdownRef.current?.contains(e.relatedTarget)) return;
setOpen(false);
setQuery('');
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
if (e.key === 'Enter' && filtered.length > 0) { handleSelect(filtered[0]); }
};
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
const inInput = inputRef.current?.contains(e.target);
const inDropdown = dropdownRef.current?.contains(e.target);
if (!inInput && !inDropdown) { setOpen(false); setQuery(''); }
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Close on page scroll — but NOT when the scroll originates inside the dropdown.
useEffect(() => {
if (!open) return;
const close = (e) => {
const path = e.composedPath?.() ?? [];
if (dropdownRef.current && path.includes(dropdownRef.current)) return;
setOpen(false);
setQuery('');
};
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [open]);
// Display: show query while typing, otherwise show selected abbreviation
const displayValue = open ? query : (value || '');
return (
<div>
{label && <FieldLabel>{label}</FieldLabel>}
{/* Input trigger */}
<div className={`flex items-center rounded-xl border transition-all duration-150
bg-white dark:bg-zinc-800/60
${open ? focusRing : 'border-zinc-200 dark:border-zinc-700'}
`}>
<input
ref={inputRef}
type="text"
value={displayValue}
placeholder={value || 'TX'}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoComplete="off"
spellCheck={false}
className="flex-1 min-w-0 py-3 pl-4 pr-1 text-sm font-medium outline-none bg-transparent
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500"
/>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className="pr-2.5 shrink-0 pointer-events-none"
>
<ChevronDown size={13} className="text-zinc-400" />
</motion.span>
</div>
{/* Portaled dropdown */}
{createPortal(
<AnimatePresence>
{open && pos && (
<motion.div
ref={dropdownRef}
key="state-dropdown"
initial={{ opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 9999,
}}
className="rounded-xl overflow-hidden
bg-white dark:bg-zinc-900
border border-zinc-200 dark:border-zinc-700
shadow-xl shadow-black/10 dark:shadow-black/50"
>
<div className="max-h-52 overflow-y-auto">
{filtered.length === 0 ? (
<p className="px-4 py-3 text-xs text-zinc-400 dark:text-zinc-500">
No states match "{query}"
</p>
) : (
filtered.map(abbr => {
const isSelected = abbr === value;
const fullName = US_STATE_NAMES[abbr] || abbr;
return (
<button
key={abbr}
type="button"
// mousedown fires before blur so selection registers
onMouseDown={(e) => { e.preventDefault(); handleSelect(abbr); }}
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm text-left transition-colors duration-100
${isSelected
? selectedAccent
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<span>
<span className="font-bold">{abbr}</span>
<span className="ml-2 text-zinc-400 dark:text-zinc-500">{fullName}</span>
</span>
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />}
</button>
);
})
)}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
+7 -11
View File
@@ -1,7 +1,6 @@
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';
@@ -17,9 +16,6 @@ function FieldLabel({ children }) {
}
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete }) {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div>
{label && <FieldLabel>{label}</FieldLabel>}
@@ -29,13 +25,13 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom
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'
}
`}
className="w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500
focus:border-blue-400 dark:focus:border-blue-500
focus:ring-2 focus:ring-blue-100 dark:focus:ring-blue-500/20"
/>
</div>
);
+187
View File
@@ -0,0 +1,187 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, Check } from 'lucide-react';
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>
);
}
/**
* accent: 'blue' | 'emerald'
* compact: true for small inline selects (phone type, email type)
*/
export default function LeadCustomSelect({
label,
value,
onChange,
options,
placeholder,
accent = 'blue',
compact = false,
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// Measure trigger position when opening
const computePos = () => {
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect();
setPos({
top: r.bottom + window.scrollY + 4,
left: r.left + window.scrollX,
width: r.width,
});
}
};
const handleToggle = () => {
if (!open) computePos();
setOpen(o => !o);
};
// Close on outside click — check both trigger and portaled dropdown
useEffect(() => {
if (!open) return;
const handler = (e) => {
const inTrigger = triggerRef.current?.contains(e.target);
const inDropdown = dropdownRef.current?.contains(e.target);
if (!inTrigger && !inDropdown) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Close on page scroll — but NOT when the scroll originates inside the dropdown.
// Use composedPath() because e.target can be unreliable across browsers/touch.
useEffect(() => {
if (!open) return;
const close = (e) => {
const path = e.composedPath?.() ?? [];
if (dropdownRef.current && path.includes(dropdownRef.current)) return;
setOpen(false);
};
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [open]);
const focusRing = accent === 'emerald'
? 'border-emerald-400 dark:border-emerald-500 ring-2 ring-emerald-100 dark:ring-emerald-500/20'
: 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-100 dark:ring-blue-500/20';
const selectedAccent = accent === 'emerald'
? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10';
// Trigger size classes
const triggerSize = compact
? 'px-3 py-[9px] text-xs font-bold uppercase tracking-wide'
: 'px-4 py-3 text-sm font-medium';
return (
<div>
{label && <FieldLabel>{label}</FieldLabel>}
{/* Trigger */}
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
className={`w-full flex items-center justify-between rounded-xl cursor-pointer
outline-none transition-all duration-150
bg-white dark:bg-zinc-800/60
border
${open ? focusRing : 'border-zinc-200 dark:border-zinc-700'}
${value
? 'text-zinc-800 dark:text-white'
: compact
? 'text-zinc-500 dark:text-zinc-400'
: 'text-zinc-400 dark:text-zinc-500'
}
${triggerSize}
`}
>
<span className="truncate">{value || placeholder || 'Select…'}</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className="ml-1.5 shrink-0"
>
<ChevronDown size={compact ? 12 : 14} className="text-zinc-400" />
</motion.span>
</button>
{/* Portaled dropdown — renders into body, escapes all overflow:hidden */}
{createPortal(
<AnimatePresence>
{open && pos && (
<motion.div
ref={dropdownRef}
key="dropdown"
initial={{ opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 9999,
}}
className="rounded-xl overflow-hidden
bg-white dark:bg-zinc-900
border border-zinc-200 dark:border-zinc-700
shadow-xl shadow-black/10 dark:shadow-black/50"
>
{/* Placeholder header */}
{placeholder && (
<div className="px-4 py-2 text-[11px] uppercase tracking-widest font-bold
text-zinc-400 dark:text-zinc-500
border-b border-zinc-100 dark:border-zinc-800">
{placeholder}
</div>
)}
{/* Options */}
<div className="max-h-52 overflow-y-auto">
{options.map(opt => {
const isSelected = opt === value;
return (
<button
key={opt}
type="button"
onClick={() => { onChange(opt); setOpen(false); }}
className={`w-full flex items-center justify-between text-left transition-colors duration-100
${compact ? 'px-3 py-2 text-xs font-semibold uppercase tracking-wide' : 'px-4 py-2.5 text-sm'}
${isSelected
? selectedAccent
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<span>{opt}</span>
{isSelected && (
<Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />
)}
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
+17 -52
View File
@@ -1,12 +1,11 @@
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';
import LeadCustomSelect from './LeadCustomSelect';
// ---------------------------------------------------------------------------
// Shared field primitives (local to Job section for now)
// Shared field primitives (local to Job section)
// ---------------------------------------------------------------------------
function FieldLabel({ children }) {
return (
@@ -16,44 +15,7 @@ function FieldLabel({ children }) {
);
}
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>
@@ -62,13 +24,14 @@ function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) {
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
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'
}
`}
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500
focus:border-blue-400 dark:focus:border-blue-500
focus:ring-2 focus:ring-blue-100 dark:focus:ring-blue-500/20"
/>
</div>
);
@@ -87,16 +50,15 @@ const collapseTransition = {
// ---------------------------------------------------------------------------
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
<LeadCustomSelect
label="Lead Source"
value={formData.leadSource}
onChange={v => updateField('leadSource', v)}
options={LEAD_FORM_OPTIONS.leadSources}
placeholder="How did you find this lead?"
accent="blue"
/>
{/* --- Lead Type — Full Form only --- */}
@@ -111,12 +73,13 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
className="overflow-hidden"
>
<div className="pt-4">
<LeadSelect
<LeadCustomSelect
label="Lead Type"
value={formData.leadType}
onChange={v => updateField('leadType', v)}
options={LEAD_FORM_OPTIONS.leadTypes}
placeholder="Residential, Commercial…"
accent="blue"
/>
</div>
</motion.div>
@@ -135,19 +98,21 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
className="overflow-hidden"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
<LeadSelect
<LeadCustomSelect
label="Work Type"
value={formData.workType}
onChange={v => updateField('workType', v)}
options={LEAD_FORM_OPTIONS.workTypes}
placeholder="Roof Replacement, Repair…"
accent="blue"
/>
<LeadSelect
<LeadCustomSelect
label="Trade Type"
value={formData.tradeType}
onChange={v => updateField('tradeType', v)}
options={LEAD_FORM_OPTIONS.tradeTypes}
placeholder="Roofing, Gutter, Siding…"
accent="blue"
/>
</div>
</motion.div>
+54 -85
View File
@@ -1,8 +1,9 @@
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 { MapPin, Camera, X, Image } from 'lucide-react';
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
import LeadCustomSelect from './LeadCustomSelect';
import LeadCombobox from './LeadCombobox';
// ---------------------------------------------------------------------------
// Shared field primitives (local)
@@ -16,8 +17,6 @@ function FieldLabel({ children }) {
}
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete, inputMode }) {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div>
{label && <FieldLabel>{label}</FieldLabel>}
@@ -28,47 +27,19 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium
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'
}
`}
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500
focus:border-emerald-400 dark:focus:border-emerald-500
focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-500/20"
/>
</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 = {
@@ -82,8 +53,6 @@ const collapseTransition = {
// Property Section
// ---------------------------------------------------------------------------
export default function LeadPropertySection({ formData, updateField, isQuickCapture }) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const fileInputRef = useRef(null);
const handlePhotoAdd = (e) => {
@@ -106,34 +75,38 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
return (
<div>
{/* ---- Street Address ---- */}
<div className="relative">
<div>
<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"
/>
{/* Flex wrapper owns the border/bg — MapPin is a real flex sibling */}
<div className="flex items-center rounded-xl
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
focus-within:border-emerald-400 dark:focus-within:border-emerald-500
focus-within:ring-2 focus-within:ring-emerald-100 dark:focus-within:ring-emerald-500/20
transition-all duration-150"
>
<MapPin size={14} className="ml-4 shrink-0 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'
}
`}
className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500"
/>
</div>
</div>
{/* ---- City / State / ZIP ---- */}
<div className="flex gap-3 pt-4">
{/* City — flex-1 */}
<div className="flex-1 min-w-0">
{/*
Mobile : City full-width on row 1, State + ZIP share row 2
sm+ : all three in one row (City flex-1, State fixed, ZIP fixed)
*/}
<div className="pt-4 grid grid-cols-2 sm:grid-cols-[1fr_5.5rem_6rem] gap-3">
{/* City — spans both columns on mobile, single column on sm+ */}
<div className="col-span-2 sm:col-span-1">
<LeadInput
label="City"
value={formData.city}
@@ -143,28 +116,25 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
/>
</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>
{/* State — combobox with fuzzy search on abbr + full name */}
<LeadCombobox
label="State"
value={formData.state}
onChange={v => updateField('state', v)}
options={LEAD_FORM_OPTIONS.usStates}
accent="emerald"
/>
{/* 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>
{/* ZIP */}
<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>
{/* ---- Property Type + Photo Upload — Full Form only ---- */}
@@ -179,12 +149,13 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
className="overflow-hidden"
>
<div className="pt-4">
<LeadSelect
<LeadCustomSelect
label="Property Type"
value={formData.propertyType}
onChange={v => updateField('propertyType', v)}
options={LEAD_FORM_OPTIONS.leadTypes}
placeholder="Residential, Commercial…"
accent="emerald"
/>
</div>
@@ -196,13 +167,11 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
<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
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'
}
`}
border-zinc-200 dark:border-zinc-700
hover:border-emerald-300 dark:hover:border-emerald-600/50
hover:bg-emerald-50/50 dark:hover:bg-emerald-500/5"
>
<div className="flex items-center gap-3 text-zinc-400 group-hover:text-emerald-500 transition-colors duration-150">
<Camera size={22} />
+20 -37
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { Phone, Star, X } from 'lucide-react';
import { useTheme } from '../../context/ThemeContext';
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
import LeadCustomSelect from './LeadCustomSelect';
const formatPhone = (raw) => {
const digits = raw.replace(/\D/g, '').slice(0, 10);
@@ -11,29 +11,25 @@ const formatPhone = (raw) => {
};
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"
/>
{/* Phone input — flex wrapper owns the border/bg so icon is a real sibling */}
<div className="flex items-center flex-1 rounded-xl
bg-white dark:bg-zinc-800/60
border border-zinc-200 dark:border-zinc-700
focus-within:border-blue-400 dark:focus-within:border-blue-500
focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-500/20
transition-all duration-150"
>
<Phone size={14} className="ml-4 shrink-0 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`}
className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent
text-zinc-800 dark:text-white
placeholder-zinc-400 dark:placeholder-zinc-500"
/>
</div>
@@ -41,27 +37,14 @@ export default function PhoneEntryRow({ phone, isOnly, showFullControls, onUpdat
<div className="flex items-center gap-2">
{/* Type selector — shown in Full Form, hidden in Quick Capture */}
{showFullControls && (
<div className="relative">
<select
<div className="w-24">
<LeadCustomSelect
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>
onChange={v => onUpdate('type', v)}
options={LEAD_FORM_OPTIONS.phoneTypes}
accent="blue"
compact
/>
</div>
)}
+14 -10
View File
@@ -3,9 +3,9 @@ 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' },
{ value: 'standard', label: 'Standard', darkColor: '#d4d4d8', lightColor: '#52525b' },
{ value: 'high', label: 'High', darkColor: '#F59E0B', lightColor: '#d97706' },
{ value: 'emergency', label: 'Emergency', darkColor: '#EF4444', lightColor: '#dc2626' },
];
export default function UrgencyPillSelector({ value, onChange }) {
@@ -13,7 +13,8 @@ export default function UrgencyPillSelector({ value, onChange }) {
const isDark = theme === 'dark';
return (
<div className={`flex items-center p-1 rounded-xl ${isDark ? 'bg-zinc-800' : 'bg-zinc-100/80'}`}>
// Container is 2 steps darker than the selected pill for clear contrast
<div className={`flex items-center p-1 rounded-xl ${isDark ? 'bg-zinc-900' : 'bg-zinc-100/80'}`}>
{OPTIONS.map(opt => {
const isSelected = value === opt.value;
return (
@@ -23,21 +24,24 @@ export default function UrgencyPillSelector({ value, onChange }) {
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 */}
{/* Sliding pill background */}
{isSelected && (
<motion.div
layoutId="urgency-pill-bg"
className={`absolute inset-0.5 rounded-[7px] shadow-sm ${isDark ? 'bg-zinc-700' : 'bg-white'}`}
className={`absolute inset-0.5 rounded-[7px] ${
isDark
? 'bg-zinc-700 shadow-lg ring-1 ring-white/10'
: 'bg-white shadow-sm'
}`}
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
/>
)}
<span
className="relative z-10"
className="relative z-10 transition-colors duration-150"
style={{
color: isSelected
? opt.color
: isDark ? '#71717a' : '#a1a1aa',
transition: 'color 0.15s',
? (isDark ? opt.darkColor : opt.lightColor)
: isDark ? '#3f3f46' : '#a1a1aa',
}}
>
{opt.label}
+1 -1
View File
@@ -245,7 +245,7 @@ export default function CreateLeadPage() {
{/* ----------------------------------------------------------------
Page content
---------------------------------------------------------------- */}
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-28 md:pb-10">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-44 md:pb-10">
{/* --- Page Header --- */}
<div className="mb-7">