diff --git a/app/actions/leads.ts b/app/actions/leads.ts index a44e74d9..b2c378c0 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -402,6 +402,11 @@ export async function saveCampaignAction(input: { angle?: string; senderName?: string; replyTo?: string; + pitchMode?: "audit" | "custom"; + pitchIntro?: string; + pitchAsk?: string; + pitchFacts?: string; + scanProspects?: boolean; }): Promise | Err> { const auth = await requireLeadAccess(input.projectId); if (!auth.ok) return auth; @@ -412,6 +417,38 @@ export async function saveCampaignAction(input: { if (!queries.length && !seedUrls.length) { return { ok: false, error: "A campaign needs at least one search query or directory URL." }; } + const pitchMode = input.pitchMode ?? "audit"; + const pitchFacts = (input.pitchFacts ?? "") + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (pitchMode === "custom") { + // The same bar the audit pitch holds itself to: without a description + // and some declared facts there is nothing truthful for a draft to say, + // and the grounding guard would reject every draft anyway. + if (!input.pitchIntro?.trim()) { + return { ok: false, error: "A custom pitch needs a description of who is writing and why." }; + } + if (!pitchFacts.length) { + return { + ok: false, + error: + "A custom pitch needs at least one fact. Drafts may only state what you list here, so an empty list means every draft gets rejected.", + }; + } + } + + // Scanning is the audit pitch's evidence step; default it off for a custom + // pitch rather than scanning people we're emailing about something else. + const scanProspects = input.scanProspects ?? pitchMode === "audit"; + if (pitchMode === "audit" && !scanProspects) { + return { + ok: false, + error: + "The audit pitch is built from scan findings, so it can't run with scanning turned off. Switch to a custom pitch instead.", + }; + } + if (input.autoSend) { const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId }); if (!postal.address) { @@ -440,6 +477,11 @@ export async function saveCampaignAction(input: { angle: input.angle?.trim() || null, sender_name: input.senderName?.trim() || null, reply_to: input.replyTo?.trim() || null, + pitch_mode: pitchMode, + pitch_intro: input.pitchIntro?.trim() || null, + pitch_ask: input.pitchAsk?.trim() || null, + pitch_facts: pitchFacts, + scan_prospects: scanProspects, }, { onConflict: "project_id,name" }, ); diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index 98f5b22a..efd74a63 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -47,6 +47,11 @@ export function CampaignPanel({ const [maxScore, setMaxScore] = useState(70); const [dailyLimit, setDailyLimit] = useState(10); const [angle, setAngle] = useState(""); + const [pitchMode, setPitchMode] = useState<"audit" | "custom">("audit"); + const [pitchIntro, setPitchIntro] = useState(""); + const [pitchAsk, setPitchAsk] = useState(""); + const [pitchFacts, setPitchFacts] = useState(""); + const [scanProspects, setScanProspects] = useState(true); const [senderName, setSenderName] = useState(""); const [replyTo, setReplyTo] = useState(""); @@ -75,6 +80,11 @@ export function CampaignPanel({ angle, senderName, replyTo, + pitchMode, + pitchIntro, + pitchAsk, + pitchFacts, + scanProspects, }), ); @@ -215,6 +225,80 @@ export function CampaignPanel({ onChange={(e) => setDailyLimit(Number(e.target.value))} /> + + + {pitchMode === "custom" && ( + <> +