modul-e / src /pages /Campaigns.tsx
sitcod3's picture
Upload src/pages/Campaigns.tsx with huggingface_hub
99b80ff verified
Raw History Blame Contribute Delete
21.1 kB
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "../lib/apiClient";
import {
Plus,
Search,
Megaphone,
Play,
Pause,
Square,
Trash2,
ArrowUpRight,
Sliders,
Clock,
Globe,
CheckCircle2,
MailCheck
} from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import StatusBadge from "../components/StatusBadge";
import ConfirmModal from "../components/ConfirmModal";
import FormDrawer from "../components/FormDrawer";
import toast from "react-hot-toast";
export default function Campaigns() {
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
const { data: campaigns, isLoading } = useQuery({
queryKey: ["campaigns"],
queryFn: async () => {
const res = await apiClient.get("/campaigns");
return res.data;
},
});
const updateStatusMutation = useMutation({
mutationFn: async ({ id, status }: { id: string; status: "START" | "PAUSE" | "STOP" }) => {
const res = await apiClient.post(`/campaigns/${id}/status`, { status });
return res.data;
},
onSuccess: (_, variables) => {
const statusLabel = variables.status === "START" ? "started" : variables.status === "PAUSE" ? "paused" : "stopped";
toast.success(`Campaign ${statusLabel}`);
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || "Failed to update campaign status");
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const res = await apiClient.delete(`/campaigns/${id}`);
return res.data;
},
onSuccess: () => {
toast.success("Campaign removed");
setSelectedForDelete(null);
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || "Failed to delete campaign");
}
});
const filteredCampaigns = (campaigns || []).filter((c: any) => {
const matchesSearch = c.name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "ALL" || c.status === statusFilter;
return matchesSearch && matchesStatus;
});
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">
<Megaphone className="w-6 h-6 text-indigo-400" />
Outreach Campaigns
</h1>
<p className="text-zinc-400 text-sm mt-1">
Automated multi-step cold email sequences with A/B variant testing.
</p>
</div>
<button
onClick={() => setIsCreateOpen(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 self-start sm:self-auto"
>
<Plus className="w-4 h-4" />
Create Campaign
</button>
</div>
{/* Filter and Search Bar */}
<div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col sm:flex-row items-stretch sm: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 campaigns by name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/40 border border-white/5 rounded-lg py-2 pl-9 pr-4 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
/>
</div>
<div className="flex items-center gap-2">
{["ALL", "ACTIVE", "PAUSED", "STOPPED"].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-300 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] ${
statusFilter === status
? "bg-indigo-600 text-white shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
: "bg-black/40 text-zinc-400 hover:text-zinc-200 border border-white/5"
}`}
>
{status}
</button>
))}
</div>
</div>
{/* Campaigns 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)]">
{isLoading ? (
<div className="py-20 flex flex-col items-center justify-center gap-3">
<div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin drop-shadow-[0_0_8px_rgba(63,156,132,0.5)]" />
<p className="text-sm text-zinc-400">Loading campaign records...</p>
</div>
) : filteredCampaigns.length > 0 ? (
<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">Campaign Name</th>
<th className="px-6 py-3.5 font-semibold">Status</th>
<th className="px-6 py-3.5 font-semibold">Leads</th>
<th className="px-6 py-3.5 font-semibold">Sent</th>
<th className="px-6 py-3.5 font-semibold">Opens</th>
<th className="px-6 py-3.5 font-semibold">Replies</th>
<th className="px-6 py-3.5 font-semibold">Bounces</th>
<th className="px-6 py-3.5 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredCampaigns.map((camp: any) => (
<tr key={camp.id} className="hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<Link
to={`/campaigns/${camp.id}`}
className="font-semibold text-zinc-100 hover:text-indigo-400 transition-colors flex items-center gap-1.5"
>
{camp.name}
<ArrowUpRight className="w-3.5 h-3.5 text-zinc-500" />
</Link>
<div className="text-[11px] text-zinc-400 mt-0.5 font-sans">
Schedule: <span className="font-mono">{camp.start_schedule || "09:00"} - {camp.end_schedule || "17:00"}</span> ({camp.timezone || "America/New_York"})
</div>
</td>
<td className="px-6 py-4">
<StatusBadge status={camp.status} />
</td>
<td className="px-6 py-4 font-mono text-zinc-300">
{camp.lead_count?.toLocaleString() || 0}
</td>
<td className="px-6 py-4 font-mono text-zinc-300">
{camp.sent_count?.toLocaleString() || 0}
</td>
<td className="px-6 py-4 font-mono text-emerald-400 drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">
{camp.open_count?.toLocaleString() || 0}
</td>
<td className="px-6 py-4 font-mono text-purple-400 font-semibold drop-shadow-[0_0_8px_rgba(178,87,49,0.3)]">
{camp.reply_count?.toLocaleString() || 0}
</td>
<td className="px-6 py-4 font-mono text-rose-400 drop-shadow-[0_0_8px_rgba(203,117,80,0.3)]">
{camp.bounce_count || 0}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{camp.status === "ACTIVE" ? (
<button
onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "PAUSE" })}
title="Pause Campaign"
className="p-1.5 bg-black/40 hover:bg-amber-500/20 text-zinc-400 hover:text-amber-400 rounded-md transition-colors border border-white/5 hover:border-amber-500/30"
>
<Pause className="w-3.5 h-3.5" />
</button>
) : (
<button
onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "START" })}
title="Start Campaign"
className="p-1.5 bg-black/40 hover:bg-emerald-500/20 text-zinc-400 hover:text-emerald-400 rounded-md transition-colors border border-white/5 hover:border-emerald-500/30"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "STOP" })}
title="Stop Campaign"
className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
>
<Square className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setSelectedForDelete(camp.id)}
title="Delete Campaign"
className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-16 text-center">
<Megaphone className="w-12 h-12 text-zinc-600 mx-auto mb-3" />
<h3 className="text-base font-medium text-zinc-300">No campaigns found</h3>
<p className="text-xs text-zinc-500 mt-1 max-w-sm mx-auto font-sans">
Create your first campaign to begin dispatching automated cold outreach sequences.
</p>
<button
onClick={() => setIsCreateOpen(true)}
className="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
>
Create Campaign
</button>
</div>
)}
</div>
{/* Delete Campaign Confirmation */}
<ConfirmModal
isOpen={Boolean(selectedForDelete)}
onClose={() => setSelectedForDelete(null)}
onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
title="Delete Campaign"
description="Are you sure you want to permanently delete this campaign and all associated sequences? This action cannot be undone."
confirmText="Delete Campaign"
isDestructive={true}
isLoading={deleteMutation.isPending}
/>
{/* Full Multi-Step Create Campaign Drawer */}
<FormDrawer
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
title="Launch New Campaign"
subtitle="Configure schedule parameters, tracking domain, and initial sequence step."
>
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const data = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
timezone: (form.elements.namedItem("timezone") as HTMLSelectElement).value,
start_schedule: (form.elements.namedItem("start_schedule") as HTMLInputElement).value,
end_schedule: (form.elements.namedItem("end_schedule") as HTMLInputElement).value,
track_settings: (form.elements.namedItem("track_settings") as HTMLSelectElement).value,
send_as_plain_text: (form.elements.namedItem("send_as_plain_text") as HTMLInputElement).checked,
follow_up_percentage: Number((form.elements.namedItem("follow_up_percentage") as HTMLInputElement).value),
unsubscribe_text: (form.elements.namedItem("unsubscribe_text") as HTMLInputElement).value,
stop_lead_settings: (form.elements.namedItem("stop_lead_settings") as HTMLSelectElement).value,
initial_subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
initial_body: (form.elements.namedItem("body") as HTMLTextAreaElement).value,
};
try {
await apiClient.post("/campaigns", data);
toast.success("Campaign launched successfully");
setIsCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
} catch (err: any) {
toast.error(err.response?.data?.error || "Failed to create campaign");
}
}}
className="space-y-5 text-sm"
>
<div>
<label className="block font-medium text-zinc-300 mb-1">Campaign Name *</label>
<input
name="name"
required
placeholder="e.g. Enterprise Founders Outbound"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-zinc-300 mb-1">Start Time</label>
<input
type="time"
name="start_schedule"
defaultValue="09:00"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">End Time</label>
<input
type="time"
name="end_schedule"
defaultValue="17:00"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Timezone</label>
<select
name="timezone"
defaultValue="America/New_York"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
>
<option value="America/New_York">America/New_York (EST)</option>
<option value="America/Chicago">America/Chicago (CST)</option>
<option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
<option value="Europe/London">Europe/London (GMT)</option>
<option value="Europe/Paris">Europe/Paris (CET)</option>
<option value="Asia/Singapore">Asia/Singapore (SGT)</option>
</select>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Tracking Settings</label>
<select
name="track_settings"
defaultValue="TRACK_EMAIL_OPEN_AND_CLICK"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
>
<option value="TRACK_EMAIL_OPEN_AND_CLICK">Track Opens and Clicks</option>
<option value="TRACK_EMAIL_OPEN">Track Email Opens Only</option>
<option value="TRACK_EMAIL_CLICK">Track Email Clicks Only</option>
<option value="DONT_TRACK_EMAIL_OPEN">Do Not Track Opens or Clicks</option>
</select>
</div>
<div className="flex items-center justify-between p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
<div>
<span className="font-medium text-zinc-200 block text-xs">Send as Plain Text</span>
<span className="text-[11px] text-zinc-500">Improves deliverability in strict enterprise inboxes</span>
</div>
<input
type="checkbox"
name="send_as_plain_text"
className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-zinc-950 border-zinc-700"
/>
</div>
<div>
<div className="flex justify-between text-xs font-medium text-zinc-300 mb-1">
<span>Follow-up Distribution %</span>
<span>85%</span>
</div>
<input
type="range"
name="follow_up_percentage"
min="10"
max="100"
defaultValue="85"
className="w-full accent-indigo-500"
/>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Unsubscribe Message</label>
<input
name="unsubscribe_text"
defaultValue="Reply unsubscribe if you prefer not to receive updates."
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
/>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Stop Lead Condition</label>
<select
name="stop_lead_settings"
defaultValue="REPLIED"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
>
<option value="REPLIED">Stop sequence once lead replies</option>
<option value="OPENED">Stop sequence once lead opens email</option>
<option value="CLICKED">Stop sequence once lead clicks a link</option>
</select>
</div>
<div className="pt-2 border-t border-zinc-800 space-y-3">
<h4 className="text-xs font-semibold uppercase text-zinc-400">Step 1 Sequence Setup</h4>
<div>
<label className="block font-medium text-zinc-300 mb-1">Subject Line</label>
<input
name="subject"
required
defaultValue="Quick sync for {{first_name}}"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
/>
</div>
<div>
<label className="block font-medium text-zinc-300 mb-1">Email Body</label>
<textarea
name="body"
rows={3}
required
defaultValue="<p>Hi {{first_name}},</p><p>Wanted to see how {{company_name}} is optimizing outbound deliverability?</p>"
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 font-mono text-xs"
/>
</div>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-zinc-800">
<button
type="button"
onClick={() => setIsCreateOpen(false)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-xs"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg text-xs shadow-md"
>
Launch Campaign
</button>
</div>
</form>
</FormDrawer>
</div>
);
}