initial commit: rocket.new export of broadcastbeat
This commit is contained in:
226
src/app/admin/email/domains/page.tsx
Normal file
226
src/app/admin/email/domains/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
|
||||
|
||||
function DnsRow({ label, valid, checkedAt }: { label: string; valid: boolean; checkedAt: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#888]">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-medium ${valid ? 'text-green-400' : 'text-red-400'}`}>{valid ? '✓ Pass' : '✗ Fail'}</span>
|
||||
{checkedAt && <span className="text-xs text-[#555]">{new Date(checkedAt).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainsContent() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [domains, setDomains] = useState<any[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [filterSite, setFilterSite] = useState(sp.get('site') ?? '');
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [verifying, setVerifying] = useState<string | null>(null);
|
||||
const [showBypass, setShowBypass] = useState<string | null>(null);
|
||||
const [bypassReason, setBypassReason] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ site: '', domain: '', domain_type: 'subdomain', is_primary: false, sort_order: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
const params = new URLSearchParams();
|
||||
if (filterSite) params.set('site', filterSite);
|
||||
const r = await fetch(`/api/email/rmp/domains?${params}`);
|
||||
const d = await r.json();
|
||||
setDomains(d.domains ?? []);
|
||||
setLoadingData(false);
|
||||
}, [filterSite]);
|
||||
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
async function runVerify(domainId: string) {
|
||||
setVerifying(domainId);
|
||||
await fetch(`/api/email/domains/${domainId}/verify`);
|
||||
setVerifying(null);
|
||||
load();
|
||||
}
|
||||
|
||||
async function toggleActive(domainId: string, current: boolean) {
|
||||
await fetch(`/api/email/rmp/domains/${domainId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: !current }) });
|
||||
load();
|
||||
}
|
||||
|
||||
async function setBypass(domainId: string) {
|
||||
await fetch(`/api/email/rmp/domains/${domainId}/bypass`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: bypassReason }) });
|
||||
setShowBypass(null);
|
||||
setBypassReason('');
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleAddDomain(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await fetch('/api/email/rmp/domains', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(addForm) });
|
||||
setSaving(false);
|
||||
setShowAddForm(false);
|
||||
load();
|
||||
}
|
||||
|
||||
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] text-white p-6">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Sending Domains</h1>
|
||||
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
||||
</div>
|
||||
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Domain</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
||||
<option value="">All Sites</option>
|
||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddDomain} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Add Rotation Domain</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Site *</label>
|
||||
<select required value={addForm.site} onChange={e => setAddForm(p => ({ ...p, site: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
||||
<option value="">Select…</option>
|
||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Domain *</label>
|
||||
<input required value={addForm.domain} onChange={e => setAddForm(p => ({ ...p, domain: e.target.value }))} placeholder="mail.example.com" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||
<select value={addForm.domain_type} onChange={e => setAddForm(p => ({ ...p, domain_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
||||
{['subdomain','sister_domain','primary'].map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add Domain'}</button>
|
||||
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bypass modal */}
|
||||
{showBypass && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-[#111] border border-yellow-400/30 rounded-lg p-5 w-80 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-yellow-400">⚠ Override DNS Verification</h3>
|
||||
<p className="text-xs text-[#888]">This allows sending before DNS is verified. Use with caution.</p>
|
||||
<textarea value={bypassReason} onChange={e => setBypassReason(e.target.value)} placeholder="Reason for bypass…" rows={3} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setBypass(showBypass)} disabled={!bypassReason} className="px-3 py-1.5 bg-yellow-600 rounded text-xs font-medium hover:bg-yellow-500 disabled:opacity-50">Set Bypass</button>
|
||||
<button onClick={() => setShowBypass(null)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loadingData ? (
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-8 text-center text-[#555] text-xs">Loading…</div>
|
||||
) : domains.length === 0 ? (
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-8 text-center text-[#555] text-xs">No domains found</div>
|
||||
) : domains.map((d: any) => (
|
||||
<div key={d.id} className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-[#161616]" onClick={() => setExpanded(expanded === d.id ? null : d.id)}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white">{d.domain}</span>
|
||||
<span className="text-xs text-[#555] capitalize">{d.site}</span>
|
||||
{d.is_primary && <span className="px-1.5 py-0.5 bg-blue-400/10 text-blue-400 rounded text-xs">Primary</span>}
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${d.dns_fully_verified ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{d.dns_fully_verified ? 'DNS OK' : 'DNS Pending'}</span>
|
||||
{d.blacklisted && <span className="px-1.5 py-0.5 bg-red-400/10 text-red-400 rounded text-xs">Blacklisted</span>}
|
||||
{d.bypass_verification && <span className="px-1.5 py-0.5 bg-yellow-400/10 text-yellow-400 rounded text-xs">Bypass Active</span>}
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${d.is_active ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{d.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-[#555]">
|
||||
<span>Health: {d.health_score}/100</span>
|
||||
<span>Sends today: {d.sends_today}/{d.daily_send_limit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[#555] text-xs">{expanded === d.id ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{expanded === d.id && (
|
||||
<div className="border-t border-[#1a1a1a] px-4 py-3 space-y-3">
|
||||
{/* DNS checks */}
|
||||
<div className="grid grid-cols-2 gap-x-6">
|
||||
<DnsRow label="SPF" valid={d.spf_valid} checkedAt={d.spf_checked_at} />
|
||||
<DnsRow label="DKIM" valid={d.dkim_valid} checkedAt={d.dkim_checked_at} />
|
||||
<DnsRow label="DMARC" valid={d.dmarc_valid} checkedAt={d.dmarc_checked_at} />
|
||||
<DnsRow label="PTR" valid={d.ptr_valid} checkedAt={d.ptr_checked_at} />
|
||||
<DnsRow label="MX" valid={d.mx_valid} checkedAt={d.mx_checked_at} />
|
||||
</div>
|
||||
|
||||
{/* DNS helper panel */}
|
||||
<div className="bg-[#0a0a0a] border border-[#1a1a1a] rounded p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-[#888]">DNS Record Helper (GoDaddy)</p>
|
||||
<div className="space-y-1 text-xs text-[#555] font-mono">
|
||||
<p>SPF: <span className="text-[#888]">v=spf1 include:sendgrid.net ~all</span></p>
|
||||
<p>DKIM: <span className="text-[#888]">Add CNAME records from your ESP</span></p>
|
||||
<p>DMARC: <span className="text-[#888]">v=DMARC1; p=quarantine; rua=mailto:dmarc@{d.domain}</span></p>
|
||||
<p>MX: <span className="text-[#888]">Point to your mail server</span></p>
|
||||
<p>PTR: <span className="text-[#888]">Set via hosting provider (reverse DNS)</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.bypass_verification && (
|
||||
<div className="bg-yellow-400/5 border border-yellow-400/20 rounded p-2 text-xs text-yellow-400">
|
||||
⚠ DNS bypass active: {d.bypass_reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button onClick={() => runVerify(d.id)} disabled={verifying === d.id} className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] disabled:opacity-50">
|
||||
{verifying === d.id ? 'Verifying…' : 'Run Full Verification'}
|
||||
</button>
|
||||
<button onClick={() => toggleActive(d.id, d.is_active)} disabled={!d.dns_fully_verified && !d.bypass_verification && !d.is_active} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white disabled:opacity-30">
|
||||
{d.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button onClick={() => setShowBypass(d.id)} className="px-3 py-1.5 bg-yellow-900/30 border border-yellow-400/20 rounded text-xs text-yellow-400 hover:bg-yellow-900/50">
|
||||
Override DNS Verification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailDomainsPage() {
|
||||
return (
|
||||
<Suspense fallback={<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>}>
|
||||
<DomainsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
124
src/app/admin/email/page.tsx
Normal file
124
src/app/admin/email/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
|
||||
|
||||
function DnsCheck({ label, valid }: { label: string; valid: boolean | null }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded ${valid === true ? 'bg-green-400/10 text-green-400' : valid === false ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>
|
||||
{valid === true ? '✓' : valid === false ? '✗' : '?'} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailDashboard() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [domains, setDomains] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
const [domainsR, statsR] = await Promise.all([
|
||||
fetch('/api/email/rmp/domains'),
|
||||
fetch('/api/email/rmp/stats'),
|
||||
]);
|
||||
const [dd, sd] = await Promise.all([domainsR.json(), statsR.json()]);
|
||||
setDomains(dd.domains ?? []);
|
||||
setStats(sd);
|
||||
setLoadingData(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
const bySite = SITES.map(site => ({
|
||||
site,
|
||||
domains: domains.filter((d: any) => d.site === site),
|
||||
}));
|
||||
|
||||
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] text-white p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Email Infrastructure</h1>
|
||||
<p className="text-[#888] text-sm mt-1">Sending domains, DNS health, suppressions</p>
|
||||
</div>
|
||||
|
||||
{/* Network Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Sends Today', value: stats?.sends_today ?? 0, color: 'text-blue-400' },
|
||||
{ label: 'Sends This Month', value: stats?.sends_month ?? 0, color: 'text-blue-400' },
|
||||
{ label: 'Total Suppressions', value: stats?.total_suppressions ?? 0, color: 'text-red-400' },
|
||||
{ label: 'Blacklisted Domains', value: stats?.blacklisted ?? 0, color: 'text-red-400' },
|
||||
].map(c => (
|
||||
<div key={c.label} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
||||
<p className={`text-2xl font-bold ${c.color}`}>{c.value}</p>
|
||||
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-site property cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{bySite.map(({ site, domains: siteDomains }) => {
|
||||
const primary = siteDomains.find((d: any) => d.is_primary);
|
||||
const suppCount = stats?.suppressions_by_site?.[site] ?? 0;
|
||||
return (
|
||||
<div key={site} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold capitalize">{site}</h3>
|
||||
<Link href={`/admin/email/domains?site=${site}`} className="text-xs text-[#3b82f6] hover:underline">Manage →</Link>
|
||||
</div>
|
||||
<div className="text-xs text-[#888]">
|
||||
<span className="text-white">{primary?.domain ?? 'No primary'}</span>
|
||||
{primary && (
|
||||
<span className={`ml-2 px-1.5 py-0.5 rounded text-xs ${primary.dns_fully_verified ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>
|
||||
{primary.dns_fully_verified ? 'DNS OK' : 'DNS Pending'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-[#555]">
|
||||
<span>{siteDomains.length} domains</span>
|
||||
<span>{siteDomains.filter((d: any) => d.is_active).length} active</span>
|
||||
<span>{suppCount} suppressions</span>
|
||||
</div>
|
||||
{primary && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<DnsCheck label="SPF" valid={primary.spf_valid} />
|
||||
<DnsCheck label="DKIM" valid={primary.dkim_valid} />
|
||||
<DnsCheck label="DMARC" valid={primary.dmarc_valid} />
|
||||
<DnsCheck label="PTR" valid={primary.ptr_valid} />
|
||||
<DnsCheck label="MX" valid={primary.mx_valid} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: 'Sending Domains', href: '/admin/email/domains' },
|
||||
{ label: 'Suppressions', href: '/admin/email/suppressions' },
|
||||
{ label: 'Send Log', href: '/admin/email/send-log' },
|
||||
].map(l => (
|
||||
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#252525] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#3b82f6] transition-colors">
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/admin/email/send-log/page.tsx
Normal file
89
src/app/admin/email/send-log/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
|
||||
|
||||
export default function SendLogPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [filterSite, setFilterSite] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
|
||||
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
const params = new URLSearchParams();
|
||||
if (filterSite) params.set('site', filterSite);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
const r = await fetch(`/api/email/rmp/send-log?${params}`);
|
||||
const d = await r.json();
|
||||
setLogs(d.logs ?? []);
|
||||
setLoadingData(false);
|
||||
}, [filterSite, filterStatus]);
|
||||
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
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] text-white p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Email Send Log</h1>
|
||||
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
||||
<option value="">All Sites</option>
|
||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
||||
<option value="">All Statuses</option>
|
||||
{['queued','sent','failed','suppressed'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1a1a1a]">
|
||||
{['Site','Recipient','Subject','Type','Domain','Status','Sent At','Opens','Clicks'].map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadingData ? (
|
||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">Loading…</td></tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No send log entries</td></tr>
|
||||
) : logs.map((l: any) => (
|
||||
<tr key={l.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{l.site}</td>
|
||||
<td className="px-3 py-2 text-white text-xs">{l.recipient_email}</td>
|
||||
<td className="px-3 py-2 text-[#ccc] text-xs max-w-xs truncate">{l.subject ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{l.send_type}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{l.rmp_email_sending_domains?.domain ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${l.status === 'sent' ? 'bg-green-400/10 text-green-400' : l.status === 'failed' ? 'bg-red-400/10 text-red-400' : l.status === 'suppressed' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{l.status}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{l.sent_at ? new Date(l.sent_at).toLocaleString() : '—'}</td>
|
||||
<td className="px-3 py-2 text-[#888] text-xs">{l.opens}</td>
|
||||
<td className="px-3 py-2 text-[#888] text-xs">{l.clicks}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
src/app/admin/email/suppressions/page.tsx
Normal file
229
src/app/admin/email/suppressions/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
|
||||
const SUPP_TYPES = ['unsubscribe','bounce_hard','bounce_soft','spam_complaint','hostile_reply','manual'];
|
||||
|
||||
export default function SuppressionsPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [site, setSite] = useState('broadcastbeat');
|
||||
const [suppressions, setSuppressions] = useState<any[]>([]);
|
||||
const [keywords, setKeywords] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showKeywordForm, setShowKeywordForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ email: '', suppression_type: 'manual', notes: '' });
|
||||
const [keywordForm, setKeywordForm] = useState({ keyword: '', keyword_type: 'unsubscribe' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
const params = new URLSearchParams({ site });
|
||||
if (filterType) params.set('type', filterType);
|
||||
if (search) params.set('search', search);
|
||||
const [suppR, kwR, statsR] = await Promise.all([
|
||||
fetch(`/api/email/rmp/suppressions?${params}`),
|
||||
fetch('/api/email/rmp/keywords'),
|
||||
fetch(`/api/email/rmp/suppressions/stats?site=${site}`),
|
||||
]);
|
||||
const [sd, kd, stD] = await Promise.all([suppR.json(), kwR.json(), statsR.json()]);
|
||||
setSuppressions(sd.suppressions ?? []);
|
||||
setKeywords(kd.keywords ?? []);
|
||||
setStats(stD);
|
||||
setLoadingData(false);
|
||||
}, [site, filterType, search]);
|
||||
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
async function handleAddSuppression(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await fetch('/api/email/rmp/suppressions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...addForm, site }) });
|
||||
setSaving(false);
|
||||
setShowAddForm(false);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleAddKeyword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await fetch('/api/email/rmp/keywords', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(keywordForm) });
|
||||
setSaving(false);
|
||||
setShowKeywordForm(false);
|
||||
load();
|
||||
}
|
||||
|
||||
async function removeSuppression(id: string) {
|
||||
await fetch(`/api/email/rmp/suppressions/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
}
|
||||
|
||||
async function toggleKeyword(id: string, active: boolean) {
|
||||
await fetch(`/api/email/rmp/keywords/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active: !active }) });
|
||||
load();
|
||||
}
|
||||
|
||||
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] text-white p-6">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Email Suppressions</h1>
|
||||
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Suppression</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site selector */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{SITES.map(s => (
|
||||
<button key={s} onClick={() => setSite(s)} className={`px-3 py-1 rounded text-xs capitalize ${site === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
||||
<p className="text-xl font-bold text-red-400">{stats.total ?? 0}</p>
|
||||
<p className="text-xs text-[#888] mt-0.5">Total Suppressed</p>
|
||||
</div>
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
||||
<p className="text-xl font-bold text-yellow-400">{stats.added_this_month ?? 0}</p>
|
||||
<p className="text-xs text-[#888] mt-0.5">Added This Month</p>
|
||||
</div>
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
||||
<p className="text-xl font-bold text-orange-400">{stats.hostile_replies ?? 0}</p>
|
||||
<p className="text-xs text-[#888] mt-0.5">Hostile Replies</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
||||
<option value="">All Types</option>
|
||||
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||
</select>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search email…" className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddSuppression} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Add Manual Suppression</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Email *</label>
|
||||
<input required type="email" value={addForm.email} onChange={e => setAddForm(p => ({ ...p, email: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||
<select value={addForm.suppression_type} onChange={e => setAddForm(p => ({ ...p, suppression_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
||||
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||
<input value={addForm.notes} onChange={e => setAddForm(p => ({ ...p, notes: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add'}</button>
|
||||
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Suppressions table */}
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1a1a1a]">
|
||||
{['Email','Type','Source','Reply Text','Domain','Date',''].map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadingData ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">Loading…</td></tr>
|
||||
) : suppressions.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No suppressions found</td></tr>
|
||||
) : suppressions.map((s: any) => (
|
||||
<tr key={s.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
||||
<td className="px-3 py-2 text-white text-xs">{s.email}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${s.suppression_type === 'hostile_reply' ? 'bg-red-400/10 text-red-400' : s.suppression_type === 'unsubscribe' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{s.suppression_type?.replace(/_/g, ' ')}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{s.source}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs max-w-xs truncate">{s.reply_text ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{s.sending_domain ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{s.suppressed_at ? new Date(s.suppressed_at).toLocaleDateString() : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => removeSuppression(s.id)} className="text-xs text-red-400 hover:underline">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Keyword Management */}
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#252525] flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Suppression Keywords</h2>
|
||||
<button onClick={() => setShowKeywordForm(true)} className="text-xs text-[#3b82f6] hover:underline">+ Add Keyword</button>
|
||||
</div>
|
||||
{showKeywordForm && (
|
||||
<form onSubmit={handleAddKeyword} className="px-4 py-3 border-b border-[#1a1a1a] flex gap-2 items-end">
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Keyword *</label>
|
||||
<input required value={keywordForm.keyword} onChange={e => setKeywordForm(p => ({ ...p, keyword: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||
<select value={keywordForm.keyword_type} onChange={e => setKeywordForm(p => ({ ...p, keyword_type: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
||||
<option value="unsubscribe">unsubscribe</option>
|
||||
<option value="hostile">hostile</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Add</button>
|
||||
<button type="button" onClick={() => setShowKeywordForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888]">Cancel</button>
|
||||
</form>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1a1a1a]">{['Keyword','Type','Active',''].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{keywords.map((k: any) => (
|
||||
<tr key={k.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
||||
<td className="px-4 py-2 text-white text-xs">{k.keyword}</td>
|
||||
<td className="px-4 py-2 text-[#888] text-xs">{k.keyword_type}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${k.active ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{k.active ? 'Active' : 'Inactive'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button onClick={() => toggleKeyword(k.id, k.active)} className="text-xs text-[#3b82f6] hover:underline">{k.active ? 'Deactivate' : 'Activate'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user