modul-e / src /pages /Leads.tsx
sitcod3's picture
Upload src/pages/Leads.tsx with huggingface_hub
34dbea5 verified
Raw History Blame Contribute Delete
28.3 kB
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "../lib/apiClient";
import {
Users,
Search,
Plus,
ShieldAlert,
Trash2,
Mail,
Building,
Globe,
MapPin,
Linkedin,
Phone,
Filter,
Clock,
CheckCircle2,
Ban,
ArrowUpRight
} from "lucide-react";
import { useState } from "react";
import StatusBadge from "../components/StatusBadge";
import FormDrawer from "../components/FormDrawer";
import CsvExportButton from "../components/CsvExportButton";
import ConfirmModal from "../components/ConfirmModal";
import toast from "react-hot-toast";
const STATUSES = [
"ALL",
"INTERESTED",
"MEETING_BOOKED",
"MEETING_DONE",
"REPLIED",
"NOT_INTERESTED",
"UNSUBSCRIBED",
"BOUNCED",
"DO_NOT_CONTACT"
];
export default function Leads() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"leads" | "blocklist">("leads");
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState("ALL");
const [selectedLead, setSelectedLead] = useState<any | null>(null);
const [isAddLeadOpen, setIsAddLeadOpen] = useState(false);
const [isAddBlocklistOpen, setIsAddBlocklistOpen] = useState(false);
const [blocklistType, setBlocklistType] = useState<"email" | "domain">("email");
const { data: leads = [], isLoading } = useQuery({
queryKey: ["leads-search", searchQuery, selectedStatus],
queryFn: async () => {
const res = await apiClient.get("/leads/search", {
params: { q: searchQuery, status: selectedStatus },
});
return res.data;
},
});
const { data: emailBlockList = [], refetch: refetchEmailBlockList } = useQuery({
queryKey: ["blocklist-emails"],
queryFn: async () => {
const res = await apiClient.get("/leads/block-list");
return res.data;
},
});
const { data: domainBlockList = [], refetch: refetchDomainBlockList } = useQuery({
queryKey: ["blocklist-domains"],
queryFn: async () => {
const res = await apiClient.get("/leads/domain-block-list");
return res.data;
},
});
const updateLeadStatusMutation = useMutation({
mutationFn: async ({ campaignId, leadId, status }: { campaignId: string; leadId: string; status: string }) => {
const res = await apiClient.post(`/campaigns/${campaignId}/leads/status`, { lead_id: leadId, status });
return res.data;
},
onSuccess: () => {
toast.success("Lead status updated");
queryClient.invalidateQueries({ queryKey: ["leads-search"] });
},
});
const deleteLeadMutation = useMutation({
mutationFn: async ({ campaignId, leadId }: { campaignId: string; leadId: string }) => {
const res = await apiClient.delete(`/campaigns/${campaignId}/leads/${leadId}`);
return res.data;
},
onSuccess: () => {
toast.success("Lead removed");
setSelectedLead(null);
queryClient.invalidateQueries({ queryKey: ["leads-search"] });
},
});
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
<Users className="w-6 h-6 text-teal-400" />
Lead Intelligence Database
</h1>
<p className="text-zinc-400 text-sm mt-1">
Global repository of targeted prospects, responses, and suppression blocklists.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setIsAddLeadOpen(true)}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Add Prospect
</button>
</div>
</div>
{/* Main Tabs */}
<div className="border-b border-zinc-800 flex items-center gap-2 text-sm font-medium">
<button
onClick={() => setActiveTab("leads")}
className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
activeTab === "leads"
? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
: "border-transparent text-zinc-400 hover:text-zinc-200"
}`}
>
<Users className="w-4 h-4" /> Global Leads ({leads.length})
</button>
<button
onClick={() => setActiveTab("blocklist")}
className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors ${
activeTab === "blocklist"
? "border-indigo-500 text-indigo-400 bg-indigo-500/5"
: "border-transparent text-zinc-400 hover:text-zinc-200"
}`}
>
<ShieldAlert className="w-4 h-4" /> Suppression Blocklists
</button>
</div>
{activeTab === "leads" && (
<div className="space-y-4">
{/* Filter Bar */}
<div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
<input
type="text"
placeholder="Search by name, email, or company..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-black/40 border border-white/5 rounded-lg py-2 pl-9 pr-4 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
/>
</div>
<div className="flex items-center gap-3">
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="bg-black/40 border border-white/5 rounded-lg py-2 px-3 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
>
{STATUSES.map((st) => (
<option key={st} value={st} className="bg-zinc-900">
Status: {st.replace(/_/g, " ")}
</option>
))}
</select>
<CsvExportButton data={leads} filename="smartlead-global-leads.csv" />
</div>
</div>
{/* Leads Table */}
<div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-black/20 border-b border-white/5 text-zinc-400 text-xs uppercase font-sans tracking-wider">
<tr>
<th className="px-6 py-3.5 font-semibold">Prospect</th>
<th className="px-6 py-3.5 font-semibold">Company</th>
<th className="px-6 py-3.5 font-semibold">Status</th>
<th className="px-6 py-3.5 font-semibold">Engagement</th>
<th className="px-6 py-3.5 font-semibold">Location</th>
<th className="px-6 py-3.5 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{leads.map((lead: any) => (
<tr
key={lead.id}
onClick={() => setSelectedLead(lead)}
className="hover:bg-white/5 transition-colors cursor-pointer"
>
<td className="px-6 py-4">
<div className="font-semibold text-zinc-100 flex items-center gap-2">
{lead.first_name} {lead.last_name}
</div>
<div className="text-xs text-zinc-400 font-mono flex items-center gap-1 mt-0.5">
<Mail className="w-3 h-3 text-zinc-500" /> {lead.email}
</div>
</td>
<td className="px-6 py-4 text-zinc-300">
<div className="font-medium text-zinc-200">{lead.company_name}</div>
{lead.website && (
<div className="text-[11px] text-zinc-500 font-mono truncate max-w-[150px]">
{lead.website.replace("https://", "")}
</div>
)}
</td>
<td className="px-6 py-4">
<StatusBadge status={lead.status} size="sm" />
</td>
<td className="px-6 py-4">
<div className="text-xs text-zinc-300 font-mono">
<span className="text-emerald-400 drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">{lead.open_count || 0}</span> opens •{" "}
<span className="text-indigo-400 drop-shadow-[0_0_8px_rgba(37,125,104,0.3)]">{lead.click_count || 0}</span> clicks
</div>
</td>
<td className="px-6 py-4 text-xs text-zinc-400 font-sans">
{lead.location || "United States"}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedLead(lead);
}}
className="px-3 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-300 text-xs font-medium rounded-lg border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
>
View Profile
</button>
</td>
</tr>
))}
{leads.length === 0 && !isLoading && (
<tr>
<td colSpan={6} className="py-16 text-center text-xs text-zinc-500">
No prospect leads matching current filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* SUB-TAB: BLOCKLIST MANAGER */}
{activeTab === "blocklist" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Email Blocklist */}
<div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-zinc-100 flex items-center gap-2">
<Mail className="w-4 h-4 text-rose-400 drop-shadow-[0_0_8px_rgba(203,117,80,0.5)]" />
Email Address Blocklist
</h3>
<p className="text-xs text-zinc-400 mt-0.5">Suppressed emails never receive outreach.</p>
</div>
<button
onClick={() => {
setBlocklistType("email");
setIsAddBlocklistOpen(true);
}}
className="px-2.5 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-200 text-xs font-medium rounded-md border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
>
+ Add Email
</button>
</div>
<div className="space-y-2">
{emailBlockList.map((item: any) => (
<div
key={item.id}
className="flex items-center justify-between p-3 bg-black/40 border border-white/5 rounded-lg text-xs hover:bg-white/5 transition-colors"
>
<div>
<span className="font-mono text-zinc-200 font-medium">{item.email}</span>
<div className="text-[10px] text-zinc-500 font-sans mt-0.5">{item.reason}</div>
</div>
<button
onClick={async () => {
await apiClient.delete(`/leads/block-list/${item.id}`);
toast.success("Removed from blocklist");
refetchEmailBlockList();
}}
className="text-zinc-500 hover:text-rose-400 p-1.5 rounded-md hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
{/* Domain Blocklist */}
<div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-5 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)] space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-zinc-100 flex items-center gap-2">
<Globe className="w-4 h-4 text-amber-400 drop-shadow-[0_0_8px_rgba(194,160,74,0.5)]" />
Domain Suppression List
</h3>
<p className="text-xs text-zinc-400 mt-0.5">Blocks all emails under specified root domains.</p>
</div>
<button
onClick={() => {
setBlocklistType("domain");
setIsAddBlocklistOpen(true);
}}
className="px-2.5 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-200 text-xs font-medium rounded-md border border-white/5 transition-all shadow-[inset_0_1px_1px_rgba(246,248,242,0.05)]"
>
+ Add Domain
</button>
</div>
<div className="space-y-2">
{domainBlockList.map((item: any) => (
<div
key={item.id}
className="flex items-center justify-between p-3 bg-black/40 border border-white/5 rounded-lg text-xs hover:bg-white/5 transition-colors"
>
<div>
<span className="font-mono text-zinc-200 font-medium">@{item.domain}</span>
<div className="text-[10px] text-zinc-500 font-sans mt-0.5">{item.reason}</div>
</div>
<button
onClick={async () => {
await apiClient.delete(`/leads/domain-block-list/${item.id}`);
toast.success("Domain removed from blocklist");
refetchDomainBlockList();
}}
className="text-zinc-500 hover:text-rose-400 p-1.5 rounded-md hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* Lead Detail Slide-Over Drawer */}
<FormDrawer
isOpen={Boolean(selectedLead)}
onClose={() => setSelectedLead(null)}
title="Prospect Dossier"
subtitle={selectedLead ? `${selectedLead.first_name} ${selectedLead.last_name} (${selectedLead.email})` : ""}
>
{selectedLead && (
<div className="space-y-6 text-xs">
{/* Status Change Card */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
<label className="block font-medium text-zinc-400 mb-2">Lead Outreach Status</label>
<select
value={selectedLead.status}
onChange={(e) => {
updateLeadStatusMutation.mutate({
campaignId: selectedLead.campaign_id,
leadId: selectedLead.id,
status: e.target.value,
});
setSelectedLead({ ...selectedLead, status: e.target.value });
}}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2 text-zinc-100 font-medium"
>
{STATUSES.filter((s) => s !== "ALL").map((st) => (
<option key={st} value={st}>
{st.replace(/_/g, " ")}
</option>
))}
</select>
</div>
{/* Profile Fields */}
<div className="space-y-3 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
<h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Contact Info</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-zinc-500 block">Company</span>
<span className="text-zinc-200 font-medium">{selectedLead.company_name}</span>
</div>
<div>
<span className="text-zinc-500 block">Location</span>
<span className="text-zinc-200 font-medium">{selectedLead.location || "N/A"}</span>
</div>
<div>
<span className="text-zinc-500 block">Phone</span>
<span className="text-zinc-200 font-medium">{selectedLead.phone_number || "N/A"}</span>
</div>
<div>
<span className="text-zinc-500 block">LinkedIn</span>
{selectedLead.linkedin_profile ? (
<a
href={selectedLead.linkedin_profile}
target="_blank"
rel="noreferrer"
className="text-indigo-400 hover:underline"
>
View LinkedIn Profile
</a>
) : (
<span className="text-zinc-500">Not provided</span>
)}
</div>
</div>
</div>
{/* Custom Fields */}
{selectedLead.custom_fields && Object.keys(selectedLead.custom_fields).length > 0 && (
<div className="space-y-2 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
<h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Custom Attributes</h4>
<div className="grid grid-cols-2 gap-2 font-mono text-[11px]">
{Object.entries(selectedLead.custom_fields).map(([key, val]) => (
<div key={key} className="bg-zinc-950 p-2 rounded border border-zinc-800">
<span className="text-zinc-500 block text-[10px]">{key}</span>
<span className="text-zinc-200 font-semibold">{String(val)}</span>
</div>
))}
</div>
</div>
)}
{/* Timeline */}
<div className="space-y-3 bg-zinc-900/60 border border-zinc-800 rounded-xl p-4">
<h4 className="font-semibold text-zinc-300 uppercase tracking-wider text-[10px]">Activity Timeline</h4>
<div className="space-y-3">
<div className="flex items-start gap-2.5">
<div className="w-2 h-2 rounded-full bg-emerald-400 mt-1" />
<div>
<span className="text-zinc-300 font-medium">Email Delivered & Opened</span>
<div className="text-[10px] text-zinc-500">2 hours ago</div>
</div>
</div>
<div className="flex items-start gap-2.5">
<div className="w-2 h-2 rounded-full bg-indigo-400 mt-1" />
<div>
<span className="text-zinc-300 font-medium">Enrolled in Enterprise SaaS Sequence</span>
<div className="text-[10px] text-zinc-500">3 days ago</div>
</div>
</div>
</div>
</div>
{/* Delete Lead Button */}
<div className="pt-2 flex justify-end">
<button
onClick={() => {
deleteLeadMutation.mutate({
campaignId: selectedLead.campaign_id,
leadId: selectedLead.id,
});
}}
className="px-3 py-1.5 bg-rose-600/20 hover:bg-rose-600 text-rose-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5" /> Remove Lead
</button>
</div>
</div>
)}
</FormDrawer>
{/* Add Lead Slide-Over */}
<FormDrawer
isOpen={isAddLeadOpen}
onClose={() => setIsAddLeadOpen(false)}
title="Add Global Prospect Lead"
subtitle="Input contact info, company details, and custom variables for personalization."
>
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const newLead = {
first_name: (form.elements.namedItem("first_name") as HTMLInputElement).value,
last_name: (form.elements.namedItem("last_name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
phone_number: (form.elements.namedItem("phone_number") as HTMLInputElement).value,
company_name: (form.elements.namedItem("company_name") as HTMLInputElement).value,
website: (form.elements.namedItem("website") as HTMLInputElement).value,
location: (form.elements.namedItem("location") as HTMLInputElement).value,
linkedin_profile: (form.elements.namedItem("linkedin_profile") as HTMLInputElement).value,
status: "INTERESTED",
};
try {
await apiClient.post("/campaigns/camp_9901/leads", newLead);
toast.success("Prospect added to database");
setIsAddLeadOpen(false);
queryClient.invalidateQueries({ queryKey: ["leads-search"] });
} catch (err: any) {
toast.error("Failed to add prospect");
}
}}
className="space-y-3.5 text-xs"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-zinc-300 mb-1">First Name *</label>
<input name="first_name" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Last Name</label>
<input name="last_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Email Address *</label>
<input type="email" name="email" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-zinc-300 mb-1">Company Name</label>
<input name="company_name" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Phone Number</label>
<input name="phone_number" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Website URL</label>
<input name="website" placeholder="https://" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-zinc-300 mb-1">Location</label>
<input name="location" placeholder="e.g. San Francisco, CA" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">LinkedIn URL</label>
<input name="linkedin_profile" placeholder="https://linkedin.com/in/..." className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2 text-zinc-100" />
</div>
</div>
<div className="pt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsAddLeadOpen(false)}
className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium shadow"
>
Save Prospect
</button>
</div>
</form>
</FormDrawer>
{/* Add Blocklist Modal */}
<FormDrawer
isOpen={isAddBlocklistOpen}
onClose={() => setIsAddBlocklistOpen(false)}
title={blocklistType === "email" ? "Add Email to Suppression Blocklist" : "Add Domain to Suppression Blocklist"}
subtitle="Blocked records will never receive any emails from any campaign."
>
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const val = (form.elements.namedItem("val") as HTMLInputElement).value;
const reason = (form.elements.namedItem("reason") as HTMLInputElement).value;
try {
if (blocklistType === "email") {
await apiClient.post("/leads/block-list", { email: val, reason });
refetchEmailBlockList();
} else {
await apiClient.post("/leads/domain-block-list", { domain: val, reason });
refetchDomainBlockList();
}
toast.success("Suppression entry saved");
setIsAddBlocklistOpen(false);
} catch (err: any) {
toast.error("Failed to add entry");
}
}}
className="space-y-4 text-xs"
>
<div>
<label className="block font-medium text-zinc-300 mb-1">
{blocklistType === "email" ? "Email Address" : "Domain (e.g. competitor.com)"}
</label>
<input name="val" required className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Reason for Suppression</label>
<input name="reason" placeholder="e.g. Legal opt-out, Competitor" className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100" />
</div>
<div className="pt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsAddBlocklistOpen(false)}
className="px-3 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-rose-600 text-white rounded-lg font-medium"
>
Block Record
</button>
</div>
</form>
</FormDrawer>
</div>
);
}