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.
-