Download src/pages/CampaignDetail.tsx from sitcod3/modul-e: direct link, hf CLI and curl.
- Browser
- Download file 32.5 kB
-
https://huggingface.co/spaces/sitcod3/modul-e/resolve/main/src/pages/CampaignDetail.tsx
- Command line
-
hf download hf://spaces/sitcod3/modul-e/src/pages/CampaignDetail.tsx
-
curl -L -o CampaignDetail.tsx https://huggingface.co/spaces/sitcod3/modul-e/resolve/main/src/pages/CampaignDetail.tsx
32.5 kB
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; | |
| import { apiClient } from "../lib/apiClient"; | |
| import { useParams, Link } from "react-router-dom"; | |
| import { | |
| ArrowLeft, | |
| Megaphone, | |
| Layers, | |
| Mail, | |
| Users, | |
| BarChart2, | |
| Settings as SettingsIcon, | |
| Plus, | |
| Trash2, | |
| Play, | |
| Pause, | |
| Clock, | |
| CheckCircle2, | |
| Sparkles, | |
| Download, | |
| Send, | |
| Split, | |
| RefreshCw | |
| } from "lucide-react"; | |
| import { useState } from "react"; | |
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; | |
| import StatusBadge from "../components/StatusBadge"; | |
| import StatCard from "../components/StatCard"; | |
| import RichTextEditor from "../components/RichTextEditor"; | |
| import FormDrawer from "../components/FormDrawer"; | |
| import CsvExportButton from "../components/CsvExportButton"; | |
| import toast from "react-hot-toast"; | |
| export default function CampaignDetail() { | |
| const { id } = useParams<{ id: string }>(); | |
| const queryClient = useQueryClient(); | |
| const [activeTab, setActiveTab] = useState<"overview" | "sequences" | "accounts" | "leads" | "analytics" | "settings">("overview"); | |
| const [isAddLeadOpen, setIsAddLeadOpen] = useState(false); | |
| const [selectedVariantTab, setSelectedVariantTab] = useState<Record<string, number>>({}); | |
| const { data: campaign, isLoading, isError, refetch: refetchCampaign } = useQuery({ | |
| queryKey: ["campaign", id], | |
| queryFn: async () => { | |
| const res = await apiClient.get(`/campaigns/${id}`); | |
| return res.data; | |
| }, | |
| enabled: Boolean(id), | |
| }); | |
| const { data: sequences = [], refetch: refetchSequences } = useQuery({ | |
| queryKey: ["campaign-sequences", id], | |
| queryFn: async () => { | |
| const res = await apiClient.get(`/campaigns/${id}/sequences`); | |
| return res.data; | |
| }, | |
| enabled: Boolean(id), | |
| }); | |
| const { data: campaignLeads = [], refetch: refetchLeads } = useQuery({ | |
| queryKey: ["campaign-leads", id], | |
| queryFn: async () => { | |
| const res = await apiClient.get(`/campaigns/${id}/leads`); | |
| return res.data; | |
| }, | |
| enabled: Boolean(id), | |
| }); | |
| const { data: assignedAccounts = [], refetch: refetchAccounts } = useQuery({ | |
| queryKey: ["campaign-accounts", id], | |
| queryFn: async () => { | |
| const res = await apiClient.get(`/campaigns/${id}/email-accounts`); | |
| return res.data; | |
| }, | |
| enabled: Boolean(id), | |
| }); | |
| const { data: allEmailAccounts = [] } = useQuery({ | |
| queryKey: ["email-accounts"], | |
| queryFn: async () => { | |
| const res = await apiClient.get("/email-accounts"); | |
| return res.data; | |
| }, | |
| }); | |
| const { data: analyticsHistory = [] } = useQuery({ | |
| queryKey: ["campaign-analytics-date", id], | |
| queryFn: async () => { | |
| const res = await apiClient.get(`/campaigns/${id}/analytics-by-date`); | |
| return res.data; | |
| }, | |
| enabled: Boolean(id), | |
| }); | |
| // Save sequences mutation | |
| const saveSequencesMutation = useMutation({ | |
| mutationFn: async (updatedSequences: any[]) => { | |
| const res = await apiClient.post(`/campaigns/${id}/sequences`, { sequences: updatedSequences }); | |
| return res.data; | |
| }, | |
| onSuccess: () => { | |
| toast.success("Sequence saved successfully"); | |
| refetchSequences(); | |
| queryClient.invalidateQueries({ queryKey: ["campaign", id] }); | |
| }, | |
| onError: (err: any) => { | |
| toast.error(err.response?.data?.error || "Failed to save sequences"); | |
| }, | |
| }); | |
| const addSequenceStep = () => { | |
| const nextSeqNum = (sequences.length || 0) + 1; | |
| const newStep = { | |
| id: "seq_" + Math.random().toString(36).substring(2, 7), | |
| seq_number: nextSeqNum, | |
| delay_days: nextSeqNum === 1 ? 0 : 3, | |
| subject: `Follow-up #${nextSeqNum - 1}: Re: {{company_name}}`, | |
| email_body: `<p>Hi {{first_name}},</p><p>Wanted to quickly circle back on my earlier note. Any thoughts?</p>`, | |
| variant_distribution_percentage: 100, | |
| variants: [], | |
| }; | |
| saveSequencesMutation.mutate([...sequences, newStep]); | |
| }; | |
| const removeSequenceStep = (stepIndex: number) => { | |
| const filtered = sequences.filter((_: any, idx: number) => idx !== stepIndex); | |
| saveSequencesMutation.mutate(filtered); | |
| }; | |
| const addVariantToStep = (stepIndex: number) => { | |
| const updated = [...sequences]; | |
| const target = updated[stepIndex]; | |
| if (!target.variants) target.variants = []; | |
| target.variant_distribution_percentage = 50; | |
| target.variants.push({ | |
| id: "var_" + Math.random().toString(36).substring(2, 7), | |
| variant_label: `Variant ${String.fromCharCode(66 + target.variants.length)}`, | |
| subject: `Alternative: ${target.subject}`, | |
| email_body: `<p>Hey {{first_name}}, short follow up regarding {{company_name}}.</p>`, | |
| }); | |
| saveSequencesMutation.mutate(updated); | |
| }; | |
| const addAccountToCampaign = async (accountId: string) => { | |
| try { | |
| await apiClient.post(`/campaigns/${id}/email-accounts`, { email_account_id: accountId }); | |
| toast.success("Mailbox assigned to campaign"); | |
| refetchAccounts(); | |
| } catch (err: any) { | |
| toast.error("Failed to assign account"); | |
| } | |
| }; | |
| const removeAccountFromCampaign = async (accountId: string) => { | |
| try { | |
| await apiClient.delete(`/campaigns/${id}/email-accounts/${accountId}`); | |
| toast.success("Mailbox removed from campaign"); | |
| refetchAccounts(); | |
| } catch (err: any) { | |
| toast.error("Failed to remove account"); | |
| } | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="p-12 flex justify-center items-center"> | |
| <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" /> | |
| </div> | |
| ); | |
| } | |
| if (isError || !campaign) { | |
| return ( | |
| <div className="p-12 flex flex-col items-center justify-center gap-3 text-center"> | |
| <h1 className="text-lg font-semibold text-zinc-100">Campaign unavailable</h1> | |
| <p className="text-sm text-zinc-400">We couldn’t load this campaign. Try again or return to the campaign list.</p> | |
| <div className="flex items-center gap-2"> | |
| <button type="button" onClick={() => refetchCampaign()} className="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg inline-flex items-center gap-2"> | |
| <RefreshCw className="w-3.5 h-3.5" /> Retry | |
| </button> | |
| <Link to="/campaigns" className="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium rounded-lg"> | |
| Back to campaigns | |
| </Link> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="p-6 max-w-7xl mx-auto space-y-6"> | |
| {/* Back Navigation and Header */} | |
| <div className="space-y-3"> | |
| <Link | |
| to="/campaigns" | |
| className="inline-flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-200 transition-colors" | |
| > | |
| <ArrowLeft className="w-3.5 h-3.5" /> Back to all campaigns | |
| </Link> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> | |
| <div> | |
| <div className="flex items-center gap-3"> | |
| <h1 className="text-2xl font-bold text-zinc-100 tracking-tight">{campaign.name}</h1> | |
| <StatusBadge status={campaign.status} /> | |
| </div> | |
| <p className="text-xs text-zinc-400 mt-1"> | |
| Timezone: {campaign.timezone} • Schedule: {campaign.start_schedule} - {campaign.end_schedule} | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setIsAddLeadOpen(true)} | |
| className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5" | |
| > | |
| <Plus className="w-3.5 h-3.5" /> Add Leads | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Tabs Bar */} | |
| <div className="border-b border-zinc-800 flex items-center gap-1 text-sm font-medium overflow-x-auto"> | |
| {[ | |
| { key: "overview", label: "Overview", icon: BarChart2 }, | |
| { key: "sequences", label: `Sequences (${sequences.length})`, icon: Layers }, | |
| { key: "accounts", label: `Email Accounts (${assignedAccounts.length})`, icon: Mail }, | |
| { key: "leads", label: `Leads (${campaignLeads.length})`, icon: Users }, | |
| { key: "analytics", label: "Analytics", icon: BarChart2 }, | |
| { key: "settings", label: "Settings", icon: SettingsIcon }, | |
| ].map((tab) => ( | |
| <button | |
| key={tab.key} | |
| onClick={() => setActiveTab(tab.key as any)} | |
| className={`px-4 py-2.5 border-b-2 font-medium text-xs flex items-center gap-2 transition-colors whitespace-nowrap ${ | |
| activeTab === tab.key | |
| ? "border-indigo-500 text-indigo-400 bg-indigo-500/5" | |
| : "border-transparent text-zinc-400 hover:text-zinc-200 hover:border-zinc-700" | |
| }`} | |
| > | |
| <tab.icon className="w-4 h-4" /> | |
| {tab.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* TAB 1: OVERVIEW */} | |
| {activeTab === "overview" && ( | |
| <div className="space-y-6"> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> | |
| <StatCard | |
| title="Leads in Sequence" | |
| value={campaign.lead_count || 0} | |
| icon={Users} | |
| iconColor="teal" | |
| /> | |
| <StatCard | |
| title="Emails Sent" | |
| value={campaign.sent_count || 0} | |
| icon={Send} | |
| iconColor="blue" | |
| /> | |
| <StatCard | |
| title="Unique Opens" | |
| value={campaign.open_count || 0} | |
| subtitle={`${((campaign.open_count / (campaign.sent_count || 1)) * 100).toFixed(1)}% Open Rate`} | |
| icon={Mail} | |
| iconColor="emerald" | |
| /> | |
| <StatCard | |
| title="Replies Received" | |
| value={campaign.reply_count || 0} | |
| subtitle={`${((campaign.reply_count / (campaign.sent_count || 1)) * 100).toFixed(1)}% Reply Rate`} | |
| icon={CheckCircle2} | |
| iconColor="purple" | |
| /> | |
| </div> | |
| {/* 14-Day Trajectory */} | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 shadow-sm"> | |
| <h3 className="text-sm font-semibold text-zinc-100 mb-4"> | |
| Engagement Trajectory (Last 14 Days) | |
| </h3> | |
| <div className="h-[260px] w-full"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <LineChart data={analyticsHistory} margin={{ top: 5, right: 10, bottom: 5, left: -15 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="var(--nt-border-subtle)" vertical={false} /> | |
| <XAxis dataKey="date" stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} /> | |
| <YAxis stroke="var(--nt-text-muted)" fontSize={11} tickLine={false} axisLine={false} /> | |
| <Tooltip | |
| contentStyle={{ backgroundColor: "var(--nt-bg-root)", borderColor: "var(--nt-border-accent)", borderRadius: "8px", fontSize: "12px" }} | |
| /> | |
| <Line type="monotone" dataKey="sent" stroke="var(--nt-deep-teal-600)" strokeWidth={2} name="Sent" dot={false} /> | |
| <Line type="monotone" dataKey="opens" stroke="var(--nt-deep-teal-500)" strokeWidth={2} name="Opens" dot={false} /> | |
| <Line type="monotone" dataKey="replies" stroke="var(--nt-terracotta-500)" strokeWidth={2} name="Replies" dot={false} /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* TAB 2: SEQUENCES WITH A/B VARIANTS */} | |
| {activeTab === "sequences" && ( | |
| <div className="space-y-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h3 className="text-base font-semibold text-zinc-100">Multi-Step Sequence Flow</h3> | |
| <p className="text-xs text-zinc-400 mt-0.5"> | |
| Set delay days, customize subject lines, and configure A/B variants per email step. | |
| </p> | |
| </div> | |
| <button | |
| onClick={addSequenceStep} | |
| className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5" | |
| > | |
| <Plus className="w-3.5 h-3.5" /> Add Email Step | |
| </button> | |
| </div> | |
| <div className="space-y-4"> | |
| {sequences.map((step: any, stepIndex: number) => { | |
| const currentVariantTab = selectedVariantTab[step.id] || 0; | |
| const hasVariants = step.variants && step.variants.length > 0; | |
| return ( | |
| <div key={step.id || stepIndex} className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-sm"> | |
| {/* Step Header */} | |
| <div className="px-5 py-3.5 bg-zinc-950 border-b border-zinc-800 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <span className="w-6 h-6 rounded-full bg-indigo-500/20 text-indigo-400 font-bold text-xs flex items-center justify-center border border-indigo-500/30"> | |
| {step.seq_number} | |
| </span> | |
| <div> | |
| <span className="text-xs font-semibold text-zinc-200"> | |
| {step.seq_number === 1 ? "Initial Outreach (Step 1)" : `Follow-up #${step.seq_number - 1}`} | |
| </span> | |
| <div className="flex items-center gap-2 text-[11px] text-zinc-400 mt-0.5"> | |
| <Clock className="w-3 h-3 text-zinc-500" /> | |
| <span>Delay:</span> | |
| <input | |
| type="number" | |
| min="0" | |
| value={step.delay_days} | |
| onChange={(e) => { | |
| const updated = [...sequences]; | |
| updated[stepIndex].delay_days = Number(e.target.value); | |
| saveSequencesMutation.mutate(updated); | |
| }} | |
| className="w-12 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 text-xs text-zinc-100" | |
| /> | |
| <span>days after previous email</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => addVariantToStep(stepIndex)} | |
| className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-medium rounded-md transition-colors flex items-center gap-1" | |
| > | |
| <Split className="w-3.5 h-3.5 text-indigo-400" /> + A/B Variant | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => removeSequenceStep(stepIndex)} | |
| className="p-1.5 text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-md transition-colors" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Step Body & Variant Tabs */} | |
| <div className="p-5 space-y-4"> | |
| {hasVariants && ( | |
| <div className="flex items-center justify-between border-b border-zinc-800 pb-3"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => setSelectedVariantTab((prev) => ({ ...prev, [step.id]: 0 }))} | |
| className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${ | |
| currentVariantTab === 0 ? "bg-indigo-600 text-white" : "bg-zinc-800 text-zinc-400" | |
| }`} | |
| > | |
| Variant A (Default) | |
| </button> | |
| {step.variants.map((v: any, vIdx: number) => ( | |
| <button | |
| key={v.id || vIdx} | |
| type="button" | |
| onClick={() => setSelectedVariantTab((prev) => ({ ...prev, [step.id]: vIdx + 1 }))} | |
| className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${ | |
| currentVariantTab === vIdx + 1 ? "bg-indigo-600 text-white" : "bg-zinc-800 text-zinc-400" | |
| }`} | |
| > | |
| {v.variant_label || `Variant ${String.fromCharCode(66 + vIdx)}`} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="text-xs text-zinc-400 font-mono"> | |
| Split Traffic: 50% / 50% | |
| </div> | |
| </div> | |
| )} | |
| {/* Active Variant Editor */} | |
| {currentVariantTab === 0 ? ( | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-400 mb-1"> | |
| Subject Line (Variant A) | |
| </label> | |
| <input | |
| type="text" | |
| value={step.subject} | |
| onChange={(e) => { | |
| const updated = [...sequences]; | |
| updated[stepIndex].subject = e.target.value; | |
| saveSequencesMutation.mutate(updated); | |
| }} | |
| className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-400 mb-1"> | |
| Email Body (Variant A) | |
| </label> | |
| <RichTextEditor | |
| content={step.email_body} | |
| onChange={(html) => { | |
| const updated = [...sequences]; | |
| updated[stepIndex].email_body = html; | |
| saveSequencesMutation.mutate(updated); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-400 mb-1"> | |
| Subject Line ({step.variants[currentVariantTab - 1]?.variant_label}) | |
| </label> | |
| <input | |
| type="text" | |
| value={step.variants[currentVariantTab - 1]?.subject || ""} | |
| onChange={(e) => { | |
| const updated = [...sequences]; | |
| updated[stepIndex].variants[currentVariantTab - 1].subject = e.target.value; | |
| saveSequencesMutation.mutate(updated); | |
| }} | |
| className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-400 mb-1"> | |
| Email Body ({step.variants[currentVariantTab - 1]?.variant_label}) | |
| </label> | |
| <RichTextEditor | |
| content={step.variants[currentVariantTab - 1]?.email_body || ""} | |
| onChange={(html) => { | |
| const updated = [...sequences]; | |
| updated[stepIndex].variants[currentVariantTab - 1].email_body = html; | |
| saveSequencesMutation.mutate(updated); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* TAB 3: EMAIL ACCOUNTS ASSIGNMENT */} | |
| {activeTab === "accounts" && ( | |
| <div className="space-y-6"> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5"> | |
| <h3 className="text-base font-semibold text-zinc-100 mb-1">Assigned Sending Inboxes</h3> | |
| <p className="text-xs text-zinc-400 mb-4"> | |
| Emails will be rotated across these connected mailboxes to preserve deliverability. | |
| </p> | |
| <div className="space-y-3"> | |
| {assignedAccounts.map((acc: any) => ( | |
| <div | |
| key={acc.id} | |
| className="flex items-center justify-between p-3 bg-zinc-950 border border-zinc-800 rounded-lg" | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-indigo-500/10 text-indigo-400 rounded-md"> | |
| <Mail className="w-4 h-4" /> | |
| </div> | |
| <div> | |
| <span className="text-xs font-semibold text-zinc-200">{acc.from_name}</span> | |
| <div className="text-[11px] text-zinc-400">{acc.from_email}</div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <StatusBadge status={acc.status} size="sm" /> | |
| <button | |
| onClick={() => removeAccountFromCampaign(acc.id)} | |
| className="text-xs text-rose-400 hover:text-rose-300 font-medium" | |
| > | |
| Remove | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| {assignedAccounts.length === 0 && ( | |
| <div className="p-6 text-center text-xs text-zinc-500 bg-zinc-950/60 rounded-lg border border-dashed border-zinc-800"> | |
| No email accounts assigned yet. Select from available mailboxes below. | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5"> | |
| <h4 className="text-xs font-semibold uppercase tracking-wider text-zinc-400 mb-3"> | |
| Available Mailboxes | |
| </h4> | |
| <div className="space-y-2"> | |
| {allEmailAccounts | |
| .filter((a: any) => !assignedAccounts.some((aa: any) => aa.id === a.id)) | |
| .map((acc: any) => ( | |
| <div | |
| key={acc.id} | |
| className="flex items-center justify-between p-3 bg-zinc-950/40 border border-zinc-800/80 rounded-lg" | |
| > | |
| <div> | |
| <span className="text-xs font-medium text-zinc-200">{acc.from_email}</span> | |
| <div className="text-[10px] text-zinc-500"> | |
| Provider: {acc.provider} • Daily limit: {acc.daily_limit} emails | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => addAccountToCampaign(acc.id)} | |
| className="px-3 py-1 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded text-xs font-medium transition-colors" | |
| > | |
| + Assign to Campaign | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* TAB 4: LEADS */} | |
| {activeTab === "leads" && ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-xs text-zinc-400"> | |
| Showing {campaignLeads.length} leads in this campaign. | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <CsvExportButton data={campaignLeads} filename={`campaign-${id}-leads.csv`} /> | |
| <button | |
| onClick={() => setIsAddLeadOpen(true)} | |
| className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5" | |
| > | |
| <Plus className="w-3.5 h-3.5" /> Add Lead | |
| </button> | |
| </div> | |
| </div> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden"> | |
| <table className="w-full text-sm text-left"> | |
| <thead className="bg-zinc-950 border-b border-zinc-800 text-zinc-400 text-xs uppercase"> | |
| <tr> | |
| <th className="px-5 py-3 font-semibold">Lead</th> | |
| <th className="px-5 py-3 font-semibold">Company</th> | |
| <th className="px-5 py-3 font-semibold">Status</th> | |
| <th className="px-5 py-3 font-semibold">Opens</th> | |
| <th className="px-5 py-3 font-semibold">Clicks</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-800"> | |
| {campaignLeads.map((lead: any) => ( | |
| <tr key={lead.id} className="hover:bg-zinc-800/30"> | |
| <td className="px-5 py-3.5"> | |
| <div className="font-medium text-zinc-200"> | |
| {lead.first_name} {lead.last_name} | |
| </div> | |
| <div className="text-xs text-zinc-400 font-mono">{lead.email}</div> | |
| </td> | |
| <td className="px-5 py-3.5 text-zinc-300">{lead.company_name}</td> | |
| <td className="px-5 py-3.5"> | |
| <StatusBadge status={lead.status} size="sm" /> | |
| </td> | |
| <td className="px-5 py-3.5 text-zinc-300 font-mono">{lead.open_count || 0}</td> | |
| <td className="px-5 py-3.5 text-zinc-300 font-mono">{lead.click_count || 0}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* TAB 5: ANALYTICS */} | |
| {activeTab === "analytics" && ( | |
| <div className="space-y-6"> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4"> | |
| <span className="text-xs text-zinc-400">Open Rate</span> | |
| <div className="text-2xl font-bold text-emerald-400 mt-1">56.2%</div> | |
| <span className="text-[11px] text-zinc-500">Above industry avg (28%)</span> | |
| </div> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4"> | |
| <span className="text-xs text-zinc-400">Reply Rate</span> | |
| <div className="text-2xl font-bold text-purple-400 mt-1">6.4%</div> | |
| <span className="text-[11px] text-zinc-500">Strong positive intent</span> | |
| </div> | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4"> | |
| <span className="text-xs text-zinc-400">Bounce Rate</span> | |
| <div className="text-2xl font-bold text-rose-400 mt-1">0.6%</div> | |
| <span className="text-[11px] text-zinc-500">Safe zone (< 2%)</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* TAB 6: SETTINGS */} | |
| {activeTab === "settings" && ( | |
| <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 max-w-2xl"> | |
| <h3 className="text-base font-semibold text-zinc-100 mb-4">Campaign Parameters</h3> | |
| <div className="space-y-4 text-xs"> | |
| <div> | |
| <label className="block text-zinc-400 font-medium mb-1">Timezone</label> | |
| <input | |
| disabled | |
| value={campaign.timezone} | |
| className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-300" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-zinc-400 font-medium mb-1">Unsubscribe Footer</label> | |
| <input | |
| disabled | |
| value={campaign.unsubscribe_text} | |
| className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2.5 text-zinc-300" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Add Lead Slide-Over */} | |
| <FormDrawer | |
| isOpen={isAddLeadOpen} | |
| onClose={() => setIsAddLeadOpen(false)} | |
| title="Add Lead to Campaign" | |
| subtitle="Upload prospect details to be enrolled into this sequence." | |
| > | |
| <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, | |
| company_name: (form.elements.namedItem("company_name") as HTMLInputElement).value, | |
| website: (form.elements.namedItem("website") as HTMLInputElement).value, | |
| status: "INTERESTED", | |
| }; | |
| try { | |
| await apiClient.post(`/campaigns/${id}/leads`, newLead); | |
| toast.success("Lead enrolled in campaign"); | |
| setIsAddLeadOpen(false); | |
| refetchLeads(); | |
| } catch (err: any) { | |
| toast.error("Failed to add lead"); | |
| } | |
| }} | |
| className="space-y-4 text-xs" | |
| > | |
| <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.5 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.5 text-zinc-100" /> | |
| </div> | |
| <div> | |
| <label className="block font-medium text-zinc-300 mb-1">Email *</label> | |
| <input type="email" name="email" 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">Company Name</label> | |
| <input name="company_name" 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">Website</label> | |
| <input name="website" placeholder="https://" 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={() => 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" | |
| > | |
| Enroll Lead | |
| </button> | |
| </div> | |
| </form> | |
| </FormDrawer> | |
| </div> | |
| ); | |
| } | |