diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx index ea08cc3e..f71e4cd9 100644 --- a/app/(app)/projects/[id]/leads/page.tsx +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -91,7 +91,38 @@ export default async function LeadsPage({ const prospects = (prospectData as ProspectRow[] | null) ?? []; const sends = (sendData as SendRow[] | null) ?? []; - const campaigns = (campaignData as CampaignSummary[] | null) ?? []; + const campaignRows = (campaignData as (CampaignSummary & { id: string })[] | null) ?? []; + + // Recent ticks per campaign. last_run_note holds only the newest line, so + // without this there is no way to see that a campaign has been erroring for + // an hour, or that it has never found anything at all. + const { data: runData } = await supabase + .from("outreach_campaign_runs") + .select("campaign_id, ran_at, summary, ok, errors") + .in("campaign_id", campaignRows.map((c) => c.id)) + .order("ran_at", { ascending: false }) + .limit(60); + + const runsByCampaign = new Map(); + for (const r of (runData as Record[] | null) ?? []) { + const key = r.campaign_id as string; + const list = runsByCampaign.get(key) ?? []; + if (list.length >= 8) continue; + list.push({ + ran_at: r.ran_at as string, + summary: (r.summary as string) ?? "", + ok: (r.ok as boolean) ?? true, + errors: Array.isArray(r.errors) ? (r.errors as string[]) : [], + }); + runsByCampaign.set(key, list); + } + + const campaigns: CampaignSummary[] = campaignRows.map((c) => ({ + ...c, + pitch_facts: Array.isArray(c.pitch_facts) ? c.pitch_facts : [], + auth_required_hosts: Array.isArray(c.auth_required_hosts) ? c.auth_required_hosts : [], + runs: runsByCampaign.get(c.id) ?? [], + })); const byStatus = new Map(); for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1); diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index efd74a63..e72223c2 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -8,6 +8,13 @@ import { toggleCampaignAction, } from "@/app/actions/leads"; +export type CampaignRun = { + ran_at: string; + summary: string; + ok: boolean; + errors: string[]; +}; + export type CampaignSummary = { name: string; active: boolean; @@ -18,6 +25,13 @@ export type CampaignSummary = { seed_urls: string[]; last_run_at: string | null; last_run_note: string | null; + pitch_mode: "audit" | "custom"; + pitch_intro: string | null; + pitch_ask: string | null; + pitch_facts: string[]; + scan_prospects: boolean; + auth_required_hosts: string[]; + runs: CampaignRun[]; }; /** @@ -37,6 +51,14 @@ export function CampaignPanel({ }) { const router = useRouter(); const [pending, start] = useTransition(); + // Which button is in flight, so only that one shows a spinner instead of + // every button in the panel going quiet at once. + const [busy, setBusy] = useState(null); + // Result of the last action per campaign, shown next to that campaign + // rather than in one shared line at the top of the panel. + const [rowNote, setRowNote] = useState>({}); + const [editing, setEditing] = useState(null); + const [showHistory, setShowHistory] = useState(null); const [open, setOpen] = useState(campaigns.length === 0); const [note, setNote] = useState(null); const [error, setError] = useState(null); @@ -66,6 +88,44 @@ export function CampaignPanel({ } else setError(res.error); }); + // Runs one campaign-row action: marks that button busy, then reports the + // outcome inline next to the campaign it belongs to. + const rowAct = ( + key: string, + campaignName: string, + fn: () => Promise<{ ok: true; note?: string } | { ok: false; error: string }>, + ) => + start(async () => { + setBusy(key); + setRowNote((prev) => ({ ...prev, [campaignName]: undefined as never })); + const res = await fn(); + setRowNote((prev) => ({ + ...prev, + [campaignName]: res.ok + ? { ok: true, text: res.note ?? "Done." } + : { ok: false, text: res.error }, + })); + setBusy(null); + if (res.ok) router.refresh(); + }); + + // Load an existing campaign into the form so it can be changed. Saving + // upserts on (project, name), so keeping the name edits in place. + const startEdit = (c: CampaignSummary) => { + setEditing(c.name); + setName(c.name); + setQueries(c.queries.join("\n")); + setSeedUrls(c.seed_urls.join("\n")); + setMaxScore(c.max_score); + setDailyLimit(c.daily_send_limit); + setPitchMode(c.pitch_mode); + setPitchIntro(c.pitch_intro ?? ""); + setPitchAsk(c.pitch_ask ?? ""); + setPitchFacts((c.pitch_facts ?? []).join("\n")); + setScanProspects(c.scan_prospects); + setOpen(true); + }; + const save = () => act(async () => saveCampaignAction({ @@ -98,7 +158,13 @@ export function CampaignPanel({ auto-send on.

- @@ -132,22 +198,33 @@ export function CampaignPanel({
+ + {c.runs.length > 0 && ( + + )}
+ + {rowNote[c.name] && ( +

+ {rowNote[c.name].text} +

+ )} + + {c.auth_required_hosts.length > 0 && ( +

+ waiting_for_auth{" "} + + needs a sign-in for {c.auth_required_hosts.length} seed — add one under Seed + logins below. + +

+ )} + + {showHistory === c.name && ( +
+

Recent ticks

+
    + {c.runs.map((r) => ( +
  • + + {r.ok ? "✓" : "✕"} {r.ran_at.slice(0, 16).replace("T", " ")} + {" "} + {r.summary} + {r.errors.length > 0 && ( +
      + {r.errors.slice(0, 5).map((e, i) => ( +
    • + {e} +
    • + ))} +
    + )} +
  • + ))} +
+
+ )} ))} @@ -314,7 +448,7 @@ export function CampaignPanel({

Starts in drafts-only mode. Read a few of what it writes, then enable sending. diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index 58102fb3..a6773ed4 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -271,14 +271,33 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise