initial commit: rocket.new export of broadcastbeat
This commit is contained in:
473
src/app/admin/newsletter/analytics/page.tsx
Normal file
473
src/app/admin/newsletter/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
730
src/app/admin/newsletter/campaign-editor/page.tsx
Normal file
730
src/app/admin/newsletter/campaign-editor/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
868
src/app/admin/newsletter/page.tsx
Normal file
868
src/app/admin/newsletter/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
702
src/app/admin/newsletter/templates/page.tsx
Normal file
702
src/app/admin/newsletter/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user