'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([]); 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(null); // Bulk selection state const [selectedIds, setSelectedIds] = useState>(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([ { 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([]); const [loadingTemplates, setLoadingTemplates] = useState(false); const [showTemplateDrawer, setShowTemplateDrawer] = useState(false); const [appliedTemplate, setAppliedTemplate] = useState(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 (
); } if (!user) return null; return (
{/* Notification */} {notification && (
{notification.message}
)} {/* Header */}

Newsletter

Manage subscribers & send digests via SMTP

Templates New Campaign Analytics

{activeCount}

active subscribers

{totalCount}

total

{/* Tabs */}
{/* Subscribers Tab */} {activeTab === 'subscribers' && (
{/* Filters + Actions Row */}
setSearchQuery(e.target.value)} className="search-input flex-1 py-2 px-3 text-sm" />
{(['all', 'active', 'unsubscribed'] as const).map((s) => ( ))}
{/* Export CSV */}
{/* Bulk Actions Bar */} {someSelected && (
{selectedIds.size} selected
{bulkLoading && (
)}
)} {/* Table */}
{loadingData ? (
) : subscribers.length === 0 ? (

No subscribers found

) : ( {subscribers.map((sub) => ( ))}
Email Topics Status Subscribed
toggleSelect(sub.id)} className="w-4 h-4 rounded border-[#444] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer" /> {sub.email}
{sub.topics?.slice(0, 3).map((t) => ( {t} ))} {sub.topics?.length > 3 && ( +{sub.topics.length - 3} )}
{sub.status} {timeAgo(sub.subscribed_at)} {sub.status === 'active' && ( )}
)}
{/* Footer count */} {subscribers.length > 0 && (

Showing {subscribers.length} subscriber{subscribers.length !== 1 ? 's' : ''} {statusFilter !== 'all' && ` · filtered by "${statusFilter}"`} {searchQuery && ` · matching "${searchQuery}"`}

)}
)} {/* Send Digest Tab */} {activeTab === 'send-digest' && (
{/* SMTP warning if not configured */}

SMTP Required

Set SMTP_HOST, SMTP_USER, SMTP_PASS, and SMTP_FROM_EMAIL in your environment variables to enable sending.

{/* Applied template badge */} {appliedTemplate && (
Template: {appliedTemplate.name} ({appliedTemplate.layout})
)} {/* Load Template button */}

Fill in the fields below or start from a template

{digestResult && (

✓ Digest sent successfully

Sent to {digestResult.sent} subscribers {digestResult.failed > 0 && `, ${digestResult.failed} failed`}

)}
{/* Subject */}
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" />
{/* Preview text */}
setDigestPreview(e.target.value)} placeholder="Short intro shown in email clients..." className="search-input w-full py-2.5 px-3 text-sm" />
{/* Custom message */}