diff --git a/.env.example b/.env.example index cada1ef..3443d04 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/advertorial/page.tsx b/app/advertorial/page.tsx new file mode 100644 index 0000000..ee70ecd --- /dev/null +++ b/app/advertorial/page.tsx @@ -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 ( +
+
+ + + + channel47 + + + + Book a teardown + +
+ + {/* Hero — typography is the product */} +
+

+ The page between the ad and the checkout +

+ +
+

+ + The missing + + + page between + + + ad and sale. + +

+ +

+ 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 real customer language: + research, personas, angles, then the page. +

+
+ +
+
+
+ + {/* Where it fits — editorial sequence, no cards */} +
+

Where it fits

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

+ {stage.name} +

+

+ {stage.note} +

+
+ ))} +
+
+ + {/* The system — the exact process, as a line not a grid of boxes */} +
+
+
+

Built live

+

+ The exact process I’ll run on your funnel. +

+
+

+ An agent skill runs the work in order — research the buyer, generate the personas, pull the angles, draft + the advertorial. On the call I’ll run the first stage against your real product, live, so you can see + exactly where it earns its place. +

+
+ +
    + {PROCESS.map((step, i) => ( +
  1. + {step} + {i < PROCESS.length - 1 && ( + + )} +
  2. + ))} +
+
+ + {/* Book — the real calendar */} +
+
+
+

Take the next step

+

+ Book a free 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. +

+

+ Availability: Tuesdays & Thursdays, 2–4pm PT. +

+
+
+ +
+
+
+ + +
+ ) +} diff --git a/app/api/book-call/route.ts b/app/api/book-call/route.ts new file mode 100644 index 0000000..a3ea877 --- /dev/null +++ b/app/api/book-call/route.ts @@ -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 + + 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 = {} + + 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." }) +} diff --git a/app/globals.css b/app/globals.css index 8b3c750..f6e4c96 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); @@ -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; diff --git a/app/layout.tsx b/app/layout.tsx index 2e8f379..f4ec011 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,6 @@ import type React from "react" import type { Metadata } from "next" -import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google" +import { IBM_Plex_Sans, IBM_Plex_Mono, DM_Sans } from "next/font/google" import { Analytics } from "@vercel/analytics/next" import "./globals.css" @@ -14,6 +14,12 @@ const ibmPlexMono = IBM_Plex_Mono({ subsets: ["latin"], variable: "--font-ibm-plex-mono", }) +// Direction A display face — typography is the product. +const dmSans = DM_Sans({ + weight: ["500", "600", "700"], + subsets: ["latin"], + variable: "--font-dm-sans", +}) export const metadata: Metadata = { title: "channel47", @@ -44,7 +50,9 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/components/booking-calendar.tsx b/components/booking-calendar.tsx new file mode 100644 index 0000000..e273b35 --- /dev/null +++ b/components/booking-calendar.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { generateSlots, type Slot } from "@/lib/slots" + +type Errors = Partial> + +type DayGroup = { key: string; label: string; slots: Slot[] } + +const labelClass = "font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground" +const fieldClass = + "mt-2 w-full border-0 border-b border-border bg-transparent px-0 py-2 text-[15px] text-foreground placeholder:text-muted-foreground/50 focus-visible:border-accent focus-visible:outline-none" + +function formatLocalTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) +} + +function groupByDay(slots: Slot[]): DayGroup[] { + const groups = new Map() + for (const slot of slots) { + const key = new Date(slot.startISO).toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" }) + const list = groups.get(key) ?? [] + list.push(slot) + groups.set(key, list) + } + return Array.from(groups, ([label, daySlots]) => ({ key: label, label, slots: daySlots })) +} + +export function BookingCalendar() { + // Slots are computed on the client so they render in the visitor's own + // timezone; deferring to mount avoids an SSR/client hydration mismatch. + const [slots, setSlots] = useState([]) + const [selected, setSelected] = useState(null) + const [status, setStatus] = useState<"idle" | "submitting" | "done">("idle") + const [errors, setErrors] = useState({}) + const [serverError, setServerError] = useState(null) + const [localTz, setLocalTz] = useState("") + + useEffect(() => { + setSlots(generateSlots()) + setLocalTz(Intl.DateTimeFormat().resolvedOptions().timeZone ?? "") + }, []) + + const days = useMemo(() => groupByDay(slots), [slots]) + const selectedLabel = selected + ? `${new Date(selected).toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" })} at ${formatLocalTime(selected)}` + : null + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + setErrors({}) + setServerError(null) + + if (!selected) { + setErrors({ slot: "Pick an available time." }) + return + } + + setStatus("submitting") + 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") ?? ""), + slotStart: selected, + } + + 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) + // A 409 means the slot vanished — drop it so they re-pick. + if (res.status === 409) { + setSlots((current) => current.filter((s) => s.startISO !== selected)) + setSelected(null) + } + setStatus("idle") + return + } + setStatus("done") + } catch { + setServerError("Something went wrong. Please try again.") + setStatus("idle") + } + } + + if (status === "done") { + return ( +
+

Confirmed

+

{selectedLabel}.

+

+ The calendar invite is on its way to your inbox. See you then. +

+
+ ) + } + + return ( +
+
+ + Pick a time{localTz ? ` · shown in ${localTz.replace(/_/g, " ")}` : ""} + + {days.length === 0 ? ( +

Loading availability…

+ ) : ( +
+ {days.map((day) => ( +
+ {day.label} +
+ {day.slots.map((slot) => { + const isSelected = selected === slot.startISO + return ( + + ) + })} +
+
+ ))} +
+ )} + {errors.slot &&

{errors.slot}

} +
+ +
+
+
+ + + {errors.name &&

{errors.name}

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

{errors.email}

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

{errors.product}

} +
+
+ +
+ +