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
9 changes: 7 additions & 2 deletions app/(marketing)/hire/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export const metadata = {
},
};

export default function HirePage() {
export default async function HirePage({
searchParams,
}: {
searchParams: Promise<{ email?: string; website?: string }>;
}) {
const sp = await searchParams;
return (
<main className="mx-auto max-w-3xl px-4 sm:px-6 py-16">
<p className="text-xs uppercase tracking-wider text-[var(--color-accent)]">
Expand Down Expand Up @@ -63,7 +68,7 @@ export default function HirePage() {
A few quick details and we&apos;ll reply with next steps.
</p>
<div className="mt-4">
<HireForm />
<HireForm defaultEmail={sp.email ?? ""} defaultWebsite={sp.website ?? ""} />
</div>
</div>
</main>
Expand Down
239 changes: 239 additions & 0 deletions app/api/admin/lead-campaign/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { buildShareCard } from "@/lib/audit/share-card";
import { leadCampaignEmailHtml, sendMarketingEmail } from "@/lib/email";
import { recordLead } from "@/lib/marketing";
import {
campaignSubject,
hireUrlFor,
selectRecipients,
type LeadRow,
type Segment,
} from "@/lib/leadCampaign";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const maxDuration = 300;

// One-off re-engagement of people who ran a scan and asked for the PDF.
//
// Why this exists rather than /api/admin/email-broadcast: that route selects
// from `profiles`, so it cannot reach a lead who never registered, and it
// sends raw HTML through sendBulk with no unsubscribe footer and no
// List-Unsubscribe headers. This one goes through sendMarketingEmail, which
// adds both, and it filters on unsubscribed_at.
//
// DRY RUN BY DEFAULT. You must pass {"dryRun": false} to actually send.

async function checkAdmin(): Promise<{ ok: true } | { ok: false; response: NextResponse }> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
const { data: me } = await supabase
.from("profiles")
.select("is_admin")
.eq("id", user.id)
.maybeSingle();
if (!me?.is_admin) {
return { ok: false, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { ok: true };
}

async function loadAudience(): Promise<LeadRow[]> {
const svc = serviceClient();

// Every completed scan that captured an email, newest first — the first row
// per address therefore carries their most recent report.
const { data: audits } = await svc
.from("audits")
.select("pdf_email, target_url, share_token, status, score, engine, summary, id, completed_at")
.not("pdf_email", "is", null)
.eq("status", "complete")
.order("completed_at", { ascending: false })
.limit(2000);

const rows = (audits ?? []) as Array<Record<string, unknown>>;
const emails = Array.from(
new Set(rows.map((r) => String(r.pdf_email ?? "").trim().toLowerCase()).filter(Boolean)),
);
if (emails.length === 0) return [];

const [{ data: contacts }, { data: profiles }] = await Promise.all([
svc.from("marketing_contacts").select("email, unsubscribed_at, consented_at").in("email", emails),
svc.from("profiles").select("email").in("email", emails),
]);

const contactByEmail = new Map(
(contacts ?? []).map((c) => [String(c.email).toLowerCase(), c]),
);
const customerEmails = new Set(
(profiles ?? []).map((p) => String(p.email ?? "").toLowerCase()),
);

// Top findings for the most recent report per address.
const firstAuditIdByEmail = new Map<string, string>();
for (const r of rows) {
const e = String(r.pdf_email ?? "").trim().toLowerCase();
if (e && !firstAuditIdByEmail.has(e)) firstAuditIdByEmail.set(e, String(r.id));
}
const { data: findings } = await svc
.from("audit_findings")
.select("audit_id, title, status, priority")
.in("audit_id", Array.from(firstAuditIdByEmail.values()))
.in("status", ["fail", "warn"])
.order("priority", { ascending: true });

const issuesByAudit = new Map<string, string[]>();
for (const f of (findings ?? []) as Array<{ audit_id: string; title: string }>) {
const list = issuesByAudit.get(f.audit_id) ?? [];
if (list.length < 3) list.push(f.title);
issuesByAudit.set(f.audit_id, list);
}

const out: LeadRow[] = [];
const seen = new Set<string>();
for (const r of rows) {
const email = String(r.pdf_email ?? "").trim().toLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);

const card = buildShareCard(r as Parameters<typeof buildShareCard>[0]);
const contact = contactByEmail.get(email);
out.push({
email,
host: card.host,
reportToken: (r.share_token as string | null) ?? null,
score: card.score,
scoreLabel: card.label,
scaleHint: card.scaleHint,
topIssues: issuesByAudit.get(String(r.id)) ?? [],
isCustomer: customerEmails.has(email),
unsubscribedAt: (contact?.unsubscribed_at as string | null) ?? null,
consentedAt: (contact?.consented_at as string | null) ?? null,
});
}
return out;
}

export async function GET(req: NextRequest) {
const auth = await checkAdmin();
if (!auth.ok) return auth.response;

const segment = (req.nextUrl.searchParams.get("segment") ?? "all") as Segment;
const audience = await loadAudience();
const { send, excluded } = selectRecipients(audience, segment);

const byReason: Record<string, number> = {};
for (const e of excluded) byReason[e.reason] = (byReason[e.reason] ?? 0) + 1;

return NextResponse.json({
segment,
wouldSend: send.length,
excluded: byReason,
customers: send.filter((r) => r.isCustomer).length,
coldLeads: send.filter((r) => !r.isCustomer).length,
sample: send.slice(0, 5).map((r) => ({
email: r.email,
host: r.host,
score: r.score,
subject: campaignSubject(r),
topIssues: r.topIssues,
})),
});
}

export async function POST(req: NextRequest) {
const auth = await checkAdmin();
if (!auth.ok) return auth.response;

let body: { segment?: Segment; dryRun?: boolean; limit?: number; testTo?: string } = {};
try {
body = await req.json();
} catch {
/* empty body = dry run over everything */
}

const segment: Segment = body.segment ?? "all";
// Opt IN to sending. An accidental POST must never mail 100 people.
const dryRun = body.dryRun !== false;
const limit = Math.max(1, Math.min(body.limit ?? 500, 500));

const audience = await loadAudience();
const { send } = selectRecipients(audience, segment);
const batch = send.slice(0, limit);

if (dryRun) {
return NextResponse.json({
dryRun: true,
segment,
wouldSend: batch.length,
recipients: batch.map((r) => r.email),
});
}

const svc = serviceClient();
const base = env.siteUrl.replace(/\/$/, "");
let sent = 0;
let failed = 0;
const errors: string[] = [];

for (const row of batch) {
// A send-to-one override so the whole campaign can be previewed in a real
// inbox before it goes out.
const to = body.testTo || row.email;

// Every recipient needs a marketing_contacts row, because that is where
// the unsubscribe token lives. recordLead is a no-op if one exists and
// never upgrades consent.
await recordLead({ email: row.email, source: "lead-campaign" });
const { data: contact } = await svc
.from("marketing_contacts")
.select("unsubscribe_token, unsubscribed_at")
.ilike("email", row.email)
.maybeSingle();

// Re-check immediately before sending: the audience was selected earlier
// in this request and somebody may have unsubscribed in between.
if (!contact?.unsubscribe_token || contact.unsubscribed_at) {
failed++;
errors.push(`${row.email}: no token or unsubscribed`);
continue;
}

const res = await sendMarketingEmail({
to,
subject: campaignSubject(row),
html: leadCampaignEmailHtml({
host: row.host,
scoreLabel: row.scoreLabel,
score: row.score,
scaleHint: row.scaleHint,
topIssues: row.topIssues,
reportUrl: `${base}/r/${row.reportToken}`,
hireUrl: hireUrlFor(row, base),
isCustomer: row.isCustomer,
}),
unsubscribeToken: contact.unsubscribe_token as string,
});

if (res.sent) sent++;
else {
failed++;
if (errors.length < 10) errors.push(`${row.email}: ${res.error}`);
}

// Paced so a burst doesn't trip the provider's rate limit or look like a
// spam cannon to receiving domains.
await new Promise((r) => setTimeout(r, 400));
if (body.testTo) break;
}

console.log(`[admin/lead-campaign] segment=${segment} sent=${sent} failed=${failed}`);
return NextResponse.json({ dryRun: false, segment, sent, failed, errors });
}
15 changes: 12 additions & 3 deletions components/hire-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@
import { useState, useTransition } from "react";
import { submitHireInquiry } from "@/app/actions/hireInquiry";

export function HireForm() {
// defaultEmail / defaultWebsite are prefilled from the query string when the
// visitor arrives from a campaign email, so the form is already half-filled
// with what we know. Every field stays editable.
export function HireForm({
defaultEmail = "",
defaultWebsite = "",
}: {
defaultEmail?: string;
defaultWebsite?: string;
} = {}) {
const [pending, start] = useTransition();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [email, setEmail] = useState(defaultEmail);
const [phone, setPhone] = useState("");
const [website, setWebsite] = useState("");
const [website, setWebsite] = useState(defaultWebsite);
const [monthlyRevenue, setMonthlyRevenue] = useState("");
const [location, setLocation] = useState("");
const [message, setMessage] = useState("");
Expand Down
94 changes: 94 additions & 0 deletions lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,3 +829,97 @@ export async function sendWatchChangeEmail(input: {
if (!res.sent) return { sent: false, error: res.error };
return { sent: true };
}

// ============================================================
// Lead re-engagement campaign — personalised from the recipient's OWN scan.
//
// Deliberately not a newsletter blast. Everyone who gets this asked us for a
// PDF of a specific report, so the email is about that report: their host,
// their score, their worst findings. That converts better than a generic
// pitch, and it keeps the message tied to the thing they actually requested.
// ============================================================

export function leadCampaignEmailHtml(input: {
host: string;
scoreLabel: string;
score: number | null;
scaleHint: string;
topIssues: string[];
reportUrl: string;
hireUrl: string;
isCustomer: boolean;
}): string {
const issues = input.topIssues.length
? `<ul style="margin:14px 0 0;padding-left:18px;color:#9aa3b2;font-size:14px;line-height:1.8;">
${input.topIssues.map((i) => `<li>${escapeHtml(i)}</li>`).join("")}
</ul>`
: "";

const scoreBlock =
input.score !== null
? `<tr>
<td style="padding:20px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background:#12161c;border:1px solid #1f2630;color:#e7e9ee;font-weight:800;font-size:26px;padding:14px 22px;border-radius:12px;line-height:1;">
${input.score}<span style="font-size:13px;opacity:.6;font-weight:700;"> / 100</span>
</td>
<td style="padding-left:14px;color:#9aa3b2;font-size:13px;">
${escapeHtml(input.scoreLabel)}<br>
<span style="color:#64748b;font-size:12px;">${escapeHtml(input.scaleHint)}</span>
</td>
</tr>
</table>
</td>
</tr>`
: "";

const innerHtml = `<tr>
<td style="padding:24px 32px 0;">
<h1 style="margin:0;font-size:22px;line-height:1.3;font-weight:800;color:#e7e9ee;">
About your ${escapeHtml(input.host)} scan
</h1>
<p style="margin:12px 0 0;font-size:14px;line-height:1.6;color:#9aa3b2;">
You ran a CrawlProof report on
<strong style="color:#e7e9ee;">${escapeHtml(input.host)}</strong>
and asked us to email you the PDF. Here's where it landed${input.isCustomer ? "" : " — and an offer, if you'd rather not fix it yourself"}.
</p>
</td>
</tr>
${scoreBlock}
<tr>
<td style="padding:18px 32px 0;">
${input.topIssues.length ? `<p style="margin:0;font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:#64748b;">Biggest items on that report</p>` : ""}
${issues}
</td>
</tr>
<tr>
<td style="padding:22px 32px 0;">
<p style="margin:0;font-size:14px;line-height:1.6;color:#9aa3b2;">
We also fix these directly. Tell us about the site and we'll come
back with what it takes to move that number — no obligation.
</p>
</td>
</tr>
<tr>
<td style="padding:18px 32px 8px;">
<a href="${input.hireUrl}"
style="display:inline-block;padding:12px 22px;background:#6ee7b7;color:#042f1a;text-decoration:none;border-radius:10px;font-weight:700;font-size:15px;">
Get a fix quote →
</a>
</td>
</tr>
<tr>
<td style="padding:8px 32px 24px;">
<p style="margin:8px 0 0;font-size:12px;line-height:1.6;color:#64748b;">
Your report is still live:<br>
<a href="${input.reportUrl}" style="color:#9aa3b2;word-break:break-all;">${input.reportUrl}</a>
</p>
</td>
</tr>`;

return emailShell({
title: `Your CrawlProof scan of ${input.host}`,
innerHtml,
});
}
Loading
Loading