← back to Patty
components/campaigns/CampaignsTab.tsx
483 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Mail, Plus, Send, Clock, CheckCircle, Loader2,
X, Sparkles, Eye, MousePointer, AlertCircle,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const CAMP_SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'status', label: 'Status A-Z' },
{ value: 'opens_desc', label: 'Most Opens' },
];
const CAMP_SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
status: { key: 'status', direction: 'asc', type: 'string' },
opens_desc: { key: 'open_count', direction: 'desc', type: 'number' },
};
/* ─── Types ─────────────────────────────────────────────────── */
interface Campaign {
id: string;
subject: string;
body_html: string;
body_text: string | null;
status: string;
petition_id: string | null;
petition_title: string | null;
send_to: string;
recipient_count: number;
open_count: number;
click_count: number;
scheduled_at: string | null;
sent_at: string | null;
created_at: string;
}
interface PetitionOption {
id: string;
title: string;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ─── Component ─────────────────────────────────────────────── */
export default function CampaignsTab() {
const { addToast } = useToast();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
const [sortBy, setSortBy] = useState('date_desc');
const sortedCampaigns = useClientSort(campaigns, sortBy, CAMP_SORT_CONFIGS);
// Create modal state
const [showCreate, setShowCreate] = useState(false);
const [petitions, setPetitions] = useState<PetitionOption[]>([]);
const [createPetitionId, setCreatePetitionId] = useState('');
const [createSubject, setCreateSubject] = useState('');
const [createBody, setCreateBody] = useState('');
const [createSchedule, setCreateSchedule] = useState('');
const [createSaving, setCreateSaving] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
// Preview modal
const [previewCampaign, setPreviewCampaign] = useState<Campaign | null>(null);
/* ── Fetch Campaigns ────────────────────────────────────────── */
const fetchCampaigns = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
const res = await fetch(`/api/campaigns?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setCampaigns(data.rows || []);
setStatusCounts(data.statusCounts || {});
}
} catch (err) {
console.error('Fetch campaigns error:', err);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { fetchCampaigns(); }, [fetchCampaigns]);
/* ── Escape key handler ───────────────────────────────────────── */
useEffect(() => {
if (!showCreate && !previewCampaign) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showCreate) setShowCreate(false);
if (previewCampaign) setPreviewCampaign(null);
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [showCreate, previewCampaign]);
/* ── Fetch Petitions for Dropdown ───────────────────────────── */
async function fetchPetitions() {
try {
const res = await fetch('/api/petitions');
if (res.ok) {
const data = await res.json();
setPetitions((data.rows || []).map((r: { id: string; title: string }) => ({ id: r.id, title: r.title })));
}
} catch (err) {
console.error('Fetch petitions error:', err);
}
}
function openCreate() {
setShowCreate(true);
setCreatePetitionId('');
setCreateSubject('');
setCreateBody('');
setCreateSchedule('');
fetchPetitions();
}
/* ── AI Generate ────────────────────────────────────────────── */
async function handleAiGenerate() {
setAiGenerating(true);
try {
const res = await fetch('/api/campaigns/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
petition_id: createPetitionId || null,
tone: 'urgent',
call_to_action_type: 'sign',
}),
});
if (res.ok) {
const data = await res.json();
setCreateSubject(data.subject || '');
setCreateBody(data.body_html || data.body_text || '');
addToast('Content generated', 'success');
} else {
addToast('AI generation failed', 'error');
}
} catch (err) {
console.error('AI generate error:', err);
addToast('AI generation failed', 'error');
} finally {
setAiGenerating(false);
}
}
/* ── Create Campaign ────────────────────────────────────────── */
async function handleCreate() {
if (!createSubject.trim() || !createBody.trim()) return;
setCreateSaving(true);
try {
const res = await fetch('/api/campaigns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: createSubject,
body_html: createBody,
petition_id: createPetitionId || null,
scheduled_at: createSchedule || null,
}),
});
if (res.ok) {
setShowCreate(false);
fetchCampaigns();
addToast('Campaign created', 'success');
} else {
addToast('Failed to create campaign', 'error');
}
} catch (err) {
console.error('Create campaign error:', err);
addToast('Failed to create campaign', 'error');
} finally {
setCreateSaving(false);
}
}
/* ── Send Now ───────────────────────────────────────────────── */
async function handleSendNow(id: string) {
try {
const res = await fetch('/api/campaigns', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status: 'sending', sent_at: new Date().toISOString() }),
});
if (res.ok) {
fetchCampaigns();
addToast('Campaign sending', 'success');
} else {
addToast('Send failed', 'error');
}
} catch (err) {
console.error('Send now error:', err);
addToast('Send failed', 'error');
}
}
/* ── Helpers ────────────────────────────────────────────────── */
const statusFilters = ['all', 'draft', 'scheduled', 'sending', 'sent', 'failed'];
const statusBadge: Record<string, { cls: string; icon: React.ElementType }> = {
draft: { cls: 'badge-draft', icon: Mail },
scheduled: { cls: 'badge-paused', icon: Clock },
sending: { cls: 'badge-warning', icon: Send },
sent: { cls: 'badge-active', icon: CheckCircle },
failed: { cls: 'badge-closed', icon: AlertCircle },
};
const formatDate = (d: string | null) => {
if (!d) return '--';
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const openRate = (c: Campaign) => {
if (c.recipient_count === 0) return '0%';
return `${Math.round((c.open_count / c.recipient_count) * 100)}%`;
};
const clickRate = (c: Campaign) => {
if (c.recipient_count === 0) return '0%';
return `${Math.round((c.click_count / c.recipient_count) * 100)}%`;
};
/* ── Stats ──────────────────────────────────────────────────── */
const totalCampaigns = statusCounts.all || 0;
const sentCount = statusCounts.sent || 0;
const scheduledCount = statusCounts.scheduled || 0;
const avgOpen = campaigns.length > 0
? Math.round(campaigns.reduce((sum, c) => sum + (c.recipient_count > 0 ? (c.open_count / c.recipient_count) * 100 : 0), 0) / campaigns.length)
: 0;
/* ─── Render ────────────────────────────────────────────────── */
return (
<div style={{ padding: '24px' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Email Campaigns</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Send email blasts to your petition signers and subscribers
</p>
</div>
<button className="btn btn-primary" onClick={openCreate}>
<Plus size={16} />
Create Campaign
</button>
</div>
{/* Stats Row */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Campaigns', value: String(totalCampaigns), icon: Mail, color: 'var(--color-primary)' },
{ label: 'Sent', value: String(sentCount), icon: Send, color: 'var(--color-success)' },
{ label: 'Scheduled', value: String(scheduledCount), icon: Clock, color: 'var(--color-warning)' },
{ label: 'Avg Open Rate', value: `${avgOpen}%`, icon: CheckCircle, color: 'var(--color-secondary)' },
].map((stat, i) => (
<div key={i} className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${stat.color}15`, border: `1px solid ${stat.color}30` }}
>
<stat.icon size={18} style={{ color: stat.color }} />
</div>
<div>
<div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</div>
</div>
</div>
))}
</div>
{/* Sort + Status Filters */}
<div className="flex items-center gap-1 mb-6 flex-wrap">
<SortDropdown options={CAMP_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{statusFilters.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`btn btn-sm ${statusFilter === s ? 'btn-primary' : 'btn-ghost'}`}
style={{ textTransform: 'capitalize' }}
>
{s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
</button>
))}
</div>
{/* Loading */}
{loading && (
<SkeletonList count={4} />
)}
{/* Empty State */}
{!loading && campaigns.length === 0 && (
<div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
style={{
background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}
>
<Mail size={28} style={{ color: 'var(--color-primary)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No campaigns yet</h3>
<p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Create email campaigns to update petition signers and rally supporters.
</p>
<button className="btn btn-primary" onClick={openCreate}>
<Plus size={16} />
Create First Campaign
</button>
</div>
)}
{/* Campaign Cards */}
{!loading && sortedCampaigns.length > 0 && (
<div className="flex flex-col gap-3">
{sortedCampaigns.map((c) => {
const badge = statusBadge[c.status] || statusBadge.draft;
return (
<div key={c.id} className="card">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{c.subject}
</h3>
<span className={`badge ${badge.cls}`}>{c.status}</span>
</div>
{c.petition_title && (
<p className="text-xs mb-2" style={{ color: 'var(--color-secondary)' }}>
Linked: {c.petition_title}
</p>
)}
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<span className="flex items-center gap-1">
<Mail size={12} /> {c.recipient_count} recipients
</span>
<span className="flex items-center gap-1">
<Eye size={12} /> {openRate(c)} opens
</span>
<span className="flex items-center gap-1">
<MousePointer size={12} /> {clickRate(c)} clicks
</span>
<span>
{c.sent_at ? `Sent ${formatDate(c.sent_at)}` : c.scheduled_at ? `Scheduled ${formatDate(c.scheduled_at)}` : `Created ${formatDate(c.created_at)}`}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(c)} title="Preview">
<Eye size={14} />
</button>
{c.status === 'draft' && (
<button className="btn btn-sm btn-primary" onClick={() => handleSendNow(c.id)}>
<Send size={14} /> Send Now
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* ─── Create Campaign Modal ────────────────────────────────── */}
{showCreate && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
>
<div style={{
width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Campaign</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Linked Petition (optional)</label>
<select className="input" value={createPetitionId} onChange={(e) => setCreatePetitionId(e.target.value)}>
<option value="">-- None --</option>
{petitions.map((p) => (
<option key={p.id} value={p.id}>{p.title}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Subject *</label>
<input className="input" value={createSubject} onChange={(e) => setCreateSubject(e.target.value)} placeholder="Email subject line" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
<button className="btn btn-ghost btn-sm" onClick={handleAiGenerate} disabled={aiGenerating}>
{aiGenerating ? <><Loader2 size={12} className="animate-spin" /> Generating...</> : <><Sparkles size={12} /> AI Generate</>}
</button>
</div>
<textarea
className="input"
rows={8}
value={createBody}
onChange={(e) => setCreateBody(e.target.value)}
placeholder="Email body content (HTML supported)"
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Schedule (optional)</label>
<input className="input" type="datetime-local" value={createSchedule} onChange={(e) => setCreateSchedule(e.target.value)} />
</div>
<button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createSubject.trim() || !createBody.trim()}>
{createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Campaign</>}
</button>
</div>
</div>
</div>
)}
{/* ─── Preview Modal ────────────────────────────────────────── */}
{previewCampaign && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setPreviewCampaign(null); }}
>
<div style={{
width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Preview</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(null)}><X size={16} /></button>
</div>
<div className="mb-3">
<div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>Subject</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{previewCampaign.subject}</div>
</div>
<div
className="text-sm"
style={{
color: 'var(--color-text-secondary)',
padding: 12, borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
whiteSpace: 'pre-wrap',
maxHeight: 400, overflow: 'auto',
}}
>
{stripHtml(previewCampaign.body_html)}
</div>
</div>
</div>
)}
</div>
);
}