Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SeedLogins } from "@/components/leads/seed-logins";
import { FunnelPanel } from "@/components/leads/funnel-panel";
import { RepliesPanel, type ReplyRow } from "@/components/leads/replies-panel";
import { ContactsPanel } from "@/components/leads/contacts-panel";
import { IntentPanel, type IntentSignalRow } from "@/components/leads/intent-panel";
import { contactNiches } from "@/lib/outreach/contactsExport";
import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel";
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
Expand Down Expand Up @@ -91,7 +92,7 @@ export default async function LeadsPage({
supabase
.from("outreach_campaigns")
.select(
"id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, angle, sender_name, reply_to",
"id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, min_intent, angle, sender_name, reply_to",
)
.eq("project_id", projectId)
.order("updated_at", { ascending: false })
Expand Down Expand Up @@ -180,6 +181,17 @@ export default async function LeadsPage({
.limit(20);
const replies = (replyData as ReplyRow[] | null) ?? [];

// Public requests to buy, strongest and freshest first. A separate queue
// from the pipeline: these are people on a platform, not domains to email.
const { data: intentData } = await supabase
.from("outreach_intent_signals")
.select("source, url, title, snippet, score, tier, reasons, posted_at, status")
.eq("project_id", projectId)
.neq("status", "dismissed")
.order("score", { ascending: false })
.limit(25);
const intentSignals = (intentData as IntentSignalRow[] | null) ?? [];

let seedCredentials: StoredSeedCredential[] = [];
if (orgId) seedCredentials = await listSeedCredentials(orgId);

Expand Down Expand Up @@ -239,6 +251,8 @@ export default async function LeadsPage({

<CampaignPanel projectId={projectId} campaigns={campaigns} canSendLive={canSendLive} />

<IntentPanel signals={intentSignals} />

<FunnelPanel project={funnel} campaigns={perCampaignFunnel} />

<RepliesPanel replies={replies} />
Expand Down
39 changes: 38 additions & 1 deletion app/(marketing)/lead-generation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LEADS_PER_CHARGE } from "@/lib/outreach/billing";
export const metadata = {
title: "Lead generation & cold outreach",
description:
"Point CrawlProof at a directory or a search, and it finds the companies, finds the people, finds the addresses, writes the email, and reads the replies — from your own mailbox, grounded in facts you supply. 3 credits a run.",
"Watch for people publicly asking to buy what you sell, and work them first. Then find the companies, the people and the addresses, write the email and read the replies — from your own mailbox, grounded in facts you supply.",
alternates: { canonical: "/lead-generation" },
openGraph: {
title: "Lead generation & cold outreach · CrawlProof",
Expand Down Expand Up @@ -85,6 +85,43 @@ export default function LeadGenerationPage() {
</p>
</section>

<section className="mt-20 rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-8">
<h2 className="text-2xl font-bold">Start with people who already asked</h2>
<p className="mt-2 max-w-3xl text-[var(--color-muted)]">
The hard part of cold outreach is not building the list. It is that writing to people who
have shown no intent converts badly however good the email is, and targeting by
resemblance — right industry, right headcount, right tech stack — does not fix that. It
selects for people who look like buyers, not people who are buying.
</p>
<p className="mt-3 max-w-3xl text-[var(--color-muted)]">
So a campaign can watch for the opposite signal: somebody saying, in public and recently,
that they want to buy what you sell. Those leads are ranked ahead of everything found by
resemblance, because they are a different kind of lead rather than a better-matched one.
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card
title="Recency and explicitness, scored"
body="A budget named outright outranks a request for suggestions, which outranks a complaint. All of it decays with age — a fortnight-old request has already been answered by somebody."
/>
<Card
title="Everywhere people ask"
body="Reddit, Hacker News, Stack Exchange, Quora, Indie Hackers, X, LinkedIn and contract boards. One community's regulars are not your whole market."
/>
<Card
title="Somebody else's lead is not yours"
body="A flawless “anyone recommend a good accountant, happy to pay” is worth nothing if you sell load testing, so intent is only counted when it lands on your topic."
/>
<Card
title="It respects a no"
body="“No vendors please”, “don't DM me”, free-only and job ads are dropped outright, whatever else they score. The fastest way to lose a channel is to answer the person who said not to."
/>
</div>
<p className="mt-6 text-sm text-[var(--color-muted)]">
Reply where they asked, in public, as a reply — these are people on a platform, not
addresses to mail. The email pipeline below is for the companies you go and find.
</p>
</section>

<section className="mt-20">
<h2 className="text-2xl font-bold">It reads the directory like a person would</h2>
<p className="mt-2 max-w-3xl text-[var(--color-muted)]">
Expand Down
12 changes: 12 additions & 0 deletions app/actions/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,12 @@ export async function saveCampaignAction(input: {
pitchAsk?: string;
pitchFacts?: string;
scanProspects?: boolean;
/**
* Minimum public buying intent a lead must show, 0-100. Null or absent
* leaves the campaign selecting on resemblance alone, which is what every
* campaign did before intent existed.
*/
minIntent?: number | null;
}): Promise<Ok<{ note: string }> | Err> {
const auth = await requireLeadAccess(input.projectId);
if (!auth.ok) return auth;
Expand Down Expand Up @@ -572,6 +578,12 @@ export async function saveCampaignAction(input: {
max_score: input.maxScore,
daily_send_limit: input.dailySendLimit,
auto_send: input.autoSend,
// Clamped rather than rejected: a bar above 100 silently matches
// nothing, which looks like the sweep being broken.
min_intent:
input.minIntent === null || input.minIntent === undefined
? null
: Math.max(0, Math.min(100, Math.round(input.minIntent))),
angle: input.angle?.trim() || null,
sender_name: input.senderName?.trim() || null,
reply_to: input.replyTo?.trim() || null,
Expand Down
42 changes: 42 additions & 0 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type CampaignSummary = {
pitch_ask: string | null;
pitch_facts: string[];
scan_prospects: boolean;
min_intent: number | null;
auth_required_hosts: string[];
runs: CampaignRun[];
};
Expand Down Expand Up @@ -83,6 +84,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);
// Null means "don't qualify on intent" — the behaviour every campaign had
// before this existed, and the default for anything already running.
const [minIntent, setMinIntent] = useState<number | null>(null);
const [goal, setGoal] = useState("");
const [generating, setGenerating] = useState(false);
const [pitchNote, setPitchNote] = useState<string | null>(null);
Expand Down Expand Up @@ -159,6 +163,7 @@ export function CampaignPanel({
setPitchAsk(c.pitch_ask ?? "");
setPitchFacts((c.pitch_facts ?? []).join("\n"));
setScanProspects(c.scan_prospects);
setMinIntent(c.min_intent ?? null);
// Every field the form submits has to be loaded, not just the ones that
// changed recently: save() sends the whole form, so anything left blank
// here is written back as blank and silently wipes the column.
Expand Down Expand Up @@ -191,6 +196,7 @@ export function CampaignPanel({
pitchAsk,
pitchFacts,
scanProspects,
minIntent,
});
if (result.ok) setEditing(null);
return result;
Expand Down Expand Up @@ -492,6 +498,42 @@ export function CampaignPanel({
</>
)}

<label className="flex items-start gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
className="mt-1"
checked={minIntent !== null}
onChange={(e) => setMinIntent(e.target.checked ? 40 : null)}
/>
<span>
Only work leads who publicly asked to buy
<span className="mt-0.5 block text-xs text-[var(--color-muted)]">
Sweeps Reddit, forums, Q&amp;A sites and the social networks for people asking for
what you sell, and ranks them by how explicitly they asked and how recently. Cold
outreach to people who never asked converts badly however good the copy is.
</span>
</span>
</label>

{minIntent !== null && (
<label className="text-sm sm:col-span-2">
Minimum intent score: <span className="font-mono">{minIntent}</span>
<input
type="range"
min={10}
max={90}
step={5}
className="mt-1 w-full"
value={minIntent}
onChange={(e) => setMinIntent(Number(e.target.value))}
/>
<span className="mt-0.5 block text-xs text-[var(--color-muted)]">
Around 40 is a fresh &ldquo;can anyone recommend…&rdquo;. Above 70 is someone naming
a budget. Higher means fewer and better.
</span>
</label>
)}

<label className="flex items-start gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
Expand Down
79 changes: 79 additions & 0 deletions components/leads/intent-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type IntentSignalRow = {
source: string;
url: string;
title: string | null;
snippet: string | null;
score: number;
tier: string;
reasons: string[];
posted_at: string | null;
status: string;
};

const TIER_TONE: Record<string, string> = {
purchase: "badge-pass",
switching: "badge-pass",
solicitation: "badge-warn",
pain: "badge-unknown",
};

function age(posted: string | null): string {
if (!posted) return "undated";
const hours = (Date.now() - new Date(posted).getTime()) / 3_600_000;
if (hours < 1) return "just now";
if (hours < 24) return `${Math.round(hours)}h ago`;
return `${Math.round(hours / 24)}d ago`;
}

/**
* People who publicly asked to buy, strongest and freshest first.
*
* Deliberately a separate queue from the lead pipeline rather than a column on
* it. These are not companies with an address to email — they are individuals
* on a platform, and the reply goes where they asked, in public, as a reply.
* Treating them as prospects would file every Reddit thread under the domain
* reddit.com and lose the person entirely.
*/
export function IntentPanel({ signals }: { signals: IntentSignalRow[] }) {
if (!signals.length) return null;

return (
<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Asked to buy</h2>
<p className="text-xs text-[var(--color-muted)]">
Ranked by how explicitly they asked and how recently. Reply where they asked — not by
email.
</p>
</div>

<ul className="mt-3 divide-y divide-[var(--color-border)]">
{signals.map((s) => (
<li key={s.url} className="py-3">
<p className="flex flex-wrap items-center gap-2 text-sm">
<span className={`badge ${TIER_TONE[s.tier] ?? "badge-unknown"}`}>{s.tier}</span>
<span className="font-mono text-xs">{s.score}/100</span>
<span className="text-xs text-[var(--color-muted)]">
{s.source} · {age(s.posted_at)}
</span>
</p>
<a
href={s.url}
target="_blank"
rel="noreferrer"
className="mt-1 block font-medium underline"
>
{s.title || s.url}
</a>
{s.snippet && (
<p className="mt-1 text-xs text-[var(--color-muted)]">{s.snippet}</p>
)}
{s.reasons.length > 0 && (
<p className="mt-1 text-xs text-[var(--color-muted)]">{s.reasons.join(" · ")}</p>
)}
</li>
))}
</ul>
</section>
);
}
Loading
Loading