← back to Trademarks Copyright

src/app/admin/drops/page.tsx

150 lines

"use client";

import { useEffect, useState } from "react";
import DropEditor from "@/components/DropEditor";

type AdminData = {
  subs: { tier: string; status: string; count: string }[];
  drops: { id: number; drop_date: string; subject: string; status: string; items: string; deliveries: string; opens?: string; bounces?: string }[];
  freshCounts: { source_kind: string; count: string }[];
  backendCounts: { delivered_via: string; count: string }[];
};

export default function AdminDropsPage() {
  const [data, setData] = useState<AdminData | null>(null);
  const [log, setLog] = useState<string[]>([]);
  const [busy, setBusy] = useState<string | null>(null);
  const [testEmail, setTestEmail] = useState("");
  const [itemsPerDrop, setItemsPerDrop] = useState(10);
  const [editing, setEditing] = useState<number | null>(null);

  async function load() {
    const r = await fetch("/api/drops/admin");
    setData(await r.json());
  }
  useEffect(() => { load(); }, []);

  function append(msg: string) {
    setLog((L) => [...L.slice(-300), `${new Date().toLocaleTimeString()}  ${msg}`]);
  }

  async function compose(force = false) {
    setBusy("compose"); append(`→ composing ${force ? "(force) " : ""}today's drop…`);
    const r = await fetch("/api/drops/compose", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ itemsPerDrop, force }),
    });
    const j = await r.json();
    append(r.ok ? `✓ drop ${j.dropId} — "${j.subject}" (${j.items} items)` : `✗ ${j.error}`);
    await load(); setBusy(null);
  }

  async function send(opts: { dropId?: number; testEmail?: string; dryRun?: boolean }) {
    setBusy("send"); append(`→ send ${JSON.stringify(opts)}`);
    const r = await fetch("/api/drops/send", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify(opts),
    });
    const j = await r.json();
    if (!r.ok) { append(`✗ ${j.error}`); setBusy(null); return; }
    append(`✓ backend=${j.backend}  sent=${j.sent}  failed=${j.failed}  drop=${j.dropId}`);
    for (const res of j.results || []) {
      append(res.ok ? `   → ${res.email}  ${res.id ?? ""}` : `   ✗ ${res.email}  ${res.error}`);
    }
    await load(); setBusy(null);
  }

  const subsByTier = (data?.subs ?? []).reduce<Record<string, number>>((m, r) => {
    m[r.tier] = (m[r.tier] || 0) + Number(r.count);
    return m;
  }, {});

  return (
    <div>
      <h1 className="text-3xl font-semibold">Drops admin</h1>
      <p className="text-sm text-ink/60">Compose, preview, and send daily drops. Dev-only — add auth before going live.</p>

      <div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
        <div className="card p-4">
          <div className="text-xs uppercase tracking-wide text-ink/50">Subscribers</div>
          <div className="mt-1 text-3xl font-semibold">
            {(subsByTier.trial ?? 0) + (subsByTier.standard ?? 0) + (subsByTier.pro ?? 0) + (subsByTier.comp ?? 0)}
          </div>
          <div className="mt-1 text-xs text-ink/60">
            trial {subsByTier.trial ?? 0} · standard {subsByTier.standard ?? 0} · pro {subsByTier.pro ?? 0} · comp {subsByTier.comp ?? 0}
          </div>
        </div>
        <div className="card p-4">
          <div className="text-xs uppercase tracking-wide text-ink/50">Fresh pool</div>
          <div className="mt-1 text-3xl font-semibold">
            {(data?.freshCounts ?? []).reduce((s, r) => s + Number(r.count), 0)}
          </div>
          <div className="mt-1 text-xs text-ink/60">
            {data?.freshCounts.map((r) => `${r.source_kind}=${r.count}`).join(" · ")}
          </div>
        </div>
        <div className="card p-4">
          <div className="text-xs uppercase tracking-wide text-ink/50">Deliveries</div>
          <div className="mt-1 text-3xl font-semibold">
            {(data?.backendCounts ?? []).reduce((s, r) => s + Number(r.count), 0)}
          </div>
          <div className="mt-1 text-xs text-ink/60">
            {data?.backendCounts.map((r) => `${r.delivered_via}=${r.count}`).join(" · ") || "—"}
          </div>
        </div>
      </div>

      <div className="mt-6 card p-4">
        <h2 className="text-lg font-semibold">Today's action</h2>
        <div className="mt-2 flex flex-wrap items-center gap-2">
          <label className="text-sm text-ink/70">items</label>
          <input type="number" min={1} max={20} value={itemsPerDrop} onChange={(e) => setItemsPerDrop(Number(e.target.value) || 10)} className="w-20" />
          <button className="btn" onClick={() => compose(false)} disabled={busy !== null}>Compose today</button>
          <button className="btn-ghost" onClick={() => compose(true)} disabled={busy !== null}>Force recompose</button>
          <span className="mx-2 h-6 border-l border-ink/10" />
          <input type="email" placeholder="preview to email…" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} className="w-64" />
          <button className="btn-ghost" disabled={!testEmail || busy !== null} onClick={() => send({ testEmail })}>Preview send</button>
          <button className="btn" disabled={busy !== null} onClick={() => send({})}>Send to all subscribers</button>
        </div>
        <pre className="mt-3 max-h-56 overflow-auto rounded bg-ledger p-3 font-mono text-xs text-parchment whitespace-pre-wrap">{log.join("\n") || "(no activity yet)"}</pre>
      </div>

      <div className="mt-6 card overflow-x-auto">
        <table className="min-w-full text-sm">
          <thead className="bg-ink/5 text-left">
            <tr>
              <th className="p-2">Date</th><th className="p-2">Subject</th><th className="p-2">Status</th>
              <th className="p-2">Items</th><th className="p-2">Sent</th><th className="p-2">Opens</th><th className="p-2">Bounces</th><th className="p-2">Actions</th>
            </tr>
          </thead>
          <tbody>
            {(data?.drops ?? []).map((d) => (
              <tr key={d.id} className="border-t border-ink/5 hover:bg-brass/5">
                <td className="p-2">{d.drop_date.slice(0, 10)}</td>
                <td className="p-2 font-medium">{d.subject}</td>
                <td className="p-2"><span className="tag">{d.status}</span></td>
                <td className="p-2">{d.items}</td>
                <td className="p-2">{d.deliveries}</td>
                <td className="p-2">
                  {d.opens ?? 0}
                  {Number(d.deliveries) > 0 && (
                    <span className="ml-1 text-xs text-ink/50">
                      ({Math.round((Number(d.opens ?? 0) / Number(d.deliveries)) * 100)}%)
                    </span>
                  )}
                </td>
                <td className="p-2 text-red-700">{Number(d.bounces ?? 0) > 0 ? d.bounces : "—"}</td>
                <td className="p-2 flex gap-1">
                  <button className="btn-ghost text-xs" onClick={() => setEditing(d.id)}>Edit</button>
                  <button className="btn-ghost text-xs" onClick={() => send({ dropId: d.id })} disabled={busy !== null}>Send →</button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {editing !== null && <DropEditor dropId={editing} onClose={() => { setEditing(null); load(); }} />}
    </div>
  );
}