diff --git a/src/components/leads/bulk-action-bar.tsx b/src/components/leads/bulk-action-bar.tsx new file mode 100644 index 0000000..b45ce4d --- /dev/null +++ b/src/components/leads/bulk-action-bar.tsx @@ -0,0 +1,50 @@ +interface BulkActionBarProps { + selectedCount: number; + onAssign: () => void; + onWhatsApp: () => void; + onMarkSpam: () => void; + onDeselect: () => void; +} + +export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => { + if (selectedCount === 0) return null; + + const buttonBase = 'rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear'; + + return ( +
+ {selectedCount} leads selected + +
+ + + + +
+
+ ); +}; diff --git a/src/components/leads/filter-pills.tsx b/src/components/leads/filter-pills.tsx new file mode 100644 index 0000000..4d5856a --- /dev/null +++ b/src/components/leads/filter-pills.tsx @@ -0,0 +1,48 @@ +import { X } from '@untitledui/icons'; +import { cx } from '@/utils/cx'; + +type FilterPill = { + key: string; + label: string; + value: string; +}; + +interface FilterPillsProps { + filters: FilterPill[]; + onRemove: (key: string) => void; + onClearAll: () => void; +} + +export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps) => { + if (filters.length === 0) return null; + + return ( +
+ {filters.map((filter) => ( + + {filter.label}: {filter.value} + + + ))} + +
+ ); +}; diff --git a/src/components/leads/lead-table.tsx b/src/components/leads/lead-table.tsx new file mode 100644 index 0000000..b5a07a7 --- /dev/null +++ b/src/components/leads/lead-table.tsx @@ -0,0 +1,310 @@ +import { useMemo, useState } from 'react'; +import { TableBody as AriaTableBody } from 'react-aria-components'; +import type { SortDescriptor, Selection } from 'react-aria-components'; +import { DotsVertical } from '@untitledui/icons'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table } from '@/components/application/table/table'; +import { LeadStatusBadge } from '@/components/shared/status-badge'; +import { SourceTag } from '@/components/shared/source-tag'; +import { AgeIndicator } from '@/components/shared/age-indicator'; +import { formatPhone, formatShortDate } from '@/lib/format'; +import { cx } from '@/utils/cx'; +import type { Lead } from '@/types/entities'; + +type LeadTableProps = { + leads: Lead[]; + onSelectionChange: (selectedIds: string[]) => void; + selectedIds: string[]; + sortField: string; + sortDirection: 'asc' | 'desc'; + onSort: (field: string) => void; +}; + +type TableRow = { + id: string; + type: 'lead' | 'dup-sub'; + lead: Lead; +}; + +const SpamDisplay = ({ score }: { score: number }) => { + const colorClass = + score < 30 + ? 'text-success-primary' + : score < 60 + ? 'text-warning-primary' + : 'text-error-primary'; + + const bgClass = score >= 60 ? 'rounded px-1.5 py-0.5 bg-error-primary' : ''; + + return {score}%; +}; + +export const LeadTable = ({ + leads, + onSelectionChange, + selectedIds, + sortField, + sortDirection, + onSort, +}: LeadTableProps) => { + const [expandedDupId, setExpandedDupId] = useState(null); + + const selectedKeys: Selection = new Set(selectedIds); + + const handleSelectionChange = (keys: Selection) => { + if (keys === 'all') { + // Only select actual lead rows, not dup sub-rows + onSelectionChange(leads.map((l) => l.id)); + } else { + // Filter out dup sub-row IDs + const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith('-dup')) as string[]; + onSelectionChange(leadOnlyIds); + } + }; + + const sortDescriptor: SortDescriptor = { + column: sortField, + direction: sortDirection === 'asc' ? 'ascending' : 'descending', + }; + + const handleSortChange = (descriptor: SortDescriptor) => { + if (descriptor.column) { + onSort(String(descriptor.column)); + } + }; + + // Flatten leads + expanded dup sub-rows into a single list + const tableRows = useMemo(() => { + const rows: TableRow[] = []; + for (const lead of leads) { + rows.push({ id: lead.id, type: 'lead', lead }); + if (lead.isDuplicate === true && expandedDupId === lead.id) { + rows.push({ id: `${lead.id}-dup`, type: 'dup-sub', lead }); + } + } + return rows; + }, [leads, expandedDupId]); + + const columns = [ + { id: 'phone', label: 'Phone', allowsSorting: true }, + { id: 'name', label: 'Name', allowsSorting: true }, + { id: 'email', label: 'Email', allowsSorting: false }, + { id: 'campaign', label: 'Campaign', allowsSorting: false }, + { id: 'ad', label: 'Ad', allowsSorting: false }, + { id: 'source', label: 'Source', allowsSorting: true }, + { id: 'firstContactedAt', label: 'First Contact', allowsSorting: true }, + { id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true }, + { id: 'status', label: 'Status', allowsSorting: true }, + { id: 'createdAt', label: 'Age', allowsSorting: true }, + { id: 'spamScore', label: 'Spam', allowsSorting: true }, + { id: 'dups', label: 'Dups', allowsSorting: false }, + { id: 'actions', label: '', allowsSorting: false }, + ]; + + return ( +
+ + + {(column) => ( + + )} + + + + {(row) => { + const { lead } = row; + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + const name = `${firstName} ${lastName}`.trim() || '\u2014'; + const phone = lead.contactPhone?.[0] + ? formatPhone(lead.contactPhone[0]) + : '\u2014'; + const email = lead.contactEmail?.[0]?.address ?? '\u2014'; + + // Render duplicate sub-row + if (row.type === 'dup-sub') { + return ( + + + {phone} + + + {name} + + + {email} + + + {lead.leadSource ? ( + + ) : ( + {'\u2014'} + )} + + + + Same phone number + + + + + {lead.createdAt + ? formatShortDate(lead.createdAt) + : '\u2014'} + + + + + + + + + +
+ + +
+
+
+ ); + } + + // Render normal lead row + const isSpamRow = (lead.spamScore ?? 0) >= 60; + const isSelected = selectedIds.includes(lead.id); + const isDup = lead.isDuplicate === true; + const isExpanded = expandedDupId === lead.id; + + return ( + + + {phone} + + + {name} + + + {email} + + + {lead.utmCampaign ? ( + + {lead.utmCampaign} + + ) : ( + {'\u2014'} + )} + + + {lead.adId ? ( + + Ad + + ) : ( + {'\u2014'} + )} + + + {lead.leadSource ? ( + + ) : ( + {'\u2014'} + )} + + + + {lead.firstContactedAt + ? formatShortDate(lead.firstContactedAt) + : '\u2014'} + + + + + {lead.lastContactedAt + ? formatShortDate(lead.lastContactedAt) + : '\u2014'} + + + + {lead.leadStatus ? ( + + ) : ( + {'\u2014'} + )} + + + {lead.createdAt ? ( + + ) : ( + {'\u2014'} + )} + + + {lead.spamScore != null ? ( + + ) : ( + 0% + )} + + + {isDup ? ( + + ) : ( + 0 + )} + + +
+
+ ); +}; diff --git a/src/pages/all-leads.tsx b/src/pages/all-leads.tsx index 018d4f0..f92d81a 100644 --- a/src/pages/all-leads.tsx +++ b/src/pages/all-leads.tsx @@ -1,11 +1,258 @@ -import { TopBar } from "@/components/layout/top-bar"; +import { useMemo, useState } from 'react'; +import { ArrowLeft, Download01, FilterLines, SearchLg, SwitchVertical01 } from '@untitledui/icons'; +import { Button } from '@/components/base/buttons/button'; +import { Input } from '@/components/base/input/input'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { PaginationPageDefault } from '@/components/application/pagination/pagination'; +import { TopBar } from '@/components/layout/top-bar'; +import { LeadTable } from '@/components/leads/lead-table'; +import { BulkActionBar } from '@/components/leads/bulk-action-bar'; +import { FilterPills } from '@/components/leads/filter-pills'; +import { useLeads } from '@/hooks/use-leads'; +import type { LeadSource, LeadStatus } from '@/types/entities'; + +type TabKey = 'new' | 'all'; + +type ActiveFilter = { + key: string; + label: string; + value: string; +}; + +const PAGE_SIZE = 25; export const AllLeadsPage = () => { + const [tab, setTab] = useState('new'); + const [selectedIds, setSelectedIds] = useState([]); + const [sortField, setSortField] = useState('createdAt'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [sourceFilter, setSourceFilter] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + + const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined; + + const { leads: filteredLeads, total } = useLeads({ + source: sourceFilter ?? undefined, + status: statusFilter, + search: searchQuery || undefined, + }); + + // Client-side sorting + const sortedLeads = useMemo(() => { + const sorted = [...filteredLeads]; + sorted.sort((a, b) => { + let aVal: string | number | null = null; + let bVal: string | number | null = null; + + switch (sortField) { + case 'phone': + aVal = a.contactPhone?.[0]?.number ?? ''; + bVal = b.contactPhone?.[0]?.number ?? ''; + break; + case 'name': + aVal = `${a.contactName?.firstName ?? ''} ${a.contactName?.lastName ?? ''}`.trim(); + bVal = `${b.contactName?.firstName ?? ''} ${b.contactName?.lastName ?? ''}`.trim(); + break; + case 'source': + aVal = a.leadSource ?? ''; + bVal = b.leadSource ?? ''; + break; + case 'firstContactedAt': + aVal = a.firstContactedAt ?? ''; + bVal = b.firstContactedAt ?? ''; + break; + case 'lastContactedAt': + aVal = a.lastContactedAt ?? ''; + bVal = b.lastContactedAt ?? ''; + break; + case 'status': + aVal = a.leadStatus ?? ''; + bVal = b.leadStatus ?? ''; + break; + case 'createdAt': + aVal = a.createdAt ?? ''; + bVal = b.createdAt ?? ''; + break; + case 'spamScore': + aVal = a.spamScore ?? 0; + bVal = b.spamScore ?? 0; + break; + default: + return 0; + } + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return sortDirection === 'asc' ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal); + const bStr = String(bVal); + return sortDirection === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); + }); + return sorted; + }, [filteredLeads, sortField, sortDirection]); + + // Client-side pagination + const totalPages = Math.max(1, Math.ceil(sortedLeads.length / PAGE_SIZE)); + const pagedLeads = sortedLeads.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const handleSort = (field: string) => { + if (field === sortField) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('asc'); + } + setCurrentPage(1); + }; + + const handleTabChange = (key: string | number) => { + setTab(key as TabKey); + setCurrentPage(1); + setSelectedIds([]); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + setSelectedIds([]); + }; + + // Build active filters for pills display + const activeFilters: ActiveFilter[] = []; + if (sourceFilter) { + activeFilters.push({ key: 'source', label: 'Source', value: sourceFilter }); + } + if (searchQuery) { + activeFilters.push({ key: 'search', label: 'Search', value: searchQuery }); + } + + const handleRemoveFilter = (key: string) => { + if (key === 'source') setSourceFilter(null); + if (key === 'search') setSearchQuery(''); + setCurrentPage(1); + }; + + const handleClearAllFilters = () => { + setSourceFilter(null); + setSearchQuery(''); + setCurrentPage(1); + }; + + const tabItems = [ + { id: 'new', label: 'New', badge: tab === 'new' ? total : undefined }, + { id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined }, + ]; + return (
- -
-

All Leads — coming soon

+ + +
+ {/* Tabs + Controls row */} +
+
+
+ +
+ + +
+ { + setSearchQuery(value); + setCurrentPage(1); + }} + aria-label="Search leads" + /> +
+ +
+
+ + {/* Active filters */} + {activeFilters.length > 0 && ( +
+ +
+ )} + + {/* Bulk action bar */} + {selectedIds.length > 0 && ( +
+ {}} + onWhatsApp={() => {}} + onMarkSpam={() => {}} + onDeselect={() => setSelectedIds([])} + /> +
+ )} + + {/* Table */} +
+ +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )}
);