feat(dispatch): Phase 2 — DispatchLeadQueue with tabs, cards, and live drop-in
- DispatchLeadQueue: 4-tab filter (All / Unassigned / At Risk / Confirmed) with live counts; AnimatePresence popLayout for smooth card entrance/exit - LeadCard: urgency badge (EMERGENCY pulse / HIGH / STANDARD), source icon, customer name, lead type, address, status pill, assigned rep initials, time ago — colored left border by urgency, blue ring when selected - Storm mode: emergency leads float to top via sort; PRIORITY badge on emergency_tarp cards; amber Unassigned pill in storm context - 20s interval drops new leads from 3-lead pool; NEW badge fades after 5s - LynkDispatchPage: left panel now renders DispatchLeadQueue; handleSelectLead triggers 1.2s isProcessing state; right panel shows processing/selected/empty states ready for Phase 3
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Globe, MapPin, Users, Phone, Clock, CloudLightning } from 'lucide-react';
|
||||
import { useMockStore, DISPATCH_REPS } from '../../data/mockStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config maps
|
||||
// ---------------------------------------------------------------------------
|
||||
const SOURCE_CONFIG = {
|
||||
website_form: { icon: Globe, label: 'Web Form' },
|
||||
canvassing: { icon: MapPin, label: 'Canvassing' },
|
||||
referral: { icon: Users, label: 'Referral' },
|
||||
call_center: { icon: Phone, label: 'Call Center' },
|
||||
};
|
||||
|
||||
const LEAD_TYPE_LABELS = {
|
||||
emergency_tarp: 'Emergency Tarp',
|
||||
roof_inspection: 'Roof Inspection',
|
||||
insurance_claim: 'Insurance Claim',
|
||||
retail_estimate: 'Retail Estimate',
|
||||
};
|
||||
|
||||
const URGENCY_CONFIG = {
|
||||
emergency: { label: 'EMERGENCY', color: '#EF4444', bg: '#EF444415', pulse: true },
|
||||
high: { label: 'HIGH', color: '#F59E0B', bg: '#F59E0B15', pulse: false },
|
||||
standard: { label: 'STANDARD', color: '#6B7280', bg: '#6B728015', pulse: false },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
unassigned: { label: 'Unassigned', color: '#EF4444', bg: '#EF444412' },
|
||||
at_risk: { label: 'At Risk', color: '#F59E0B', bg: '#F59E0B12' },
|
||||
assigned: { label: 'Assigned', color: '#3B82F6', bg: '#3B82F612' },
|
||||
confirmed: { label: 'Confirmed', color: '#10B981', bg: '#10B98112' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'unassigned', label: 'Unassigned' },
|
||||
{ id: 'at_risk', label: 'At Risk' },
|
||||
{ id: 'confirmed', label: 'Confirmed' },
|
||||
];
|
||||
|
||||
const TAB_STATUS_FILTER = {
|
||||
all: null,
|
||||
unassigned: ['unassigned'],
|
||||
at_risk: ['at_risk'],
|
||||
confirmed: ['assigned', 'confirmed'],
|
||||
};
|
||||
|
||||
// Fake leads that "drop in" every 20s during demo — cycled through pool
|
||||
const DROP_POOL = [
|
||||
{
|
||||
source: 'call_center', leadType: 'roof_inspection', urgency: 'high',
|
||||
customer: { name: 'Marcus Webb' },
|
||||
property: { address: '1620 Mapleshade Ln', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0545, lng: -96.7634 },
|
||||
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||
},
|
||||
{
|
||||
source: 'website_form', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||
customer: { name: 'Sandra Kim' },
|
||||
property: { address: '3488 Midway Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0712, lng: -96.8090 },
|
||||
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||
},
|
||||
{
|
||||
source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||
customer: { name: 'David Okonkwo' },
|
||||
property: { address: '892 McDermott Dr', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0921, lng: -96.7812 },
|
||||
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LeadCard
|
||||
// ---------------------------------------------------------------------------
|
||||
const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index }) => {
|
||||
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
||||
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
||||
const SrcIcon = srcCfg.icon;
|
||||
const assignedRep = lead.assignedRepId ? DISPATCH_REPS.find(r => r.id === lead.assignedRepId) : null;
|
||||
const timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
|
||||
const borderColor = isSelected ? accent : urgCfg.color;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -16, transition: { duration: 0.18 } }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 32, delay: index * 0.03 }}
|
||||
onClick={() => onSelect(lead)}
|
||||
whileHover={{ y: -2 }}
|
||||
className={`relative cursor-pointer rounded-xl border transition-all duration-200 p-3 select-none
|
||||
${isSelected
|
||||
? 'bg-blue-50/80 dark:bg-blue-500/10 border-blue-300 dark:border-blue-500/40 shadow-md shadow-blue-100/60 dark:shadow-blue-500/5'
|
||||
: 'bg-white dark:bg-zinc-800/50 border-zinc-200 dark:border-white/[0.06] hover:border-zinc-300 dark:hover:border-white/[0.12] hover:shadow-md'
|
||||
}
|
||||
`}
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: borderColor }}
|
||||
>
|
||||
{/* Top row: urgency badge + NEW badge + source */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span
|
||||
className={`text-[10px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${urgCfg.pulse ? 'animate-pulse' : ''}`}
|
||||
style={{ backgroundColor: urgCfg.bg, color: urgCfg.color }}
|
||||
>
|
||||
{urgCfg.label}
|
||||
</span>
|
||||
<AnimatePresence>
|
||||
{isNew && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 shrink-0"
|
||||
>
|
||||
NEW
|
||||
</motion.span>
|
||||
)}
|
||||
{stormMode && lead.leadType === 'emergency_tarp' && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-0.5 text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
|
||||
>
|
||||
<CloudLightning size={9} />
|
||||
PRIORITY
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 text-zinc-400 dark:text-zinc-500">
|
||||
<SrcIcon size={11} />
|
||||
<span className="text-[10px]">{srcCfg.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer name + lead type */}
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white leading-snug">
|
||||
{lead.customer.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
|
||||
<span className="text-zinc-300 dark:text-zinc-600 mx-1">·</span>
|
||||
{lead.estimatedDuration} min est.
|
||||
</p>
|
||||
|
||||
{/* Address */}
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-1 truncate">
|
||||
{lead.property.address}, {lead.property.city}
|
||||
</p>
|
||||
|
||||
{/* Bottom row: status + time */}
|
||||
<div className="flex items-center justify-between mt-2 gap-2">
|
||||
{assignedRep ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium min-w-0">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
|
||||
style={{ backgroundColor: statusCfg.color }}
|
||||
>
|
||||
{assignedRep.initials.charAt(0)}
|
||||
</div>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: statusCfg.color }}
|
||||
>
|
||||
{assignedRep.name.split(' ')[0]}
|
||||
<span className="text-zinc-400 dark:text-zinc-600 font-normal"> · {statusCfg.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusCfg.bg, color: statusCfg.color }}
|
||||
>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-[10px] text-zinc-400 shrink-0">
|
||||
<Clock size={10} />
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Lead ID */}
|
||||
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 mt-1.5 font-mono">{lead.id}</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DispatchLeadQueue
|
||||
// ---------------------------------------------------------------------------
|
||||
const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
|
||||
const { dispatchLeads } = useMockStore();
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [droppedLeads, setDropped] = useState([]);
|
||||
const [newLeadIds, setNewLeadIds] = useState(new Set());
|
||||
const dropIndexRef = useRef(0);
|
||||
|
||||
// 20-second interval: drop in a new lead from the pool
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const template = DROP_POOL[dropIndexRef.current % DROP_POOL.length];
|
||||
dropIndexRef.current++;
|
||||
const lead = { ...template, id: `LD-D${Date.now()}` };
|
||||
|
||||
setDropped(prev => [lead, ...prev]);
|
||||
setNewLeadIds(prev => new Set([...prev, lead.id]));
|
||||
|
||||
// Clear "New" badge after 5s
|
||||
setTimeout(() => {
|
||||
setNewLeadIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(lead.id);
|
||||
return next;
|
||||
});
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Merge dropped leads (newest first) with store leads
|
||||
const allLeads = [...droppedLeads, ...dispatchLeads];
|
||||
|
||||
// Tab counts
|
||||
const counts = {
|
||||
all: allLeads.length,
|
||||
unassigned: allLeads.filter(l => l.status === 'unassigned').length,
|
||||
at_risk: allLeads.filter(l => l.status === 'at_risk').length,
|
||||
confirmed: allLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length,
|
||||
};
|
||||
|
||||
// Filter by tab
|
||||
const statusFilter = TAB_STATUS_FILTER[activeTab];
|
||||
let filteredLeads = statusFilter
|
||||
? allLeads.filter(l => statusFilter.includes(l.status))
|
||||
: allLeads;
|
||||
|
||||
// Storm mode: float emergency leads to top
|
||||
if (stormMode) {
|
||||
filteredLeads = [...filteredLeads].sort((a, b) => {
|
||||
const aScore = a.urgency === 'emergency' ? 0 : a.leadType === 'emergency_tarp' ? 1 : 2;
|
||||
const bScore = b.urgency === 'emergency' ? 0 : b.leadType === 'emergency_tarp' ? 1 : 2;
|
||||
return aScore - bScore;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="shrink-0 flex items-center gap-1 p-2 border-b border-zinc-100 dark:border-white/[0.06] bg-zinc-50/50 dark:bg-zinc-900/30">
|
||||
{TABS.map(tab => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200 flex-1 justify-center
|
||||
${isActive
|
||||
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`text-[10px] font-black px-1 rounded min-w-[18px] text-center transition-all duration-200
|
||||
${isActive ? 'text-white' : 'text-zinc-400 dark:text-zinc-600'}
|
||||
`}
|
||||
style={isActive ? { backgroundColor: accent } : {}}
|
||||
>
|
||||
{counts[tab.id]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Lead list */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1.5 custom-scrollbar">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{filteredLeads.length === 0 ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500 font-medium">No leads in this view</p>
|
||||
<p className="text-xs text-zinc-300 dark:text-zinc-700 mt-1">New leads appear automatically</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
filteredLeads.map((lead, index) => (
|
||||
<LeadCard
|
||||
key={lead.id}
|
||||
lead={lead}
|
||||
isSelected={selectedLead?.id === lead.id}
|
||||
isNew={newLeadIds.has(lead.id)}
|
||||
onSelect={onSelectLead}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DispatchLeadQueue;
|
||||
@@ -3,6 +3,7 @@ import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, B
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
||||
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel — themed container used by all 3 side panels
|
||||
@@ -63,6 +64,13 @@ const LynkDispatchPage = () => {
|
||||
const [stormMode, setStormMode] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleSelectLead = (lead) => {
|
||||
if (selectedLead?.id === lead.id) return;
|
||||
setSelectedLead(lead);
|
||||
setIsProcessing(true);
|
||||
setTimeout(() => setIsProcessing(false), 1200);
|
||||
};
|
||||
|
||||
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||
|
||||
@@ -140,43 +148,23 @@ const LynkDispatchPage = () => {
|
||||
<Panel className="lg:overflow-hidden">
|
||||
<PanelHeader
|
||||
title="Lead Queue"
|
||||
subtitle={`${unassigned} unassigned · ${dispatchLeads.length} total`}
|
||||
subtitle="Live · auto-updates every 20s"
|
||||
accent={accent}
|
||||
right={
|
||||
<span
|
||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
{dispatchLeads.length}
|
||||
{dispatchLeads.length}+
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{/* Placeholder — Phase 2 builds this */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div className="w-12 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<Inbox size={22} className="text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{dispatchLeads.length} leads loaded</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Lead queue renders in Phase 2</p>
|
||||
</div>
|
||||
{/* Quick stat pills */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{[
|
||||
{ label: 'Unassigned', count: dispatchLeads.filter(l => l.status === 'unassigned').length, color: '#EF4444' },
|
||||
{ label: 'At Risk', count: dispatchLeads.filter(l => l.status === 'at_risk').length, color: '#F59E0B' },
|
||||
{ label: 'Confirmed', count: dispatchLeads.filter(l => l.status === 'confirmed').length, color: '#10B981' },
|
||||
].map(s => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
||||
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
||||
>
|
||||
{s.count} {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DispatchLeadQueue
|
||||
selectedLead={selectedLead}
|
||||
onSelectLead={handleSelectLead}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* CENTER — Map */}
|
||||
@@ -235,8 +223,52 @@ const LynkDispatchPage = () => {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* Empty state — Phase 3 builds this */}
|
||||
{/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{isProcessing ? (
|
||||
<motion.div
|
||||
key="processing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl border-2 border-dashed flex items-center justify-center animate-pulse"
|
||||
style={{ borderColor: `${accent}60`, backgroundColor: `${accent}10` }}
|
||||
>
|
||||
<Bot size={24} style={{ color: accent }} />
|
||||
</div>
|
||||
<p className="text-sm font-semibold" style={{ color: accent }}>Analyzing lead...</p>
|
||||
<p className="text-xs text-zinc-400">AI scoring reps</p>
|
||||
</motion.div>
|
||||
) : selectedLead ? (
|
||||
<motion.div
|
||||
key="selected"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
<Bot size={24} />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{selectedLead.id}</p>
|
||||
<p className="text-xs text-zinc-400">{selectedLead.customer.name} · {selectedLead.property.address}</p>
|
||||
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
||||
Recommendation engine — Phase 3
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
@@ -245,16 +277,12 @@ const LynkDispatchPage = () => {
|
||||
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
{selectedLead ? 'Processing...' : 'No lead selected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Click a lead in the queue to see AI-scored rep recommendations
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">No lead selected</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Click a lead in the queue to see AI-scored rep recommendations</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
||||
Recommendation engine — Phase 3
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user