Iniciativa Cidadã Independente · Transparência Parlamentar
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
  )
}