Download src/pages/Campaigns.tsx from sitcod3/modul-e: direct link, hf CLI and curl.
- Browser
- Download file 21.1 kB
-
https://huggingface.co/spaces/sitcod3/modul-e/resolve/main/src/pages/Campaigns.tsx
- Command line
-
hf download hf://spaces/sitcod3/modul-e/src/pages/Campaigns.tsx
-
curl -L -o Campaigns.tsx https://huggingface.co/spaces/sitcod3/modul-e/resolve/main/src/pages/Campaigns.tsx
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> | |
| ); | |
| } | |