- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
731 lines
34 KiB
TypeScript
731 lines
34 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import Link from 'next/link';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
// ─── 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('/login?next=' + encodeURIComponent(window.location.pathname));
|
||
}, [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: '#1D4ED8',
|
||
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-[#FFFFFF]">
|
||
<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-[#FFFFFF] 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-[#FFFFFF] hover:bg-[#222] border border-[#DCE6F2] 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-[#FFFFFF] rounded-xl p-4 animate-pulse">
|
||
<div className="h-3 bg-[#1e1e1e] rounded w-3/4 mb-2" />
|
||
<div className="h-2.5 bg-[#FFFFFF] 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-[#FFFFFF] hover:border-[#DCE6F2]'
|
||
}`}
|
||
>
|
||
<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-[#FFFFFF] 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-[#FFFFFF] 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-[#FFFFFF] 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-[#FFFFFF] 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-[#FFFFFF] text-[#888] hover:border-[#DCE6F2] 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-[#FFFFFF] 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-[#FFFFFF] 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-[#FFFFFF] 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-[#FFFFFF] rounded-xl overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-[#FFFFFF] 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-[#FFFFFF] bg-[#F8FAFC]">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] 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">AV Beat</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-[#FFFFFF]">
|
||
<p className="text-xs font-bold text-white tracking-widest uppercase">AV Beat</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-[#FFFFFF] 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-[#FFFFFF]">
|
||
<p className="text-[9px] text-[#333]">AV Beat · Unsubscribe · View in browser</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Publish Summary ── */}
|
||
<div className="bg-[#111] border border-[#FFFFFF] 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-[#FFFFFF] hover:bg-[#222] border border-[#DCE6F2] 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>
|
||
);
|
||
}
|