Files
avbeat-com/src/app/admin/newsletter/page.tsx
Claude d43f78b161 av: clone broadcastbeat sources + AV Beat rebrand (Phase 1)
- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch)
- Domain: broadcastbeat.com -> avbeat.com
- Brand: Broadcast Beat -> AV Beat
- Slug: broadcastbeat -> avbeat (package, identifiers)
- Schema fallback: bb -> av (env var name unchanged)
- Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg
- All BB/RMP logo references repointed

Outstanding before public DNS swap:
  - Supabase av schema bootstrap (mirror of bb tables)
  - WordPress archive import (avbeat-com -> av.articles)
  - Coolify env vars
  - dev-avbeat.onsethost.com staging deploy + visual review
2026-06-02 15:32:56 +00:00

869 lines
41 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
interface Subscriber {
id: string;
email: string;
topics: string[];
status: 'active' | 'unsubscribed';
subscribed_at: string;
unsubscribed_at: string | null;
}
interface DigestArticle {
title: string;
excerpt: string;
url: string;
category: string;
}
interface NewsletterTemplate {
id: string;
name: string;
description: string;
layout: string;
style: string;
subject_prefix: string;
header_text: string;
footer_text: string;
accent_color: string;
article_blocks: DigestArticle[];
is_preset: boolean;
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function exportToCSV(subscribers: Subscriber[]) {
const headers = ['Email', 'Status', 'Topics', 'Subscribed At', 'Unsubscribed At'];
const rows = subscribers.map((s) => [
s.email,
s.status,
(s.topics || []).join('; '),
s.subscribed_at ? new Date(s.subscribed_at).toISOString() : '',
s.unsubscribed_at ? new Date(s.unsubscribed_at).toISOString() : '',
]);
const csvContent = [headers, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `subscribers_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
export default function AdminNewsletterPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<'subscribers' | 'send-digest'>('subscribers');
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'unsubscribed'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const [showBulkMenu, setShowBulkMenu] = useState(false);
// Digest state
const [digestSubject, setDigestSubject] = useState('');
const [digestPreview, setDigestPreview] = useState('');
const [digestMessage, setDigestMessage] = useState('');
const [digestArticles, setDigestArticles] = useState<DigestArticle[]>([
{ title: '', excerpt: '', url: '', category: '' },
]);
const [sendingDigest, setSendingDigest] = useState(false);
const [digestResult, setDigestResult] = useState<{ sent: number; failed: number; total: number } | null>(null);
// Template state
const [templates, setTemplates] = useState<NewsletterTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [showTemplateDrawer, setShowTemplateDrawer] = useState(false);
const [appliedTemplate, setAppliedTemplate] = useState<NewsletterTemplate | null>(null);
useEffect(() => {
if (!loading && !user) router.replace('/login?next=' + encodeURIComponent(window.location.pathname));
}, [user, loading, router]);
// Handle ?template= query param (from templates page "Use" button)
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const templateId = params.get('template');
if (templateId && user) {
setActiveTab('send-digest');
fetch('/api/newsletter/templates')
.then((r) => r.json())
.then((data) => {
const tpl = (data.templates || []).find((t: NewsletterTemplate) => t.id === templateId);
if (tpl) applyTemplate(tpl);
})
.catch(() => {});
}
}, [user]); // eslint-disable-line
const showNotification = (type: 'success' | 'error', message: string) => {
setNotification({ type, message });
setTimeout(() => setNotification(null), 4000);
};
const fetchSubscribers = useCallback(async () => {
setLoadingData(true);
try {
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (searchQuery) params.set('search', searchQuery);
const res = await fetch(`/api/newsletter/subscribers?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setSubscribers(data.subscribers || []);
setSelectedIds(new Set());
}
} catch {
// silent
} finally {
setLoadingData(false);
}
}, [statusFilter, searchQuery]);
const fetchTemplates = useCallback(async () => {
setLoadingTemplates(true);
try {
const res = await fetch('/api/newsletter/templates');
if (res.ok) {
const data = await res.json();
setTemplates(data.templates || []);
}
} catch {
// silent
} finally {
setLoadingTemplates(false);
}
}, []);
useEffect(() => {
if (user) fetchSubscribers();
}, [user, fetchSubscribers]);
useEffect(() => {
const t = setTimeout(() => {
if (user) fetchSubscribers();
}, 400);
return () => clearTimeout(t);
}, [searchQuery]); // eslint-disable-line
const applyTemplate = (tpl: NewsletterTemplate) => {
setAppliedTemplate(tpl);
if (tpl.subject_prefix) setDigestSubject(tpl.subject_prefix + ' ');
if (tpl.header_text) setDigestPreview(tpl.header_text);
if (tpl.article_blocks?.length) {
setDigestArticles(tpl.article_blocks.map((b) => ({
title: b.title || '',
excerpt: b.excerpt || '',
url: b.url || '',
category: b.category || '',
})));
}
setShowTemplateDrawer(false);
showNotification('success', `Template "${tpl.name}" applied`);
};
const handleUnsubscribe = async (id: string, email: string) => {
if (!confirm(`Unsubscribe ${email}?`)) return;
setRemovingId(id);
try {
const res = await fetch('/api/newsletter/subscribers', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setSubscribers((prev) => prev.map((s) => s.id === id ? { ...s, status: 'unsubscribed' } : s));
showNotification('success', `${email} unsubscribed`);
} else {
showNotification('error', 'Failed to unsubscribe');
}
} catch {
showNotification('error', 'Failed to unsubscribe');
} finally {
setRemovingId(null);
}
};
// ── Bulk selection helpers ──────────────────────────────────────────────────
const allVisibleIds = subscribers.map((s) => s.id);
const allSelected = allVisibleIds.length > 0 && allVisibleIds.every((id) => selectedIds.has(id));
const someSelected = selectedIds.size > 0;
const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(allVisibleIds));
}
};
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleBulkUnsubscribe = async () => {
const ids = Array.from(selectedIds);
const activeSelected = subscribers.filter((s) => ids.includes(s.id) && s.status === 'active');
if (activeSelected.length === 0) {
showNotification('error', 'No active subscribers selected');
setShowBulkMenu(false);
return;
}
if (!confirm(`Unsubscribe ${activeSelected.length} subscriber(s)?`)) return;
setBulkLoading(true);
setShowBulkMenu(false);
try {
const res = await fetch('/api/newsletter/subscribers', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'bulk_unsubscribe', ids: activeSelected.map((s) => s.id) }),
});
if (res.ok) {
const data = await res.json();
setSubscribers((prev) =>
prev.map((s) => activeSelected.some((a) => a.id === s.id) ? { ...s, status: 'unsubscribed' } : s)
);
setSelectedIds(new Set());
showNotification('success', `${data.count || activeSelected.length} subscriber(s) unsubscribed`);
} else {
showNotification('error', 'Bulk unsubscribe failed');
}
} catch {
showNotification('error', 'Bulk unsubscribe failed');
} finally {
setBulkLoading(false);
}
};
const handleBulkDelete = async () => {
const ids = Array.from(selectedIds);
if (!confirm(`Permanently delete ${ids.length} subscriber record(s)? This cannot be undone.`)) return;
setBulkLoading(true);
setShowBulkMenu(false);
try {
const res = await fetch('/api/newsletter/subscribers', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'bulk_delete', ids }),
});
if (res.ok) {
const data = await res.json();
setSubscribers((prev) => prev.filter((s) => !ids.includes(s.id)));
setSelectedIds(new Set());
showNotification('success', `${data.count || ids.length} record(s) deleted`);
} else {
showNotification('error', 'Bulk delete failed');
}
} catch {
showNotification('error', 'Bulk delete failed');
} finally {
setBulkLoading(false);
}
};
const handleExportCSV = () => {
const toExport = someSelected
? subscribers.filter((s) => selectedIds.has(s.id))
: subscribers;
exportToCSV(toExport);
showNotification('success', `Exported ${toExport.length} subscriber(s) to CSV`);
};
const addArticle = () => {
setDigestArticles((prev) => [...prev, { title: '', excerpt: '', url: '', category: '' }]);
};
const removeArticle = (index: number) => {
setDigestArticles((prev) => prev.filter((_, i) => i !== index));
};
const updateArticle = (index: number, field: keyof DigestArticle, value: string) => {
setDigestArticles((prev) => prev.map((a, i) => i === index ? { ...a, [field]: value } : a));
};
const handleSendDigest = async () => {
if (!digestSubject.trim()) {
showNotification('error', 'Subject is required');
return;
}
const validArticles = digestArticles.filter((a) => a.title.trim());
if (validArticles.length === 0) {
showNotification('error', 'At least one article with a title is required');
return;
}
setSendingDigest(true);
setDigestResult(null);
try {
const res = await fetch('/api/newsletter/subscribers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: digestSubject,
previewText: digestPreview,
customMessage: digestMessage,
articles: validArticles,
}),
});
const data = await res.json();
if (res.ok) {
setDigestResult(data);
showNotification('success', `Digest sent to ${data.sent} subscribers`);
} else {
showNotification('error', data.error || 'Failed to send digest');
}
} catch {
showNotification('error', 'Failed to send digest');
} finally {
setSendingDigest(false);
}
};
const activeCount = subscribers.filter((s) => s.status === 'active').length;
const totalCount = subscribers.length;
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a]">
{/* Notification */}
{notification && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-body font-semibold shadow-lg transition-all ${notification.type === 'success' ? 'bg-green-500/20 border border-green-500/40 text-green-400' : 'bg-red-500/20 border border-red-500/40 text-red-400'}`}>
{notification.message}
</div>
)}
{/* Header */}
<div className="bg-[#111] border-b border-[#252525] px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-[#555] hover:text-[#888] transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="font-display font-bold text-white text-lg">Newsletter</h1>
<p className="text-[#555] text-xs font-body">Manage subscribers & send digests via SMTP</p>
</div>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/newsletter/templates"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
Templates
</Link>
<Link
href="/admin/newsletter/campaign-editor"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-white bg-[#3b82f6] hover:bg-[#2563eb] rounded-lg transition-colors">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
New Campaign
</Link>
<Link
href="/admin/newsletter/analytics"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
Analytics
</Link>
<div className="w-px h-8 bg-[#252525]" />
<div className="text-right">
<p className="text-white font-display font-bold text-xl">{activeCount}</p>
<p className="text-[#555] text-xs font-body">active subscribers</p>
</div>
<div className="w-px h-8 bg-[#252525]" />
<div className="text-right">
<p className="text-white font-display font-bold text-xl">{totalCount}</p>
<p className="text-[#555] text-xs font-body">total</p>
</div>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-6">
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
<button
onClick={() => setActiveTab('subscribers')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'subscribers' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}>
Subscribers
</button>
<button
onClick={() => setActiveTab('send-digest')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'send-digest' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}>
Send Digest
</button>
</div>
{/* Subscribers Tab */}
{activeTab === 'subscribers' && (
<div>
{/* Filters + Actions Row */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<input
type="search"
placeholder="Search by email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input flex-1 py-2 px-3 text-sm"
/>
<div className="flex gap-1 bg-[#111] border border-[#252525] rounded-lg p-1">
{(['all', 'active', 'unsubscribed'] as const).map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-colors ${statusFilter === s ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}
>
{s}
</button>
))}
</div>
{/* Export CSV */}
<button
onClick={handleExportCSV}
disabled={subscribers.length === 0}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 015.25 21h13.5A2.25 2.25 0 0121 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{someSelected ? `Export (${selectedIds.size})` : 'Export CSV'}
</button>
</div>
{/* Bulk Actions Bar */}
{someSelected && (
<div className="flex items-center justify-between bg-[#1a2535] border border-[#1e3a5f] rounded-lg px-4 py-3 mb-4">
<div className="flex items-center gap-3">
<span className="text-sm font-body font-semibold text-[#3b82f6]">
{selectedIds.size} selected
</span>
<button
onClick={() => setSelectedIds(new Set())}
className="text-xs text-[#555] hover:text-[#888] font-body transition-colors"
>
Clear
</button>
</div>
<div className="flex items-center gap-2 relative">
<button
onClick={handleBulkUnsubscribe}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-yellow-400 border border-yellow-400/30 bg-yellow-400/10 rounded-lg hover:bg-yellow-400/20 transition-colors disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Unsubscribe
</button>
<button
onClick={handleBulkDelete}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-red-400 border border-red-400/30 bg-red-400/10 rounded-lg hover:bg-red-400/20 transition-colors disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968 4.5V3" />
</svg>
Delete
</button>
{bulkLoading && (
<div className="w-4 h-4 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
)}
</div>
</div>
)}
{/* Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
{loadingData ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : subscribers.length === 0 ? (
<div className="text-center py-16">
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<p className="text-[#555] font-body text-sm">No subscribers found</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-[#252525]">
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-[#444] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer"
/>
</th>
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider">Email</th>
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden md:table-cell">Topics</th>
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden sm:table-cell">Subscribed</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{subscribers.map((sub) => (
<tr
key={sub.id}
className={`border-b border-[#1a1a1a] transition-colors ${selectedIds.has(sub.id) ? 'bg-[#0d1a2d]' : 'hover:bg-[#0d0d0d]'}`}
>
<td className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selectedIds.has(sub.id)}
onChange={() => toggleSelect(sub.id)}
className="w-4 h-4 rounded border-[#444] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer"
/>
</td>
<td className="px-4 py-3 text-sm font-body text-[#e0e0e0]">{sub.email}</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{sub.topics?.slice(0, 3).map((t) => (
<span key={t} className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{t}</span>
))}
{sub.topics?.length > 3 && (
<span className="text-xs text-[#555] font-body">+{sub.topics.length - 3}</span>
)}
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-body font-bold px-2 py-1 rounded-full ${sub.status === 'active' ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#1a1a1a]'}`}>
{sub.status}
</span>
</td>
<td className="px-4 py-3 text-xs font-body text-[#555] hidden sm:table-cell">
{timeAgo(sub.subscribed_at)}
</td>
<td className="px-4 py-3 text-right">
{sub.status === 'active' && (
<button
onClick={() => handleUnsubscribe(sub.id, sub.email)}
disabled={removingId === sub.id}
className="text-xs text-[#555] hover:text-red-400 font-body transition-colors disabled:opacity-50"
>
{removingId === sub.id ? 'Removing…' : 'Unsubscribe'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Footer count */}
{subscribers.length > 0 && (
<p className="text-xs text-[#555] font-body mt-3">
Showing {subscribers.length} subscriber{subscribers.length !== 1 ? 's' : ''}
{statusFilter !== 'all' && ` · filtered by "${statusFilter}"`}
{searchQuery && ` · matching "${searchQuery}"`}
</p>
)}
</div>
)}
{/* Send Digest Tab */}
{activeTab === 'send-digest' && (
<div className="max-w-2xl">
{/* SMTP warning if not configured */}
<div className="bg-[#1a2535] border border-[#1e3a5f] rounded-lg p-4 mb-6 flex gap-3">
<svg className="w-5 h-5 text-[#3b82f6] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-[#e0e0e0] text-sm font-body font-semibold">SMTP Required</p>
<p className="text-[#777] text-xs font-body mt-0.5">
Set <code className="text-[#3b82f6]">SMTP_HOST</code>, <code className="text-[#3b82f6]">SMTP_USER</code>, <code className="text-[#3b82f6]">SMTP_PASS</code>, and <code className="text-[#3b82f6]">SMTP_FROM_EMAIL</code> in your environment variables to enable sending.
</p>
</div>
</div>
{/* Applied template badge */}
{appliedTemplate && (
<div className="flex items-center justify-between bg-[#1a2535] border border-[#1e3a5f] rounded-lg px-4 py-3 mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: appliedTemplate.accent_color + '20' }}>
<span style={{ color: appliedTemplate.accent_color }}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</span>
</div>
<span className="text-sm font-body text-[#e0e0e0]">Template: <strong>{appliedTemplate.name}</strong></span>
<span className="text-xs text-[#555] font-body capitalize">({appliedTemplate.layout})</span>
</div>
<button
onClick={() => {
setAppliedTemplate(null);
setDigestSubject('');
setDigestPreview('');
setDigestMessage('');
setDigestArticles([{ title: '', excerpt: '', url: '', category: '' }]);
}}
className="text-xs text-[#555] hover:text-red-400 font-body transition-colors"
>
Clear
</button>
</div>
)}
{/* Load Template button */}
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-body text-[#555]">Fill in the fields below or start from a template</p>
<button
onClick={() => { fetchTemplates(); setShowTemplateDrawer(true); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#3b82f6] border border-[#1e3a5f] rounded-lg hover:bg-[#1a2535] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
Load Template
</button>
</div>
{digestResult && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 mb-6">
<p className="text-green-400 font-body font-semibold text-sm"> Digest sent successfully</p>
<p className="text-[#888] text-xs font-body mt-1">
Sent to <strong className="text-white">{digestResult.sent}</strong> subscribers
{digestResult.failed > 0 && `, ${digestResult.failed} failed`}
</p>
</div>
)}
<div className="space-y-4">
{/* Subject */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Subject *</label>
<input
type="text"
value={digestSubject}
onChange={(e) => setDigestSubject(e.target.value)}
placeholder="e.g. AV Beat Weekly — NAB Show Highlights"
className="search-input w-full py-2.5 px-3 text-sm"
/>
</div>
{/* Preview text */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Preview Text</label>
<input
type="text"
value={digestPreview}
onChange={(e) => setDigestPreview(e.target.value)}
placeholder="Short intro shown in email clients..."
className="search-input w-full py-2.5 px-3 text-sm"
/>
</div>
{/* Custom message */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Custom Message (optional)</label>
<textarea
value={digestMessage}
onChange={(e) => setDigestMessage(e.target.value)}
placeholder="Add a personal note or announcement..."
rows={3}
className="search-input w-full py-2.5 px-3 text-sm resize-none"
/>
</div>
{/* Articles */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-body font-bold text-[#888] uppercase tracking-wider">Articles *</label>
<button
type="button"
onClick={addArticle}
className="text-xs text-[#3b82f6] hover:text-blue-400 font-body font-semibold transition-colors"
>
+ Add Article
</button>
</div>
<div className="space-y-3">
{digestArticles.map((article, index) => (
<div key={index} className="bg-[#111] border border-[#252525] rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-body text-[#555]">Article {index + 1}</span>
{digestArticles.length > 1 && (
<button
type="button"
onClick={() => removeArticle(index)}
className="text-xs text-[#555] hover:text-red-400 font-body transition-colors"
>
Remove
</button>
)}
</div>
<div className="space-y-2">
<input
type="text"
value={article.title}
onChange={(e) => updateArticle(index, 'title', e.target.value)}
placeholder="Article title *"
className="search-input w-full py-2 px-3 text-sm"
/>
<input
type="text"
value={article.excerpt}
onChange={(e) => updateArticle(index, 'excerpt', e.target.value)}
placeholder="Short excerpt"
className="search-input w-full py-2 px-3 text-sm"
/>
<div className="flex gap-2">
<input
type="text"
value={article.url}
onChange={(e) => updateArticle(index, 'url', e.target.value)}
placeholder="/articles/slug"
className="search-input flex-1 py-2 px-3 text-sm"
/>
<input
type="text"
value={article.category}
onChange={(e) => updateArticle(index, 'category', e.target.value)}
placeholder="Category"
className="search-input w-32 py-2 px-3 text-sm"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Send button */}
<div className="flex items-center justify-between pt-2">
<p className="text-xs font-body text-[#555]">
Will send to <strong className="text-[#888]">{activeCount}</strong> active subscribers
</p>
<button
onClick={handleSendDigest}
disabled={sendingDigest || activeCount === 0}
className={`btn-subscribe py-2.5 px-6 text-sm font-semibold ${sendingDigest || activeCount === 0 ? 'opacity-60 cursor-not-allowed' : ''}`}
>
{sendingDigest ? 'Sending…' : `Send Digest`}
</button>
</div>
</div>
</div>
)}
</div>
{/* Template Picker Drawer */}
{showTemplateDrawer && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setShowTemplateDrawer(false)}>
<div className="bg-[#111] border border-[#252525] rounded-t-2xl sm:rounded-xl w-full sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525] flex-shrink-0">
<h2 className="font-display font-bold text-white text-base">Choose a Template</h2>
<div className="flex items-center gap-3">
<Link href="/admin/newsletter/templates" className="text-xs text-[#3b82f6] font-body hover:text-blue-400 transition-colors">
Manage templates
</Link>
<button onClick={() => setShowTemplateDrawer(false)} className="text-[#555] hover:text-white transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="overflow-y-auto flex-1 p-4">
{loadingTemplates ? (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="text-center py-12">
<p className="text-[#555] font-body text-sm">No templates found.</p>
<Link href="/admin/newsletter/templates" className="text-[#3b82f6] text-sm font-body hover:text-blue-400 mt-2 inline-block">
Create your first template
</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{templates.map((tpl) => (
<button
key={tpl.id}
onClick={() => applyTemplate(tpl)}
className="flex items-start gap-3 p-4 bg-[#0d0d0d] border border-[#252525] rounded-xl text-left hover:border-[#3b82f6] hover:bg-[#1a2535] transition-colors group"
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: tpl.accent_color + '20' }}>
<span style={{ color: tpl.accent_color }}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="font-body font-semibold text-white text-sm">{tpl.name}</p>
{tpl.is_preset && <span className="text-xs text-[#555] font-body">Preset</span>}
</div>
{tpl.description && <p className="text-xs text-[#777] font-body line-clamp-1">{tpl.description}</p>}
<div className="flex gap-1.5 mt-1.5">
<span className="text-xs bg-[#1a1a1a] text-[#888] px-1.5 py-0.5 rounded font-body capitalize">{tpl.layout}</span>
<span className="text-xs bg-[#1a1a1a] text-[#888] px-1.5 py-0.5 rounded font-body">{tpl.article_blocks?.length || 0} slots</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}