diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx new file mode 100644 index 00000000..a123cb08 --- /dev/null +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -0,0 +1,219 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { serviceClient } from "@/lib/supabase/service"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { env } from "@/lib/env"; +import { LeadFinder } from "@/components/leads/lead-finder"; +import { LeadActions } from "@/components/leads/lead-actions"; +import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign-panel"; + +export const metadata = { title: "Leads" }; +export const dynamic = "force-dynamic"; + +type ProspectRow = { + target_key: string; + channel: string; + status: string; + score: number | null; + score_kind: string | null; + contact_email: string | null; + contact_source: string | null; + quote_usd: number | null; + top_issues: string[] | null; + report_token: string | null; + discovery_label: string | null; + last_sent_at: string | null; + last_step: number; +}; + +type SendRow = { + channel: string; + step: number; + recipient: string; + subject: string | null; + dry_run: boolean; + sent_at: string; +}; + +const STATUS_TONE: Record = { + contacted: "badge-pass", + replied: "badge-pass", + won: "badge-pass", + researched: "badge-warn", + drafted: "badge-warn", + new: "badge-unknown", + skipped: "badge-unknown", + lost: "badge-fail", +}; + +export default async function LeadsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id: projectId } = await params; + const access = await requireProjectAccess(projectId, { allowViewer: true }); + if (!access.ok) notFound(); + + // The outreach tables have no RLS policies — reads run on the service + // client after the access check above, the same way the rest of the + // project pages work. It also keeps outreach_sends unwritable from a + // browser, so the record of what was sent stays honest. + const supabase = serviceClient(); + const [{ data: prospectData }, { data: sendData }, { data: campaignData }] = await Promise.all([ + supabase + .from("outreach_prospects") + .select( + "target_key, channel, status, score, score_kind, contact_email, contact_source, quote_usd, top_issues, report_token, discovery_label, last_sent_at, last_step", + ) + .eq("project_id", projectId) + .order("updated_at", { ascending: false }) + .limit(200), + supabase + .from("outreach_sends") + .select("channel, step, recipient, subject, dry_run, sent_at") + .eq("project_id", projectId) + .order("sent_at", { ascending: false }) + .limit(10), + supabase + .from("outreach_campaigns") + .select("name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note") + .eq("project_id", projectId) + .order("updated_at", { ascending: false }) + .limit(10), + ]); + + const prospects = (prospectData as ProspectRow[] | null) ?? []; + const sends = (sendData as SendRow[] | null) ?? []; + const campaigns = (campaignData as CampaignSummary[] | null) ?? []; + + const byStatus = new Map(); + for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1); + + const since = Date.now() - 24 * 3600 * 1000; + const liveToday = sends.filter((s) => !s.dry_run && new Date(s.sent_at).getTime() >= since).length; + const canSendLive = Boolean(env.outreachPostalAddress); + + return ( +
+
+

Leads

+

+ Find businesses, scan their sites, and pitch the fix on behalf of this project — every + email opens with something the scan actually found, linked to a report they can check. +

+
+ + {!canSendLive && ( +

+ Live sending is off. Set OUTREACH_POSTAL_ADDRESS — CAN-SPAM + requires a physical postal address in commercial email. Dry runs work without it. +

+ )} + + + + + +
+
+

Pipeline

+

+ {[...byStatus.entries()].map(([s, n]) => `${n} ${s}`).join(" · ") || "no leads yet"} ·{" "} + {liveToday}/{env.outreachDailyCap} sent today +

+
+ + {prospects.length === 0 ? ( +

+ Nothing yet. Search for businesses above, or set up a campaign to keep the funnel full on + its own. +

+ ) : ( +
    + {prospects.map((p) => { + const isSlop = p.score_kind === "slop"; + return ( +
  • +
    +

    + {p.channel === "reddit" ? `u/${p.target_key}` : p.target_key} + {p.status} + {p.score !== null && ( + + {p.score}/100 {isSlop ? "slop" : "AEO"} + + )} + {p.quote_usd ? ( + fix ≈ ${p.quote_usd} + ) : null} +

    + +

    + {p.contact_email ? ( + <> + {p.contact_email} + {p.contact_source === "manual" ? " (manual)" : ""} + + ) : ( + "no contact address found" + )} + {p.discovery_label ? ` · found as “${p.discovery_label}”` : ""} + {p.last_sent_at + ? ` · step ${p.last_step} on ${p.last_sent_at.slice(0, 10)}` + : ""} +

    + + {p.top_issues && p.top_issues.length > 0 && ( +
      + {p.top_issues.slice(0, 3).map((issue) => ( +
    • {issue}
    • + ))} +
    + )} + + {p.report_token && ( + + View report ↗ + + )} +
    + + {p.channel === "email" && ( + + )} +
  • + ); + })} +
+ )} +
+ + {sends.length > 0 && ( +
+

Recent sends

+
    + {sends.map((s, i) => ( +
  • + + {s.dry_run ? "· dry " : "✓ live"} + {" "} + {s.sent_at.slice(0, 16).replace("T", " ")} {s.channel} step {s.step} → {s.recipient} + {s.subject ? ` — ${s.subject}` : ""} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/app/(marketing)/hire/page.tsx b/app/(marketing)/hire/page.tsx index fbaded18..ceb338a5 100644 --- a/app/(marketing)/hire/page.tsx +++ b/app/(marketing)/hire/page.tsx @@ -1,14 +1,14 @@ import { HireForm } from "@/components/hire-form"; export const metadata = { - title: "Hire us — Fix your AEO in under 24 hours", + title: "Hire us — done-for-you AEO fixes", description: - "Get a human team to fix your website's AEO (Answer Engine Optimization) so AI assistants and bots can find, cite, and recommend your site. Turnaround under 24 hours.", + "Get a human team to fix your website's AEO (Answer Engine Optimization) so AI assistants and bots can find, cite, and recommend your site. Scoped from your scan and billed by the hour.", alternates: { canonical: "/hire" }, openGraph: { title: "Hire us · CrawlProof", description: - "Get your website's AEO fixed by a human team in under 24 hours.", + "Get your website's AEO fixed by a human team, scoped from a real scan of your site.", url: "/hire", }, }; @@ -25,7 +25,7 @@ export default async function HirePage({ Done-for-you

- We'll fix your site's AEO in under 24 hours. + We'll fix your site's AEO — properly.

Schema, llms.txt, AI-bot-friendly rendering, internal linking, @@ -36,9 +36,10 @@ export default async function HirePage({