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
219 changes: 219 additions & 0 deletions app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, number>();
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Leads</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
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.
</p>
</div>

{!canSendLive && (
<p className="card border-[var(--color-warn,#facc15)] p-3 text-sm">
<strong>Live sending is off.</strong> Set <code>OUTREACH_POSTAL_ADDRESS</code> — CAN-SPAM
requires a physical postal address in commercial email. Dry runs work without it.
</p>
)}

<LeadFinder projectId={projectId} />

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

<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Pipeline</h2>
<p className="text-sm text-[var(--color-muted)]">
{[...byStatus.entries()].map(([s, n]) => `${n} ${s}`).join(" · ") || "no leads yet"} ·{" "}
{liveToday}/{env.outreachDailyCap} sent today
</p>
</div>

{prospects.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">
Nothing yet. Search for businesses above, or set up a campaign to keep the funnel full on
its own.
</p>
) : (
<ul className="mt-4 divide-y divide-[var(--color-border)]">
{prospects.map((p) => {
const isSlop = p.score_kind === "slop";
return (
<li key={`${p.channel}:${p.target_key}`} className="flex flex-wrap items-start justify-between gap-4 py-4">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 font-medium">
{p.channel === "reddit" ? `u/${p.target_key}` : p.target_key}
<span className={`badge ${STATUS_TONE[p.status] ?? "badge-unknown"}`}>{p.status}</span>
{p.score !== null && (
<span className="font-mono text-xs text-[var(--color-muted)]">
{p.score}/100 {isSlop ? "slop" : "AEO"}
</span>
)}
{p.quote_usd ? (
<span className="text-xs text-[var(--color-muted)]">fix ≈ ${p.quote_usd}</span>
) : null}
</p>

<p className="mt-1 text-xs text-[var(--color-muted)]">
{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>

{p.top_issues && p.top_issues.length > 0 && (
<ul className="mt-2 list-inside list-disc text-xs text-[var(--color-muted)]">
{p.top_issues.slice(0, 3).map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}

{p.report_token && (
<Link
href={`/r/${p.report_token}`}
className="mt-2 inline-block text-xs underline"
target="_blank"
>
View report ↗
</Link>
)}
</div>

{p.channel === "email" && (
<LeadActions
projectId={projectId}
host={p.target_key}
hasContact={Boolean(p.contact_email)}
nextStep={Math.min((p.last_step ?? 0) + 1, 3)}
/>
)}
</li>
);
})}
</ul>
)}
</section>

{sends.length > 0 && (
<section className="card p-4">
<h2 className="text-lg font-semibold">Recent sends</h2>
<ul className="mt-3 space-y-1 font-mono text-xs text-[var(--color-muted)]">
{sends.map((s, i) => (
<li key={`${s.sent_at}-${i}`}>
<span className={s.dry_run ? "" : "text-[var(--color-fg)]"}>
{s.dry_run ? "· dry " : "✓ live"}
</span>{" "}
{s.sent_at.slice(0, 16).replace("T", " ")} {s.channel} step {s.step} → {s.recipient}
{s.subject ? ` — ${s.subject}` : ""}
</li>
))}
</ul>
</section>
)}
</div>
);
}
13 changes: 7 additions & 6 deletions app/(marketing)/hire/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
},
};
Expand All @@ -25,7 +25,7 @@ export default async function HirePage({
Done-for-you
</p>
<h1 className="mt-2 text-4xl font-extrabold leading-tight sm:text-5xl">
We&apos;ll fix your site&apos;s AEO in under 24 hours.
We&apos;ll fix your site&apos;s AEO — properly.
</h1>
<p className="mt-4 max-w-2xl text-lg text-[var(--color-muted)]">
Schema, llms.txt, AI-bot-friendly rendering, internal linking,
Expand All @@ -36,9 +36,10 @@ export default async function HirePage({

<ul className="mt-6 grid gap-3 text-sm sm:grid-cols-2">
<li className="card p-4">
<strong>&lt; 24h turnaround</strong>
<strong>Scoped from your scan</strong>
<p className="mt-1 text-[var(--color-muted)]">
Most fixes are live the same business day after we get access.
We price the work off what the report actually found, at $100/hour.
Most engagements run two to three weeks.
</p>
</li>
<li className="card p-4">
Expand Down
Loading
Loading