- 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
869 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|