initial commit: rocket.new export of broadcastbeat

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

View File

@@ -0,0 +1,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>
);
}