import { useState, useCallback, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { AppLayout } from '../../components/layout/AppLayout';
import { LoadingSpinner } from '../..//components/ui/LoadingSpinner';
import {
angiLeadsApi,
type AngiLead,
type LeadStatus,
type LeadPriority,
type LeadActionType,
} from '../../api/angiLeads';
import {
Search,
Filter,
X,
Phone,
Mail,
MapPin,
Calendar,
DollarSign,
Clock,
CheckCircle2,
MessageSquare,
Ban,
ChevronRight,
AlertTriangle,
Star,
RefreshCw,
ClipboardList,
ArrowRight,
Check,
AlertCircle,
ExternalLink,
} from 'lucide-react';
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function timeAgo(dateStr: string | null | undefined): string {
if (!dateStr) return 'β';
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function formatCurrency(value: number | null): string {
if (value == null || value === 0) return 'β';
if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`;
return `$${value}`;
}
function responseTimeColor(minutes: number): string {
if (minutes <= 5) return 'text-emerald-600 bg-emerald-50';
if (minutes <= 30) return 'text-amber-600 bg-amber-50';
return 'text-red-600 bg-red-50';
}
function responseTimeLabel(minutes: number): string {
if (minutes == null || minutes === 0) return 'β';
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
function statusColor(status: LeadStatus): string {
const map: Record<LeadStatus, string> = {
new: 'bg-blue-100 text-blue-700',
accepted: 'bg-indigo-100 text-indigo-700',
responded: 'bg-violet-100 text-violet-700',
contacted: 'bg-cyan-100 text-cyan-700',
scheduled: 'bg-orange-100 text-orange-700',
won: 'bg-emerald-100 text-emerald-700',
lost: 'bg-red-100 text-red-700',
expired: 'bg-slate-100 text-slate-600',
rejected: 'bg-gray-100 text-gray-500',
};
return map[status] || 'bg-gray-100 text-gray-600';
}
function statusLabel(status: LeadStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function priorityColor(priority: LeadPriority): string {
const map: Record<LeadPriority, string> = {
high: 'text-red-600 bg-red-50',
medium: 'text-amber-600 bg-amber-50',
low: 'text-slate-600 bg-slate-50',
};
return map[priority] || '';
}
function priorityLabel(priority: LeadPriority): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
// βββ Status Timeline ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const STATUS_FLOW: LeadStatus[] = ['new', 'accepted', 'responded', 'contacted', 'scheduled', 'won'];
const TERMINAL_STATUSES: LeadStatus[] = ['won', 'lost', 'expired', 'rejected'];
function StatusTimeline({ lead }: { lead: AngiLead }) {
const currentIndex = STATUS_FLOW.indexOf(lead.status);
return (
<div className="flex items-center gap-1 overflow-x-auto py-2">
{STATUS_FLOW.map((step, i) => {
const isCompleted = i <= currentIndex && !TERMINAL_STATUSES.includes(step);
const isActive = lead.status === step;
const isTerminal = TERMINAL_STATUSES.includes(lead.status) && TERMINAL_STATUSES.includes(step);
return (
<div key={step} className="flex items-center">
<div
className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap ${
isActive
? 'bg-brand-600 text-white'
: isCompleted || isTerminal
? 'bg-emerald-100 text-emerald-700'
: 'bg-surface-100 text-surface-400'
}`}
>
{(isCompleted || isTerminal) && <Check className="h-3 w-3" />}
{statusLabel(step)}
</div>
{i < STATUS_FLOW.length - 1 && (
<ArrowRight className="h-3 w-3 text-surface-300 shrink-0 mx-0.5" />
)}
</div>
);
})}
</div>
);
}
// βββ Action Icon ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function actionIcon(type: LeadActionType): React.ReactNode {
switch (type) {
case 'received':
return <ClipboardList className="h-4 w-4 text-blue-500" />;
case 'responded':
return <MessageSquare className="h-4 w-4 text-violet-500" />;
case 'accepted':
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
case 'rejected':
return <Ban className="h-4 w-4 text-red-500" />;
case 'status_change':
return <ArrowRight className="h-4 w-4 text-amber-500" />;
case 'note':
return <AlertCircle className="h-4 w-4 text-slate-400" />;
default:
return <AlertCircle className="h-4 w-4 text-slate-400" />;
}
}
// βββ Lead Detail Panel ββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface LeadDetailPanelProps {
lead: AngiLead;
onClose: () => void;
onAction: () => void;
}
function LeadDetailPanel({ lead, onClose, onAction }: LeadDetailPanelProps) {
const { user } = useAuth();
const companyId = user?.tenantId as string;
const queryClient = useQueryClient();
const [notes, setNotes] = useState(lead.internal_notes || '');
const [noteDirty, setNoteDirty] = useState(false);
const [respondMessage, setRespondMessage] = useState('');
const [showRespond, setShowRespond] = useState(false);
// Update lead status
const updateMutation = useMutation({
mutationFn: (payload: { status?: LeadStatus; priority?: LeadPriority; internal_notes?: string }) =>
angiLeadsApi.update(companyId, lead.id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
onAction();
},
});
// Accept lead
const acceptMutation = useMutation({
mutationFn: () => angiLeadsApi.accept(companyId, lead.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
onAction();
},
});
// Reject lead
const rejectMutation = useMutation({
mutationFn: () => angiLeadsApi.reject(companyId, lead.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
onAction();
},
});
// Respond to lead
const respondMutation = useMutation({
mutationFn: (message: string) => angiLeadsApi.respond(companyId, lead.id, { message }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
setShowRespond(false);
setRespondMessage('');
onAction();
},
});
const handleSaveNotes = () => {
if (notes === lead.internal_notes) return;
updateMutation.mutate({ internal_notes: notes });
setNoteDirty(false);
};
const isTerminal = TERMINAL_STATUSES.includes(lead.status);
return (
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
{/* Panel */}
<div className="relative z-10 flex h-full w-full max-w-lg flex-col overflow-y-auto bg-white shadow-xl border-l border-surface-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-surface-200 px-6 py-4">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-surface-900">
{lead.first_name} {lead.last_name}
</h2>
{lead.is_premium && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700">
<Star className="h-3 w-3" /> Premium
</span>
)}
{lead.is_duplicate && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700">
Duplicate
</span>
)}
</div>
<p className="text-sm text-surface-500">{lead.project_type}</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-surface-400 hover:bg-surface-100 hover:text-surface-600 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Status Timeline */}
<div className="border-b border-surface-200 px-6 py-3">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-2">Status Timeline</p>
<StatusTimeline lead={lead} />
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 gap-3 border-b border-surface-200 px-6 py-4">
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Status</p>
<span className={`inline-block mt-1 rounded-full px-2 py-0.5 text-xs font-semibold ${statusColor(lead.status)}`}>
{statusLabel(lead.status)}
</span>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Priority</p>
<span className={`inline-block mt-1 rounded-full px-2 py-0.5 text-xs font-semibold ${priorityColor(lead.priority)}`}>
{priorityLabel(lead.priority)}
</span>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Budget</p>
<p className="mt-1 text-sm font-semibold text-surface-900">{lead.budget || formatCurrency(lead.budget_value)}</p>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Response Time</p>
<p className={`mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${responseTimeColor(lead.response_time_minutes)}`}>
{lead.responded_at ? responseTimeLabel(lead.response_time_minutes) : 'Not responded'}
</p>
</div>
</div>
{/* Contact Info */}
<div className="border-b border-surface-200 px-6 py-4">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-3">Contact Info</p>
<div className="space-y-2">
{lead.email && (
<a href={`mailto:${lead.email}`} className="flex items-center gap-2 text-sm text-surface-700 hover:text-brand-600">
<Mail className="h-4 w-4 text-surface-400" />
{lead.email}
</a>
)}
{lead.phone && (
<a href={`tel:${lead.phone}`} className="flex items-center gap-2 text-sm text-surface-700 hover:text-brand-600">
<Phone className="h-4 w-4 text-surface-400" />
{lead.phone}
</a>
)}
{lead.address && (
<div className="flex items-start gap-2 text-sm text-surface-700">
<MapPin className="h-4 w-4 text-surface-400 mt-0.5 shrink-0" />
<span>
{lead.address}
{lead.city && `, ${lead.city}`}
{lead.state && ` ${lead.state}`}
{lead.zip_code && ` ${lead.zip_code}`}
</span>
</div>
)}
</div>
</div>
{/* Project Details */}
<div className="border-b border-surface-200 px-6 py-4">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-3">Project Details</p>
<div className="space-y-2 text-sm text-surface-700">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-surface-400" />
<span className="font-medium">{lead.project_type}</span>
</div>
{lead.description && (
<p className="text-surface-600 leading-relaxed">{lead.description}</p>
)}
{lead.timeline && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-surface-400" />
<span>Timeline: {lead.timeline}</span>
</div>
)}
{lead.budget && (
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-surface-400" />
<span>Budget: {lead.budget}</span>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="border-b border-surface-200 px-6 py-4">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-3">Actions</p>
<div className="flex flex-wrap gap-2">
{!isTerminal && lead.status === 'new' && (
<button
onClick={() => acceptMutation.mutate()}
disabled={acceptMutation.isPending || updateMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 min-h-[44px]"
>
<CheckCircle2 className="h-4 w-4" />
Accept
</button>
)}
{!isTerminal && lead.status !== 'rejected' && (
<button
onClick={() => setShowRespond(true)}
disabled={respondMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
<MessageSquare className="h-4 w-4" />
Respond
</button>
)}
{!isTerminal && lead.status !== 'rejected' && (
<button
onClick={() => rejectMutation.mutate()}
disabled={rejectMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-50 min-h-[44px]"
>
<Ban className="h-4 w-4" />
Reject
</button>
)}
</div>
{/* Respond Modal */}
{showRespond && (
<div className="mt-3 rounded-lg border border-surface-200 bg-surface-50 p-4">
<p className="text-sm font-medium text-surface-700 mb-2">Send Response</p>
<textarea
value={respondMessage}
onChange={(e) => setRespondMessage(e.target.value)}
placeholder="Type your response to the lead..."
rows={3}
className="w-full rounded-lg border border-surface-300 bg-white px-3 py-2 text-sm outline-none focus:border-brand-500 resize-none"
/>
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => { setShowRespond(false); setRespondMessage(''); }}
className="rounded-lg border border-surface-300 px-3 py-1.5 text-sm text-surface-600 hover:bg-surface-100 min-h-[44px]"
>
Cancel
</button>
<button
onClick={() => respondMutation.mutate(respondMessage)}
disabled={!respondMessage.trim() || respondMutation.isPending}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
Send
</button>
</div>
</div>
)}
</div>
{/* Internal Notes */}
<div className="border-b border-surface-200 px-6 py-4">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-3">Internal Notes</p>
<textarea
value={notes}
onChange={(e) => { setNotes(e.target.value); setNoteDirty(true); }}
placeholder="Add internal notes about this lead..."
rows={3}
className="w-full rounded-lg border border-surface-300 bg-white px-3 py-2 text-sm outline-none focus:border-brand-500 resize-none"
/>
{noteDirty && (
<div className="mt-2 flex justify-end">
<button
onClick={handleSaveNotes}
disabled={updateMutation.isPending}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
Save Notes
</button>
</div>
)}
</div>
{/* Activity Log */}
{lead.actions && lead.actions.length > 0 && (
<div className="px-6 py-4">
<p className="text-xs font-medium text-surface-500 uppercase tracking-wide mb-3">Activity Log</p>
<div className="space-y-3">
{[...lead.actions].reverse().map((action) => (
<div key={action.id} className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-100">
{actionIcon(action.action_type)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-surface-700">{action.details}</p>
<p className="text-xs text-surface-400">
{action.performed_by} Β· {timeAgo(action.created_at)}
</p>
{action.message_content && (
<p className="mt-1 rounded-lg bg-surface-50 p-2 text-xs text-surface-600">
{action.message_content}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Received At */}
<div className="px-6 py-4 text-center text-xs text-surface-400">
Received {timeAgo(lead.received_at)}
</div>
</div>
</div>
);
}
// βββ Lead Row Component ββββββββββββββββββββββββββββββββββββββββββββββββββ
function LeadRow({
lead,
isSelected,
onSelect,
onAccept,
onReject,
onRespond,
isActionLoading,
}: {
lead: AngiLead;
isSelected: boolean;
onSelect: () => void;
onAccept: () => void;
onReject: () => void;
onRespond: () => void;
isActionLoading: boolean;
}) {
const isTerminal = TERMINAL_STATUSES.includes(lead.status);
return (
<tr
onClick={onSelect}
className={`group cursor-pointer border-b border-surface-100 last:border-0 transition-colors ${
isSelected ? 'bg-brand-50' : 'hover:bg-surface-50'
} ${lead.is_premium ? 'border-l-2 border-l-amber-400' : ''}`}
>
{/* Lead Info */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-200 text-xs font-semibold text-surface-600">
{lead.first_name?.[0]}{lead.last_name?.[0]}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium text-surface-900">
{lead.first_name} {lead.last_name}
</p>
{lead.is_premium && (
<Star className="h-3.5 w-3.5 shrink-0 text-amber-500" />
)}
{lead.is_duplicate && (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
</div>
<p className="truncate text-xs text-surface-500">{lead.project_type}</p>
</div>
</div>
</td>
{/* Status */}
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2.5 py-1 text-xs font-semibold ${statusColor(lead.status)}`}>
{statusLabel(lead.status)}
</span>
</td>
{/* Priority */}
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2.5 py-1 text-xs font-semibold ${priorityColor(lead.priority)}`}>
{priorityLabel(lead.priority)}
</span>
</td>
{/* Budget */}
<td className="px-4 py-3 text-sm text-surface-700">
{lead.budget || formatCurrency(lead.budget_value)}
</td>
{/* Received */}
<td className="px-4 py-3 text-sm text-surface-500">
{timeAgo(lead.received_at)}
</td>
{/* Response Time */}
<td className="px-4 py-3">
{lead.responded_at ? (
<span className={`inline-block rounded-full px-2.5 py-1 text-xs font-semibold ${responseTimeColor(lead.response_time_minutes)}`}>
{responseTimeLabel(lead.response_time_minutes)}
</span>
) : (
<span className="text-xs text-surface-400">β</span>
)}
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100" onClick={(e) => e.stopPropagation()}>
{!isTerminal && lead.status === 'new' && (
<button
onClick={onAccept}
disabled={isActionLoading}
className="rounded-lg p-2 text-emerald-600 hover:bg-emerald-50 disabled:opacity-50 min-h-[44px] min-w-[44px] flex items-center justify-center"
title="Accept lead"
>
<CheckCircle2 className="h-4 w-4" />
</button>
)}
{!isTerminal && lead.status !== 'rejected' && (
<button
onClick={onRespond}
disabled={isActionLoading}
className="rounded-lg p-2 text-brand-600 hover:bg-brand-50 disabled:opacity-50 min-h-[44px] min-w-[44px] flex items-center justify-center"
title="Respond"
>
<MessageSquare className="h-4 w-4" />
</button>
)}
{!isTerminal && lead.status !== 'rejected' && (
<button
onClick={onReject}
disabled={isActionLoading}
className="rounded-lg p-2 text-red-600 hover:bg-red-50 disabled:opacity-50 min-h-[44px] min-w-[44px] flex items-center justify-center"
title="Reject"
>
<Ban className="h-4 w-4" />
</button>
)}
<button
onClick={onSelect}
className="rounded-lg p-2 text-surface-400 hover:bg-surface-100 min-h-[44px] min-w-[44px] flex items-center justify-center"
title="View details"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
}
// βββ Main Page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function AngiLeadsPage() {
const { user } = useAuth();
const companyId = user?.tenantId as string;
const queryClient = useQueryClient();
// Filters
const [statusFilter, setStatusFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [dateRange, setDateRange] = useState<string>('30d');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [showFilters, setShowFilters] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Selected lead for detail panel
const [selectedLead, setSelectedLead] = useState<AngiLead | null>(null);
// Active action (for loading state on buttons)
const [activeAction, setActiveAction] = useState<string | null>(null);
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}
}, [toast]);
const buildParams = useMemo(() => {
const params: Record<string, string | number> = { page, per_page: 25 };
if (statusFilter !== 'all') params.status = statusFilter;
if (priorityFilter !== 'all') params.priority = priorityFilter;
params.date_range = dateRange;
if (search.trim()) params.search = search.trim();
return params;
}, [statusFilter, priorityFilter, dateRange, search, page]);
// Fetch leads
const {
data: leadsData,
isLoading: leadsLoading,
isFetching: leadsFetching,
} = useQuery({
queryKey: ['angi-leads', buildParams],
queryFn: () => angiLeadsApi.getAll(companyId, buildParams),
enabled: !!companyId,
staleTime: 15_000,
});
// Fetch analytics
const { data: analyticsData } = useQuery({
queryKey: ['angi-leads-analytics'],
queryFn: () => angiLeadsApi.getAnalytics(companyId),
enabled: !!companyId,
staleTime: 60_000,
});
// Sync mutation
const syncMutation = useMutation({
mutationFn: () => angiLeadsApi.sync(companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
queryClient.invalidateQueries({ queryKey: ['angi-leads-analytics'] });
setToast({ message: 'Angi Leads synced successfully', type: 'success' });
},
onError: (err: unknown) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Angi Leads sync failed';
setToast({ message, type: 'error' });
},
});
// Action mutations
const acceptMutation = useMutation({
mutationFn: (leadId: string) => angiLeadsApi.accept(companyId, leadId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
queryClient.invalidateQueries({ queryKey: ['angi-leads-analytics'] });
if (selectedLead) {
queryClient.invalidateQueries({ queryKey: ['angi-lead-detail', selectedLead.id] });
}
},
});
const rejectMutation = useMutation({
mutationFn: (leadId: string) => angiLeadsApi.reject(companyId, leadId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
queryClient.invalidateQueries({ queryKey: ['angi-leads-analytics'] });
},
});
const respondMutation = useMutation({
mutationFn: ({ leadId, message }: { leadId: string; message: string }) =>
angiLeadsApi.respond(companyId, leadId, { message }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['angi-leads'] });
queryClient.invalidateQueries({ queryKey: ['angi-leads-analytics'] });
},
});
const handleAccept = useCallback(
(leadId: string) => {
setActiveAction(leadId);
acceptMutation.mutate(leadId, {
onSettled: () => setActiveAction(null),
});
},
[acceptMutation]
);
const handleReject = useCallback(
(leadId: string) => {
setActiveAction(leadId);
rejectMutation.mutate(leadId, {
onSettled: () => setActiveAction(null),
});
},
[rejectMutation]
);
const handleRespond = useCallback(
(leadId: string) => {
setSelectedLead(
leadsData?.data.items.find((l) => l.id === leadId) || null
);
},
[leadsData]
);
const handleDetailAction = useCallback(() => {
// Refresh selected lead data
if (selectedLead) {
queryClient.invalidateQueries({ queryKey: ['angi-lead-detail', selectedLead.id] });
}
}, [queryClient, selectedLead]);
const leads = leadsData?.data.items || [];
const total = leadsData?.data.total || 0;
const totalPages = Math.ceil(total / 25);
const analytics = analyticsData?.data;
// Quick respond from table
const handleQuickRespond = useCallback(
(leadId: string) => {
const message = prompt('Quick respond to lead:');
if (message?.trim()) {
setActiveAction(leadId);
respondMutation.mutate({ leadId, message: message.trim() }, {
onSettled: () => setActiveAction(null),
});
}
},
[respondMutation]
);
// Stats
const stats = [
{
label: 'New Leads',
value: analytics?.new_leads ?? 0,
icon: ClipboardList,
color: 'text-blue-600 bg-blue-50',
},
{
label: 'Responded',
value: analytics?.responded ?? 0,
icon: MessageSquare,
color: 'text-violet-600 bg-violet-50',
},
{
label: 'Won',
value: analytics?.won ?? 0,
icon: CheckCircle2,
color: 'text-emerald-600 bg-emerald-50',
},
{
label: 'Lost',
value: analytics?.lost ?? 0,
icon: Ban,
color: 'text-red-600 bg-red-50',
},
{
label: 'Avg Response',
value: analytics ? `${(analytics.avg_response_time || 0).toFixed(0)}m` : 'β',
icon: Clock,
color: 'text-amber-600 bg-amber-50',
},
{
label: 'Conversion Rate',
value: analytics
? `${((analytics.conversion_rate || 0) * 100).toFixed(1)}%`
: 'β',
icon: DollarSign,
color: 'text-green-600 bg-green-50',
},
];
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [statusFilter, priorityFilter, dateRange]);
return (
<AppLayout title="Angi Leads">
{/* Toast */}
{toast && (
<div className="fixed top-4 right-4 z-[70] flex items-center gap-2 rounded-lg bg-surface-900 px-4 py-3 text-sm text-white shadow-lg">
{toast.type === 'success' ? (
<CheckCircle2 className="h-4 w-4 text-green-400" />
) : (
<AlertCircle className="h-4 w-4 text-red-400" />
)}
{toast.message}
</div>
)}
<div data-testid="page-content">
{/* Stats Cards */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{stats.map((stat) => (
<div key={stat.label} className="rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-2">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-surface-500 truncate">{stat.label}</p>
<p className="text-lg font-bold text-surface-900">{stat.value}</p>
</div>
</div>
</div>
))}
</div>
{/* Filters Bar */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
{/* Search */}
<div className="relative flex-1 sm:flex-initial sm:w-72">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search leads..."
className="w-full rounded-lg border border-surface-300 py-2.5 pl-9 pr-4 text-sm outline-none focus:border-brand-500 sm:w-72"
/>
</div>
{/* Toggle filters on mobile */}
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-1.5 rounded-lg border border-surface-300 px-3 py-2 text-sm text-surface-600 hover:bg-surface-50 sm:hidden min-h-[44px]"
>
<Filter className="h-4 w-4" />
Filters
</button>
{/* Filter dropdowns */}
<div className={`${showFilters ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-2`}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-surface-300 py-2 px-3 text-sm outline-none focus:border-brand-500 min-h-[44px]"
>
<option value="all">All Statuses</option>
<option value="new">New</option>
<option value="accepted">Accepted</option>
<option value="responded">Responded</option>
<option value="contacted">Contacted</option>
<option value="scheduled">Scheduled</option>
<option value="won">Won</option>
<option value="lost">Lost</option>
<option value="expired">Expired</option>
<option value="rejected">Rejected</option>
</select>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="rounded-lg border border-surface-300 py-2 px-3 text-sm outline-none focus:border-brand-500 min-h-[44px]"
>
<option value="all">All Priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="rounded-lg border border-surface-300 py-2 px-3 text-sm outline-none focus:border-brand-500 min-h-[44px]"
>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="90d">Last 90 Days</option>
<option value="all">All Time</option>
</select>
</div>
</div>
{/* Sync button */}
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-surface-300 px-4 py-2.5 text-sm font-medium text-surface-700 hover:bg-surface-50 disabled:opacity-50 min-h-[44px]"
>
<RefreshCw className={`h-4 w-4 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
Sync
</button>
</div>
{/* Results info */}
{!leadsLoading && (
<p className="mb-3 text-sm text-surface-500">
Showing {leads.length} of {total} leads
</p>
)}
{/* Lead Table */}
{leadsLoading ? (
<div className="flex items-center justify-center rounded-xl border border-surface-200 bg-white py-20">
<LoadingSpinner size="md" />
<span className="ml-3 text-sm text-surface-500">Loading leadsβ¦</span>
</div>
) : leads.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-20">
<ClipboardList className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">
{search || statusFilter !== 'all' || priorityFilter !== 'all'
? 'No leads match your filters'
: 'No Angi leads yet'}
</p>
<p className="mt-1 text-sm text-surface-500">
{search || statusFilter !== 'all' || priorityFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Connect Angi in Integrations to start receiving leads'}
</p>
</div>
) : (
<>
<div className="overflow-x-auto rounded-xl border border-surface-200 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-200 bg-surface-50">
<th className="px-4 py-3 text-left font-medium text-surface-600">Lead</th>
<th className="px-4 py-3 text-left font-medium text-surface-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-surface-600">Priority</th>
<th className="px-4 py-3 text-left font-medium text-surface-600">Budget</th>
<th className="px-4 py-3 text-left font-medium text-surface-600">Received</th>
<th className="px-4 py-3 text-left font-medium text-surface-600">Response</th>
<th className="px-4 py-3 w-24 font-medium text-surface-600">Actions</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<LeadRow
key={lead.id}
lead={lead}
isSelected={selectedLead?.id === lead.id}
onSelect={() => setSelectedLead(lead)}
onAccept={() => handleAccept(lead.id)}
onReject={() => handleReject(lead.id)}
onRespond={() => handleQuickRespond(lead.id)}
isActionLoading={activeAction === lead.id}
/>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 disabled:opacity-40 min-h-[44px]"
>
Previous
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] min-w-[44px] flex items-center justify-center ${
page === pageNum
? 'bg-brand-600 text-white'
: 'text-surface-600 hover:bg-surface-100'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 disabled:opacity-40 min-h-[44px]"
>
Next
</button>
</div>
)}
</>
)}
</div>
{/* Lead Detail Panel */}
{selectedLead && (
<LeadDetailPanel
lead={selectedLead}
onClose={() => setSelectedLead(null)}
onAction={handleDetailAction}
/>
)}
</AppLayout>
);
}