diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx new file mode 100644 index 00000000..c87f59fe --- /dev/null +++ b/app/(marketing)/lead-generation/page.tsx @@ -0,0 +1,273 @@ +import Link from "next/link"; +import { LEAD_RUN_CREDITS, CREDIT_RACK_CENTS, dollars } from "@/lib/credits"; +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, and writes the email — from your own mailbox, grounded in facts you supply. 3 credits a run.", + alternates: { canonical: "/lead-generation" }, + openGraph: { + title: "Lead generation & cold outreach · CrawlProof", + description: + "Find the companies, find the people, find the addresses, write the email — from your own mailbox, grounded in facts you supply. 3 credits a run.", + url: "/lead-generation", + }, +}; + +/** + * The sales page for project-level lead generation. + * + * Every claim here is one the pipeline actually makes good on. Two things it + * deliberately does not claim: open tracking and automatic reply detection. + * Neither is built, and a lead-gen page that oversells is the exact thing the + * drafting guard exists to stop the product doing to other people's inboxes. + */ + +const LADDER = [ + { + step: "1", + title: "The site itself", + body: "Contact and about pages, footers, mailto: links, and the obfuscated addresses that are written to defeat scrapers.", + }, + { + step: "2", + title: "Team & leadership pages", + body: "A named person outperforms a shared inbox, so /our-team, /leadership and /people are read before anything is paid for.", + }, + { + step: "3", + title: "Linked PDFs", + body: "Capability statements, media kits and data-sheets routinely carry an address the page linking to them never mentions.", + }, + { + step: "4", + title: "Search", + body: "Only now does a run spend on a search call — for the people it has a name for but no way to reach.", + }, + { + step: "5", + title: "A role address, last", + body: "When a company publishes nothing at all, a conventional address like press@ is tried — and marked as a guess, not passed off as found.", + }, +]; + +export default function LeadGenerationPage() { + const runRack = LEAD_RUN_CREDITS * CREDIT_RACK_CENTS; + + return ( +
+
+

+ Project feature · Leads +

+

+ The boring half of cold outreach, done for you +

+

+ Finding who to email is most of the work and none of the fun. Point a campaign at a + directory or a search, and CrawlProof finds the companies, finds the people, finds the + addresses, and drafts the email — from your mailbox, in your words, about your thing. +

+
+ + Start a campaign + + + See pricing + +
+

+ {LEAD_RUN_CREDITS} credits a run (~{dollars(runRack)}). A run that finds nothing to do + costs nothing. +

+
+ +
+

It reads the directory like a person would

+

+ Most lead directories are a wall of JavaScript. Fetching the HTML gets you an empty shell, + which is why so much of this work ends up being done by hand. +

+
+ + + +
+
+ +
+

Directories that name people, not companies

+

+ Plenty of the best lists give you a name, a title and a location and withhold the email — + that is the product they are selling. Those are read as people: name, role, employer, + location and profile links are kept, and the address becomes something to go and find + rather than a reason to discard the lead. +

+
+ +
+

Five places to look, cheapest first

+

+ The order is deliberate. Everything free is exhausted before anything paid is tried, so a + run only spends on the prospects that genuinely publish nothing. +

+
    + {LADDER.map((l) => ( +
  1. + + {l.step} + +
    +

    {l.title}

    +

    {l.body}

    +
    +
  2. + ))} +
+
+ +
+

The email can only say what you said

+

+ A campaign carries three things: who is writing, the one small thing you are asking for, + and a list of checkable facts. Every draft is written from those and then checked against + them — a draft that states something outside the list is thrown away rather than sent. +

+
+ + + + +
+
+ +
+

Sent from your mailbox

+

+ Give it the address you already send from and its settings are discovered automatically — + the same lookup your mail client does. Replies come back to you, because the mail came + from you. +

+
+ + + +
+
+ +
+

It will refuse to email some people

+

+ Cold outreach earns its reputation one careless send at a time. Privacy, legal, abuse, + opt-out and accessibility addresses are never contacted, whatever a page publishes. A + postal address for the footer is required before live sending is possible at all, because + CAN-SPAM requires one. Anyone who asks to be left alone is suppressed across every + campaign you run, permanently. +

+
+ +
+

Your numbers, not somebody else's

+

+ Sends, replies and closes are counted at three levels: the whole project, each campaign, + and each individual run. Rates stay hidden until there is enough volume to mean anything — + one reply out of three sends is not a 33% reply rate, and showing it as one invites a + decision the sample cannot support. +

+
+ +
+

What it costs

+
+
+
+ {LEAD_RUN_CREDITS} credits + + per run (~{dollars(runRack)}) + +
+

+ A campaign run is one pass of the whole funnel: discovery, contact lookup, drafting + and sending. A run with nothing to do is not charged, so leaving a campaign switched + on between batches is free. +

+
+
+
+ {LEAD_RUN_CREDITS} credits + + per {LEADS_PER_CHARGE} leads + +
+

+ For a one-off search of a directory, priced by how much you ask for. Find nothing and + it is refunded. +

+
+
+

+ Credits are the same ones scans and articles use — no separate plan, no subscription, and + they do not expire. Volume packs bring a run down to about{" "} + {dollars(Math.round(runRack / 2))}. +

+
+ + Get started + + + Credit packs + +
+
+ +

+ Lead generation runs on projects you own. You are responsible for the mail you send and for + complying with the rules where your recipients are — CrawlProof enforces a suppression list + and a footer address, not the law. +

+
+ ); +} + +function Card({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

+
+ ); +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index ddeed2ef..0b37a19a 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -106,6 +106,7 @@ export default function HomePage() { + @@ -422,14 +423,38 @@ function FeatureCard({ title, body }: { title: string; body: string }) { ); } -function ModuleCard({ emoji, title, body }: { emoji: string; title: string; body: string }) { - return ( -
+function ModuleCard({ + emoji, + title, + body, + href, +}: { + emoji: string; + title: string; + body: string; + /** Set only for modules that have a page of their own to go on to. */ + href?: string; +}) { + const inner = ( + <>
{emoji}

{title}

{body}

-
+ {href &&

Learn more →

} + + ); + // A card without somewhere to go stays a card. Wrapping every one in a link + // to nowhere would make eight of the nine look clickable and do nothing. + return href ? ( + + {inner} + + ) : ( +
{inner}
); } diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 1c59d0b0..0786f454 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -3,6 +3,9 @@ import { CREDIT_PACKS, CREDIT_RACK_CENTS, ENGINES, + LEAD_RUN_CREDITS, + OUTREACH_CREDITS, + SCAN_CREDITS, discountPct, dollars, perCreditCents, @@ -58,6 +61,49 @@ export default function PricingPage() { })} + {/* Credits buy more than scans, and a page that only prices scans makes + every other module look like it is either free or unavailable. */} +
+

What else credits buy

+

+ One balance across the whole platform. No module has its own plan. +

+
+
+
+ {LEAD_RUN_CREDITS} credits +
+

Lead generation run

+

+ One pass of a campaign: discovery, contact lookup, drafting and sending. A run with + nothing to do is not charged.{" "} + + How it works + +

+
+
+
+ {OUTREACH_CREDITS} credit +
+

Outreach send

+

+ One email, SMS recipient or social post sent by hand, outside a campaign run. +

+
+
+
+ {SCAN_CREDITS} credits +
+

Autoblog post

+

+ A researched, illustrated, published long-form article — the same price as an + AI-model scan. +

+
+
+
+
{CREDIT_PACKS.map((p) => { const off = discountPct(p); diff --git a/app/actions/leads.ts b/app/actions/leads.ts index 7f8d547d..9a2fe8c1 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -17,6 +17,7 @@ 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 { leadRunBilling, manualRunPrice } from "@/lib/outreach/billing"; import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; type Ok> = { ok: true } & T; @@ -73,10 +74,21 @@ export async function findLeadsAction(input: { // A directory can list hundreds of businesses; capping a one-shot run at // ten meant the form reported a fraction of a page and looked broken. const limit = Math.min(input.limit ?? 100, 1000); + // Priced per hundred leads asked for. A thousand-lead run does ten times the + // paid search of a hundred-lead one, so charging both the same would make + // the largest runs the cheapest place to spend our money. + const price = manualRunPrice(limit); + const billing = leadRunBilling(auth.userId, price.credits); + if (!(await billing.authorize())) { + return { + ok: false, + error: `Finding up to ${limit} leads costs ${price.credits} credits; not enough balance. Buy credits in Billing.`, + }; + } // Contact lookup is the part that costs money — up to two SERP calls per - // prospect that publishes no address — so a thousand-lead run gets an - // explicit ceiling rather than an unbounded bill. - const contactSearchBudget = { remaining: Math.min(limit, 100) }; + // prospect that publishes no address — so the ceiling is what the run paid + // for rather than an unbounded bill. + const contactSearchBudget = { remaining: price.contactSearches }; const { data: projectRow } = await serviceClient() .from("projects") .select("organization_id") @@ -115,6 +127,8 @@ export async function findLeadsAction(input: { } if (!found.prospects.length && !peopleRecorded) { + // Nothing to show for it, so nothing to charge for it. + await billing.refund(); return { ok: false, error: found.errors.join("; ") || "No businesses found for that search." }; } @@ -147,8 +161,8 @@ export async function findLeadsAction(input: { added, scanning, note: peopleRecorded - ? `${added} leads added, ${peopleRecorded} people recorded.` - : `${added} leads added.`, + ? `${added} leads added, ${peopleRecorded} people recorded — ${price.credits} credits.` + : `${added} leads added — ${price.credits} credits.`, }; } diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index 5aeea23d..5e411ed9 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -8,6 +8,7 @@ import { saveCampaignAction, toggleCampaignAction, } from "@/app/actions/leads"; +import { LEAD_RUN_CREDITS } from "@/lib/credits"; export type CampaignRun = { ran_at: string; @@ -202,7 +203,8 @@ export function CampaignPanel({

Campaigns

Runs every 15 minutes: finds leads, scans them, writes drafts. Only sends when you turn - auto-send on. + auto-send on. {LEAD_RUN_CREDITS} credits per run that does work — a run with nothing to + do is free, so leaving a campaign on between batches costs nothing.

{mode === "query" ? "Finds businesses and looks up a contact address for each. It does not scan them." - : "Every outbound link on that page becomes a candidate. Platforms and aggregators are filtered out."} + : "Every outbound link on that page becomes a candidate. Platforms and aggregators are filtered out."}{" "} + Charged on results — a search that finds nothing is refunded.

{note &&

{note}

} {error &&

{error}

} diff --git a/components/site-footer.tsx b/components/site-footer.tsx index 3b2a2dad..0770679c 100644 --- a/components/site-footer.tsx +++ b/components/site-footer.tsx @@ -14,6 +14,7 @@ export function SiteFooter() {
Product
  • Pricing
  • +
  • Lead generation
  • Slop Score
  • Get guide
  • Recent scans
  • diff --git a/lib/credits.ts b/lib/credits.ts index 2842d444..e07f1fed 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -35,6 +35,40 @@ export const OUTREACH_CREDITS = 1; */ export const LEAD_RUN_CREDITS = 3; +/** Leads covered by one charge on the manual finder. */ +export const LEADS_PER_CHARGE = 100; + +/** + * Paid search lookups a single charge buys. + * + * Matches the per-tick ceiling the price above was measured against, so a + * manual run and an automated one cost the same per unit of the thing that + * actually spends money. Without it a thousand-lead run would buy a hundred + * lookups for the price of ten. + */ +export const CONTACT_SEARCHES_PER_CHARGE = 10; + +/** + * What a manual find-leads run costs, and the search budget it buys. + * + * Priced per hundred leads asked for rather than per lead: the request size is + * what the user chooses, and it is the only figure available before the run + * starts. Rounded up, so asking for one lead is not free. + * + * Pure, and kept here rather than beside the deduction, so the form can show + * the price without pulling a database client into the browser bundle. + */ +export function manualRunPrice(limit: number): { + credits: number; + contactSearches: number; +} { + const blocks = Math.max(1, Math.ceil(limit / LEADS_PER_CHARGE)); + return { + credits: blocks * LEAD_RUN_CREDITS, + contactSearches: blocks * CONTACT_SEARCHES_PER_CHARGE, + }; +} + export type CreditPack = { id: string; label: string; diff --git a/lib/outreach/billing.ts b/lib/outreach/billing.ts new file mode 100644 index 00000000..412a18d2 --- /dev/null +++ b/lib/outreach/billing.ts @@ -0,0 +1,74 @@ +// Charging for lead generation. +// +// The price existed as a constant for a while before anything read it, which +// meant every campaign tick and every one-shot search ran on the house — and +// the expensive part is not the AI, it is the search calls behind contact +// lookup. This is the meter. +// +// Two rules shape it: +// +// A tick is billed only when it is about to do billable work. The cron fires +// every fifteen minutes whether or not a campaign has anything to do, so +// charging per tick would cost 288 credits a day to keep an idle campaign +// switched on. The gate below is therefore lazy: nothing is charged until a +// stage actually reaches for search or a model. +// +// And a tick is billed once, not per stage. Discovery, research and drafting +// inside one run are one unit of work from the user's side, and metering them +// separately would make the cost of leaving a campaign on unpredictable. + +import { LEAD_RUN_CREDITS } from "@/lib/credits"; + +export { manualRunPrice, LEADS_PER_CHARGE, CONTACT_SEARCHES_PER_CHARGE } from "@/lib/credits"; +import { consumeCredit, refundCredit } from "@/lib/rateLimit"; + +export type RunBilling = { + /** + * Charge for this run if it has not been charged yet. + * + * Returns false when the balance will not cover it, which callers treat as + * "stop here" rather than "carry on unbilled". + */ + authorize: () => Promise; + /** True once the run has been paid for. */ + charged: () => boolean; + /** True when a charge was attempted and the balance would not cover it. */ + declined: () => boolean; + /** Hand the money back — for a run that turned out to do nothing. */ + refund: () => Promise; +}; + +/** + * A single charge, taken at most once, on first use. + * + * Deliberately not an up-front charge on the whole tick: what a tick will do + * is not knowable before it looks, and a prospect whose scan has not landed + * costs nothing to skip. + */ +export function leadRunBilling(ownerId: string, credits = LEAD_RUN_CREDITS): RunBilling { + let state: "unbilled" | "billed" | "declined" = "unbilled"; + + return { + async authorize() { + if (state === "billed") return true; + // One decline ends the run. Retrying per stage would hammer the balance + // check for a campaign that has simply run out. + if (state === "declined") return false; + const res = await consumeCredit(ownerId, credits); + state = res.ok ? "billed" : "declined"; + return res.ok; + }, + charged: () => state === "billed", + declined: () => state === "declined", + async refund() { + if (state !== "billed") return; + await refundCredit(ownerId, credits); + state = "unbilled"; + }, + }; +} + +/** What the run history says when a campaign cannot pay for a tick. */ +export function outOfCreditsNote(credits = LEAD_RUN_CREDITS): string { + return `out of credits — a run costs ${credits} credit${credits === 1 ? "" : "s"}; top up in Billing to resume`; +} diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index ff02e2e5..ec35f0ab 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -24,6 +24,8 @@ import { type ProspectRow, } from "./pipeline"; import { nextStepReadyAt, type OutreachStep } from "./cold"; +import { leadRunBilling, outOfCreditsNote } from "./billing"; +import { LEAD_RUN_CREDITS } from "@/lib/credits"; export type CampaignRow = { id: string; @@ -68,6 +70,8 @@ export type TickResult = { autoSend: boolean; /** Seed URLs sitting behind a sign-in with no stored credential. */ awaitingAuth: string[]; + /** Credits this tick actually charged. Zero when it found nothing to do. */ + creditsSpent: number; skipped: string[]; errors: string[]; }; @@ -94,6 +98,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise => { + if (await billing.authorize()) return true; + if (!result.errors.includes(outOfCreditsNote())) result.errors.push(outOfCreditsNote()); + return false; + }; + const { data: pool } = await sb .from("outreach_prospects") .select(PROSPECT_COLUMNS) @@ -131,6 +148,8 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise ["new", "researched", "drafted"].includes(p.status)).length; - if (liveCount < campaign.target_pipeline) { + // Discovery is the most expensive stage — several search calls before a + // single prospect exists — so it is gated like the rest. + if (liveCount < campaign.target_pipeline && (await canSpend())) { const want = Math.min(campaign.target_pipeline - liveCount, MAX_DISCOVER_PER_TICK); const { data: projectRow } = await sb .from("projects") @@ -285,6 +311,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise ({ + consumeCredit: (...a: unknown[]) => consumeCredit(...a), + refundCredit: (...a: unknown[]) => refundCredit(...a), +})); + +const { leadRunBilling, outOfCreditsNote } = await import("@/lib/outreach/billing"); + +beforeEach(() => { + consumeCredit.mockReset().mockResolvedValue({ ok: true }); + refundCredit.mockReset().mockResolvedValue(undefined); +}); + +describe("manualRunPrice", () => { + it("charges one unit for a run inside the first block", () => { + expect(manualRunPrice(LEADS_PER_CHARGE).credits).toBe(LEAD_RUN_CREDITS); + expect(manualRunPrice(1).credits).toBe(LEAD_RUN_CREDITS); + }); + + it("never gives a run away, however small", () => { + // Rounding down would make a one-lead run free, and a one-lead run still + // pays for a search. + expect(manualRunPrice(0).credits).toBe(LEAD_RUN_CREDITS); + }); + + it("scales with the size asked for", () => { + // Ten times the leads is ten times the paid search behind them; charging + // the same would make the biggest runs the cheapest place to spend on us. + expect(manualRunPrice(1000).credits).toBe(10 * LEAD_RUN_CREDITS); + }); + + it("rounds a part-block up", () => { + expect(manualRunPrice(LEADS_PER_CHARGE + 1).credits).toBe(2 * LEAD_RUN_CREDITS); + }); + + it("buys search budget in proportion to the charge", () => { + const small = manualRunPrice(LEADS_PER_CHARGE); + const large = manualRunPrice(LEADS_PER_CHARGE * 10); + expect(large.contactSearches / small.contactSearches).toBe( + large.credits / small.credits, + ); + }); +}); + +describe("leadRunBilling", () => { + it("charges once however many stages ask", async () => { + // A tick's stages each check before spending; the user bought one run. + const b = leadRunBilling("owner-1"); + expect(await b.authorize()).toBe(true); + expect(await b.authorize()).toBe(true); + expect(await b.authorize()).toBe(true); + expect(consumeCredit).toHaveBeenCalledTimes(1); + expect(consumeCredit).toHaveBeenCalledWith("owner-1", LEAD_RUN_CREDITS); + }); + + it("does not charge a tick that never asks", async () => { + // The cron fires every fifteen minutes. Billing an idle campaign per tick + // would cost 288 credits a day to leave switched on. + leadRunBilling("owner-1"); + expect(consumeCredit).not.toHaveBeenCalled(); + }); + + it("stops asking once the balance has declined", async () => { + consumeCredit.mockResolvedValue({ ok: false }); + const b = leadRunBilling("owner-1"); + expect(await b.authorize()).toBe(false); + expect(await b.authorize()).toBe(false); + // Retrying per stage would hammer the balance check for a campaign that + // has simply run out. + expect(consumeCredit).toHaveBeenCalledTimes(1); + expect(b.declined()).toBe(true); + expect(b.charged()).toBe(false); + }); + + it("refunds what it charged", async () => { + const b = leadRunBilling("owner-1"); + await b.authorize(); + await b.refund(); + expect(refundCredit).toHaveBeenCalledWith("owner-1", LEAD_RUN_CREDITS); + expect(b.charged()).toBe(false); + }); + + it("refunds nothing when nothing was charged", async () => { + // A refund path that fires without a charge is free credits. + const b = leadRunBilling("owner-1"); + await b.refund(); + expect(refundCredit).not.toHaveBeenCalled(); + }); + + it("does not refund twice", async () => { + const b = leadRunBilling("owner-1"); + await b.authorize(); + await b.refund(); + await b.refund(); + expect(refundCredit).toHaveBeenCalledTimes(1); + }); + + it("honours a custom price, for runs charged by size", async () => { + const b = leadRunBilling("owner-1", 30); + await b.authorize(); + expect(consumeCredit).toHaveBeenCalledWith("owner-1", 30); + }); + + it("names the price in the out-of-credits note", async () => { + // It lands in the run history, which is the only place a paused campaign + // gets to explain itself. + expect(outOfCreditsNote()).toContain(String(LEAD_RUN_CREDITS)); + expect(outOfCreditsNote()).toMatch(/billing/i); + }); +}); + +describe("the meter is actually connected", () => { + // The price sat in the source as an unreferenced constant for a while, so + // every campaign tick and every manual search ran free. A unit test of the + // pricing function would have passed the whole time it was broken — the bug + // was that nobody called it. These read the source instead. + const read = async (p: string) => + (await import("node:fs")).readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); + + it("charges the campaign tick", async () => { + const src = await read("lib/outreach/runner.ts"); + expect(src).toContain("leadRunBilling"); + }); + + it("gates discovery, the most expensive stage, on being able to pay", async () => { + const src = await read("lib/outreach/runner.ts"); + const discovery = src.slice(src.indexOf("---- 4. Top the funnel up")); + expect(discovery.slice(0, 400)).toMatch(/canSpend\(\)/); + }); + + it("charges the manual finder", async () => { + const src = await read("app/actions/leads.ts"); + expect(src).toContain("manualRunPrice"); + expect(src).toContain("leadRunBilling"); + }); + + it("sizes the manual search budget by what was paid for", async () => { + // Not by the request size: that is what let a thousand-lead run buy a + // hundred lookups for the price of ten. + const src = await read("app/actions/leads.ts"); + expect(src).toContain("remaining: price.contactSearches"); + }); +});