diff --git a/app/actions/leads.ts b/app/actions/leads.ts index b3541f40..7f8d547d 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -16,6 +16,7 @@ import { addSuppression } from "@/lib/outreach/suppress"; import { loadAddressSettings } from "@/lib/outreach/postalAddress"; import { discoverProspects } from "@/lib/outreach/discover"; import { upsertContact } from "@/lib/outreach/contacts"; +import { generatePitch } from "@/lib/outreach/generatePitch"; import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; type Ok> = { ok: true } & T; @@ -160,6 +161,29 @@ export async function findLeadsAction(input: { * only ever report sends, and a reply rate that is structurally zero is * worse than no reply rate at all. */ +/** + * Turn a rough goal into the three pitch fields. + * + * The form asks for precisely-scoped inputs and the grounding guard refuses + * drafts that stray outside them, which is a lot to ask of someone who just + * wants to describe what they are doing. This does the splitting, and + * deliberately does not author: a fact invented here is one the guard will + * pass straight through to a stranger, because drafts are checked against + * this output rather than against reality. + */ +export async function generatePitchAction(input: { + projectId: string; + goal: string; + senderName?: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + + const res = await generatePitch({ goal: input.goal, senderName: input.senderName }); + if (!res.ok) return { ok: false, error: res.error }; + return { ok: true, ...res.pitch }; +} + export async function markLeadOutcomeAction(input: { projectId: string; host: string; diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index e3851403..5aeea23d 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { + generatePitchAction, runCampaignAction, saveCampaignAction, toggleCampaignAction, @@ -81,6 +82,9 @@ export function CampaignPanel({ // scan of the prospect to contribute. Selecting the audit pitch turns it // back on, because that pitch is built from findings. const [scanProspects, setScanProspects] = useState(false); + const [goal, setGoal] = useState(""); + const [generating, setGenerating] = useState(false); + const [pitchNote, setPitchNote] = useState(null); const [senderName, setSenderName] = useState(""); const [replyTo, setReplyTo] = useState(""); @@ -95,6 +99,30 @@ export function CampaignPanel({ } else setError(res.error); }); + // Fills the three pitch fields from a plain description of the goal. + // Writing an intro, a single ask and a list of checkable claims is a + // precise task, and the grounding guard refuses drafts that stray outside + // them — which punishes anyone who describes their goal the way people + // actually describe goals. + const generate = () => + start(async () => { + setPitchNote(null); + setError(null); + setGenerating(true); + const res = await generatePitchAction({ projectId, goal, senderName }); + setGenerating(false); + if (!res.ok) { + setError(res.error); + return; + } + setPitchIntro(res.intro); + setPitchAsk(res.ask); + setPitchFacts(res.facts.join("\n")); + setPitchMode("custom"); + setScanProspects(false); + setPitchNote("Filled in below — read it before saving. Nothing was invented, but check it says what you meant."); + }); + // Runs one campaign-row action: marks that button busy, then reports the // outcome inline next to the campaign it belongs to. const rowAct = ( @@ -398,6 +426,34 @@ export function CampaignPanel({ {pitchMode === "custom" && ( <> +
+