initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,473 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubscriberGrowthPoint {
month: string;
subscribers: number;
unsubscribes: number;
net: number;
}
interface DigestPerformance {
id: string;
subject: string;
sent_at: string;
recipients: number;
opens: number;
clicks: number;
unsubscribes: number;
open_rate: number;
click_rate: number;
}
interface OverviewMetric {
label: string;
value: string;
sub: string;
accent: string;
icon: React.ReactNode;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function pct(n: number): string {
return n.toFixed(1) + '%';
}
function fmtDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: {
label: string; value: string; sub: string; accent?: string; icon: React.ReactNode;
}) {
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex items-start gap-4">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}18` }}
>
<span style={{ color: accent }}>{icon}</span>
</div>
<div className="min-w-0">
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">{label}</p>
<p className="text-white text-2xl font-bold font-heading leading-none">{value}</p>
<p className="text-[#555] text-xs font-body mt-1">{sub}</p>
</div>
</div>
);
}
function ChartTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
{label && <p className="text-[#888] mb-1">{label}</p>}
{payload.map((entry: any, i: number) => (
<p key={i} style={{ color: entry.color }} className="leading-5">
{entry.name}: <span className="font-bold text-white">{entry.value}</span>
</p>
))}
</div>
);
}
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="mb-5">
<h2 className="text-white text-base font-bold font-heading uppercase tracking-wider">{title}</h2>
{subtitle && <p className="text-[#555] text-xs font-body mt-0.5">{subtitle}</p>}
</div>
);
}
// ─── Mock data generators (replace with real API when available) ──────────────
function generateGrowthData(): SubscriberGrowthPoint[] {
const months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar'];
let running = 120;
return months.map((month) => {
const newSubs = Math.floor(Math.random() * 80) + 20;
const unsubs = Math.floor(Math.random() * 15) + 2;
running += newSubs - unsubs;
return { month, subscribers: running, unsubscribes: unsubs, net: newSubs - unsubs };
});
}
function generateDigestData(): DigestPerformance[] {
const subjects = [
'Weekly Digest: Top Stories This Week',
'Breaking: Major Industry Shifts',
'Monthly Roundup — February 2026',
'Special Report: Trends to Watch',
'Weekly Digest: Editor\'s Picks',
'Flash Update: Breaking News',
'Weekly Digest: Community Highlights',
'Monthly Roundup — January 2026',
];
return subjects.map((subject, i) => {
const recipients = Math.floor(Math.random() * 300) + 200;
const opens = Math.floor(recipients * (Math.random() * 0.3 + 0.2));
const clicks = Math.floor(opens * (Math.random() * 0.25 + 0.05));
const unsubscribes = Math.floor(Math.random() * 5);
const daysAgo = (i + 1) * 7;
const sentAt = new Date(Date.now() - daysAgo * 86400000).toISOString();
return {
id: `digest-${i + 1}`,
subject,
sent_at: sentAt,
recipients,
opens,
clicks,
unsubscribes,
open_rate: (opens / recipients) * 100,
click_rate: (clicks / recipients) * 100,
};
});
}
function generateRatesTrend(digests: DigestPerformance[]) {
return [...digests].reverse().map((d) => ({
label: fmtDate(d.sent_at),
'Open Rate': parseFloat(d.open_rate.toFixed(1)),
'Click Rate': parseFloat(d.click_rate.toFixed(1)),
}));
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NewsletterAnalyticsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [growthData, setGrowthData] = useState<SubscriberGrowthPoint[]>([]);
const [digestData, setDigestData] = useState<DigestPerformance[]>([]);
const [ratesTrend, setRatesTrend] = useState<any[]>([]);
const [totalSubscribers, setTotalSubscribers] = useState(0);
const [loadingData, setLoadingData] = useState(true);
const [sortField, setSortField] = useState<keyof DigestPerformance>('sent_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
if (!loading && !user) router.replace('/account');
}, [user, loading, router]);
const fetchData = useCallback(async () => {
setLoadingData(true);
try {
// Fetch real subscriber count
const res = await fetch('/api/newsletter/subscribers');
if (res.ok) {
const data = await res.json();
const subs: any[] = data.subscribers ?? [];
setTotalSubscribers(subs.filter((s) => s.status === 'active').length);
}
} catch {
// silent
} finally {
// Generate derived/mock analytics data
const growth = generateGrowthData();
const digests = generateDigestData();
setGrowthData(growth);
setDigestData(digests);
setRatesTrend(generateRatesTrend(digests));
setLoadingData(false);
}
}, []);
useEffect(() => {
if (user) fetchData();
}, [user, fetchData]);
const handleSort = (field: keyof DigestPerformance) => {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir('desc');
}
};
const sortedDigests = [...digestData].sort((a, b) => {
const av = a[sortField];
const bv = b[sortField];
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc'
? String(av).localeCompare(String(bv))
: String(bv).localeCompare(String(av));
});
// Aggregate metrics
const avgOpenRate =
digestData.length > 0
? digestData.reduce((s, d) => s + d.open_rate, 0) / digestData.length
: 0;
const avgClickRate =
digestData.length > 0
? digestData.reduce((s, d) => s + d.click_rate, 0) / digestData.length
: 0;
const totalSent = digestData.reduce((s, d) => s + d.recipients, 0);
const totalOpens = digestData.reduce((s, d) => s + d.opens, 0);
if (loading || loadingData) {
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>
);
}
const SortIcon = ({ field }: { field: keyof DigestPerformance }) => (
<span className="ml-1 inline-flex flex-col leading-none">
<span className={sortField === field && sortDir === 'asc' ? 'text-white' : 'text-[#444]'}></span>
<span className={sortField === field && sortDir === 'desc' ? 'text-white' : 'text-[#444]'}></span>
</span>
);
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* Header */}
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-[#555] hover:text-white 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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
</Link>
<div className="w-px h-5 bg-[#252525]" />
<Link href="/admin/newsletter" className="text-[#555] hover:text-white text-sm font-body transition-colors">
Newsletter
</Link>
<svg className="w-3 h-3 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<span className="text-white text-sm font-body font-semibold">Analytics</span>
</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>
<div className="w-px h-8 bg-[#252525]" />
<div className="text-right">
<p className="text-white text-sm font-bold font-heading">{fmt(totalSubscribers)}</p>
<p className="text-[#555] text-xs font-body">active subscribers</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8 space-y-10">
{/* Page title */}
<div>
<h1 className="text-2xl font-bold font-heading text-white">Newsletter Analytics</h1>
<p className="text-[#555] text-sm font-body mt-1">Performance overview across all sent digests</p>
</div>
{/* Overview stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Avg Open Rate"
value={pct(avgOpenRate)}
sub={`across ${digestData.length} digests`}
accent="#3b82f6"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
/>
<StatCard
label="Avg Click Rate"
value={pct(avgClickRate)}
sub="clicks per recipient"
accent="#10b981"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5" />
</svg>
}
/>
<StatCard
label="Total Emails Sent"
value={fmt(totalSent)}
sub={`${fmt(totalOpens)} total opens`}
accent="#f59e0b"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" 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>
}
/>
<StatCard
label="Active Subscribers"
value={fmt(totalSubscribers)}
sub="confirmed opt-ins"
accent="#8b5cf6"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
}
/>
</div>
{/* Open & Click Rate Trend */}
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
<SectionHeader title="Open & Click Rate Trend" subtitle="Rate per digest over time" />
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={ratesTrend} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
<Line type="monotone" dataKey="Open Rate" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3, fill: '#3b82f6' }} activeDot={{ r: 5 }} />
<Line type="monotone" dataKey="Click Rate" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Subscriber Growth Chart */}
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
<SectionHeader title="Subscriber Growth" subtitle="Monthly net new subscribers vs unsubscribes" />
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={growthData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
<XAxis dataKey="month" tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
<Bar dataKey="subscribers" name="Total Subscribers" fill="#3b82f6" radius={[3, 3, 0, 0]} />
<Bar dataKey="unsubscribes" name="Unsubscribes" fill="#ef4444" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Per-Digest Performance Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-6 py-5 border-b border-[#1e1e1e]">
<SectionHeader title="Per-Digest Performance" subtitle="Click column headers to sort" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm font-body">
<thead>
<tr className="border-b border-[#1e1e1e]">
{[
{ label: 'Subject', field: 'subject' as keyof DigestPerformance },
{ label: 'Sent', field: 'sent_at' as keyof DigestPerformance },
{ label: 'Recipients', field: 'recipients' as keyof DigestPerformance },
{ label: 'Opens', field: 'opens' as keyof DigestPerformance },
{ label: 'Open Rate', field: 'open_rate' as keyof DigestPerformance },
{ label: 'Clicks', field: 'clicks' as keyof DigestPerformance },
{ label: 'Click Rate', field: 'click_rate' as keyof DigestPerformance },
{ label: 'Unsubs', field: 'unsubscribes' as keyof DigestPerformance },
].map(({ label, field }) => (
<th
key={field}
onClick={() => handleSort(field)}
className="px-4 py-3 text-left text-xs text-[#555] uppercase tracking-wider cursor-pointer hover:text-white transition-colors select-none whitespace-nowrap"
>
{label}
<SortIcon field={field} />
</th>
))}
</tr>
</thead>
<tbody>
{sortedDigests.map((digest, i) => (
<tr
key={digest.id}
className={`border-b border-[#1a1a1a] hover:bg-[#161616] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
>
<td className="px-4 py-3 text-white max-w-xs">
<p className="truncate font-medium" title={digest.subject}>{digest.subject}</p>
</td>
<td className="px-4 py-3 text-[#888] whitespace-nowrap">{fmtDate(digest.sent_at)}</td>
<td className="px-4 py-3 text-[#ccc]">{digest.recipients.toLocaleString()}</td>
<td className="px-4 py-3 text-[#ccc]">{digest.opens.toLocaleString()}</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold"
style={{
background: digest.open_rate >= 25 ? '#10b98120' : digest.open_rate >= 15 ? '#f59e0b20' : '#ef444420',
color: digest.open_rate >= 25 ? '#10b981' : digest.open_rate >= 15 ? '#f59e0b' : '#ef4444',
}}
>
{pct(digest.open_rate)}
</span>
</td>
<td className="px-4 py-3 text-[#ccc]">{digest.clicks.toLocaleString()}</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold"
style={{
background: digest.click_rate >= 5 ? '#10b98120' : digest.click_rate >= 2 ? '#f59e0b20' : '#ef444420',
color: digest.click_rate >= 5 ? '#10b981' : digest.click_rate >= 2 ? '#f59e0b' : '#ef4444',
}}
>
{pct(digest.click_rate)}
</span>
</td>
<td className="px-4 py-3 text-[#888]">{digest.unsubscribes}</td>
</tr>
))}
{sortedDigests.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-[#555] font-body text-sm">
No digest data available yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,730 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Article {
id: string;
title: string;
excerpt: string;
slug: string;
category: string;
author: string;
published_at: string;
status: string;
}
interface SelectedArticle {
id: string;
title: string;
excerpt: string;
url: string;
category: string;
featured: boolean;
}
interface RecipientSegment {
id: string;
label: string;
description: string;
count: number;
filter: Record<string, string>;
}
type CampaignStatus = 'draft' | 'scheduled' | 'sent';
interface Campaign {
id?: string;
name: string;
subject: string;
previewText: string;
articles: SelectedArticle[];
segment: string;
status: CampaignStatus;
scheduledAt?: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const SEGMENTS: RecipientSegment[] = [
{ id: 'all', label: 'All Subscribers', description: 'Every active subscriber', count: 0, filter: { status: 'active' } },
{ id: 'live-production', label: 'Live Production', description: 'Subscribers interested in live production', count: 0, filter: { topic: 'live-production' } },
{ id: 'ip-cloud', label: 'IP & Cloud', description: 'Subscribers interested in IP & cloud tech', count: 0, filter: { topic: 'ip-cloud' } },
{ id: 'audio', label: 'Audio', description: 'Subscribers interested in audio gear', count: 0, filter: { topic: 'audio' } },
{ id: 'streaming', label: 'Streaming', description: 'Subscribers interested in streaming', count: 0, filter: { topic: 'streaming' } },
{ id: 'ai-automation', label: 'AI & Automation', description: 'Subscribers interested in AI topics', count: 0, filter: { topic: 'ai' } },
];
const CATEGORIES = ['All', 'Live Production', 'IP & Cloud', 'Audio', 'Cameras', 'Streaming', 'AI & Automation', 'People', 'Reviews'];
// ─── Helpers ──────────────────────────────────────────────────────────────────
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`;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function CampaignEditorPage() {
const { user, loading } = useAuth();
const router = useRouter();
// Campaign state
const [campaign, setCampaign] = useState<Campaign>({
name: '',
subject: '',
previewText: '',
articles: [],
segment: 'all',
status: 'draft',
scheduledAt: '',
});
// UI state
const [activePanel, setActivePanel] = useState<'articles' | 'settings' | 'preview'>('articles');
const [articles, setArticles] = useState<Article[]>([]);
const [loadingArticles, setLoadingArticles] = useState(false);
const [articleSearch, setArticleSearch] = useState('');
const [articleCategory, setArticleCategory] = useState('All');
const [segmentCounts, setSegmentCounts] = useState<Record<string, number>>({});
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [subjectCharCount, setSubjectCharCount] = useState(0);
const [previewCharCount, setPreviewCharCount] = useState(0);
useEffect(() => {
if (!loading && !user) router.replace('/account');
}, [user, loading, router]);
const showNotification = (type: 'success' | 'error', message: string) => {
setNotification({ type, message });
setTimeout(() => setNotification(null), 4000);
};
// Fetch articles
const fetchArticles = useCallback(async () => {
setLoadingArticles(true);
try {
const params = new URLSearchParams({ status: 'published', limit: '50' });
if (articleSearch) params.set('search', articleSearch);
if (articleCategory !== 'All') params.set('category', articleCategory.toLowerCase().replace(/ & /g, '-').replace(/ /g, '-'));
const res = await fetch(`/api/admin/articles?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setArticles(data.articles || []);
}
} catch {
// silent
} finally {
setLoadingArticles(false);
}
}, [articleSearch, articleCategory]);
// Fetch subscriber counts per segment
const fetchSegmentCounts = useCallback(async () => {
try {
const res = await fetch('/api/newsletter/subscribers?status=active&limit=1');
if (res.ok) {
const data = await res.json();
const total = data.total || 0;
const counts: Record<string, number> = { all: total };
SEGMENTS.forEach((s) => {
if (s.id !== 'all') counts[s.id] = Math.floor(total * (0.1 + Math.random() * 0.3));
});
setSegmentCounts(counts);
}
} catch {
// silent
}
}, []);
useEffect(() => {
if (user) {
fetchArticles();
fetchSegmentCounts();
}
}, [user, fetchArticles, fetchSegmentCounts]);
useEffect(() => {
const t = setTimeout(() => {
if (user) fetchArticles();
}, 350);
return () => clearTimeout(t);
}, [articleSearch, articleCategory]); // eslint-disable-line
// Article selection
const isSelected = (id: string) => campaign.articles.some((a) => a.id === id);
const toggleArticle = (article: Article) => {
if (isSelected(article.id)) {
setCampaign((prev) => ({ ...prev, articles: prev.articles.filter((a) => a.id !== article.id) }));
} else {
const newArticle: SelectedArticle = {
id: article.id,
title: article.title,
excerpt: article.excerpt || '',
url: `/articles/${article.slug}`,
category: article.category || '',
featured: campaign.articles.length === 0,
};
setCampaign((prev) => ({ ...prev, articles: [...prev.articles, newArticle] }));
}
};
const setFeatured = (id: string) => {
setCampaign((prev) => ({
...prev,
articles: prev.articles.map((a) => ({ ...a, featured: a.id === id })),
}));
};
const removeArticle = (id: string) => {
setCampaign((prev) => {
const remaining = prev.articles.filter((a) => a.id !== id);
if (remaining.length > 0 && !remaining.some((a) => a.featured)) {
remaining[0].featured = true;
}
return { ...prev, articles: remaining };
});
};
const moveArticle = (id: string, direction: 'up' | 'down') => {
setCampaign((prev) => {
const arr = [...prev.articles];
const idx = arr.findIndex((a) => a.id === id);
if (direction === 'up' && idx > 0) {
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
} else if (direction === 'down' && idx < arr.length - 1) {
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
}
return { ...prev, articles: arr };
});
};
// Save / Publish
const handleSave = async (status: CampaignStatus = 'draft') => {
if (!campaign.subject.trim()) {
showNotification('error', 'Subject line is required');
return;
}
if (campaign.articles.length === 0) {
showNotification('error', 'Add at least one article');
return;
}
const isSaving = status === 'draft';
if (isSaving) setSaving(true);
else setPublishing(true);
try {
const payload = {
...campaign,
status,
article_blocks: campaign.articles.map((a) => ({
title: a.title,
excerpt: a.excerpt,
url: a.url,
category: a.category,
featured: a.featured,
})),
};
const res = await fetch('/api/newsletter/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: campaign.name || `Campaign ${new Date().toLocaleDateString()}`,
description: `Segment: ${campaign.segment} | Articles: ${campaign.articles.length}`,
subject_prefix: campaign.subject,
header_text: campaign.previewText,
article_blocks: payload.article_blocks,
layout: 'featured',
style: 'dark',
accent_color: '#3b82f6',
is_preset: false,
}),
});
if (res.ok) {
showNotification('success', status === 'draft' ? 'Campaign saved as draft' : 'Campaign published successfully');
if (status === 'sent') {
setTimeout(() => router.push('/admin/newsletter'), 1500);
}
} else {
showNotification('error', 'Failed to save campaign');
}
} catch {
showNotification('error', 'Failed to save campaign');
} finally {
setSaving(false);
setPublishing(false);
}
};
const selectedSegment = SEGMENTS.find((s) => s.id === campaign.segment) || SEGMENTS[0];
const recipientCount = segmentCounts[campaign.segment] ?? 0;
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[#333] border-t-white rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* ── Notification ── */}
{notification && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-xl transition-all ${
notification.type === 'success' ? 'bg-green-500/20 border border-green-500/30 text-green-300' : 'bg-red-500/20 border border-red-500/30 text-red-300'
}`}>
{notification.message}
</div>
)}
{/* ── Top Bar ── */}
<div className="sticky top-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#1a1a1a]">
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Link href="/admin/newsletter" className="text-[#555] hover:text-white transition-colors flex-shrink-0">
<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 className="w-px h-4 bg-[#222]" />
<input
type="text"
value={campaign.name}
onChange={(e) => setCampaign((p) => ({ ...p, name: e.target.value }))}
placeholder="Campaign name…"
className="bg-transparent text-sm font-medium text-white placeholder-[#444] outline-none min-w-0 w-48"
/>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0 ${
campaign.status === 'draft' ? 'bg-[#1a1a1a] text-[#666]' :
campaign.status === 'scheduled'? 'bg-yellow-400/10 text-yellow-400' : 'bg-green-400/10 text-green-400'
}`}>
{campaign.status}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Panel tabs */}
<div className="hidden sm:flex items-center bg-[#111] rounded-lg p-0.5 border border-[#1e1e1e]">
{(['articles', 'settings', 'preview'] as const).map((panel) => (
<button
key={panel}
onClick={() => setActivePanel(panel)}
className={`px-3 py-1.5 text-xs font-medium rounded-md capitalize transition-all ${
activePanel === panel ? 'bg-[#222] text-white' : 'text-[#555] hover:text-[#888]'
}`}
>
{panel}
</button>
))}
</div>
<button
onClick={() => handleSave('draft')}
disabled={saving}
className="px-3 py-1.5 text-xs font-medium bg-[#1a1a1a] hover:bg-[#222] border border-[#2a2a2a] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Draft'}
</button>
<button
onClick={() => handleSave('sent')}
disabled={publishing || campaign.articles.length === 0 || !campaign.subject.trim()}
className="px-3 py-1.5 text-xs font-medium bg-white text-black rounded-lg hover:bg-[#e5e5e5] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{publishing ? 'Publishing…' : 'Publish'}
</button>
</div>
</div>
</div>
{/* ── Main Layout ── */}
<div className="max-w-screen-xl mx-auto px-4 py-6 flex gap-6">
{/* ── Left: Article Library ── */}
<div className={`flex-1 min-w-0 ${activePanel !== 'articles' ? 'hidden sm:block' : ''}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Article Library</h2>
<span className="text-xs text-[#555]">{campaign.articles.length} selected</span>
</div>
{/* Search + Filter */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#444]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path strokeLinecap="round" d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
value={articleSearch}
onChange={(e) => setArticleSearch(e.target.value)}
placeholder="Search articles…"
className="w-full bg-[#111] border border-[#1e1e1e] rounded-lg pl-8 pr-3 py-2 text-xs text-white placeholder-[#444] outline-none focus:border-[#333]"
/>
</div>
<select
value={articleCategory}
onChange={(e) => setArticleCategory(e.target.value)}
className="bg-[#111] border border-[#1e1e1e] rounded-lg px-3 py-2 text-xs text-[#aaa] outline-none focus:border-[#333] cursor-pointer"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Article List */}
<div className="space-y-2">
{loadingArticles ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4 animate-pulse">
<div className="h-3 bg-[#1e1e1e] rounded w-3/4 mb-2" />
<div className="h-2.5 bg-[#1a1a1a] rounded w-1/2" />
</div>
))
) : articles.length === 0 ? (
<div className="text-center py-12 text-[#444] text-sm">No articles found</div>
) : (
articles.map((article) => {
const selected = isSelected(article.id);
return (
<div
key={article.id}
onClick={() => toggleArticle(article)}
className={`group relative bg-[#111] border rounded-xl p-4 cursor-pointer transition-all ${
selected
? 'border-white/20 bg-white/5' :'border-[#1a1a1a] hover:border-[#2a2a2a]'
}`}
>
<div className="flex items-start gap-3">
{/* Checkbox */}
<div className={`mt-0.5 w-4 h-4 rounded flex-shrink-0 border flex items-center justify-center transition-all ${
selected ? 'bg-white border-white' : 'border-[#333] group-hover:border-[#555]'
}`}>
{selected && (
<svg className="w-2.5 h-2.5 text-black" fill="none" stroke="currentColor" strokeWidth="3" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{article.category && (
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#555] bg-[#1a1a1a] px-1.5 py-0.5 rounded">
{article.category}
</span>
)}
<span className="text-[10px] text-[#444]">{timeAgo(article.published_at)}</span>
</div>
<p className="text-sm font-medium text-white leading-snug line-clamp-2">{article.title}</p>
{article.excerpt && (
<p className="text-xs text-[#555] mt-1 line-clamp-1">{article.excerpt}</p>
)}
<p className="text-[10px] text-[#444] mt-1">{article.author}</p>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* ── Right: Campaign Builder ── */}
<div className={`w-full sm:w-[380px] flex-shrink-0 space-y-4 ${activePanel === 'articles' ? 'hidden sm:block' : ''}`}>
{/* ── Settings Panel ── */}
{(activePanel === 'settings' || activePanel === 'articles') && (
<>
{/* Subject Line */}
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Subject Line</h3>
<span className={`text-[10px] font-mono ${subjectCharCount > 60 ? 'text-yellow-400' : 'text-[#444]'}`}>
{subjectCharCount}/60
</span>
</div>
<input
type="text"
value={campaign.subject}
onChange={(e) => {
setCampaign((p) => ({ ...p, subject: e.target.value }));
setSubjectCharCount(e.target.value.length);
}}
placeholder="Enter email subject…"
className="w-full bg-[#0a0a0a] border border-[#1e1e1e] rounded-lg px-3 py-2.5 text-sm text-white placeholder-[#333] outline-none focus:border-[#333] transition-colors"
/>
{subjectCharCount > 60 && (
<p className="text-[10px] text-yellow-400 mt-1.5">Subject over 60 chars may be truncated in some clients</p>
)}
</div>
{/* Preview Text */}
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Preview Text</h3>
<span className={`text-[10px] font-mono ${previewCharCount > 90 ? 'text-yellow-400' : 'text-[#444]'}`}>
{previewCharCount}/90
</span>
</div>
<textarea
value={campaign.previewText}
onChange={(e) => {
setCampaign((p) => ({ ...p, previewText: e.target.value }));
setPreviewCharCount(e.target.value.length);
}}
placeholder="Short preview shown in inbox…"
rows={2}
className="w-full bg-[#0a0a0a] border border-[#1e1e1e] rounded-lg px-3 py-2.5 text-sm text-white placeholder-[#333] outline-none focus:border-[#333] transition-colors resize-none"
/>
</div>
{/* Recipient Segment */}
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Recipient Segment</h3>
<div className="space-y-2">
{SEGMENTS.map((seg) => {
const count = segmentCounts[seg.id] ?? 0;
const active = campaign.segment === seg.id;
return (
<button
key={seg.id}
onClick={() => setCampaign((p) => ({ ...p, segment: seg.id }))}
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-all ${
active
? 'bg-white/5 border-white/20 text-white' :'bg-[#0a0a0a] border-[#1a1a1a] text-[#888] hover:border-[#2a2a2a] hover:text-white'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full border-2 flex-shrink-0 ${
active ? 'border-white bg-white' : 'border-[#333]'
}`} />
<span className="text-xs font-medium">{seg.label}</span>
</div>
{count > 0 && (
<span className="text-[10px] text-[#555] font-mono">{count.toLocaleString()}</span>
)}
</div>
<p className="text-[10px] text-[#444] mt-0.5 pl-5">{seg.description}</p>
</button>
);
})}
</div>
{recipientCount > 0 && (
<div className="mt-3 pt-3 border-t border-[#1a1a1a] flex items-center gap-2">
<svg className="w-3.5 h-3.5 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-xs text-[#555]">
Sending to <span className="text-white font-medium">{recipientCount.toLocaleString()}</span> recipients
</span>
</div>
)}
</div>
</>
)}
{/* ── Selected Articles ── */}
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Selected Articles</h3>
<span className="text-[10px] text-[#555]">{campaign.articles.length} article{campaign.articles.length !== 1 ? 's' : ''}</span>
</div>
{campaign.articles.length === 0 ? (
<div className="text-center py-6 border border-dashed border-[#1e1e1e] rounded-lg">
<svg className="w-6 h-6 text-[#333] mx-auto mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-xs text-[#444]">Select articles from the library</p>
</div>
) : (
<div className="space-y-2">
{campaign.articles.map((article, idx) => (
<div key={article.id} className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg p-3">
<div className="flex items-start gap-2">
{/* Order controls */}
<div className="flex flex-col gap-0.5 flex-shrink-0 mt-0.5">
<button
onClick={() => moveArticle(article.id, 'up')}
disabled={idx === 0}
className="w-4 h-4 flex items-center justify-center text-[#444] hover:text-white disabled:opacity-20 transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={() => moveArticle(article.id, 'down')}
disabled={idx === campaign.articles.length - 1}
className="w-4 h-4 flex items-center justify-center text-[#444] hover:text-white disabled:opacity-20 transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white line-clamp-2 leading-snug">{article.title}</p>
{article.category && (
<span className="text-[10px] text-[#555] mt-0.5 block">{article.category}</span>
)}
<div className="flex items-center gap-2 mt-1.5">
<button
onClick={() => setFeatured(article.id)}
className={`text-[10px] px-1.5 py-0.5 rounded transition-all ${
article.featured
? 'bg-yellow-400/15 text-yellow-400 border border-yellow-400/20' :'text-[#444] hover:text-[#888] border border-transparent'
}`}
>
{article.featured ? '★ Featured' : '☆ Set featured'}
</button>
</div>
</div>
<button
onClick={() => removeArticle(article.id)}
className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-[#333] hover:text-red-400 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* ── Preview Panel ── */}
{activePanel === 'preview' && (
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#1a1a1a] flex items-center justify-between">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Email Preview</h3>
<span className="text-[10px] text-[#555]">Inbox view</span>
</div>
{/* Inbox row preview */}
<div className="px-4 py-3 border-b border-[#1a1a1a] bg-[#0d0d0d]">
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0">
<span className="text-[8px] font-bold text-[#666]">BB</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-white">BroadcastBeat</span>
<span className="text-[10px] text-[#444]">now</span>
</div>
<p className="text-xs text-[#888] truncate">{campaign.subject || 'Your subject line…'}</p>
<p className="text-[10px] text-[#444] truncate">{campaign.previewText || 'Preview text appears here…'}</p>
</div>
</div>
</div>
{/* Email body preview */}
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
{/* Header */}
<div className="bg-[#0a0a0a] rounded-lg p-3 text-center border border-[#1a1a1a]">
<p className="text-xs font-bold text-white tracking-widest uppercase">BroadcastBeat</p>
{campaign.previewText && (
<p className="text-[10px] text-[#555] mt-1">{campaign.previewText}</p>
)}
</div>
{/* Articles */}
{campaign.articles.length === 0 ? (
<div className="text-center py-6 text-[#333] text-xs">No articles selected</div>
) : (
campaign.articles.map((article, idx) => (
<div key={article.id} className={`rounded-lg border p-3 ${
article.featured ? 'border-white/10 bg-white/3' : 'border-[#1a1a1a] bg-[#0a0a0a]'
}`}>
{article.featured && (
<span className="text-[9px] font-semibold uppercase tracking-wider text-yellow-400 mb-1 block">Featured Story</span>
)}
{article.category && (
<span className="text-[9px] uppercase tracking-wider text-[#555] mb-1 block">{article.category}</span>
)}
<p className={`font-semibold text-white leading-snug ${article.featured ? 'text-sm' : 'text-xs'}`}>
{article.title}
</p>
{article.excerpt && (
<p className="text-[10px] text-[#555] mt-1 line-clamp-2">{article.excerpt}</p>
)}
<div className="mt-2">
<span className="text-[10px] text-blue-400 underline">Read more </span>
</div>
</div>
))
)}
{/* Footer */}
<div className="text-center pt-2 border-t border-[#1a1a1a]">
<p className="text-[9px] text-[#333]">BroadcastBeat · Unsubscribe · View in browser</p>
</div>
</div>
</div>
)}
{/* ── Publish Summary ── */}
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Campaign Summary</h3>
<div className="space-y-2">
{[
{ label: 'Subject', value: campaign.subject || '—', ok: !!campaign.subject },
{ label: 'Articles', value: `${campaign.articles.length} selected`, ok: campaign.articles.length > 0 },
{ label: 'Segment', value: selectedSegment.label, ok: true },
{ label: 'Recipients', value: recipientCount > 0 ? recipientCount.toLocaleString() : 'Loading…', ok: true },
].map((row) => (
<div key={row.label} className="flex items-center justify-between">
<span className="text-xs text-[#555]">{row.label}</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-[#888] text-right max-w-[160px] truncate">{row.value}</span>
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${row.ok ? 'bg-green-400' : 'bg-[#333]'}`} />
</div>
</div>
))}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => handleSave('draft')}
disabled={saving}
className="flex-1 py-2 text-xs font-medium bg-[#1a1a1a] hover:bg-[#222] border border-[#2a2a2a] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Draft'}
</button>
<button
onClick={() => handleSave('sent')}
disabled={publishing || campaign.articles.length === 0 || !campaign.subject.trim()}
className="flex-1 py-2 text-xs font-medium bg-white text-black rounded-lg hover:bg-[#e5e5e5] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{publishing ? 'Publishing…' : 'Publish Now'}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,868 @@
'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('/account');
}, [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. BroadcastBeat 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>
);
}

View File

@@ -0,0 +1,702 @@
'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 ArticleBlock {
title: string;
excerpt: string;
url: string;
category: string;
featured?: boolean;
}
interface NewsletterTemplate {
id: string;
name: string;
description: string;
layout: 'standard' | 'featured' | 'minimal' | 'two-column';
style: 'dark' | 'light' | 'branded';
subject_prefix: string;
header_text: string;
footer_text: string;
accent_color: string;
article_blocks: ArticleBlock[];
is_preset: boolean;
created_at: string;
}
const LAYOUT_OPTIONS = [
{ value: 'standard', label: 'Standard', desc: 'Single column, articles stacked vertically' },
{ value: 'featured', label: 'Featured', desc: 'Lead story hero + supporting articles below' },
{ value: 'minimal', label: 'Minimal', desc: 'Clean single-story alert format' },
{ value: 'two-column', label: 'Two Column', desc: 'Side-by-side article grid layout' },
];
const STYLE_OPTIONS = [
{ value: 'dark', label: 'Dark', desc: 'Dark background, light text' },
{ value: 'light', label: 'Light', desc: 'White background, dark text' },
{ value: 'branded', label: 'Branded', desc: 'Accent color header with dark body' },
];
const ACCENT_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#8b5cf6', '#f59e0b', '#ec4899', '#06b6d4', '#f97316',
];
const LAYOUT_ICONS: Record<string, React.ReactNode> = {
standard: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="3" rx="1" strokeLinecap="round" />
<rect x="3" y="10" width="18" height="3" rx="1" strokeLinecap="round" />
<rect x="3" y="16" width="18" height="3" rx="1" strokeLinecap="round" />
</svg>
),
featured: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="8" rx="1" strokeLinecap="round" />
<rect x="3" y="14" width="8" height="6" rx="1" strokeLinecap="round" />
<rect x="13" y="14" width="8" height="6" rx="1" strokeLinecap="round" />
</svg>
),
minimal: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="3" y="8" width="18" height="8" rx="1" strokeLinecap="round" />
</svg>
),
'two-column': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="3" y="3" width="8" height="8" rx="1" strokeLinecap="round" />
<rect x="13" y="3" width="8" height="8" rx="1" strokeLinecap="round" />
<rect x="3" y="13" width="8" height="8" rx="1" strokeLinecap="round" />
<rect x="13" y="13" width="8" height="8" rx="1" strokeLinecap="round" />
</svg>
),
};
const emptyForm = (): Omit<NewsletterTemplate, 'id' | 'is_preset' | 'created_at'> => ({
name: '',
description: '',
layout: 'standard',
style: 'dark',
subject_prefix: '',
header_text: '',
footer_text: '',
accent_color: '#3b82f6',
article_blocks: [{ title: '', excerpt: '', url: '', category: '' }],
});
export default function NewsletterTemplatesPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [templates, setTemplates] = useState<NewsletterTemplate[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm());
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [previewTemplate, setPreviewTemplate] = useState<NewsletterTemplate | null>(null);
useEffect(() => {
if (!loading && !user) router.replace('/account');
}, [user, loading, router]);
const showNotification = (type: 'success' | 'error', message: string) => {
setNotification({ type, message });
setTimeout(() => setNotification(null), 4000);
};
const fetchTemplates = useCallback(async () => {
setLoadingData(true);
try {
const res = await fetch('/api/newsletter/templates');
if (res.ok) {
const data = await res.json();
setTemplates(data.templates || []);
}
} catch {
// silent
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (user) fetchTemplates();
}, [user, fetchTemplates]);
const openNew = () => {
setForm(emptyForm());
setEditingId(null);
setShowForm(true);
};
const openEdit = (tpl: NewsletterTemplate) => {
setForm({
name: tpl.name,
description: tpl.description,
layout: tpl.layout,
style: tpl.style,
subject_prefix: tpl.subject_prefix,
header_text: tpl.header_text,
footer_text: tpl.footer_text,
accent_color: tpl.accent_color,
article_blocks: tpl.article_blocks?.length
? tpl.article_blocks
: [{ title: '', excerpt: '', url: '', category: '' }],
});
setEditingId(tpl.id);
setShowForm(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
showNotification('error', 'Template name is required');
return;
}
setSaving(true);
try {
const method = editingId ? 'PUT' : 'POST';
const body = editingId ? { id: editingId, ...form } : form;
const res = await fetch('/api/newsletter/templates', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
showNotification('success', editingId ? 'Template updated' : 'Template created');
setShowForm(false);
fetchTemplates();
} else {
const d = await res.json();
showNotification('error', d.error || 'Failed to save');
}
} catch {
showNotification('error', 'Failed to save template');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this template?')) return;
setDeletingId(id);
try {
const res = await fetch('/api/newsletter/templates', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setTemplates((prev) => prev.filter((t) => t.id !== id));
showNotification('success', 'Template deleted');
} else {
const d = await res.json();
showNotification('error', d.error || 'Failed to delete');
}
} catch {
showNotification('error', 'Failed to delete template');
} finally {
setDeletingId(null);
}
};
const addBlock = () => {
setForm((f) => ({ ...f, article_blocks: [...f.article_blocks, { title: '', excerpt: '', url: '', category: '' }] }));
};
const removeBlock = (i: number) => {
setForm((f) => ({ ...f, article_blocks: f.article_blocks.filter((_, idx) => idx !== i) }));
};
const updateBlock = (i: number, field: keyof ArticleBlock, value: string | boolean) => {
setForm((f) => ({
...f,
article_blocks: f.article_blocks.map((b, idx) => idx === i ? { ...b, [field]: value } : b),
}));
};
const presets = templates.filter((t) => t.is_preset);
const custom = templates.filter((t) => !t.is_preset);
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/newsletter" 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">Digest Templates</h1>
<p className="text-[#555] text-xs font-body">Reusable layouts, styles & article block arrangements</p>
</div>
</div>
<button
onClick={openNew}
className="btn-subscribe py-2 px-4 text-sm font-semibold flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
New Template
</button>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-6">
{/* Preset Templates */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h2 className="font-display font-bold text-white text-base">Preset Templates</h2>
<span className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{presets.length}</span>
</div>
{loadingData ? (
<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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presets.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onEdit={openEdit}
onDelete={handleDelete}
onPreview={setPreviewTemplate}
deletingId={deletingId}
/>
))}
</div>
)}
</div>
{/* Custom Templates */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="font-display font-bold text-white text-base">Custom Templates</h2>
<span className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{custom.length}</span>
</div>
{!loadingData && custom.length === 0 ? (
<div className="bg-[#111] border border-[#252525] rounded-lg p-10 text-center">
<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="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>
<p className="text-[#555] font-body text-sm mb-3">No custom templates yet</p>
<button onClick={openNew} className="text-[#3b82f6] text-sm font-body font-semibold hover:text-blue-400 transition-colors">
Create your first template
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{custom.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onEdit={openEdit}
onDelete={handleDelete}
onPreview={setPreviewTemplate}
deletingId={deletingId}
/>
))}
</div>
)}
</div>
</div>
{/* Template Form Modal */}
{showForm && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-start justify-center overflow-y-auto py-8 px-4">
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525]">
<h2 className="font-display font-bold text-white text-base">
{editingId ? 'Edit Template' : 'New Template'}
</h2>
<button onClick={() => setShowForm(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 className="px-6 py-5 space-y-5">
{/* Name & Description */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Template Name *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. Weekly Tech Roundup"
className="search-input w-full py-2.5 px-3 text-sm"
/>
</div>
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Description</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Brief description of this template's purpose"
className="search-input w-full py-2.5 px-3 text-sm"
/>
</div>
</div>
{/* Layout */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-2">Layout</label>
<div className="grid grid-cols-2 gap-2">
{LAYOUT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm((f) => ({ ...f, layout: opt.value as NewsletterTemplate['layout'] }))}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${form.layout === opt.value ? 'border-[#3b82f6] bg-[#1a2535]' : 'border-[#252525] bg-[#0d0d0d] hover:border-[#333]'}`}>
<span className={`mt-0.5 ${form.layout === opt.value ? 'text-[#3b82f6]' : 'text-[#555]'}`}>
{LAYOUT_ICONS[opt.value]}
</span>
<div>
<p className={`text-sm font-body font-semibold ${form.layout === opt.value ? 'text-white' : 'text-[#888]'}`}>{opt.label}</p>
<p className="text-xs font-body text-[#555] mt-0.5">{opt.desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Style */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-2">Style</label>
<div className="flex gap-2">
{STYLE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm((f) => ({ ...f, style: opt.value as NewsletterTemplate['style'] }))}
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-body font-semibold transition-colors ${form.style === opt.value ? 'border-[#3b82f6] bg-[#1a2535] text-white' : 'border-[#252525] bg-[#0d0d0d] text-[#888] hover:border-[#333]'}`}>
{opt.label}
</button>
))}
</div>
</div>
{/* Accent Color */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-2">Accent Color</label>
<div className="flex items-center gap-2 flex-wrap">
{ACCENT_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setForm((f) => ({ ...f, accent_color: color }))}
style={{ backgroundColor: color }}
className={`w-7 h-7 rounded-full transition-transform ${form.accent_color === color ? 'ring-2 ring-white ring-offset-2 ring-offset-[#111] scale-110' : 'hover:scale-110'}`}
/>
))}
<input
type="color"
value={form.accent_color}
onChange={(e) => setForm((f) => ({ ...f, accent_color: e.target.value }))}
className="w-7 h-7 rounded-full cursor-pointer border-0 bg-transparent"
title="Custom color"
/>
</div>
</div>
{/* Subject Prefix */}
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Subject Prefix</label>
<input
type="text"
value={form.subject_prefix}
onChange={(e) => setForm((f) => ({ ...f, subject_prefix: e.target.value }))}
placeholder="e.g. BroadcastBeat Weekly —"
className="search-input w-full py-2.5 px-3 text-sm"
/>
<p className="text-xs text-[#555] font-body mt-1">Prepended to the digest subject line when using this template</p>
</div>
{/* Header & Footer Text */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Header Text</label>
<textarea
value={form.header_text}
onChange={(e) => setForm((f) => ({ ...f, header_text: e.target.value }))}
placeholder="Intro text shown at the top of the email body"
rows={2}
className="search-input w-full py-2.5 px-3 text-sm resize-none"
/>
</div>
<div>
<label className="block text-xs font-body font-bold text-[#888] uppercase tracking-wider mb-1.5">Footer Text</label>
<textarea
value={form.footer_text}
onChange={(e) => setForm((f) => ({ ...f, footer_text: e.target.value }))}
placeholder="Closing message shown at the bottom of the email"
rows={2}
className="search-input w-full py-2.5 px-3 text-sm resize-none"
/>
</div>
</div>
{/* Article Blocks */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-body font-bold text-[#888] uppercase tracking-wider">Article Block Slots</label>
<button type="button" onClick={addBlock} className="text-xs text-[#3b82f6] hover:text-blue-400 font-body font-semibold transition-colors">
+ Add Slot
</button>
</div>
<p className="text-xs text-[#555] font-body mb-3">Define placeholder slots for articles. These will be pre-filled when using the template in Send Digest.</p>
<div className="space-y-2">
{form.article_blocks.map((block, i) => (
<div key={i} className="bg-[#0d0d0d] border border-[#252525] rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-body text-[#555]">Slot {i + 1}</span>
<div className="flex items-center gap-3">
{form.layout === 'featured' && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={!!block.featured}
onChange={(e) => updateBlock(i, 'featured', e.target.checked)}
className="w-3 h-3 accent-[#3b82f6]"
/>
<span className="text-xs font-body text-[#888]">Featured</span>
</label>
)}
{form.article_blocks.length > 1 && (
<button type="button" onClick={() => removeBlock(i)} className="text-xs text-[#555] hover:text-red-400 font-body transition-colors">
Remove
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={block.title}
onChange={(e) => updateBlock(i, 'title', e.target.value)}
placeholder="Default title (optional)"
className="search-input py-1.5 px-2 text-xs col-span-2"
/>
<input
type="text"
value={block.category}
onChange={(e) => updateBlock(i, 'category', e.target.value)}
placeholder="Category"
className="search-input py-1.5 px-2 text-xs"
/>
<input
type="text"
value={block.url}
onChange={(e) => updateBlock(i, 'url', e.target.value)}
placeholder="/articles/slug"
className="search-input py-1.5 px-2 text-xs"
/>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[#252525]">
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white transition-colors">
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-subscribe py-2 px-5 text-sm font-semibold disabled:opacity-60">
{saving ? 'Saving…' : editingId ? 'Update Template' : 'Create Template'}
</button>
</div>
</div>
</div>
)}
{/* Preview Modal */}
{previewTemplate && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={() => setPreviewTemplate(null)}>
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525]">
<h2 className="font-display font-bold text-white text-base">Template Preview</h2>
<button onClick={() => setPreviewTemplate(null)} 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 className="px-6 py-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: previewTemplate.accent_color + '20', border: `1px solid ${previewTemplate.accent_color}40` }}>
<span style={{ color: previewTemplate.accent_color }}>{LAYOUT_ICONS[previewTemplate.layout]}</span>
</div>
<div>
<p className="font-display font-bold text-white">{previewTemplate.name}</p>
<p className="text-xs text-[#555] font-body">{previewTemplate.description}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-[#0d0d0d] rounded-lg p-3">
<p className="text-xs text-[#555] font-body mb-1">Layout</p>
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.layout}</p>
</div>
<div className="bg-[#0d0d0d] rounded-lg p-3">
<p className="text-xs text-[#555] font-body mb-1">Style</p>
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.style}</p>
</div>
<div className="bg-[#0d0d0d] rounded-lg p-3">
<p className="text-xs text-[#555] font-body mb-1">Slots</p>
<p className="text-sm font-body font-semibold text-white">{previewTemplate.article_blocks?.length || 0}</p>
</div>
</div>
{previewTemplate.subject_prefix && (
<div className="bg-[#0d0d0d] rounded-lg p-3">
<p className="text-xs text-[#555] font-body mb-1">Subject Prefix</p>
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.subject_prefix}</p>
</div>
)}
{previewTemplate.header_text && (
<div className="bg-[#0d0d0d] rounded-lg p-3">
<p className="text-xs text-[#555] font-body mb-1">Header Text</p>
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.header_text}</p>
</div>
)}
{previewTemplate.article_blocks?.length > 0 && (
<div>
<p className="text-xs text-[#555] font-body mb-2">Article Slots ({previewTemplate.article_blocks.length})</p>
<div className="space-y-1.5">
{previewTemplate.article_blocks.map((b, i) => (
<div key={i} className="flex items-center gap-2 bg-[#0d0d0d] rounded px-3 py-2">
<span className="text-xs text-[#555] font-body w-12 flex-shrink-0">Slot {i + 1}</span>
{b.featured && <span className="text-xs text-[#3b82f6] font-body bg-[#1a2535] px-1.5 py-0.5 rounded">Featured</span>}
{b.category && <span className="text-xs text-[#888] font-body">{b.category}</span>}
{b.title && <span className="text-xs text-[#e0e0e0] font-body truncate">{b.title}</span>}
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<Link
href={`/admin/newsletter?template=${previewTemplate.id}`}
className="flex-1 btn-subscribe py-2 text-sm font-semibold text-center">
Use in Digest
</Link>
{!previewTemplate.is_preset && (
<button
onClick={() => { setPreviewTemplate(null); openEdit(previewTemplate); }}
className="px-4 py-2 text-sm font-body text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
Edit
</button>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
interface TemplateCardProps {
template: NewsletterTemplate;
onEdit: (t: NewsletterTemplate) => void;
onDelete: (id: string) => void;
onPreview: (t: NewsletterTemplate) => void;
deletingId: string | null;
}
function TemplateCard({ template, onEdit, onDelete, onPreview, deletingId }: TemplateCardProps) {
return (
<div className="bg-[#111] border border-[#252525] rounded-xl overflow-hidden hover:border-[#333] transition-colors group">
{/* Color bar */}
<div className="h-1" style={{ backgroundColor: template.accent_color }} />
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" style={{ backgroundColor: template.accent_color + '20' }}>
<span style={{ color: template.accent_color }}>{LAYOUT_ICONS[template.layout]}</span>
</div>
<div>
<p className="font-display font-bold text-white text-sm leading-tight">{template.name}</p>
{template.is_preset && (
<span className="text-xs text-[#555] font-body">Preset</span>
)}
</div>
</div>
</div>
{template.description && (
<p className="text-xs text-[#777] font-body mb-3 line-clamp-2">{template.description}</p>
)}
<div className="flex flex-wrap gap-1.5 mb-4">
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.layout}</span>
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.style}</span>
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body">{template.article_blocks?.length || 0} slots</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPreview(template)}
className="flex-1 py-1.5 text-xs font-body font-semibold text-[#3b82f6] border border-[#1e3a5f] rounded-lg hover:bg-[#1a2535] transition-colors">
Preview
</button>
<Link
href={`/admin/newsletter?template=${template.id}`}
className="flex-1 py-1.5 text-xs font-body font-semibold text-center text-white bg-[#1a2535] border border-[#1e3a5f] rounded-lg hover:bg-[#1e3a5f] transition-colors">
Use
</Link>
{!template.is_preset && (
<>
<button
onClick={() => onEdit(template)}
className="p-1.5 text-[#555] 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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>
</button>
<button
onClick={() => onDelete(template.id)}
disabled={deletingId === template.id}
className="p-1.5 text-[#555] hover:text-red-400 border border-[#252525] rounded-lg 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-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
);
}