From 198dd185116f7d6bbc0e55e5e5e2868efa2e94a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 03:22:44 +0000 Subject: [PATCH 1/2] Add /advertorial workshop landing page with book-a-call CTA Surface a dedicated workshop CTA that routes attendees to a free advertorial teardown call. Captures qualifier leads via the existing Kit infrastructure (tagged book-call) and hands them to a configurable booking link. - app/advertorial/page.tsx: hero, ad -> advertorial -> PDP positioning, the research -> personas -> angles -> advertorial process, booking CTA - components/teardown-form.tsx: qualifier form, Kit capture, redirect to NEXT_PUBLIC_BOOKING_URL - app/api/book-call/route.ts: lead capture modeled on request-build - .env.example: document NEXT_PUBLIC_BOOKING_URL Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014kgmAddnYtsotUMyUZa8kq --- .env.example | 7 +- app/advertorial/page.tsx | 145 ++++++++++++++++++++++++++++++++ app/api/book-call/route.ts | 80 ++++++++++++++++++ components/teardown-form.tsx | 159 +++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 app/advertorial/page.tsx create mode 100644 app/api/book-call/route.ts create mode 100644 components/teardown-form.tsx diff --git a/.env.example b/.env.example index cada1ef..cdf15e4 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ -# Kit (ConvertKit) — required for the /api/subscribe and /api/request-build forms. +# Kit (ConvertKit) — required for the /api/subscribe, /api/request-build, and /api/book-call forms. # Generate a V4 API key in the Kit dashboard: Settings → Developer → API Keys. # Set this in .env.local for local dev, and in the Vercel project's Environment Variables for production. KIT_API_KEY= + +# Booking link for the /advertorial teardown CTA (Cal.com, Calendly, SavvyCal…). +# After a lead submits the teardown form they're redirected here. If left unset, +# leads are still captured in Kit and shown a "I'll follow up to schedule" message. +NEXT_PUBLIC_BOOKING_URL= diff --git a/app/advertorial/page.tsx b/app/advertorial/page.tsx new file mode 100644 index 0000000..ec0ed23 --- /dev/null +++ b/app/advertorial/page.tsx @@ -0,0 +1,145 @@ +import type { Metadata } from "next" +import { Logo47 } from "@/components/logo-47" +import { TeardownForm } from "@/components/teardown-form" + +export const metadata: Metadata = { + title: "Advertorial teardown — channel47", + description: + "The page that sits between the ad and the checkout. See where an advertorial earns its place in your funnel — book a free 15-minute teardown.", +} + +const STAGES = [ + { + tag: "01 / The ad", + title: "Wins the click", + body: "A hook and a promise. Its whole job is to interrupt the scroll and earn the next tap — nothing more.", + }, + { + tag: "02 / The advertorial", + title: "Earns the belief", + body: "The missing middle. Editorial-style proof that turns a cold, skeptical click into a warm buyer who already understands why the product is right for them.", + highlight: true, + }, + { + tag: "03 / The PDP / checkout", + title: "Takes the order", + body: "By the time they land here, the selling is done. The page just has to remove friction and close.", + }, +] + +export default function AdvertorialPage() { + return ( +
+
+ + + + channel47 + + + +
+ + {/* Hero */} +
+

+ The page between the ad and the checkout +

+

+ Your ad gets the click. Your advertorial gets the sale. +

+

+ Most ecommerce funnels send cold traffic straight from an ad to a product page and wonder why it doesn't + convert. The fix is the page in the middle — the advertorial. I build them with an agent skill that runs + customer research, generates personas, and writes the angles before a word of copy gets drafted. +

+
+ + Book a free teardown + + 15 minutes · live · no pitch deck +
+
+ + {/* Where it fits */} +
+
+

Where it fits

+
+ {STAGES.map((stage) => ( +
+

+ {stage.tag} +

+

{stage.title}

+

{stage.body}

+
+ ))} +
+
+
+ + {/* Proof — drop the advertorial you build live here */} +
+

Built live

+

+ This is the exact process I'll run on your funnel. +

+

+ Research the buyer → generate the personas → pull the angles → draft the advertorial. On the teardown I'll show + you the first stage of it against your real product, live, so you can see exactly where it earns its place. +

+
    + {["Customer research", "Personas", "Angles", "Advertorial"].map((step, i) => ( +
  1. + {String(i + 1).padStart(2, "0")} + {step} +
  2. + ))} +
+
+ + {/* Book */} +
+
+
+

Take the next step

+

+ Book a free advertorial teardown. +

+

+ Tell me what you're selling and I'll pull your funnel apart on a 15-minute call — where it's leaking, and + the advertorial angle that plugs it. No deck, no obligation. +

+
+
+ +
+
+
+ + +
+ ) +} diff --git a/app/api/book-call/route.ts b/app/api/book-call/route.ts new file mode 100644 index 0000000..ea6f02f --- /dev/null +++ b/app/api/book-call/route.ts @@ -0,0 +1,80 @@ +import { type NextRequest, NextResponse } from "next/server" +import { isKitConfigured, isSameOrigin, sanitizeFields, subscribeToKit } from "@/lib/kit" + +export const runtime = "nodejs" + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export async function POST(req: NextRequest) { + if (!isSameOrigin(req)) { + return NextResponse.json({ ok: false, error: "Cross-origin requests are not allowed." }, { status: 403 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ ok: false, error: "Invalid request body." }, { status: 400 }) + } + + const data = (body ?? {}) as Record + + const name = typeof data.name === "string" ? data.name.trim() : "" + const email = typeof data.email === "string" ? data.email.trim() : "" + const brandUrl = typeof data.brandUrl === "string" ? data.brandUrl.trim() : "" + const product = typeof data.product === "string" ? data.product.trim() : "" + const bottleneck = typeof data.bottleneck === "string" ? data.bottleneck.trim() : "" + + const errors: Record = {} + + if (!name) errors.name = "Name is required." + if (!EMAIL_RE.test(email)) errors.email = "Enter a valid email address." + if (!product) errors.product = "Tell me what you're selling." + + if (Object.keys(errors).length > 0) { + return NextResponse.json({ ok: false, errors }, { status: 422 }) + } + + const cleanEmail = email.toLowerCase() + // Always retained so a Kit outage never loses a teardown lead. + const lead = { name, email: cleanEmail, brandUrl, product, bottleneck } + + if (!isKitConfigured()) { + console.error("[kit] KIT_API_KEY is not set — teardown request not sent to Kit, logging only:", lead) + return NextResponse.json({ ok: true, message: "You're booked in. Check your inbox for the next step." }) + } + + try { + const result = await subscribeToKit(cleanEmail, { + firstName: name, + tag: "book-call", + fields: sanitizeFields({ + signup_source: "channel47_website", + signup_context: "advertorial-teardown", + brand_url: brandUrl, + product, + bottleneck, + }), + }) + + // Never lose a teardown lead: log the full payload whenever Kit didn't fully + // capture it (request failed, custom fields stripped, or fields ignored). + const incomplete = + !result.ok || result.fieldsDropped || (Array.isArray(result.warnings) && result.warnings.length > 0) + if (incomplete) { + console.error("[kit] teardown request — Kit capture incomplete, logging full lead for follow-up:", { + ok: result.ok, + status: result.status, + error: result.error, + fieldsDropped: result.fieldsDropped, + warnings: result.warnings, + lead, + }) + } + } catch (err) { + console.error("[kit] teardown request — Kit error, logging lead for follow-up:", { err, lead }) + } + + // The user always gets a success response once validation passes. + return NextResponse.json({ ok: true, message: "You're booked in. Check your inbox for the next step." }) +} diff --git a/components/teardown-form.tsx b/components/teardown-form.tsx new file mode 100644 index 0000000..9b29523 --- /dev/null +++ b/components/teardown-form.tsx @@ -0,0 +1,159 @@ +"use client" + +import { useState } from "react" + +type Errors = Partial> + +// Your scheduling link (Cal.com, Calendly, SavvyCal…). Set in .env.local and in +// Vercel → Environment Variables. If unset, leads are still captured in Kit and +// the success message tells them you'll follow up to schedule. +const BOOKING_URL = process.env.NEXT_PUBLIC_BOOKING_URL ?? "" + +const labelClass = "font-mono text-[11px] uppercase tracking-wide text-muted-foreground" +const fieldClass = + "mt-2 w-full rounded-none border border-border bg-transparent px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/60 focus-visible:border-accent focus-visible:outline-none" + +export function TeardownForm() { + const [status, setStatus] = useState<"idle" | "submitting" | "done">("idle") + const [errors, setErrors] = useState({}) + const [serverError, setServerError] = useState(null) + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + setStatus("submitting") + setErrors({}) + setServerError(null) + + const form = new FormData(event.currentTarget) + const payload = { + name: String(form.get("name") ?? ""), + email: String(form.get("email") ?? ""), + brandUrl: String(form.get("brandUrl") ?? ""), + product: String(form.get("product") ?? ""), + bottleneck: String(form.get("bottleneck") ?? ""), + } + + try { + const res = await fetch("/api/book-call", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean + errors?: Errors + error?: string + } + + if (!res.ok || !data.ok) { + if (data.errors) setErrors(data.errors) + if (data.error) setServerError(data.error) + setStatus("idle") + return + } + + // Lead captured. Hand them straight to the calendar if we have one. + if (BOOKING_URL) { + window.location.href = BOOKING_URL + return + } + setStatus("done") + } catch { + setServerError("Something went wrong. Please try again.") + setStatus("idle") + } + } + + if (status === "done") { + return ( +
+

Booked in

+

+ You're on the list for a teardown. I'll reach out within two business days to lock a time. +

+
+ ) + } + + return ( +
+
+
+ + + {errors.name &&

{errors.name}

} +
+
+ + + {errors.email &&

{errors.email}

} +
+
+ +
+ + +
+ +
+ + + {errors.product &&

{errors.product}

} +
+ +
+ +