door knock and referral attribution

This commit is contained in:
Satyam-Rastogi
2026-05-14 12:15:46 +05:30
parent 8157608ff0
commit 5aff14323c
4 changed files with 287 additions and 1 deletions
+215
View File
@@ -0,0 +1,215 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, ChevronDown, Search } from 'lucide-react';
import { useMockStore } from '../../data/mockStore';
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>
);
}
export default function CanvasserSelector({ value, onChange, onNameChange }) {
const { orgMembers } = useMockStore();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState(null);
const inputRef = useRef(null);
const dropdownRef = useRef(null);
const canvassers = useMemo(() =>
orgMembers.filter(m => m.roleKey === 'CANVASSER' && m.status === 'active'),
[orgMembers]
);
const filtered = useMemo(() => {
if (!query.trim()) return canvassers;
const q = query.toLowerCase();
return canvassers.filter(m =>
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q)
);
}, [query, canvassers]);
const selectedMember = useMemo(() =>
canvassers.find(m => m.userId === value),
[canvassers, value]
);
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, 280),
});
}
};
const handleFocus = () => {
computePos();
setQuery('');
setOpen(true);
};
const handleChange = (e) => {
setQuery(e.target.value);
if (!open) {
computePos();
setOpen(true);
}
};
const handleSelect = (member) => {
onChange(member.userId);
if (onNameChange) onNameChange(member.name);
setQuery('');
setOpen(false);
inputRef.current?.blur();
};
const handleBlur = (e) => {
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]); }
};
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]);
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]);
const displayValue = open ? query : (selectedMember?.name || '');
return (
<div>
<FieldLabel>Canvasser *</FieldLabel>
<div className={`flex items-center rounded-xl border transition-all duration-150
bg-white dark:bg-zinc-800/60
${open
? 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-100 dark:ring-blue-500/20'
: 'border-zinc-200 dark:border-zinc-700'
}`}
>
<Search size={13} className="ml-3.5 shrink-0 text-zinc-400 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={displayValue}
placeholder="Search canvasser by name or email…"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoComplete="off"
spellCheck={false}
className="flex-1 min-w-0 py-3 pl-2.5 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>
{createPortal(
<AnimatePresence>
{open && pos && (
<motion.div
ref={dropdownRef}
key="canvasser-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 canvassers match "{query}"
</p>
) : (
filtered.map(member => {
const isSelected = member.userId === value;
return (
<button
key={member.userId}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleSelect(member); }}
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm text-left transition-colors duration-100
${isSelected
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10'
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<span className="flex items-center gap-3 min-w-0">
<span className="w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-black uppercase text-white shrink-0"
style={{ backgroundColor: '#10b981' }}
>
{member.name.split(' ').map(n => n[0]).join('')}
</span>
<span className="min-w-0">
<span className="block font-semibold truncate">{member.name}</span>
<span className="block text-[11px] text-zinc-400 dark:text-zinc-500 truncate">{member.email}</span>
</span>
</span>
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />}
</button>
);
})
)}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
+47
View File
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { LEAD_FORM_OPTIONS } from '../../data/mockStore'; import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
import UrgencyPillSelector from './UrgencyPillSelector'; import UrgencyPillSelector from './UrgencyPillSelector';
import LeadCustomSelect from './LeadCustomSelect'; import LeadCustomSelect from './LeadCustomSelect';
import CanvasserSelector from './CanvasserSelector';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared field primitives (local to Job section) // Shared field primitives (local to Job section)
@@ -61,6 +62,52 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
accent="blue" accent="blue"
/> />
{/* --- Door Knock → Canvasser selector --- */}
<AnimatePresence initial={false}>
{formData.leadSource === 'Door Knock' && (
<motion.div
key="canvasser"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={collapseTransition}
className="overflow-hidden"
>
<div className="pt-4">
<CanvasserSelector
value={formData.canvasserId}
onChange={v => updateField('canvasserId', v)}
onNameChange={v => updateField('canvasserName', v)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* --- Referral → Referral Note --- */}
<AnimatePresence initial={false}>
{formData.leadSource === 'Referral' && (
<motion.div
key="referralNote"
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="Referral Note"
value={formData.referralNote}
onChange={v => updateField('referralNote', v)}
placeholder="e.g. Referred by John Smith, existing customer on Oak Street…"
rows={2}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* --- Lead Type — Full Form only --- */} {/* --- Lead Type — Full Form only --- */}
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{!isQuickCapture && ( {!isQuickCapture && (
+12 -1
View File
@@ -69,6 +69,9 @@ const SECTIONS = [
const INITIAL_FORM = { const INITIAL_FORM = {
// Job Details // Job Details
leadSource: '', leadSource: '',
canvasserId: '',
canvasserName: '',
referralNote: '',
leadType: '', leadType: '',
workType: '', workType: '',
tradeType: '', tradeType: '',
@@ -156,7 +159,14 @@ export default function CreateLeadPage() {
}, [progress]); }, [progress]);
const updateField = (field, value) => { const updateField = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => {
const next = { ...prev, [field]: value };
if (field === 'leadSource') {
if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; }
if (value !== 'Referral') { next.referralNote = ''; }
}
return next;
});
}; };
const toggleSection = (id) => { const toggleSection = (id) => {
@@ -173,6 +183,7 @@ export default function CreateLeadPage() {
if (!formData.address) missingFields.push('Street Address'); if (!formData.address) missingFields.push('Street Address');
if (!formData.city) missingFields.push('City'); if (!formData.city) missingFields.push('City');
if (!formData.leadSource) missingFields.push('Lead Source'); if (!formData.leadSource) missingFields.push('Lead Source');
if (formData.leadSource === 'Door Knock' && !formData.canvasserId) missingFields.push('Canvasser');
if (missingFields.length > 0) { if (missingFields.length > 0) {
setValidationError(`Required: ${missingFields.join(', ')}`); setValidationError(`Required: ${missingFields.join(', ')}`);
+13
View File
@@ -8,6 +8,7 @@ import PermissionGate from '../components/PermissionGate';
import { import {
Plus, Search, MapPin, Phone, Zap, FileText, Plus, Search, MapPin, Phone, Zap, FileText,
Clock, User, ChevronRight, Filter, Lock, Clock, User, ChevronRight, Filter, Lock,
DoorOpen, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -117,6 +118,18 @@ function LeadCard({ lead, index }) {
{lead.leadSource} {lead.leadSource}
</span> </span>
)} )}
{lead.canvasserName && (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<DoorOpen size={10} className="shrink-0" />
{lead.canvasserName}
</span>
)}
{lead.referralNote && (
<span className="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400 max-w-[200px] truncate">
<MessageSquare size={10} className="shrink-0" />
{lead.referralNote}
</span>
)}
</div> </div>
</div> </div>