← back to Patty
components/subscribers/SubscribersTab.tsx
447 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Users, UserPlus, Search, Loader2, X, Copy,
Tag, ToggleLeft, ToggleRight, UserMinus,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const SUB_SORT_OPTIONS = [
{ value: 'email_asc', label: 'Email A-Z' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'active_desc', label: 'Active First' },
];
const SUB_SORT_CONFIGS: Record<string, SortConfig> = {
email_asc: { key: 'email', direction: 'asc', type: 'string' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
active_desc: { key: 'is_active', direction: 'desc', type: 'string' },
};
/* ─── Types ─────────────────────────────────────────────────── */
interface Subscriber {
id: string;
email: string;
name: string | null;
zip_code: string | null;
source: string;
petition_ids: string[] | null;
tags: string[] | null;
is_active: boolean;
unsubscribed_at: string | null;
created_at: string;
}
interface Stats {
total: number;
active: number;
inactive: number;
by_source: Record<string, number>;
}
/* ─── Component ─────────────────────────────────────────────── */
export default function SubscribersTab() {
const { addToast } = useToast();
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<Stats>({ total: 0, active: 0, inactive: 0, by_source: {} });
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [sourceFilter, setSourceFilter] = useState('all');
const [sortBy, setSortBy] = useState('date_desc');
const sortedSubscribers = useClientSort(subscribers, sortBy, SUB_SORT_CONFIGS);
// Add modal
const [showAdd, setShowAdd] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addName, setAddName] = useState('');
const [addZip, setAddZip] = useState('');
const [addSaving, setAddSaving] = useState(false);
// Selected for bulk
const [selected, setSelected] = useState<Set<string>>(new Set());
/* ── Fetch ──────────────────────────────────────────────────── */
const fetchSubscribers = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (debouncedSearch) params.set('search', debouncedSearch);
if (sourceFilter !== 'all') params.set('source', sourceFilter);
const res = await fetch(`/api/subscribers?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setSubscribers(data.rows || []);
setStats(data.stats || { total: 0, active: 0, inactive: 0, by_source: {} });
}
} catch (err) {
console.error('Fetch subscribers error:', err);
} finally {
setLoading(false);
}
}, [debouncedSearch, sourceFilter]);
useEffect(() => { fetchSubscribers(); }, [fetchSubscribers]);
/* ── Escape key handler ───────────────────────────────────────── */
useEffect(() => {
if (!showAdd) return;
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdd(false); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [showAdd]);
/* ── Add Subscriber ─────────────────────────────────────────── */
async function handleAdd() {
if (!addEmail.trim()) return;
setAddSaving(true);
try {
const res = await fetch('/api/subscribers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: addEmail, name: addName || null, zip_code: addZip || null, source: 'manual' }),
});
if (res.ok) {
setShowAdd(false);
setAddEmail('');
setAddName('');
setAddZip('');
fetchSubscribers();
addToast('Subscriber added', 'success');
} else {
addToast('Failed to add subscriber', 'error');
}
} catch (err) {
console.error('Add subscriber error:', err);
addToast('Failed to add subscriber', 'error');
} finally {
setAddSaving(false);
}
}
/* ── Toggle Active ──────────────────────────────────────────── */
async function toggleActive(sub: Subscriber) {
try {
if (sub.is_active) {
// Unsubscribe
await fetch(`/api/subscribers?id=${sub.id}`, { method: 'DELETE' });
} else {
// Reactivate
await fetch('/api/subscribers', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sub.id, is_active: true }),
});
}
fetchSubscribers();
addToast(sub.is_active ? 'Subscriber deactivated' : 'Subscriber reactivated', 'info');
} catch (err) {
console.error('Toggle active error:', err);
addToast('Toggle failed', 'error');
}
}
/* ── Unsubscribe ────────────────────────────────────────────── */
async function handleUnsubscribe(id: string) {
if (!confirm('Unsubscribe this subscriber?')) return;
try {
await fetch(`/api/subscribers?id=${id}`, { method: 'DELETE' });
fetchSubscribers();
addToast('Subscriber unsubscribed', 'info');
} catch (err) {
console.error('Unsubscribe error:', err);
addToast('Unsubscribe failed', 'error');
}
}
/* ── Bulk Copy Emails ───────────────────────────────────────── */
function handleCopyEmails() {
const emails = subscribers
.filter((s) => selected.has(s.id))
.map((s) => s.email)
.join(', ');
navigator.clipboard.writeText(emails);
addToast(`${selected.size} email(s) copied`, 'success');
}
/* ── Selection ──────────────────────────────────────────────── */
function toggleSelected(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
}
function toggleAll() {
if (selected.size === subscribers.length) {
setSelected(new Set());
} else {
setSelected(new Set(subscribers.map((s) => s.id)));
}
}
/* ── Helpers ────────────────────────────────────────────────── */
const sourceLabels: string[] = ['all', ...Object.keys(stats.by_source)];
const sourceBadgeColor: Record<string, string> = {
manual: '#a78bfa',
petition: '#7c3aed',
signup: '#22c55e',
import: '#3b82f6',
};
const formatDate = (d: string) => {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
/* ─── 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)' }}>Subscribers</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Manage your email list across all petitions
</p>
</div>
<div className="flex items-center gap-2">
{selected.size > 0 && (
<button className="btn btn-secondary btn-sm" onClick={handleCopyEmails} title="Copy selected emails">
<Copy size={14} /> Copy {selected.size} Emails
</button>
)}
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>
<UserPlus size={16} />
Add Subscriber
</button>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div className="card">
<div className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>{stats.total}</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Total Subscribers</div>
</div>
<div className="card">
<div className="text-2xl font-bold" style={{ color: 'var(--color-success)' }}>{stats.active}</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Active</div>
</div>
<div className="card">
<div className="text-2xl font-bold" style={{ color: 'var(--color-error)' }}>{stats.inactive}</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Inactive</div>
</div>
<div className="card">
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(stats.by_source).map(([src, cnt]) => (
<span key={src} className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{src}: <strong>{cnt}</strong>
</span>
))}
{Object.keys(stats.by_source).length === 0 && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>No sources</span>
)}
</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>By Source</div>
</div>
</div>
{/* Sort + Search + Filters */}
<div className="flex items-center gap-3 mb-6 flex-wrap">
<SortDropdown options={SUB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<div className="flex-1 relative" style={{ minWidth: 200 }}>
<Search
size={16}
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
/>
<input
className="input"
placeholder="Search by email or name..."
style={{ paddingLeft: 36 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 flex-wrap">
{sourceLabels.map((s) => (
<button
key={s}
onClick={() => setSourceFilter(s)}
className={`btn btn-sm ${sourceFilter === s ? 'btn-primary' : 'btn-ghost'}`}
style={{ textTransform: 'capitalize' }}
>
{s === 'all' ? 'All Sources' : s}
</button>
))}
</div>
</div>
{/* Loading */}
{loading && (
<SkeletonList count={4} />
)}
{/* Empty State */}
{!loading && subscribers.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)',
}}
>
<Users size={28} style={{ color: 'var(--color-primary)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No subscribers yet</h3>
<p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Subscribers are added when people sign petitions or you add them manually.
</p>
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>
<UserPlus size={16} /> Add Manually
</button>
</div>
)}
{/* Subscriber Table */}
{!loading && subscribers.length > 0 && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ padding: '10px 12px', textAlign: 'left' }}>
<input
type="checkbox"
checked={selected.size === subscribers.length && subscribers.length > 0}
onChange={toggleAll}
style={{ accentColor: 'var(--color-primary)' }}
/>
</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Email</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Name</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Zip</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Source</th>
<th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Active</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Petitions</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Joined</th>
<th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Actions</th>
</tr>
</thead>
<tbody>
{sortedSubscribers.map((s) => {
const srcColor = sourceBadgeColor[s.source] || 'var(--color-text-muted)';
return (
<tr
key={s.id}
style={{
borderBottom: '1px solid var(--color-border)',
backgroundColor: selected.has(s.id) ? 'rgba(124, 58, 237, 0.05)' : 'transparent',
}}
>
<td style={{ padding: '8px 12px' }}>
<input
type="checkbox"
checked={selected.has(s.id)}
onChange={() => toggleSelected(s.id)}
style={{ accentColor: 'var(--color-primary)' }}
/>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text)' }}>{s.email}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{s.name || '--'}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>{s.zip_code || '--'}</td>
<td style={{ padding: '8px 12px' }}>
<span
className="badge"
style={{
backgroundColor: `${srcColor}20`,
color: srcColor,
border: `1px solid ${srcColor}40`,
}}
>
{s.source}
</span>
</td>
<td style={{ padding: '8px 12px', textAlign: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => toggleActive(s)}
style={{ color: s.is_active ? 'var(--color-success)' : 'var(--color-error)' }}
title={s.is_active ? 'Active - click to deactivate' : 'Inactive - click to reactivate'}
>
{s.is_active ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
</button>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>
{s.petition_ids?.length || 0}
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', fontSize: 12 }}>
{formatDate(s.created_at)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => handleUnsubscribe(s.id)}
style={{ color: 'var(--color-error)' }}
title="Unsubscribe"
>
<UserMinus size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* ─── Add Subscriber Modal ─────────────────────────────────── */}
{showAdd && (
<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) setShowAdd(false); }}
>
<div style={{
width: '100%', maxWidth: 420, 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)' }}>Add Subscriber</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(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)' }}>Email *</label>
<input className="input" type="email" value={addEmail} onChange={(e) => setAddEmail(e.target.value)} placeholder="email@example.com" />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name</label>
<input className="input" value={addName} onChange={(e) => setAddName(e.target.value)} placeholder="Full name" />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Zip Code</label>
<input className="input" value={addZip} onChange={(e) => setAddZip(e.target.value)} placeholder="12345" />
</div>
<button className="btn btn-primary w-full" onClick={handleAdd} disabled={addSaving || !addEmail.trim()}>
{addSaving ? <><Loader2 size={16} className="animate-spin" /> Adding...</> : <><UserPlus size={16} /> Add Subscriber</>}
</button>
</div>
</div>
</div>
)}
</div>
);
}