TSX629 linhas23.665 bytes
apps/web/src/app/admin/newsletter/page.tsx
SHA-256
ab785457118b6b5c69631eef1b8a7aa3e825cf98fdf038cec699ef89909ab4a1
Somente leiturafonte-acf635076da2
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const API = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.observasenado.org'
const SECRET_KEY = 'observa_admin_secret_v1'
type Tab = 'edicoes' | 'inscritos' | 'novo'
interface Run {
id: number
edicao: string
subject: string
status: 'preview' | 'sent'
enviados: number
falhas: number
computed_at: string
enviado_em: string | null
prompt_tokens: number | null
output_tokens: number | null
modelo: string | null
}
interface Subscriber {
id: number
email: string
status: string
created_at: string
confirmed_at: string | null
unsubscribed_at: string | null
}
interface Stats {
pending: number
active: number
unsubscribed: number
total: number
}
const DEFAULT_MD = `# Atualização do Observatório
Olá,
Este é um exemplo de boletim. Use **markdown** para formatar.
- Item um
- Item dois
- Item três
[Acesse o ranking →](https://observasenado.org)
---
*Equipe do Observatório do Senado*
`
function wrapHtml(innerHtml: string, subject: string): string {
return `<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(subject)}</title></head>
<body style="margin:0;padding:24px;background:#f7f6f3;font-family:Georgia,'Times New Roman',serif;color:#1a1a1a">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="600" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e2dfd8;border-radius:4px;overflow:hidden">
<tr><td style="padding:24px 32px;border-bottom:3px solid #C9A24B;background:#0F3D5C;color:#ffffff">
<table role="presentation" width="100%"><tr>
<td style="font-family:Georgia,serif;font-size:18px;font-weight:600;letter-spacing:0.02em">Observatório do Senado</td>
<td align="right" style="font-family:system-ui,Arial,sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:#C9A24B">Boletim</td>
</tr></table>
</td></tr>
<tr><td style="padding:32px;font-size:16px;line-height:1.7;color:#1a1a1a">
<div class="content">${innerHtml}</div>
</td></tr>
</table>
<table role="presentation" align="center" width="600" style="max-width:600px;width:100%;margin-top:8px"><tr>
<td style="padding:16px 32px;font-family:system-ui,Arial,sans-serif;font-size:11px;color:#8a8e93;line-height:1.6;text-align:center">
Iniciativa cidadã independente · sem vínculo partidário · <a href="https://observasenado.org" style="color:#0F3D5C;text-decoration:none">observasenado.org</a>
</td>
</tr></table>
</body></html>`
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!))
}
function fmtDate(s: string | null): string {
if (!s) return '—'
const d = new Date(s)
return (
d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) +
' ' +
d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
preview: 'bg-amber-100 text-amber-800',
sent: 'bg-green-100 text-green-800',
active: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
unsubscribed: 'bg-gray-100 text-gray-600',
}
return (
<span
className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${map[status] ?? 'bg-gray-100 text-gray-600'}`}
>
{status}
</span>
)
}
export default function AdminNewsletterPage() {
const [secret, setSecret] = useState('')
const [authed, setAuthed] = useState(false)
const [stats, setStats] = useState<Stats | null>(null)
const [tab, setTab] = useState<Tab>('edicoes')
// Aba Edições
const [runs, setRuns] = useState<Run[]>([])
const [runsLoading, setRunsLoading] = useState(false)
const [selectedRun, setSelectedRun] = useState<(Run & { html: string }) | null>(null)
const [sendingRun, setSendingRun] = useState<string | null>(null)
const [runLog, setRunLog] = useState('')
// Aba Inscritos
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [subsLoading, setSubsLoading] = useState(false)
// Aba Novo Disparo
const [subject, setSubject] = useState('')
const [md, setMd] = useState(DEFAULT_MD)
const [testTo, setTestTo] = useState('')
const [busy, setBusy] = useState<'idle' | 'test' | 'send'>('idle')
const [log, setLog] = useState('')
const innerHtml = useMemo(() => {
if (typeof window === 'undefined') return ''
const raw = marked.parse(md, { async: false }) as string
return DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } })
}, [md])
const fullHtml = useMemo(
() => wrapHtml(innerHtml, subject || 'Observatório do Senado'),
[innerHtml, subject],
)
useEffect(() => {
const s = localStorage.getItem(SECRET_KEY)
if (s) {
setSecret(s)
void tryAuth(s)
}
}, [])
async function tryAuth(s: string): Promise<boolean> {
try {
const r = await fetch(`${API}/api/newsletter/admin/stats`, {
headers: { 'X-Admin-Secret': s },
})
if (!r.ok) return false
setStats(await r.json())
setAuthed(true)
localStorage.setItem(SECRET_KEY, s)
return true
} catch {
return false
}
}
const fetchRuns = useCallback(async (s: string) => {
setRunsLoading(true)
try {
const r = await fetch(`${API}/api/newsletter/admin/runs`, {
headers: { 'X-Admin-Secret': s },
})
if (r.ok) setRuns(((await r.json()) as { runs: Run[] }).runs ?? [])
} finally {
setRunsLoading(false)
}
}, [])
const fetchSubscribers = useCallback(async (s: string) => {
setSubsLoading(true)
try {
const r = await fetch(`${API}/api/newsletter/admin/subscribers`, {
headers: { 'X-Admin-Secret': s },
})
if (r.ok)
setSubscribers(
((await r.json()) as { subscribers: Subscriber[] }).subscribers ?? [],
)
} finally {
setSubsLoading(false)
}
}, [])
const fetchStats = useCallback(async (s: string) => {
const r = await fetch(`${API}/api/newsletter/admin/stats`, {
headers: { 'X-Admin-Secret': s },
})
if (r.ok) setStats(await r.json())
}, [])
useEffect(() => {
if (!authed) return
if (tab === 'edicoes') void fetchRuns(secret)
if (tab === 'inscritos') void fetchSubscribers(secret)
}, [authed, tab, secret, fetchRuns, fetchSubscribers])
async function previewRun(run: Run) {
if (selectedRun?.edicao === run.edicao) {
setSelectedRun(null)
return
}
const r = await fetch(`${API}/api/newsletter/admin/run/${run.edicao}`, {
headers: { 'X-Admin-Secret': secret },
})
if (r.ok) {
const data = (await r.json()) as Run & { html: string }
setSelectedRun({ ...run, html: data.html })
}
}
async function sendRun(edicao: string) {
const n = stats?.active ?? 0
if (!confirm(`Enviar edição ${edicao} para ${n} inscritos ativos? Esta ação não pode ser desfeita.`))
return
setSendingRun(edicao)
setRunLog('')
try {
const r = await fetch(`${API}/api/newsletter/admin/send-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Secret': secret },
body: JSON.stringify({ edicao }),
})
const data = await r.json()
if (!r.ok) setRunLog(`Erro ${r.status}: ${JSON.stringify(data)}`)
else {
setRunLog(JSON.stringify(data, null, 2))
void fetchRuns(secret)
void fetchStats(secret)
}
} catch (err) {
setRunLog(`Falha: ${String(err)}`)
} finally {
setSendingRun(null)
}
}
async function sendCustom(mode: 'test' | 'all') {
if (!subject.trim() || !md.trim()) {
alert('Preencha assunto e conteúdo.')
return
}
if (mode === 'test' && !testTo.trim()) {
alert('Informe um e-mail para o teste.')
return
}
if (mode === 'all') {
const n = stats?.active ?? 0
if (!confirm(`Enviar para ${n} inscritos ativos? Esta ação não pode ser desfeita.`)) return
}
setBusy(mode === 'all' ? 'send' : 'test')
setLog('Enviando...')
try {
const r = await fetch(`${API}/api/newsletter/admin/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Secret': secret },
body: JSON.stringify({
subject,
html: fullHtml,
test_to: mode === 'test' ? testTo.trim() : undefined,
}),
})
const data = await r.json()
if (!r.ok) setLog(`Erro ${r.status}: ${JSON.stringify(data)}`)
else {
setLog(JSON.stringify(data, null, 2))
if (mode === 'all') void fetchStats(secret)
}
} catch (err) {
setLog(`Falha: ${String(err)}`)
} finally {
setBusy('idle')
}
}
function logout() {
localStorage.removeItem(SECRET_KEY)
setSecret('')
setAuthed(false)
setStats(null)
}
if (!authed) {
return (
<div className="max-w-md mx-auto py-12">
<p className="eyebrow">Restrito</p>
<h1 className="font-serif text-2xl text-ink mt-2">Admin · Newsletter</h1>
<p className="mt-2 text-sm text-muted">Informe o ADMIN_SECRET para continuar.</p>
<form
onSubmit={async (e) => {
e.preventDefault()
const ok = await tryAuth(secret)
if (!ok) alert('Secret inválido.')
}}
className="mt-6 flex flex-col gap-3"
>
<input
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="ADMIN_SECRET"
className="rounded-sm border border-border bg-surface px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none"
autoFocus
/>
<button
type="submit"
className="rounded-sm bg-primary px-4 py-2 text-xs font-medium uppercase tracking-wider text-white hover:bg-primary-hover transition-colors"
>
Entrar
</button>
</form>
</div>
)
}
return (
<div className="max-w-7xl mx-auto py-6">
{/* Header */}
<div className="flex items-center justify-between border-b border-border pb-4 mb-6">
<div>
<p className="eyebrow">Admin</p>
<h1 className="font-serif text-2xl text-ink mt-1">Newsletter</h1>
</div>
<div className="flex items-center gap-4">
{stats && (
<div className="text-sm text-muted">
<span className="font-semibold text-ink">{stats.active}</span> ativos ·{' '}
<span className="text-subtle">{stats.pending} pendentes</span> ·{' '}
<span className="text-subtle">{stats.unsubscribed} cancelados</span>
</div>
)}
<button
onClick={() => void fetchStats(secret)}
className="text-xs text-muted hover:text-primary underline"
>
atualizar
</button>
<button
onClick={logout}
className="text-xs text-muted hover:text-danger underline"
>
sair
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-border">
{(['edicoes', 'inscritos', 'novo'] as Tab[]).map((t) => {
const labels: Record<Tab, string> = {
edicoes: 'Edições Geradas',
inscritos: 'Inscritos',
novo: 'Novo Disparo',
}
return (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === t
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-ink'
}`}
>
{labels[t]}
</button>
)
})}
</div>
{/* ── Aba: Edições Geradas ─────────────────────────────── */}
{tab === 'edicoes' && (
<div className="space-y-4">
{runsLoading && <p className="text-sm text-muted">Carregando...</p>}
{!runsLoading && runs.length === 0 && (
<p className="text-sm text-muted">Nenhuma edição gerada ainda.</p>
)}
{runs.length > 0 && (
<div className={selectedRun ? 'grid grid-cols-1 lg:grid-cols-2 gap-6' : ''}>
<div>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border text-left">
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Edição
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Assunto
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Status
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Enviados
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Gerado em
</th>
<th className="pb-2 text-xs uppercase tracking-wider text-muted font-medium">
Ações
</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr
key={run.id}
className="border-b border-border/50 hover:bg-surface/50"
>
<td className="py-2 pr-4 font-mono text-xs text-ink">{run.edicao}</td>
<td
className="py-2 pr-4 text-muted max-w-[200px] truncate"
title={run.subject}
>
{run.subject}
</td>
<td className="py-2 pr-4">
<StatusBadge status={run.status} />
</td>
<td className="py-2 pr-4 text-muted">
{run.enviados > 0 ? run.enviados : '—'}
</td>
<td className="py-2 pr-4 text-muted text-xs whitespace-nowrap">
{fmtDate(run.computed_at)}
</td>
<td className="py-2">
<div className="flex items-center gap-2">
<button
onClick={() => void previewRun(run)}
className="text-xs text-primary hover:underline"
>
{selectedRun?.edicao === run.edicao ? 'Fechar' : 'Ver'}
</button>
{run.status === 'preview' && (
<button
onClick={() => void sendRun(run.edicao)}
disabled={sendingRun === run.edicao}
className="text-xs text-danger hover:underline disabled:opacity-50"
>
{sendingRun === run.edicao ? 'Enviando…' : 'Enviar'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{runLog && (
<pre className="mt-4 rounded-sm border border-border bg-panel px-3 py-2 text-[11px] font-mono text-muted whitespace-pre-wrap max-h-40 overflow-auto">
{runLog}
</pre>
)}
</div>
{selectedRun && (
<div>
<p className="text-xs uppercase tracking-wider text-muted mb-2">
Prévia — {selectedRun.edicao}
</p>
<div className="rounded-sm border border-border bg-white overflow-hidden">
<iframe
title="preview"
srcDoc={selectedRun.html}
className="w-full h-[820px] bg-white"
sandbox=""
/>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* ── Aba: Inscritos ───────────────────────────────────── */}
{tab === 'inscritos' && (
<div>
{subsLoading && <p className="text-sm text-muted">Carregando...</p>}
{!subsLoading && subscribers.length === 0 && (
<p className="text-sm text-muted">Nenhum inscrito ainda.</p>
)}
{subscribers.length > 0 && (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border text-left">
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
#
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
E-mail
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Status
</th>
<th className="pb-2 pr-4 text-xs uppercase tracking-wider text-muted font-medium">
Inscrito em
</th>
<th className="pb-2 text-xs uppercase tracking-wider text-muted font-medium">
Confirmado em
</th>
</tr>
</thead>
<tbody>
{subscribers.map((s) => (
<tr key={s.id} className="border-b border-border/50 hover:bg-surface/50">
<td className="py-2 pr-4 text-muted text-xs">{s.id}</td>
<td className="py-2 pr-4 font-mono text-xs text-ink">{s.email}</td>
<td className="py-2 pr-4">
<StatusBadge status={s.status} />
</td>
<td className="py-2 pr-4 text-muted text-xs whitespace-nowrap">
{fmtDate(s.created_at)}
</td>
<td className="py-2 text-muted text-xs whitespace-nowrap">
{fmtDate(s.confirmed_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Aba: Novo Disparo ────────────────────────────────── */}
{tab === 'novo' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider text-muted mb-1">
Assunto
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Ex.: Boletim — Outubro/2026"
className="w-full rounded-sm border border-border bg-surface px-3 py-2 text-sm focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-muted mb-1">
Conteúdo (Markdown)
</label>
<textarea
value={md}
onChange={(e) => setMd(e.target.value)}
rows={22}
className="w-full rounded-sm border border-border bg-surface px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none"
spellCheck
/>
<p className="mt-1 text-[11px] text-subtle">
Suporte a títulos, listas, links, negrito, itálico. Footer com unsubscribe
adicionado automaticamente pelo servidor.
</p>
</div>
<div className="rounded-sm border border-border bg-panel px-4 py-3">
<p className="text-xs uppercase tracking-wider text-muted mb-2">Enviar teste</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
placeholder="[email protected]"
className="flex-1 rounded-sm border border-border bg-surface px-3 py-2 text-sm"
/>
<button
onClick={() => void sendCustom('test')}
disabled={busy !== 'idle'}
className="rounded-sm border border-primary px-4 py-2 text-xs font-medium uppercase tracking-wider text-primary hover:bg-primary hover:text-white transition-colors disabled:opacity-60"
>
{busy === 'test' ? 'Enviando…' : 'Enviar teste'}
</button>
</div>
</div>
<div className="rounded-sm border border-danger/40 bg-danger/5 px-4 py-3">
<p className="text-xs uppercase tracking-wider text-danger mb-2">Disparo final</p>
<p className="text-xs text-muted mb-3">
Envia para todos os <strong>{stats?.active ?? 0}</strong> inscritos confirmados.
</p>
<button
onClick={() => void sendCustom('all')}
disabled={busy !== 'idle' || !stats?.active}
className="rounded-sm bg-danger px-5 py-2 text-xs font-medium uppercase tracking-wider text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{busy === 'send' ? 'Enviando…' : `Enviar para ${stats?.active ?? 0} inscritos`}
</button>
</div>
{log && (
<pre className="rounded-sm border border-border bg-panel px-3 py-2 text-[11px] font-mono text-muted whitespace-pre-wrap max-h-48 overflow-auto">
{log}
</pre>
)}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-muted mb-2">Pré-visualização</p>
<div className="rounded-sm border border-border bg-white overflow-hidden">
<iframe
title="preview"
srcDoc={fullHtml}
className="w-full h-[820px] bg-white"
sandbox=""
/>
</div>
</div>
</div>
)}
</div>
)
}