From e2be9b38200e45e3b0d9072ecea237ce41b8aa82 Mon Sep 17 00:00:00 2001 From: Jackson Date: Sun, 21 Jun 2026 13:37:27 -0700 Subject: [PATCH] feat: rebuild homepage into a skills marketplace with DFY services Turn the single-page advertorial landing into a full storefront while keeping the dark/amber/mono editorial aesthetic. - Typed skill catalog (src/data/skills.ts): 5 available skills across 3 labs + 3 coming-soon placeholders, with helpers + price formatting - Pages: new storefront home, /skills catalog grouped by lab, /skills/[slug] detail (SSG via generateStaticParams + generateMetadata), and the original advertorial moved to /advertorial - Components: SkillCard, SkillGrid, Navigation, CartDrawer, Footer, SkillBuyActions, reusable DFYSection - Cart: React context (items + drawer state) with nav count badge - Stripe: POST /api/checkout builds a Checkout Session from cart slugs; gracefully handles missing STRIPE_SECRET_KEY and non-purchasable items - Global chrome + metadata in layout; OG/Twitter tags updated - Extend @theme tokens (card surfaces, strong line); add stripe dep Next.js 16: async params via PageProps/RouteContext helpers. Kit subscribe route and SignupForm left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 21 +++ package-lock.json | 24 ++- package.json | 3 +- src/app/advertorial/page.tsx | 248 +++++++++++++++++++++++++++++ src/app/api/checkout/route.ts | 107 +++++++++++++ src/app/api/subscribe/route.ts | 76 +++++++++ src/app/globals.css | 74 +++++++-- src/app/layout.tsx | 37 ++++- src/app/page.tsx | 185 ++++++++++++++------- src/app/skills/[slug]/page.tsx | 179 +++++++++++++++++++++ src/app/skills/page.tsx | 70 ++++++++ src/components/CartDrawer.tsx | 166 +++++++++++++++++++ src/components/DFYSection.tsx | 41 +++++ src/components/Footer.tsx | 73 +++++++++ src/components/Navigation.tsx | 73 +++++++++ src/components/SignupForm.tsx | 110 +++++++++++++ src/components/SkillBuyActions.tsx | 58 +++++++ src/components/SkillCard.tsx | 83 ++++++++++ src/components/SkillGrid.tsx | 12 ++ src/context/cart.tsx | 72 +++++++++ src/data/skills.ts | 222 ++++++++++++++++++++++++++ src/lib/checkout.ts | 34 ++++ src/lib/site.ts | 11 ++ 23 files changed, 1902 insertions(+), 77 deletions(-) create mode 100644 .env.example create mode 100644 src/app/advertorial/page.tsx create mode 100644 src/app/api/checkout/route.ts create mode 100644 src/app/api/subscribe/route.ts create mode 100644 src/app/skills/[slug]/page.tsx create mode 100644 src/app/skills/page.tsx create mode 100644 src/components/CartDrawer.tsx create mode 100644 src/components/DFYSection.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Navigation.tsx create mode 100644 src/components/SignupForm.tsx create mode 100644 src/components/SkillBuyActions.tsx create mode 100644 src/components/SkillCard.tsx create mode 100644 src/components/SkillGrid.tsx create mode 100644 src/context/cart.tsx create mode 100644 src/data/skills.ts create mode 100644 src/lib/checkout.ts create mode 100644 src/lib/site.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f557de --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env.local and fill in. .env.local is gitignored. + +# Kit (ConvertKit) v4 API key — powers /api/subscribe email capture. +# Get it from Kit → Settings → Advanced → API (the "v4" key). +KIT_API_KEY= + +# Optional: the Kit Form ID to subscribe new emails into (recommended). +# Find it via the `kit` skill or in the form's embed URL / dashboard. +# If unset, subscribers are created on the account without a specific form. +KIT_FORM_ID= + +# Stripe — powers /api/checkout for the skills marketplace. +# Get these from Stripe → Developers → API keys. Use test keys locally. +# If STRIPE_SECRET_KEY is unset, checkout returns a friendly "not live yet" +# message instead of erroring. +STRIPE_SECRET_KEY= + +# Publishable key — safe to expose to the browser (NEXT_PUBLIC_ prefix). +# Not required for the current redirect-to-Checkout flow, but handy if you +# later add Stripe.js on the client. +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/package-lock.json b/package-lock.json index 0cdaadc..2189b83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "16.2.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^22.2.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1549,7 +1550,7 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6024,6 +6025,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.2.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.2.tgz", + "integrity": "sha512-lRXLiarjW/I2QVa6QuFfz1Ma7Dc2yBQ7vlYH6NEmp5htMJNQGfiWExmOv0McfqY5wMCGV8DLuCvsyVRIPJlUUQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6352,7 +6370,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 9630601..e8c1fd2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "next": "16.2.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^22.2.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/advertorial/page.tsx b/src/app/advertorial/page.tsx new file mode 100644 index 0000000..cc9a2a1 --- /dev/null +++ b/src/app/advertorial/page.tsx @@ -0,0 +1,248 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import SignupForm from "@/components/SignupForm"; + +export const metadata: Metadata = { + title: "The Advertorial Engine — channel47", + description: + "The gap between your ad and your checkout is where paid traffic dies. The AI system that turns real customer research into a finished DTC advertorial — the skill, the workflow, the swipe file.", + openGraph: { + title: "The Advertorial Engine — channel47", + description: + "Turn real customer research into a finished DTC advertorial. The skill, the workflow, the swipe file. Built from real accounts.", + url: "https://channel47.dev/advertorial", + siteName: "channel47", + type: "website", + }, +}; + +const KIT = [ + { + n: "01", + t: "The advertorial skill", + d: "Turns a chosen angle into a structured, ready-to-ship presell page — the part the free plugins don’t include.", + }, + { + n: "02", + t: "The research workflow", + d: "Pull real customer language from Reddit, Amazon, and Trustpilot. Build the persona. Rank the angles before you write a word.", + }, + { + n: "03", + t: "The swipe file", + d: "Real DTC advertorials, annotated section by section, so you can see exactly why each one earns the click to checkout.", + }, + { + n: "04", + t: "The prompt library", + d: "Generate, critique, and rewrite every section. Never start from a blank page again.", + }, + { + n: "05", + t: "The workshop walkthrough", + d: "The full live build, start to finish — so you can recreate it for any product the same day.", + }, +]; + +const STEPS = [ + { n: "1", t: "Drop your email", d: "Takes five seconds. No funnel, no quiz." }, + { + n: "2", + t: "Get the goods", + d: "The recording and the kit files land in your inbox the moment they ship.", + }, + { + n: "3", + t: "Build your first one", + d: "Run the system on a real product, same day.", + }, +]; + +const FAQ = [ + { + q: "Is this free?", + a: "The workshop recording and the open-source research plugins, yes. The full kit is a small one-time thing when it ships — get on the list for first dibs and the launch price.", + }, + { + q: "Do I need to know how to code?", + a: "No. If you can use Claude, you can run this. The skill does the heavy lifting; you bring the judgment.", + }, + { + q: "What’s the catch?", + a: "None. Get on the list, get the files. If you’d rather I build pages for you later, that door’s open — but it’s not the ask here.", + }, +]; + +export default function Advertorial() { + return ( +
+ {/* Hero */} +
+

The Advertorial Engine

+

+ Your ad did its job. +
+ Your page killed the sale. +

+

+ Cold traffic isn’t ready to buy — it’s ready to read. I built the AI + system that turns real customer research into a finished DTC + advertorial: the skill, the workflow, the swipe file. Drop your email + and I’ll send the workshop recording and the files the moment they + ship. +

+ +
+ +

+ The recording + the skill files when I ship them + first dibs on + what’s next. No spam, no funnel. +

+
+
+ + {/* The problem */} +
+
+

The gap

+

+ Between the click and the checkout, paid traffic dies. +

+

+ Your ad promised something specific. Your product page greets a + stranger with a buy button. The advertorial is the page in between — + the one that earns the sale before it asks for it. +

+
+
+ + {/* What's in the kit */} +
+
+

What’s in the kit

+

+ The whole system I built live — yours to keep. +

+
    + {KIT.map((item) => ( +
  • + + {item.n} + +
    +

    + {item.t} +

    +

    + {item.d} +

    +
    +
  • + ))} +
+
+
+ + {/* Who built this */} +
+
+

Who built this

+

+ I’m Jackson. I manage $6M+ in DTC ad spend. +

+

+ The research-to-creative pipeline underneath this is open-source — + anyone can grab it free. This is the part I didn’t give away: the + layer that turns that research into a page that sells. Built from + real accounts, not a course. +

+
+ + The free plugins on GitHub → + + + Browse the paid skills → + +
+
+
+ + {/* How it works */} +
+
+

How this goes

+
+ {STEPS.map((s) => ( +
+ + {s.n} + +

+ {s.t} +

+

{s.d}

+
+ ))} +
+
+
+ + {/* Quiet DFY footnote */} +
+
+
+

+ + Run a brand and would rather skip the homework? + {" "} + Reply once you’re on the list. I’m taking a couple of pilot brands + to build the first case studies — heavily discounted, in exchange + for permission to publish the before-and-after numbers. +

+
+
+
+ + {/* Final CTA */} +
+
+

+ Get the system. +

+

+ The recording, the skill files, and first dibs on the kit. One + email, no funnel. +

+
+ +
+
+
+ + {/* FAQ */} +
+
+

Questions

+
+ {FAQ.map((f) => ( +
+
{f.q}
+
+ {f.a} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..10bc465 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { getSkill } from "@/data/skills"; + +// Checkout sessions must be created at request time. +export const dynamic = "force-dynamic"; + +type Body = { slugs?: unknown }; + +export async function POST(request: Request) { + let body: Body; + try { + body = (await request.json()) as Body; + } catch { + return NextResponse.json( + { ok: false, error: "bad_request", message: "Invalid request." }, + { status: 400 }, + ); + } + + // Validate + resolve slugs to available, purchasable skills. + const slugs = Array.isArray(body.slugs) + ? body.slugs.filter((s): s is string => typeof s === "string") + : []; + + const items = slugs + .map((slug) => getSkill(slug)) + .filter( + (s): s is NonNullable> => + Boolean(s) && s!.status === "available", + ); + + if (items.length === 0) { + return NextResponse.json( + { + ok: false, + error: "empty_cart", + message: "No purchasable skills in your cart.", + }, + { status: 400 }, + ); + } + + const apiKey = process.env.STRIPE_SECRET_KEY; + + // No key wired yet → don't 500 in front of a live audience. Tell the caller + // plainly so the UI can surface a friendly message. + if (!apiKey) { + console.warn( + `[checkout] STRIPE_SECRET_KEY not set — cannot create session for: ${items + .map((s) => s.slug) + .join(", ")}`, + ); + return NextResponse.json( + { + ok: false, + error: "stripe_not_configured", + message: + "Payments aren’t live yet. Set STRIPE_SECRET_KEY to enable checkout.", + }, + { status: 503 }, + ); + } + + try { + // Imported lazily so the route still builds/serves when stripe isn't needed. + const { default: Stripe } = await import("stripe"); + const stripe = new Stripe(apiKey); + + const origin = + request.headers.get("origin") ?? new URL(request.url).origin; + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items: items.map((skill) => ({ + quantity: 1, + price_data: { + currency: "usd", + unit_amount: skill.price * 100, + product_data: { + name: skill.title, + description: skill.description, + metadata: { slug: skill.slug }, + }, + }, + })), + metadata: { slugs: items.map((s) => s.slug).join(",") }, + success_url: `${origin}/skills?checkout=success`, + cancel_url: `${origin}/skills?checkout=cancelled`, + }); + + if (!session.url) { + throw new Error("Stripe returned no checkout URL"); + } + + return NextResponse.json({ ok: true, url: session.url }); + } catch (err) { + console.error("[checkout] Failed to create Stripe session:", err); + return NextResponse.json( + { + ok: false, + error: "checkout_failed", + message: "Couldn’t start checkout. Please try again.", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/subscribe/route.ts b/src/app/api/subscribe/route.ts new file mode 100644 index 0000000..a1faeac --- /dev/null +++ b/src/app/api/subscribe/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; + +// Lead capture must always run at request time. +export const dynamic = "force-dynamic"; + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +type Body = { + email?: unknown; + company?: unknown; // honeypot + location?: unknown; +}; + +export async function POST(request: Request) { + let body: Body; + try { + body = (await request.json()) as Body; + } catch { + return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 }); + } + + const email = typeof body.email === "string" ? body.email.trim() : ""; + const company = typeof body.company === "string" ? body.company : ""; + const location = typeof body.location === "string" ? body.location : "unknown"; + + // Honeypot tripped: pretend success, capture nothing. + if (company) { + return NextResponse.json({ ok: true, captured: false }); + } + + if (!EMAIL_RE.test(email)) { + return NextResponse.json({ ok: false, error: "invalid_email" }, { status: 422 }); + } + + const apiKey = process.env.KIT_API_KEY; + const formId = process.env.KIT_FORM_ID; + + // No key wired yet → don't error in front of a live audience. + // The lead lands in the server logs so it's never silently lost. + if (!apiKey) { + console.warn( + `[subscribe] KIT_API_KEY not set — captured to logs only: ${email} (location=${location})`, + ); + return NextResponse.json({ ok: true, captured: false }); + } + + try { + // Kit (ConvertKit) v4. Preferring the form endpoint adds + creates in one call. + const endpoint = formId + ? `https://api.kit.com/v4/forms/${formId}/subscribers` + : `https://api.kit.com/v4/subscribers`; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Kit-Api-Key": apiKey, + }, + body: JSON.stringify({ email_address: email, state: "active" }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + `[subscribe] Kit responded ${res.status} for ${email}: ${detail}`, + ); + // Visitor still sees success; lead is recoverable from logs. + return NextResponse.json({ ok: true, captured: false }); + } + + return NextResponse.json({ ok: true, captured: true }); + } catch (err) { + console.error(`[subscribe] Kit request failed for ${email}:`, err); + return NextResponse.json({ ok: true, captured: false }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..7c3b687 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,70 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@theme { + --color-bg: #0a0a0a; + --color-surface: #121211; + --color-card: #100f0e; + --color-card-hover: #16150f; + --color-fg: #f4f4ef; + --color-muted: #8c8c85; + --color-faint: #56564f; + --color-line: #232320; + --color-line-strong: #353530; + --color-accent: #f59e0b; + --color-accent-dim: #c07e0a; -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html { + scroll-behavior: smooth; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--color-bg); + color: var(--color-fg); + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: var(--color-accent); + color: #0a0a0a; +} + +/* Mono eyebrow label used above section headers */ +.eyebrow { + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.7rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} + +/* Mono badge used for lab tags, prices, and status pills */ +.badge { + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.65rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +@keyframes drawer-in { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..ba0eb4d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { CartProvider } from "@/context/cart"; +import Navigation from "@/components/Navigation"; +import CartDrawer from "@/components/CartDrawer"; +import Footer from "@/components/Footer"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +17,28 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + metadataBase: new URL("https://channel47.dev"), + title: { + default: "channel47 — Skills for vibe marketers", + template: "%s", + }, + description: + "AI-powered marketing skills for vibe marketers — customer research, personas, angles, and advertorials that run inside Claude. Plus a done-for-you option. Built by a media buyer managing $6M+ in DTC spend.", + openGraph: { + title: "channel47 — Skills for vibe marketers", + description: + "AI-powered marketing skills that run inside Claude, plus a done-for-you option. Built from real DTC accounts.", + url: "https://channel47.dev", + siteName: "channel47", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "channel47 — Skills for vibe marketers", + description: + "AI-powered marketing skills that run inside Claude, plus a done-for-you option. Built from real DTC accounts.", + creator: "@ctrlswing", + }, }; export default function RootLayout({ @@ -27,7 +51,14 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + + + {children} +