Skip to content
Open
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
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# 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=

# Google Calendar — powers the /advertorial booking widget. The widget only ever
# offers Tue/Thu 2–4pm PT slots; on confirm it checks free/busy and creates the
# event (emailing the invite). If these are unset, bookings are still captured in
# Kit and logged for manual follow-up — no event is created.
#
# Setup: Google Cloud → service account + enable Calendar API → create a JSON key
# → share your calendar with the service account's client_email ("Make changes to
# events"). Then set:
GOOGLE_SERVICE_ACCOUNT_EMAIL=
GOOGLE_PRIVATE_KEY=
GOOGLE_CALENDAR_ID=
170 changes: 170 additions & 0 deletions app/advertorial/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { Metadata } from "next"
import { Logo47 } from "@/components/logo-47"
import { BookingCalendar } from "@/components/booking-calendar"

export const metadata: Metadata = {
title: "Advertorial teardown — channel47",
description:
"The page between the ad and the checkout. Book a free teardown and see where an advertorial earns its place in your funnel. Tue & Thu, 2–4pm PT.",
}

const STAGES = [
{ index: "01", name: "The ad", note: "Wins the click. A hook and a promise — nothing more." },
{
index: "02",
name: "The advertorial",
note: "Earns the belief. Turns a cold, skeptical click into a warm buyer.",
signal: true,
},
{ index: "03", name: "The checkout", note: "Takes the order. By now the selling is already done." },
]

const PROCESS = ["Customer research", "Personas", "Angles", "Advertorial"]

export default function AdvertorialPage() {
return (
<main className="min-h-screen bg-background text-foreground">
<header className="mx-auto flex w-full max-w-[var(--page-max)] items-center justify-between px-[var(--page-gutter)] py-6">
<a href="/" className="group flex items-center gap-3" aria-label="channel47 home">
<Logo47 delay={120} />
<span className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors group-hover:text-foreground">
channel47
</span>
</a>
<a
href="#book"
className="border-b border-foreground/70 pb-0.5 font-mono text-[11px] uppercase tracking-[0.08em] text-foreground transition-colors hover:border-accent hover:text-accent"
>
Book a teardown
</a>
</header>

{/* Hero — typography is the product */}
<section className="mx-auto w-full max-w-[var(--page-max)] px-[var(--page-gutter)] pb-24 pt-16 sm:pt-24">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
The page between the ad and the checkout
</p>

<div className="mt-10 grid gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(240px,340px)] lg:items-end">
<h1 className="font-display text-[clamp(3.25rem,9vw,8.5rem)] font-semibold leading-[0.88] tracking-[-0.02em]">
<span className="reveal-line">
<span>The missing</span>
</span>
<span className="reveal-line">
<span>page between</span>
</span>
<span className="reveal-line">
<span>ad and sale.</span>
</span>
</h1>

<p className="max-w-sm text-pretty text-lg leading-[1.5] text-muted-foreground lg:pb-3">
Most funnels send cold traffic straight from an ad to a product page and wonder why it stalls. The
advertorial is the page in between — built from <span className="mark">real customer language</span>:
research, personas, angles, then the page.
</p>
</div>

<div className="mt-16 flex flex-col gap-6">
<span className="rule-grow block h-px w-full bg-accent" aria-hidden="true" />
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 font-mono text-[12px] text-muted-foreground">
<a href="#book" className="text-foreground transition-colors hover:text-accent">
→ Book a free teardown
</a>
<span>15 minutes · live · Tue &amp; Thu, 2–4pm PT</span>
</div>
</div>
</section>

{/* Where it fits — editorial sequence, no cards */}
<section className="mx-auto w-full max-w-[var(--page-max)] px-[var(--page-gutter)] py-20">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Where it fits</p>
<div className="mt-10 border-t border-border">
{STAGES.map((stage) => (
<div
key={stage.index}
className="grid grid-cols-[auto_1fr] items-baseline gap-x-6 gap-y-2 border-b border-border py-8 sm:grid-cols-[5rem_minmax(0,16rem)_1fr] sm:gap-x-10"
>
<span
className={`font-mono text-[12px] ${stage.signal ? "text-accent" : "text-muted-foreground"}`}
>
{stage.index}
</span>
<h2
className={`font-display text-3xl font-medium tracking-[-0.01em] sm:text-4xl ${
stage.signal ? "text-foreground" : "text-foreground/90"
}`}
>
{stage.name}
</h2>
<p className="col-span-2 max-w-md font-mono text-[13px] leading-6 text-muted-foreground sm:col-span-1">
{stage.note}
</p>
</div>
))}
</div>
</section>

{/* The system — the exact process, as a line not a grid of boxes */}
<section className="mx-auto w-full max-w-[var(--page-max)] px-[var(--page-gutter)] py-20">
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-end">
<div>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Built live</p>
<h2 className="mt-8 font-display text-[clamp(2rem,4vw,3.25rem)] font-medium leading-[1.02] tracking-[-0.015em]">
The exact process I&rsquo;ll run on your funnel.
</h2>
</div>
<p className="max-w-md text-pretty text-base leading-7 text-muted-foreground lg:pb-2">
An agent skill runs the work in order — research the buyer, generate the personas, pull the angles, draft
the advertorial. On the call I&rsquo;ll run the first stage against your real product, live, so you can see
exactly where it earns its place.
</p>
</div>

<ol className="mt-14 flex flex-wrap items-center gap-x-5 gap-y-4 font-display text-2xl font-medium tracking-[-0.01em] sm:text-3xl">
{PROCESS.map((step, i) => (
<li key={step} className="flex items-center gap-5">
<span>{step}</span>
{i < PROCESS.length - 1 && (
<span className="font-mono text-accent" aria-hidden="true">
</span>
)}
</li>
))}
</ol>
</section>

{/* Book — the real calendar */}
<section id="book" className="scroll-mt-6 border-t border-border">
<div className="mx-auto grid w-full max-w-[var(--page-max)] gap-14 px-[var(--page-gutter)] py-20 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Take the next step</p>
<h2 className="mt-8 font-display text-[clamp(2.5rem,5vw,4.5rem)] font-semibold leading-[0.92] tracking-[-0.02em]">
Book a free teardown.
</h2>
<p className="mt-8 max-w-sm text-pretty text-base leading-7 text-muted-foreground">
Tell me what you&rsquo;re selling and I&rsquo;ll pull your funnel apart on a 15-minute call — where
it&rsquo;s leaking, and the advertorial angle that plugs it. No deck, no obligation.
</p>
<p className="mt-8 font-mono text-[12px] leading-6 text-muted-foreground">
Availability: Tuesdays &amp; Thursdays, 2–4pm PT.
</p>
</div>
<div>
<BookingCalendar />
</div>
</div>
</section>

<footer className="border-t border-border">
<div className="mx-auto flex w-full max-w-[var(--page-max)] items-center justify-between px-[var(--page-gutter)] py-7 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
<span>channel47</span>
<a className="transition-colors hover:text-foreground" href="mailto:jackson@channel47.dev">
jackson@channel47.dev
</a>
</div>
</footer>
</main>
)
}
94 changes: 94 additions & 0 deletions app/api/book-call/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { type NextRequest, NextResponse } from "next/server"
import { isKitConfigured, isSameOrigin, sanitizeFields, subscribeToKit } from "@/lib/kit"
import { createBookingEvent, isCalendarConfigured, isSlotFree } from "@/lib/google-calendar"
import { generateSlots, isValidSlot } from "@/lib/slots"

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<string, unknown>

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 slotStart = typeof data.slotStart === "string" ? data.slotStart.trim() : ""

const errors: Record<string, string> = {}

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 (!slotStart || !isValidSlot(slotStart)) errors.slot = "Pick an available time."

if (Object.keys(errors).length > 0) {
return NextResponse.json({ ok: false, errors }, { status: 422 })
}

// isValidSlot guarantees slotStart is on-grid; derive the matching end.
const startISO = new Date(slotStart).toISOString()
const endISO = generateSlots(60).find((s) => s.startISO === startISO)?.endISO ?? startISO
const cleanEmail = email.toLowerCase()
// Always retained so neither a Kit nor a Calendar outage ever loses a booking.
const lead = { name, email: cleanEmail, brandUrl, product, bottleneck, slotStart: startISO }

// 1. Calendar: make the booking real. A taken slot is the one hard failure we surface.
if (isCalendarConfigured()) {
const free = await isSlotFree(startISO, endISO)
if (!free) {
return NextResponse.json(
{ ok: false, errors: { slot: "That time was just booked. Pick another." } },
{ status: 409 },
)
}
const event = await createBookingEvent({ startISO, endISO, name, email: cleanEmail, product, brandUrl, bottleneck })
if (!event.ok) {
console.error("[book-call] calendar event creation failed, logging lead for manual follow-up:", lead)
}
} else {
console.warn("[book-call] Google Calendar not configured — booking captured, no event created:", lead)
}

// 2. Kit: segment the lead. Best-effort; never blocks the booking.
if (isKitConfigured()) {
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,
booked_slot: startISO,
}),
})
const incomplete =
!result.ok || result.fieldsDropped || (Array.isArray(result.warnings) && result.warnings.length > 0)
if (incomplete) {
console.error("[kit] teardown — Kit capture incomplete, logging full lead:", { result, lead })
}
} catch (err) {
console.error("[kit] teardown — Kit error, logging lead:", { err, lead })
}
} else {
console.error("[kit] KIT_API_KEY not set — teardown booked, logging lead only:", lead)
}

return NextResponse.json({ ok: true, message: "You're booked. The calendar invite is on its way to your inbox." })
}
74 changes: 74 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@theme inline {
--font-sans: "IBM Plex Sans", "IBM Plex Sans Fallback", sans-serif;
--font-mono: "IBM Plex Mono", "IBM Plex Mono Fallback", monospace;
--font-display: var(--font-dm-sans), "DM Sans", "IBM Plex Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted-foreground: var(--muted-foreground);
Expand Down Expand Up @@ -52,6 +53,79 @@
}
}

/* ── Editorial motion (Direction A — Quiet Signal) ──────────────────────────
Movement is limited to type reveal, one highlight sweep, and a rule grow. */

.reveal-line {
display: block;
overflow: hidden;
padding-bottom: 0.04em;
}

.reveal-line > span {
display: block;
animation: line-rise 820ms var(--motion-ease) both;
}

.reveal-line:nth-child(2) > span {
animation-delay: 80ms;
}

.reveal-line:nth-child(3) > span {
animation-delay: 160ms;
}

@keyframes line-rise {
from {
opacity: 0;
transform: translate3d(0, 110%, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}

.mark {
color: var(--foreground);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
background: linear-gradient(transparent 58%, color-mix(in srgb, var(--accent) 32%, transparent) 58%) 0 0 / 0 100%
no-repeat;
animation: mark-in 720ms var(--motion-ease) 620ms both;
}

@keyframes mark-in {
to {
background-size: 100% 100%;
}
}

.rule-grow {
transform-origin: left center;
animation: rule-grow 900ms var(--motion-ease) 400ms both;
}

@keyframes rule-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

@media (prefers-reduced-motion: reduce) {
.reveal-line > span,
.rule-grow {
animation: none;
}
.mark {
animation: none;
background-size: 100% 100%;
}
}

.logo47 {
--logo-glow: color-mix(in srgb, var(--foreground) 26%, transparent);
--logo-width: 74px;
Expand Down
Loading