diff --git a/web/.env.example b/web/.env.example index f65153b..f890873 100644 --- a/web/.env.example +++ b/web/.env.example @@ -47,3 +47,18 @@ LS_FOUNDING_PAYMENT_URL= # Owner emails for /admin (comma-separated; M13). Gates the manual plan flag. OWNER_EMAILS= + +# --- WP-09 (marketing + free tools + admin + feedback) --- + +# Global daily spend cap for the free public tools (USD, UTC day). Default 3. +FREE_TOOL_DAILY_USD= + +# PostHog public key (feedback + product events). Absent → events are skipped silently. +NEXT_PUBLIC_POSTHOG_KEY= +# PostHog host if you use the EU cloud (default https://us.i.posthog.com). +POSTHOG_HOST= + +# Resend key: feedback notifications to the owner. Absent → seam logged, no email. +RESEND_API_KEY= +# Feedback/notification sender (must be a verified Resend identity). +FEEDBACK_FROM_EMAIL=EpisodeKit diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index 761bfd0..e7c7941 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -1,5 +1,6 @@ import { UserButton } from "@clerk/nextjs"; import { AppHeader } from "@/components/app/app-header"; +import { FeedbackWidget } from "@/components/app/feedback-widget"; import { requireUser } from "@/lib/auth"; import { isClerkConfigured } from "@/lib/env"; import { getShowByOwnerSafe } from "@/lib/show/queries-safe"; @@ -14,6 +15,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod userButton={isClerkConfigured() ? : null} />
{children}
+ ); } diff --git a/web/app/(marketing)/compare/[slug]/page.tsx b/web/app/(marketing)/compare/[slug]/page.tsx new file mode 100644 index 0000000..a884d94 --- /dev/null +++ b/web/app/(marketing)/compare/[slug]/page.tsx @@ -0,0 +1,196 @@ +// /compare/podsqueeze + /compare/castmagic — ported from the legacy pages at +// the same paths. Product rows updated to the MVP facts (2 episodes free, +// $19 Pro, 7-artifact package + free tools); unverifiable third-party claims +// (Reddit attributions) removed per §0.5. Logged in DECISIONS.md. +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { COMPARE_SLUGS } from "@/lib/marketing/routes"; + +export function generateStaticParams(): Array<{ slug: (typeof COMPARE_SLUGS)[number] }> { + return COMPARE_SLUGS.map((slug) => ({ slug })); +} + +interface CompareContent { + title: string; + metaDescription: string; + sub: string; + rows: Array<[string, string, string]>; + chooseThem: { h: string; p: React.ReactNode }; + chooseUs: { h: string; p: React.ReactNode }; + fact: string; +} + +const US_INTRO = ( + <> + EpisodeKit: Free (2 episodes/mo) · Pro $19/mo. Show notes, chapters, summary, titles, blog + post, newsletter and social posts from one episode — plus free no-signup tools for + discussion kits and email series. Every quote is copied word-for-word from your transcript. + +); + +const CONTENT: Record<(typeof COMPARE_SLUGS)[number], CompareContent> = { + podsqueeze: { + title: "EpisodeKit vs Podsqueeze — honest comparison (Oct 2026)", + metaDescription: + "EpisodeKit vs Podsqueeze for podcast show notes and content repurposing: pricing, approach, verbatim-quote integrity, and who each is right for. Facts as of October 2026.", + sub: "Both turn podcast episodes into show notes and repurposed content. Here's the honest breakdown — including where Podsqueeze is the better choice.", + rows: [ + ["Entry price", "Free (2 episodes/mo) · Pro $19/mo flat", "From ~$9/mo, tiered by uploads (see their pricing page)"], + [ + "What it makes", + "Show notes, chapters, summary, titles, blog post, newsletter, social posts — plus free no-signup tools for discussion kits and email series", + "Show notes, clips, social posts, transcripts — a broad feature set", + ], + [ + "Quote integrity", + "Verbatim-only: every quote in every artifact is an exact sentence from your transcript; paraphrased quotes are rejected automatically", + "Standard AI generation", + ], + [ + "Works without an account", + "Yes — free tools (show notes, titles, discussion kit, email series) with no signup", + "No — account required", + ], + ["Audio upload", "Yes (browser-compressed) or paste transcript", "Yes, plus RSS import"], + ["Video clips", "Not in the MVP", "Yes"], + ["Positioning", "Small, sharp, honest — for indie shows and solo creators", "Established all-in-one repurposing suite"], + ], + chooseThem: { + h: "Choose Podsqueeze if…", + p: "you want an established all-in-one suite today — especially if video clips and RSS-based episode import are must-haves, or your workflow already lives in their tool. It's a real product that does a lot.", + }, + chooseUs: { + h: "Choose EpisodeKit if…", + p: ( + <> + you publish show notes, a blog or a newsletter and need artifacts beyond clips — and you + care that every quote shown to your audience is word-for-word from the episode. Try it + with no signup first:{" "} + + Generate show notes free → + + + ), + }, + fact: "Facts as of October 2026, from each product's public pricing page. Prices change — verify before buying. This page is written by the EpisodeKit maker; we've kept the trade-offs honest because a bad-fit customer churns anyway.", + }, + castmagic: { + title: "EpisodeKit vs Castmagic — honest comparison (Oct 2026)", + metaDescription: + "EpisodeKit vs Castmagic for podcast content repurposing: pricing, complexity, verbatim-quote integrity, and who each is right for. Facts as of October 2026.", + sub: "Both generate content from podcast episodes. Castmagic is the bigger, older platform; EpisodeKit is the younger, sharper tool. Honest breakdown below.", + rows: [ + ["Entry price", "Free (2 episodes/mo) · Pro $19/mo flat", "From ~$19/mo, tiering to enterprise-scale plans (see their pricing page)"], + [ + "What it makes", + "Seven artifacts, one flow: show notes, chapters, summary, titles, blog post, newsletter, social posts — plus free no-signup tools", + "Many artifact types, custom prompts, chat, integrations — a large surface", + ], + ["Learning curve", "One screen per episode, paste and go", "Broader surface — more options to learn"], + [ + "Quote integrity", + "Verbatim-only; AI output that paraphrases a quote is rejected automatically", + "Standard AI generation", + ], + ["Free without signup", "Yes — four single-artifact tools, no account", "Trial with account"], + ["Custom prompts / pipelines", "Not in the MVP", "Yes, extensive"], + ["Best for", "Indie shows, solo creators, newsletter-driven podcasts", "Production teams that want configurable pipelines and integrations"], + ], + chooseThem: { + h: "Choose Castmagic if…", + p: "you're a production team that lives in custom prompts, needs their integration breadth, or already has a workflow built there. Their depth of configuration is real.", + }, + chooseUs: { + h: "Choose EpisodeKit if…", + p: ( + <> + you want to paste an episode and get the whole package in one pass — and you've been + burned by AI tools inventing “quotes” your guests never said. Ours + architecturally can't. Start free, no signup:{" "} + + Generate show notes free → + + + ), + }, + fact: "Facts as of October 2026, from each product's public pricing page. Prices change — verify before buying. Written by the EpisodeKit maker, trade-offs kept honest.", + }, +}; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + if (!isSlug(slug)) return {}; + return { title: CONTENT[slug].title, description: CONTENT[slug].metaDescription }; +} + +function isSlug(value: string): value is (typeof COMPARE_SLUGS)[number] { + return (COMPARE_SLUGS as readonly string[]).includes(value); +} + +export default async function ComparePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + if (!isSlug(slug)) notFound(); + const c = CONTENT[slug]; + return ( +
+

+ EpisodeKit vs {slug === "podsqueeze" ? "Podsqueeze" : "Castmagic"} +

+

{c.sub}

+ +
+ + + + + + + + + + {c.rows.map(([label, us, them]) => ( + + + + + + ))} + +
EpisodeKit + {slug === "podsqueeze" ? "Podsqueeze" : "Castmagic"} +
{label}{us}{them}
+
+

EpisodeKit plans and artifacts as described above: {US_INTRO}

+ +
+
+

{c.chooseThem.h}

+

{c.chooseThem.p}

+
+
+

{c.chooseUs.h}

+

{c.chooseUs.p}

+
+
+ +

{c.fact}

+ +

+ {slug === "podsqueeze" ? ( + + vs Castmagic → + + ) : ( + + vs Podsqueeze → + + )} +

+
+ ); +} diff --git a/web/app/(marketing)/guides/repurposing/page.tsx b/web/app/(marketing)/guides/repurposing/page.tsx new file mode 100644 index 0000000..3cf3978 --- /dev/null +++ b/web/app/(marketing)/guides/repurposing/page.tsx @@ -0,0 +1,151 @@ +// /guides/repurposing — ported from site/guides/repurposing.html at the same +// path, links updated to the new routes. +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "The Podcast Repurposing Playbook (2026) — EpisodeKit", + description: + "How to turn one episode into a month of content without burning out: the priority order, the batch workflow, the verbatim rule, and the tools.", +}; + +export default function GuideRepurposingPage() { + return ( +
+

+ The Podcast Repurposing Playbook +

+

+ One episode, a month of content — without a content team. The priority order, the batch + workflow, and the rules that keep it honest. ~6 min read. +

+ +
+
+

+ The math nobody tells solo podcasters +

+

+ A weekly 45-minute episode contains roughly 6,000 spoken words — more than most people + write in a week. Nearly all of it evaporates. Repurposing isn't making + “extra” content; it's rescuing content you already made. Done + even partially, one episode yields: an episode-page writeup, a newsletter, a 5-day + email drip, 3–6 social posts with quotes, a community discussion prompt, and title + options for the clip cuts. That's 15–20 assets, most of them derived, not written. +

+
+ +
+

+ Priority order (when you can't do it all) +

+
+ + + + + + + + + + + {[ + ["Show notes / episode page", "Medium", "Compounding (search)", "Always — it's the anchor everything links to"], + ["Newsletter issue", "Low", "Highest (owned audience)", "You have any list at all"], + ["2–3 quote posts", "Very low", "Reach + guest shares", "Always — guests reshare their own best lines"], + ["Community discussion prompts", "Low", "Retention", "You have a Discord/Patreon/group"], + ["5-day email drip", "Medium", "Onboarding new subscribers for months", "List > ~500"], + ["Video clips", "High", "Volatile", "You have video source + editing time"], + ].map(([asset, effort, ret, when]) => ( + + + + + + + ))} + +
AssetEffortReturnDo it if…
{asset}{effort}{ret}{when}
+
+
+ +
+

The verbatim rule

+
+ Whatever tool writes your repurposed assets:{" "} + quotes must be word-for-word from the transcript. A fabricated “quote” + shared publicly by a guest is the fastest way to burn a relationship — and it's the + most common complaint about AI repurposing tools. Our{" "} + generator{" "} + enforces this architecturally: AI-paraphrased quotes are rejected at generation time. +
+
+ +
+

+ The batch workflow (one focused hour per episode) +

+
    +
  1. + Transcript ready (your usual ASR; or paste it). +
  2. +
  3. + Pull the 6 best moments — claims, numbers, stories. These become your quotes + and timestamps. +
  4. +
  5. + Write the notes around them —{" "} + template here, + full anatomy in the{" "} + show notes guide. +
  6. +
  7. + Derive the rest: newsletter = summary + one quote; social = quote + link; + community = 3 questions; drip = 5 moments, one per day. +
  8. +
  9. + Schedule once, then leave it alone. Batching beats dripping your own attention + all week. +
  10. +
+

+ Steps 2–4 are exactly what the{" "} + EpisodeKit app{" "} + automates — all seven artifact families from one episode — but the playbook works by + hand too; the tools are speed, not magic. +

+
+ +
+

Choosing tools honestly

+

+ All-in-one suites (Podsqueeze, Castmagic) are real products with broad feature sets; + the common trade-offs are price tiers and generic-feeling output. Ours trades breadth + for the verbatim guarantee and a no-signup floor. The honest comparisons:{" "} + vs Podsqueeze{" "} + ·{" "} + vs Castmagic. +

+
+ +
+

The one-metric check

+

+ Each month, ask: which repurposed asset actually got clicked? Kill the rest + without guilt. The playbook survives contact with your audience only if you prune it. +

+

+ Start with the free{" "} + + show notes generator + {" "} + — paste a transcript, no signup — or grab the{" "} + template{" "} + and do it by hand. +

+
+
+
+ ); +} diff --git a/web/app/(marketing)/guides/show-notes/page.tsx b/web/app/(marketing)/guides/show-notes/page.tsx new file mode 100644 index 0000000..819ca44 --- /dev/null +++ b/web/app/(marketing)/guides/show-notes/page.tsx @@ -0,0 +1,190 @@ +// /guides/show-notes — ported from site/guides/show-notes.html at the same +// path, links updated to the new routes. The copy is already podcaster-neutral. +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "The Complete Guide to Podcast Show Notes (2026) — EpisodeKit", + description: + "What show notes are for, the anatomy that works, the 5 mistakes hosts make, and how to write them fast — with a free template and a free generator.", +}; + +export default function GuideShowNotesPage() { + return ( +
+

+ The Complete Guide to Podcast Show Notes +

+

+ Everything that matters: what they're for, the anatomy that works, the mistakes, and + the fastest honest ways to write them. ~7 min read. +

+ +
+ Jump to: Why they matter ·{" "} + The anatomy ·{" "} + 5 mistakes ·{" "} + SEO realities (2026) ·{" "} + Writing them fast ·{" "} + The one-rule checklist +
+ +
+
+

+ Why show notes matter more than ever +

+

+ Show notes do four jobs at once. They let skimmers decide in ten seconds whether + the episode is for them. They give search engines text to index — your audio is + invisible to search, your notes are not. They give guests something to share (a + link with their name and best line in it gets shared; a bare audio link does not). And + they serve accessibility: deaf listeners and people who read faster than they + listen. +

+

+ The pattern that emerged once AI tools started writing notes everywhere: readers now{" "} + scan for proof. Generic notes that could describe any episode get discarded; + notes that quote the episode word-for-word get trusted and clicked. +

+
+ +
+

+ The anatomy that works +

+

+ Six parts, in this order — the full copy-paste structure lives in our{" "} + + show notes template + + : +

+
    +
  • + Title as a promise — “Attention: the budget nobody manages”, not + “Episode 47”. +
  • +
  • + TL;DR first — 2–3 sentences of payoff before any structure. Skimmers decide + here. +
  • +
  • + Key moments with timestamps — 5–8 entries, each headed by a claim, not a + topic. +
  • +
  • + The takeaway — the single most quotable idea, stated plainly. +
  • +
  • + Mentioned links — only what was genuinely referenced. +
  • +
  • + About the guest — one line, one link. +
  • +
+
+ +
+

+ The five mistakes almost every show makes +

+
    +
  • + Table-of-contents notes — “We discuss productivity” describes every + episode ever made. Say what's claimed. +
  • +
  • + Inventing quotes — the fastest way to lose a guest's trust. If you quote, + quote word-for-word (this is exactly what our{" "} + + free generator + {" "} + enforces architecturally). +
  • +
  • + Timestamps that promise nothing — “12:30 — we continue talking…” + earns no clicks. +
  • +
  • + Links dump — 20 undifferentiated links is the same as zero. +
  • +
  • + Notes written for the host — they're written for the person deciding + whether to press play. +
  • +
+
+ +
+

+ SEO realities in 2026 +

+

+ Two shifts matter. First, AI answer boxes compress informational clicks — but{" "} + episode pages with real quotes and timestamps still get cited and clicked, + because they're primary sources, not summaries. Second, search engines + increasingly reward unique text: your transcript paraphrased generically is + worth little; your best moments quoted exactly is worth a lot. (For a toolkit + comparison of how the current tools approach this, see{" "} + + EpisodeKit vs Podsqueeze + {" "} + and{" "} + + vs Castmagic + + .) +

+
+ +
+

+ Writing them fast — three honest speeds +

+
+
+ Manual (30–45 min): use the{" "} + template, + pull your 6 best moments from the transcript, quote them exactly. Best + quality-to-time ratio if you enjoy writing. +
+
+ Generator (1 min): paste your transcript into the{" "} + + free show notes generator + {" "} + — titled, sectioned notes where every line comes from what was actually said. No + signup. +
+
+ Full package (2 min): one pass in the{" "} + EpisodeKit app{" "} + produces all seven artifacts — show notes, chapters, summary, titles, blog post, + newsletter and social posts. +
+
+
+ +
+

+ The one-rule checklist before you publish +

+
    +
  • Could this title belong to any other episode? Rewrite it.
  • +
  • Is the payoff in the first three sentences? Move it there.
  • +
  • Is every quote word-for-word? Verify against the transcript.
  • +
  • Would a skim-reader press play? If not, cut until they would.
  • +
+

+ That's the whole game. Template{" "} + here, + generator{" "} + here — + both free. +

+
+
+
+ ); +} diff --git a/web/app/(marketing)/layout.tsx b/web/app/(marketing)/layout.tsx new file mode 100644 index 0000000..5d33762 --- /dev/null +++ b/web/app/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import { MarketingNav } from "@/components/marketing/nav"; +import { MarketingFooter } from "@/components/marketing/footer"; + +export default function MarketingLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/web/app/(marketing)/page.tsx b/web/app/(marketing)/page.tsx new file mode 100644 index 0000000..46dc496 --- /dev/null +++ b/web/app/(marketing)/page.tsx @@ -0,0 +1,303 @@ +// MK-1 Landing — copy verbatim from docs/relaunch/mockups/01-landing.html +// (the spec wins on conflicts). Fully static; the founding counter is the only +// server-side value and comes from the documented entitlements stub seam. +import type { Metadata } from "next"; +import Link from "next/link"; +import { CheckIcon, XIcon } from "lucide-react"; +import { FOUNDING_TOTAL } from "@/lib/billing/entitlements"; +import { foundingRemainingForMarketing } from "@/lib/marketing/founding"; + +export const metadata: Metadata = { + title: "EpisodeKit — Show notes that sound like you", + description: + "Upload an episode. Get show notes, chapters, a blog post, a newsletter and social posts in minutes — in your voice, with every quote checked against the audio.", +}; + +const PAINS = [ + { + h3: "3 hours every Sunday", + p: "Relistening, timestamping and writing show notes eats your weekend. EpisodeKit does it in minutes.", + }, + { + h3: "Generic AI copy", + p: "Other tools sound like other tools. EpisodeKit learns your voice from your past show notes.", + }, + { + h3: "Misquoted guests", + p: "Every quote is copied from the transcript and links to the exact moment in the audio.", + }, +] as const; + +const STEPS = [ + { + n: "1", + h3: "Upload", + p: "Drop your audio or paste a transcript from Riverside, Descript or Otter.", + }, + { n: "2", h3: "Review in your voice", p: "Edit anything, regenerate a section, tick it ready." }, + { + n: "3", + h3: "Copy anywhere", + p: "Formatted for Apple Podcasts, Spotify, YouTube, your blog and newsletter.", + }, +] as const; + +const GETS = [ + { h3: "Show notes", p: "Hook, highlights, chapters, CTA." }, + { h3: "Chapters", p: "Timestamps in every platform's format." }, + { h3: "Summary", p: "One-liner, short and long." }, + { h3: "Titles", p: "8 options in labelled styles." }, + { h3: "Blog post", p: "SEO title, meta and slug included." }, + { h3: "Newsletter", p: "Three subject lines to pick from." }, + { h3: "Social posts", p: "LinkedIn, a thread and Instagram." }, + { h3: "Quotes", p: "Five verified pull quotes." }, +] as const; + +// FAQ written from real product facts only (§0.5: no invented claims). The two +// mockup answers that stated unshipped specifics (30-day audio deletion, 3–5 +// minute runtime) are replaced with facts true of the shipped code — logged in +// DECISIONS.md 2026-10-27 · WP-09. +const FAQ = [ + { + q: "Is my audio used to train AI?", + a: "No. Your audio is stored privately for processing and is never used to train AI models. Transcripts stay in your account until you delete them.", + }, + { + q: "Which languages work?", + a: "English for now.", + }, + { + q: "How do you check quotes?", + a: "Quotes are copied from the transcript by position, never retyped by the AI.", + }, + { + q: "Can I cancel anytime?", + a: "Yes, in two clicks from Billing. Your episodes stay.", + }, + { + q: "Do video podcasts work?", + a: "Yes. Upload the video; we use its audio.", + }, + { + q: "What do I get from every episode?", + a: "Show notes, chapters, a summary, 8 title options, a blog post, a newsletter and social posts — plus five verified quotes.", + }, +] as const; + +export default async function LandingPage() { + const foundingRemaining = await foundingRemainingForMarketing(); + return ( +
+ {/* Hero */} +
+
+ + For podcast hosts + +

+ Show notes that sound like you — and never misquote you. +

+

+ Upload an episode. Get show notes, chapters, a blog post, a newsletter and social + posts in minutes — in your voice, with every quote checked against the audio. +

+
+ + Start free — 2 episodes/month + + + See a real sample → + +
+

No card required · Cancel anytime

+
+ + {/* Product preview (mockup content) */} +
+
+ Ep 42 · Attention is a budget + + + Ready + +
+
+ Show notes + Summary + Titles + Blog post +
+
+

+ Focus is the most expensive resource in modern work, and almost nobody budgets it. + This week: why one ping can tax a whole morning, and the calendar habit that fixes + it. +

+

In this episode

+
    +
  • Why attention compounds
  • +
  • Calendars beat to-do lists
  • +
  • Subtraction is the real upgrade
  • +
+
+

+ “We budget our dollars, we calendar our hours, and then we let anyone with a + notification spend our attention for free.” +

+

+ — from the episode{" "} + + ✓ Verified · 00:14 + +

+
+
+
+
+ + {/* Pains */} +
+
+ {PAINS.map((pain) => ( +
+

{pain.h3}

+

{pain.p}

+
+ ))} +
+
+ + {/* How it works + what you get */} +
+
+

How it works

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

{step.h3}

+

{step.p}

+
+ ))} +
+

What you get from every episode

+
+ {GETS.map((item) => ( +
+

{item.h3}

+

{item.p}

+
+ ))} +
+
+
+ + {/* Quote check */} +
+
+

Every quote is checked

+
+
+ + Kept — exact words at 07:40 + +

+ “Calendars beat to-do lists because a to-do item is a wish while a calendar block + is a promise.” +

+
+
+ + Rejected — not in the transcript + +

+ “To-do lists are wishes; calendars are promises you keep.” +

+
+
+
+
+ + {/* Pricing */} +
+
+

Simple pricing

+
+
+ Free + $0 +
    +
  • 2 episodes a month
  • +
  • Up to 60 minutes each
  • +
  • All 7 pieces of content
  • +
+ + Start free + +
+
+
+ Pro + + Most popular + +
+ + $19/month + +
    +
  • 10 episodes a month
  • +
  • Up to 2 hours each
  • +
  • No EpisodeKit footer
  • +
+ + Get Pro + +
+
+

+ Founding Pro: $99/year, price locked for life + {foundingRemaining == null ? ( + " · first 100 only" + ) : ( + <> + {" · "} + {foundingRemaining} of{" "} + {FOUNDING_TOTAL} left + + )} +

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

Questions

+
+ {FAQ.map((item) => ( +
+ {item.q} +

{item.a}

+
+ ))} +
+
+
+
+ ); +} diff --git a/web/app/(marketing)/pricing/page.tsx b/web/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..27e267c --- /dev/null +++ b/web/app/(marketing)/pricing/page.tsx @@ -0,0 +1,86 @@ +// §5.4 /pricing — M10 plans, plain copy. Static. +import type { Metadata } from "next"; +import Link from "next/link"; +import { FOUNDING_TOTAL } from "@/lib/billing/entitlements"; +import { foundingRemainingForMarketing } from "@/lib/marketing/founding"; + +export const metadata: Metadata = { + title: "Pricing — EpisodeKit", + description: + "Free: 2 episodes a month. Pro $19/month: 10 episodes, up to 2 hours each, no footer. Founding Pro $99/year, price locked for life.", +}; + +export default async function PricingPage() { + const foundingRemaining = await foundingRemainingForMarketing(); + return ( +
+
+

Pricing

+

Pay per month, cancel in two clicks. Your episodes stay either way.

+
+ +
+
+ Free + $0 +
    +
  • 2 episodes a month
  • +
  • Up to 60 minutes each
  • +
  • All 7 pieces of content
  • +
  • EpisodeKit footer on exports
  • +
+ + Start free + +
+
+
+ Pro + + Most popular + +
+ + $19/month + +
    +
  • 10 episodes a month
  • +
  • Up to 2 hours each
  • +
  • All 7 pieces of content
  • +
  • No EpisodeKit footer
  • +
  • Cancel in 2 clicks
  • +
+ + Get Pro + +
+
+ +

+ Founding Pro: $99/year, price locked for life + {foundingRemaining == null ? ( + " · first 100 only" + ) : ( + <> + {" · "} + {foundingRemaining} of{" "} + {FOUNDING_TOTAL} left + + )} +

+ +
+

+ Quota is counted when a package starts and refunded if the pipeline fails. Free-plan + exports carry the line “Show notes made with EpisodeKit”. +

+
+
+ ); +} diff --git a/web/app/(marketing)/privacy/page.tsx b/web/app/(marketing)/privacy/page.tsx new file mode 100644 index 0000000..ebbcabf --- /dev/null +++ b/web/app/(marketing)/privacy/page.tsx @@ -0,0 +1,86 @@ +// §5.4 /privacy — plain factual boilerplate describing the ACTUAL data flows +// shipped in this codebase. No invented company addresses (§0.5); contact is +// the owner email configured in the OWNER_EMAILS env var. +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Privacy — EpisodeKit", + description: + "What EpisodeKit stores: your account email, episodes and transcripts, and the third-party services used to run the product.", +}; + +const SERVICES = [ + ["Hosting", "Vercel — serves the app and stores uploaded audio in private Blob storage."], + ["Sign-in", "Clerk — your email address (and Google account, if you use Google sign-in)."], + ["Database", "Neon Postgres — your account, show, episodes, transcripts and generated content."], + ["Transcription", "Groq — audio you upload is sent to Groq's Whisper API to produce the transcript."], + [ + "Writing", + "The AI provider configured for the product (see the AI providers list on this page) receives the transcript to write your content.", + ], + ["Payments", "Lemon Squeezy — processes subscriptions. We see your email and plan status."], + ["Analytics", "PostHog — product events and the feedback you submit. Loaded only when analytics is configured."], + ["Email", "Resend — sends the 'package ready' email and magic-link sign-in emails."], +] as const; + +export default function PrivacyPage() { + return ( +
+

Privacy

+

Plain language, last updated October 2026.

+ +
+
+

What we store

+
    +
  • Your account: email address and name from Clerk sign-in.
  • +
  • Your show: name, artwork and links you enter, plus voice samples you paste.
  • +
  • + Episodes: the audio or transcript you upload, the transcript produced from it, and + the content generated for it. Audio is stored privately and only used to produce + your package. +
  • +
  • Feedback you send us, including your note and which artifact it was about.
  • +
+

+ Your audio is not used to train AI models. Transcripts and content stay in your + account until you delete the episode. +

+
+ +
+

Services we use

+
    + {SERVICES.map(([name, what]) => ( +
  • + {name}: {what} +
  • + ))} +
+
+ +
+

What we never do

+
    +
  • Sell your data.
  • +
  • Send marketing you didn't ask for.
  • +
  • Let AI providers train on your content.
  • +
+
+ +
+

Deleting your data

+

+ Deleting an episode removes its audio, transcript and content. To delete your account, + contact us and we will remove everything. +

+
+ +
+

Contact

+

EpisodeKit — contact: the owner email configured for this deployment (OWNER_EMAILS).

+
+
+
+ ); +} diff --git a/web/app/(marketing)/samples/page.tsx b/web/app/(marketing)/samples/page.tsx new file mode 100644 index 0000000..9a893f8 --- /dev/null +++ b/web/app/(marketing)/samples/page.tsx @@ -0,0 +1,316 @@ +// /samples — ported from site/samples.html at the same path. The page IS +// generated output from the demo transcript (docs/samples/demo-episode.txt, +// original CC0) produced by the deterministic engine, and says so — no manual +// edits, every quote appears word-for-word in the input (§0.5 honest). +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Sample package — EpisodeKit", + description: + "A real content package generated by EpisodeKit from a podcast transcript: show notes, discussion kit, email series, social pack, summary, titles.", +}; + +const TAGS = [ + "Attention and Version", + "The Attention We Keep Avoiding", + "What Version Gets Wrong About Attention", + "Focus Changes Everything", + "Attention for Ordinary Weeks", + "Why Attention Matters", +]; + +function Quote({ children }: { children: React.ReactNode }) { + return ( +
+ “{children}” +
+ ); +} + +function Card({ title, children }: { title?: string; children: React.ReactNode }) { + return ( +
+ {title &&

{title}

} +
{children}
+
+ ); +} + +function Meta({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export default function SamplesPage() { + return ( +
+

Attention — Version

+

+ Generated by EpisodeKit · engine: offline · 575 words in · themes: attention, version, + focus, calendar, spend +

+ +
+

+ This entire page was generated from one episode transcript — the demo input is an + original CC0 transcript in the EpisodeKit repo (docs/samples/demo-episode.txt). Every + quote below appears word-for-word in that input; repeated caption variants are trimmed + for the page, nothing else is edited.{" "} + + Try it with your own episode → + +

+
+ +
+ {TAGS.map((t) => ( + + {t} + + ))} +
+ +
+
+

+ Summary +

+

+ We budget our dollars, we calendar our hours, and then we let anyone with a + notification spend our attention for free. Because attention compounds, the cost of an + interruption is not the five minutes it takes. Calendars beat to-do lists because a + to-do item is a wish while a calendar block is a promise. Focus is treated as a + personal virtue, which makes it feel rude to defend; so we let the loudest agenda in + the room spend everyone's attention. Pick one ninety-minute block, put it on the + calendar, silence everything, and work on the single thing that has been sitting on + your list the longest. The version of your week with protected focus in it is the + version that compounds. +

+
+ +
+

+ Article (show notes) +

+
+ +

+ Today I want to talk about focus, because focus is the most expensive resource in + modern work and almost nobody manages it like money. We budget our dollars, we + calendar our hours, and then we let anyone with a notification spend our attention + for free. Here is the uncomfortable truth about deep work: it is not a personality + trait, it is an environment you build. Every one of us knows the version of + ourselves that opens a document and actually finishes it, and we all know the + version that checks one message and surfaces forty minutes later wondering where + the morning went. +

+

+ The difference between those two people is rarely discipline; it is friction. + Notice how the distracted version of you is never one decision deep — you made one + small permissive choice and the environment did the rest. Because attention + compounds, the cost of an interruption is not the five minutes it takes. The + research on task switching says it can take over twenty minutes to get back to full + depth, which means a single ping can quietly tax the entire morning. +

+
+ +

+ Remember that the next time someone says it will only take a second: seconds are + how attention debt is issued, and the interest rate is brutal. The key is deciding + before the day starts. Calendars beat to-do lists because a to-do item is a wish + while a calendar block is a promise. Imagine looking at your Tuesday and seeing two + ninety-minute blocks with your name on them, notifications off, one tab open; for + most people that single change doubles their output before lunch, because output + was never the problem — protected time was. +

+

+ And let us be honest about the tools, because this is the part nobody wants to + hear. Every app that promises to organize your attention is also competing for it. + Therefore the real upgrade is usually subtraction: fewer inputs, fewer tabs, fewer + meetings that could have been a paragraph. The professional with three tools and a + clear calendar will out-ship the one with thirty tools and a badge for organizing + them all. +

+
+ +

+ Focus is treated as a personal virtue, which makes it feel rude to defend; so we + let the loudest agenda in the room spend everyone's attention. Boundaries are + the fix, and boundaries sound like scripts: I am offline until noon; send it and I + will get there this afternoon; let us make this a document instead of a meeting. + Saying them out loud once costs less than resenting the interruption for a month. + Pick one ninety-minute block, put it on the calendar, silence everything, and work + on the single thing that has been sitting on your list the longest. +

+

+ Notice how it feels; most people tell me it feels like getting a whole extra day + back. The version of your week with protected focus in it is the version that + compounds. Because here is the takeaway I want you to carry out of this episode: + your attention is the budget everything else is bought with. Spend it like it is + yours, because it is. +

+

+ Thanks for listening; show notes and the full transcript are on the site, and if + this helped, send it to the person who keeps pinging you at nine in the morning. +

+
+
+
+ +
+

+ Discussion kit +

+
+ + Opener +

In one sentence: what did you take away from this episode?

+ Opening questions +
    +
  1. Where does attention show up in your own work or life right now?
  2. +
  3. The episode pressed into version. What about that challenged or confirmed what you thought?
  4. +
  5. If focus became real for you this month, what would change first?
  6. +
+ From the episode + + Today I want to talk about focus, because focus is the most expensive resource in + modern work and almost nobody manages it like money. + +

→ What stands out to you in that moment, and why?

+ + We budget our dollars, we calendar our hours, and then we let anyone with a + notification spend our attention for free. + +

→ Do you agree? What would you add or push back on?

+ + Every one of us knows the version of ourselves that opens a document and actually + finishes it, and we all know the version that checks one message and surfaces forty + minutes later wondering where the morning went. + +

→ Where have you seen this play out — or fail — in your own experience?

+ + Because attention compounds, the cost of an interruption is not the five minutes it + takes. + +

→ If you acted on this tomorrow, what would be the first thing to break?

+ + The research on task switching says it can take over twenty minutes to get back to + full depth, which means a single ping can quietly tax the entire morning. + +

→ Who in your world would push back hardest on this — and what would their objection be?

+ + Calendars beat to-do lists because a to-do item is a wish while a calendar block is + a promise. + +

→ What's the smallest version of this idea you could test this week?

+ Apply it +
    +
  1. What is one concrete step you can take this week based on attention?
  2. +
  3. Who do you know that needs to hear this episode — and what will you tell them about it?
  4. +
+ Closing +

+ Close by having everyone share the one thing they're going to try first, and + check in on it next time you meet. +

+
+
+
+ +
+

+ Email series — 5 days +

+
+ {[ + { + t: "Day 1: Attention", + q: "Today I want to talk about focus, because focus is the most expensive resource in modern work and almost nobody manages it like money.", + r: "Today I want to talk about focus, because focus is the most expensive resource in modern work and almost nobody manages it like money. We budget our dollars, we calendar our hours, and then we let anyone with a notification spend our attention for free. Here is the uncomfortable truth about deep work: it is not a personality trait, it is an environment you build.", + a: "Write down where you ran into attention this week, and one thing you'd do differently.", + }, + { + t: "Day 2: Version", + q: "We budget our dollars, we calendar our hours, and then we let anyone with a notification spend our attention for free.", + r: "Today I want to talk about focus, because focus is the most expensive resource in modern work and almost nobody manages it like money. We budget our dollars, we calendar our hours, and then we let anyone with a notification spend our attention for free. Here is the uncomfortable truth about deep work: it is not a personality trait, it is an environment you build.", + a: "Tell one colleague or friend what you learned about version — explaining it is how it sticks.", + }, + { + t: "Day 3: Focus", + q: "Every one of us knows the version of ourselves that opens a document and actually finishes it, and we all know the version that checks one message and surfaces forty minutes later wondering where the morning went.", + r: "Here is the uncomfortable truth about deep work: it is not a personality trait, it is an environment you build. Every one of us knows the version of ourselves that opens a document and actually finishes it, and we all know the version that checks one message and surfaces forty minutes later wondering where the morning went. The difference between those two people is rarely discipline; it is friction.", + a: "Pick the smallest possible experiment around focus and run it today.", + }, + { + t: "Day 4: Calendar", + q: "Because attention compounds, the cost of an interruption is not the five minutes it takes.", + r: "Notice how the distracted version of you is never one decision deep — you made one small permissive choice and the environment did the rest. Because attention compounds, the cost of an interruption is not the five minutes it takes. The research on task switching says it can take over twenty minutes to get back to full depth, which means a single ping can quietly tax the entire morning.", + a: "Do one small act today that applies calendar — something nobody would notice but you.", + }, + { + t: "Day 5: Spend", + q: "The research on task switching says it can take over twenty minutes to get back to full depth, which means a single ping can quietly tax the entire morning.", + r: "Because attention compounds, the cost of an interruption is not the five minutes it takes. The research on task switching says it can take over twenty minutes to get back to full depth, which means a single ping can quietly tax the entire morning. Remember that the next time someone says it will only take a second: seconds are how attention debt is issued, and the interest rate is brutal.", + a: "Look back on the week: note one moment where spend actually changed what you did. Write it down.", + }, + ].map((d) => ( + + Re-listen + {d.q} +

{d.r}

+ Do this +

{d.a}

+ Sign-off +

Reply and tell us what you think — we read everything.

+
+ ))} +
+
+ +
+

+ Social pack +

+
+ + + Today I want to talk about focus, because focus is the most expensive resource in + modern work and almost nobody manages it like money. + + + We budget our dollars, we calendar our hours, and then we let anyone with a + notification spend our attention for free. + + + Every one of us knows the version of ourselves that opens a document and actually + finishes it, and we all know the version that checks one message and surfaces forty + minutes later wondering where the morning went. + + Captions +

+ This week's episode in one line: +
+
+ “Today I want to talk about focus, because focus is the most expensive + resource in modern work and almost nobody manages it like money.” +
+
+ Full episode + show notes on our site. #attention #version #focus +

+ X posts +

+ “Calendars beat to-do lists because a to-do item is a wish while a calendar + block is a promise.” #attention #focus +

+
+
+
+
+ +

+ Generated from a podcast transcript with EpisodeKit. This page was produced by the same + engine you can try — lightly trimmed for the web, quotes untouched. +

+
+ ); +} diff --git a/web/app/(marketing)/templates/show-notes/page.tsx b/web/app/(marketing)/templates/show-notes/page.tsx new file mode 100644 index 0000000..53eef33 --- /dev/null +++ b/web/app/(marketing)/templates/show-notes/page.tsx @@ -0,0 +1,137 @@ +// /templates/show-notes — ported from site/templates/show-notes.html at the +// same path (template text + 5 rules + filled example), links updated. +import type { Metadata } from "next"; +import Link from "next/link"; +import { TemplateCopyButton } from "@/components/marketing/template-copy-button"; + +export const metadata: Metadata = { + title: "Podcast Show Notes Template (copy-paste, with example) — EpisodeKit", + description: + "A free podcast show notes template you can copy now — structure, what goes in each section, and a filled-in example. Plus a generator that writes them from your transcript.", +}; + +const TEMPLATE = `#[Episode number]: [Title — promise, not topic] + +**With:** [Guest name, one-line credential] +**Listen:** [~length] · [Apple/Spotify links] + +## TL;DR +[2–3 sentences: what the listener walks away with. Written as a promise, +not a table of contents.] + +## Key moments +- **[Timestamp] — [Heading]**: [One sentence about what happens here.] +- **[Timestamp] — [Heading]**: [...] +- **[Timestamp] — [Heading]**: [...] +(5–8 entries. Headings are promises: "Why calendar blocks beat to-do +lists" — not "Productivity talk".) + +## The takeaway +[The single most quotable idea from the episode, stated plainly.] + +## Mentioned in this episode +- [Thing/resource + link, only if genuinely referenced] + +## About [Guest/Show] +[1–2 lines + one link.]`; + +export default function TemplateShowNotesPage() { + return ( +
+

+ Podcast Show Notes Template +

+

+ Copy the structure below, or skip the writing entirely:{" "} + + the free generator + {" "} + builds show notes from your transcript — every line grounded in what was actually said. +

+ +

The template (copy-paste)

+
+
+          {TEMPLATE}
+        
+ +
+ +

What makes good show notes (the 5 rules)

+
+

+ + 1 · Title = promise + + “Focus is a budget” beats “Episode 47: Productivity”. Search and + humans both reward specificity. +

+

+ + 2 · Quote only what was said + + If you quote the episode, quote it word-for-word. (This is exactly what our generator + enforces — AI tools inventing “quotes” is the top host complaint.) +

+

+ + 3 · Timestamps are promises + + Each one should earn a click: a claim, a story, a number — never “we continue + talking about…” +

+

+ + 4 · TL;DR before contents + + Give the skim-reader the payoff first; the structure serves them after. +

+

+ + 5 · Cut everything generic + + If a sentence could belong to any podcast, delete it. +

+
+ +

Filled-in example

+
+

+ Generated by the free tool from a real transcript — unedited +

+

+ #12: Attention — The Budget Nobody Manages +

+

+ TL;DR: Attention compounds like money, and most of us let anyone spend it. This + episode walks through the friction changes — calendar blocks, input subtraction, + scripts for boundaries — that get a full morning back. +

+

+ Key moments: +
· Opening: Attention — why focus is an environment you build, not a + personality trait +
· Cost of a ping — task-switching interest rates: one notification can tax + the whole morning +
· Calendar beats to-do — “a to-do item is a wish; a calendar block is + a promise” +
· Subtraction — the professional with three tools out-ships the one with + thirty +
· Scripts for boundaries — exact sentences that defend focus without burning + bridges +

+

+ The takeaway: “Your attention is the budget everything else is bought with. + Spend it like it's yours — because it is.” +

+
+ +

+ Generate yours from a real transcript — free, no signup, quotes verbatim:{" "} + + show notes generator → + +

+
+ ); +} diff --git a/web/app/(marketing)/terms/page.tsx b/web/app/(marketing)/terms/page.tsx new file mode 100644 index 0000000..278bcdf --- /dev/null +++ b/web/app/(marketing)/terms/page.tsx @@ -0,0 +1,63 @@ +// §5.4 /terms — plain factual boilerplate for the MVP. No invented legal +// entities or addresses (§0.5); billing is via Lemon Squeezy as merchant of +// record once the store is live. +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Terms — EpisodeKit", + description: "The plain terms of using EpisodeKit: your content, your plan, cancellation.", +}; + +export default function TermsPage() { + return ( +
+

Terms

+

Plain language, last updated October 2026.

+ +
+
+

Your content

+

+ You own everything you upload and everything EpisodeKit generates for you. You can + edit, copy and export it at any time. We only process it to produce your content. +

+
+
+

Acceptable use

+

+ Upload only audio and transcripts you have the right to use. Don't use EpisodeKit to + infringe anyone's rights or to break the law. +

+
+
+

Plans and billing

+

+ The Free plan includes 2 episodes a month. Pro is $19/month with 10 episodes a month. + Founding Pro is $99/year for the first 100 customers, price locked for life. + Payments are handled by Lemon Squeezy as merchant of record; applicable taxes are + included in their checkout. +

+
+
+

Cancellation

+

+ Cancel in two clicks from Settings → Billing. You keep access until the end of the + paid period, and your episodes stay. +

+
+
+

Availability and AI limits

+

+ EpisodeKit works with English episodes. AI output can contain mistakes — quotes are + checked against your transcript, but review your content before publishing. The + service is provided as-is, without warranties to the extent permitted by law. +

+
+
+

Contact

+

EpisodeKit — contact: the owner email configured for this deployment (OWNER_EMAILS).

+
+
+
+ ); +} diff --git a/web/app/(marketing)/tools/[tool]/page.tsx b/web/app/(marketing)/tools/[tool]/page.tsx new file mode 100644 index 0000000..0b94419 --- /dev/null +++ b/web/app/(marketing)/tools/[tool]/page.tsx @@ -0,0 +1,68 @@ +// MK-10 — the four free tools at their legacy paths (M12): /tools/show-notes, +// /tools/episode-titles, /tools/email-series, /tools/discussion-guide. +// Static shell + one small client island for paste/generate. +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { TOOL_DEFS, TOOL_IDS, type ToolId } from "@/lib/tools/defs"; +import { ToolClient } from "@/components/marketing/tool-client"; + +export function generateStaticParams(): Array<{ tool: ToolId }> { + return TOOL_IDS.map((tool) => ({ tool })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ tool: string }>; +}): Promise { + const { tool } = await params; + if (!isTool(tool)) return {}; + const def = TOOL_DEFS[tool]; + return { title: def.title, description: def.metaDescription }; +} + +function isTool(value: string): value is ToolId { + return (TOOL_IDS as readonly string[]).includes(value); +} + +/** SEO tip line under the tool (ported from the legacy pages). */ +const TIPS: Record = { + "show-notes": + "Tip: paste the full transcript — more text means better section headings. Prefer a blank structure to fill yourself? Grab the free show notes template →", + "episode-titles": "Tip: paste the full transcript — more text means sharper themes and better titles.", + "email-series": + "Every quote in the series is taken word-for-word from the episode you pasted — nothing is invented.", + "discussion-guide": + "Every quote in the kit is taken word-for-word from what you pasted — nothing is invented.", +}; + +export default async function ToolPage({ params }: { params: Promise<{ tool: string }> }) { + const { tool } = await params; + if (!isTool(tool)) notFound(); + const def = TOOL_DEFS[tool]; + return ( +
+
+ FREE TOOL · NO SIGNUP +

{def.h1}

+

{def.intro}

+
+ + + +

+ {TIPS[tool]}{" "} + {tool === "show-notes" ? ( + + Show notes template + + ) : ( + + All free tools + + )} +

+
+ ); +} diff --git a/web/app/(marketing)/tools/page.tsx b/web/app/(marketing)/tools/page.tsx new file mode 100644 index 0000000..a1a0988 --- /dev/null +++ b/web/app/(marketing)/tools/page.tsx @@ -0,0 +1,70 @@ +// /tools index — ported from site/tools/index.html (path + SEO copy). +import type { Metadata } from "next"; +import Link from "next/link"; +import { TOOL_DEFS } from "@/lib/tools/defs"; + +export const metadata: Metadata = { + title: "Free Podcast Content Tools — EpisodeKit", + description: + "Free, no-signup tools for podcasters: show notes generator, episode title generator, discussion kit generator, and email series generator — all grounded in your actual transcript.", +}; + +const ORDER: Array = [ + "show-notes", + "episode-titles", + "discussion-guide", + "email-series", +]; + +const BLURB: Record = { + "show-notes": + "Paste an episode → a titled, sectioned writeup for your episode page — structured show notes built from what was actually said.", + "episode-titles": + "Paste your transcript or notes → title ideas plus the themes your episode is actually about.", + "discussion-guide": + "Paste an episode → opener, warm-up questions, quote-anchored discussion questions, and action prompts for your community.", + "email-series": + "Paste an episode → a 5-day email drip, each day anchored on a real quote with reflection, action, and sign-off.", +}; + +export default function ToolsIndexPage() { + return ( +
+

Free tools for busy podcasters

+

+ No signup, no cost. Every tool runs on the same engine as the paid product — and every + quote it shows you is taken word-for-word from the transcript you paste. +

+
+ {ORDER.map((id) => { + const def = TOOL_DEFS[id]; + return ( + +

{def.h1}

+

{BLURB[id]}

+ Open tool → + + ); + })} +
+

+ Want the whole package at once?{" "} + + That's EpisodeKit → + {" "} + · Comparing tools?{" "} + + vs Podsqueeze + {" "} + ·{" "} + + vs Castmagic + +

+
+ ); +} diff --git a/web/app/admin/page.tsx b/web/app/admin/page.tsx new file mode 100644 index 0000000..1f79e85 --- /dev/null +++ b/web/app/admin/page.tsx @@ -0,0 +1,124 @@ +// M13 / MK-11 — /admin, owner-only (OWNER_EMAILS via lib/ai/owner). Keyless or +// non-owner → the shell with empty states and a clear reason; never fake +// numbers (§0.5). Stats + feedback are DB-derived; the AI provider card talks +// to WP-04's existing /api/admin/ai routes. +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { connection } from "next/server"; +import { requireOwner } from "@/lib/ai/owner"; +import { loadAdminStats } from "@/lib/admin/stats"; +import { listFeedback } from "@/db/queries"; +import { StatTiles } from "@/components/admin/stat-tiles"; +import { AiProviderCard } from "@/components/admin/ai-provider-card"; +import type { FeedbackListRow } from "@/db/queries"; + +export const metadata: Metadata = { + title: "Admin — EpisodeKit", + robots: { index: false }, +}; + + +const DENY_COPY: Record = { + unauthenticated: "Sign in with the owner account to see admin.", + not_owner: "This page is owner-only. Your account isn't in OWNER_EMAILS.", + owner_not_configured: + "Admin unlocks when OWNER_EMAILS is set in the environment. Until then everything below shows empty states — no example numbers.", + auth_unavailable: + "Auth isn't configured in this deployment (keyless preview). The admin shell renders with empty states; no numbers are faked.", +}; + +function fmtDate(d: Date): string { + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +export default function AdminPage() { + return ( + // Request-time data (owner gate, 7-day windows) streams inside Suspense; + // the shell prerenders (cacheComponents). + }> + + + ); +} + +function AdminSkeleton() { + return ( +
+

Admin

+
+
+
+ ); +} + +async function AdminContent() { + // request-time boundary: owner gate + 7-day windows need the live request + await connection(); + const gate = await requireOwner(); + const stats = await loadAdminStats(); + + let feedback: FeedbackListRow[] | null = null; + if (gate.ok) { + try { + feedback = await listFeedback(50); + } catch { + feedback = null; + } + } + + return ( +
+
+

Admin

+ + {gate.ok ? `Owner · ${gate.email}` : "Locked"} · Last {7} days + +
+ + + + + +
+

Feedback

+ {!gate.ok ? ( +

+ {DENY_COPY[gate.reason] ?? "Owner-only."} +

+ ) : feedback == null ? ( +

+ Feedback needs the database, which isn't reachable right now. +

+ ) : feedback.length === 0 ? ( +

+ No feedback yet. It appears here the moment someone rates an artifact or uses the + Feedback button. +

+ ) : ( +
+ + + + + + + + + + + {feedback.map((row) => ( + + + + + + + ))} + +
WhenArtifactRatingNote
{fmtDate(row.createdAt)}{row.artifactKind ?? "—"}{row.rating === 1 ? "👍" : row.rating === -1 ? "👎" : "—"}{row.note ?? "—"}
+
+ )} +
+
+ ); +} diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts new file mode 100644 index 0000000..6547adf --- /dev/null +++ b/web/app/api/feedback/route.ts @@ -0,0 +1,22 @@ +// §7.5 POST /api/feedback — M11. Authenticated (in-app feedback); stores the +// row, captures a PostHog event when the key exists, emails the owner via +// Resend only when RESEND_API_KEY is set (otherwise the seam is logged once). +import { getAuthUserId } from "@/lib/auth"; +import { handleFeedback, notifyOwnerByResend } from "@/lib/feedback/service"; +import { insertFeedback } from "@/db/queries"; +import { captureServerEvent } from "@/lib/analytics/posthog"; + +export async function POST(request: Request) { + const userId = await getAuthUserId(); + const body = await request.json().catch(() => null); + const res = await handleFeedback(userId, body, { + insert: insertFeedback, + capture: async (event, props) => { + await captureServerEvent(userId ?? "anonymous", event, props); + }, + notify: async (row) => { + await notifyOwnerByResend(row); + }, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/web/app/api/public/tools/[tool]/route.ts b/web/app/api/public/tools/[tool]/route.ts new file mode 100644 index 0000000..8bbe011 --- /dev/null +++ b/web/app/api/public/tools/[tool]/route.ts @@ -0,0 +1,83 @@ +// §7.5 POST /api/public/tools/:tool (public) — free tools: BotID + 5/day/IP + +// global daily cost cap; real LLM through the lib/ai registry, deterministic +// engine fallback with the honest note. No auth, no quota (M12). +import { NextResponse } from "next/server"; +import { isToolId } from "@/lib/tools/defs"; +import { verifyHuman } from "@/lib/tools/botid"; +import { consumeDaily } from "@/lib/tools/rate-limit"; +import { capStatus, memorySpendStore, type FreeToolSpendStore } from "@/lib/tools/cost-cap"; +import { dbSpendStore } from "@/lib/tools/spend-db"; +import { CAP_NOTE, checkToolInput, runFreeTool } from "@/lib/tools/run"; +import { consoleUsageLogger, type UsageEvent } from "@/lib/ai/usage"; + +const DAILY_IP_LIMIT = 5; + +// Per-instance fallback when DATABASE_URL is absent (keyless preview): better +// than a fresh store per request, still not shared state — the real cap needs +// the DB row. Documented in DECISIONS.md 2026-10-27 · WP-09. +const instanceSpendStore = memorySpendStore(); + +export async function POST(request: Request, ctx: { params: Promise<{ tool: string }> }) { + const { tool } = await ctx.params; + if (!isToolId(tool)) { + return NextResponse.json({ error: "unknown_tool" }, { status: 404 }); + } + + // 1) BotID (fail-open on infra failure — see lib/tools/botid.ts) + const bot = await verifyHuman(request); + if (bot.blocked) { + return NextResponse.json({ error: "bot_detected" }, { status: 403 }); + } + + // 2) 5/day/IP + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + const rl = consumeDaily(ip, DAILY_IP_LIMIT); + if (!rl.allowed) { + return NextResponse.json( + { error: "rate_limited", message: "You've used today's 5 free generations. Come back tomorrow." }, + { status: 429, headers: { "Retry-After": String(Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000))) } }, + ); + } + + // 3) payload + const body = (await request.json().catch(() => null)) as { text?: unknown } | null; + const text = typeof body?.text === "string" ? body.text : ""; + const check = checkToolInput(text); + if (!check.ok) { + const message = + check.code === "too_long" + ? "That's over 15,000 words. Paste a shorter transcript." + : check.code === "not_enough_sentences" + ? "Couldn't find enough complete sentences — paste fuller transcript text." + : "Please paste at least ~80 words of transcript so the result is grounded in the episode."; + return NextResponse.json({ error: check.code, message }, { status: 400 }); + } + + // 4) daily cost cap → deterministic when exceeded (M12) + const spend: FreeToolSpendStore = dbSpendStore() ?? instanceSpendStore; + const cap = await capStatus(spend); + const run = await runFreeTool(tool, text, { + aiDisabled: cap.exceeded, + usage: loggingUsage(spend), + }); + + return NextResponse.json({ + tool, + mode: run.mode, + note: run.mode === "deterministic" ? cap.exceeded ? CAP_NOTE : run.note : null, + result: run.result, + }); +} + +/** Structured console line + spend accounting (usage_events cannot hold anonymous rows). */ +function loggingUsage(spend: { addCost: (usd: number) => Promise }) { + return (event: UsageEvent) => { + consoleUsageLogger(event); + const cost = event.costUsd || 0; + if (cost > 0) return spend.addCost(cost); + return Promise.resolve(); + }; +} diff --git a/web/app/page.tsx b/web/app/page.tsx deleted file mode 100644 index 7215d59..0000000 --- a/web/app/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// WP-00 scaffold placeholder. The real landing page ships in WP-09 (MK-1). -// All copy below is verbatim from the spec (01-product.md §0.2, 02-design.md MK-1). -export default function Home() { - return ( -
-

- Show notes that sound like you — and never misquote you. -

-

- Upload an episode. Get show notes, chapters, a blog post, a newsletter - and social posts in minutes — in your voice, with every quote checked - against the audio. -

-

- EpisodeKit is being rebuilt. This page is a scaffold placeholder. -

-
- ); -} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts new file mode 100644 index 0000000..8e7a4af --- /dev/null +++ b/web/app/sitemap.ts @@ -0,0 +1,13 @@ +// Sitemap — keeps the legacy public URLs (M12/U17) plus the new §5.4 routes. +import type { MetadataRoute } from "next"; +import { LEGACY_PUBLIC_PATHS, NEW_PUBLIC_PATHS } from "@/lib/marketing/routes"; + +const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://episodekit.vercel.app"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [...LEGACY_PUBLIC_PATHS, ...NEW_PUBLIC_PATHS].map((path) => ({ + url: `${BASE}${path === "/" ? "" : path}`, + changeFrequency: "monthly", + priority: path === "/" ? 1 : 0.8, + })); +} diff --git a/web/components/admin/ai-provider-card.tsx b/web/components/admin/ai-provider-card.tsx new file mode 100644 index 0000000..915d233 --- /dev/null +++ b/web/components/admin/ai-provider-card.tsx @@ -0,0 +1,221 @@ +"use client"; + +// MK-11 AI provider card — consumes WP-04's admin API as-is: +// GET /api/admin/ai → selection + provider list (key presence) +// POST /api/admin/ai/test → "OK · 1.8 s" / error text +// PUT /api/admin/ai → save selection +// Base URL is preset-derived (the PUT contract has no baseUrl field), so it is +// displayed read-only with a hint for the custom provider's env var. +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface ProviderInfo { + id: string; + label: string; + baseUrl: string | null; + keyEnv: string; + keyPresent: boolean; + defaultLong: string | null; + defaultFast: string | null; +} + +interface GetBody { + selection?: { provider: string; longModel: string; fastModel: string; fallbackProvider: string }; + source?: string; + storeAvailable?: boolean; + providers?: ProviderInfo[]; + error?: string; +} + +type TestState = { kind: "ok"; latencyMs: number } | { kind: "error"; text: string } | null; + +export function AiProviderCard() { + const [state, setState] = React.useState<"loading" | "ready" | "denied">("loading"); + const [body, setBody] = React.useState(null); + const [provider, setProvider] = React.useState(""); + const [longModel, setLongModel] = React.useState(""); + const [fastModel, setFastModel] = React.useState(""); + const [test, setTest] = React.useState(null); + const [testing, setTesting] = React.useState(false); + const [saveState, setSaveState] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + fetch("/api/admin/ai") + .then(async (res) => ({ ok: res.ok, json: (await res.json().catch(() => null)) as GetBody | null })) + .then(({ ok, json }) => { + if (!ok || !json?.selection) { + setState("denied"); + return; + } + setBody(json); + setProvider(json.selection.provider); + setLongModel(json.selection.longModel); + setFastModel(json.selection.fastModel); + setState("ready"); + }) + .catch(() => setState("denied")); + }, []); + + const providers = body?.providers ?? []; + const current = providers.find((p) => p.id === provider); + + async function runTest() { + setTesting(true); + setTest(null); + try { + const res = await fetch("/api/admin/ai/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + const json = (await res.json().catch(() => null)) as + | { ok?: boolean; latencyMs?: number; error?: { message?: string }; text?: string } + | null; + if (json?.ok) setTest({ kind: "ok", latencyMs: json.latencyMs ?? 0 }); + else + setTest({ + kind: "error", + text: json?.error?.message ?? json?.text ?? "Test failed — check the provider key and model.", + }); + } catch { + setTest({ kind: "error", text: "Test request failed." }); + } finally { + setTesting(false); + } + } + + async function save() { + setSaving(true); + setSaveState(null); + try { + const res = await fetch("/api/admin/ai", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, longModel, fastModel, fallbackProvider: body?.selection?.fallbackProvider ?? "groq" }), + }); + setSaveState(res.ok ? "Saved" : `Save failed (${res.status})`); + } catch { + setSaveState("Save failed"); + } finally { + setSaving(false); + } + } + + if (state === "loading") { + return ( +
+

AI provider

+

Loading…

+
+ ); + } + + if (state === "denied") { + return ( +
+

AI provider

+

+ Owner-only. This card unlocks when Clerk is configured and your email is in + OWNER_EMAILS. +

+
+ ); + } + + return ( +
+

AI provider

+
+
+ + + + Key {current?.keyEnv ?? "—"} ·{" "} + {current?.keyPresent ? ( + present ✓ + ) : ( + missing ✗ + )} + +
+
+ + + + From the provider preset{provider === "custom" ? " (set AI_CUSTOM_BASE_URL)" : ""}. + +
+
+ + setLongModel(e.target.value)} + className="font-mono" + /> +
+
+ + setFastModel(e.target.value)} + className="font-mono" + /> +
+
+
+ + {test?.kind === "ok" && ( + + OK · {(test.latencyMs / 1000).toFixed(1)} s + + )} + {test?.kind === "error" && ( + + {test.text} + + )} + + + {saveState && {saveState}} +
+

+ Keys are set in Vercel environment variables. This page never shows them. + {body?.storeAvailable === false && " Saving is unavailable without the database."} +

+
+ ); +} diff --git a/web/components/admin/stat-tiles.tsx b/web/components/admin/stat-tiles.tsx new file mode 100644 index 0000000..2989f65 --- /dev/null +++ b/web/components/admin/stat-tiles.tsx @@ -0,0 +1,42 @@ +// MK-11 stat tiles — every value is DB-derived (lib/admin/stats). null renders +// "—" (keyless/DB-less shell, or not-tracked metrics): never example numbers. +import type { AdminStats } from "@/lib/admin/stats"; +import { STAT_WINDOW_DAYS } from "@/lib/admin/stats"; + +function fmtMoney(usd: number): string { + return `$${usd.toFixed(2)}`; +} + +export function StatTiles({ stats }: { stats: AdminStats }) { + const tiles: Array<{ label: string; value: string; note?: string }> = [ + { + label: `SIGNUPS ${STAT_WINDOW_DAYS}D`, + value: stats.signups7d == null ? "—" : String(stats.signups7d), + }, + { + label: `EPISODES ${STAT_WINDOW_DAYS}D`, + value: stats.episodes7d == null ? "—" : String(stats.episodes7d), + }, + { + label: `EXPORTS ${STAT_WINDOW_DAYS}D`, + value: "—", + note: "Copy/export events aren't tracked yet", + }, + { label: "PAYING", value: stats.paying == null ? "—" : String(stats.paying) }, + { + label: `AI + ASR COST ${STAT_WINDOW_DAYS}D`, + value: stats.aiCost7dUsd == null ? "—" : fmtMoney(stats.aiCost7dUsd), + }, + ]; + return ( +
+ {tiles.map((t) => ( +
+ {t.label} + {t.value} + {t.note && {t.note}} +
+ ))} +
+ ); +} diff --git a/web/components/app/artifact-feedback.tsx b/web/components/app/artifact-feedback.tsx new file mode 100644 index 0000000..e6d8613 --- /dev/null +++ b/web/components/app/artifact-feedback.tsx @@ -0,0 +1,49 @@ +"use client"; + +// M11 — per-artifact 👍/👎 in the workspace header (MK-6 "☐ Ready 👍 👎"). +// A thumb sends immediately with the artifact kind + episode id (stored + +// PostHog when the key exists); the optional note goes through the floating +// Feedback widget (tie-break: fewer clicks — logged in DECISIONS.md WP-09). +import * as React from "react"; +import { toast } from "sonner"; +import { sendFeedback } from "@/components/app/feedback-widget"; +import type { ArtifactKind } from "@/lib/artifacts/schemas"; + +export function ArtifactFeedback({ kind, episodeId }: { kind: ArtifactKind; episodeId: string }) { + const [sent, setSent] = React.useState<"up" | "down" | null>(null); + + const rate = async (value: "up" | "down") => { + if (sent === value) return; // one rating per artifact per session view + setSent(value); + const ok = await sendFeedback({ + rating: value === "up" ? 1 : -1, + artifactKind: kind, + episodeId, + }); + if (ok) toast("Thanks — noted"); + else toast("Couldn't send feedback — try again"); + }; + + return ( + + + + + ); +} diff --git a/web/components/app/feedback-widget.tsx b/web/components/app/feedback-widget.tsx new file mode 100644 index 0000000..6a43b70 --- /dev/null +++ b/web/components/app/feedback-widget.tsx @@ -0,0 +1,93 @@ +"use client"; + +// M11 — floating "Feedback" button → 👍/👎 (FeedbackThumbs) + note → +// POST /api/feedback (stored + PostHog when the key exists). WP-06 seam: the +// workspace passes `artifactKind` per artifact to sendFeedback() — the same +// wiring, one call site. +import * as React from "react"; +import { MessageSquareIcon, XIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { FeedbackThumbs } from "@/components/app/feedback-thumbs"; +import type { ArtifactKind } from "@/lib/artifacts/schemas"; + +export async function sendFeedback(input: { + rating?: 1 | -1; + note?: string; + artifactKind?: ArtifactKind; + episodeId?: string; + page?: string; +}): Promise { + const res = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return res.ok; +} + +export function FeedbackWidget({ page }: { page?: string }) { + const [open, setOpen] = React.useState(false); + const [rating, setRating] = React.useState<1 | -1 | null>(null); + const [note, setNote] = React.useState(""); + const [sending, setSending] = React.useState(false); + + async function submit() { + if (rating == null && !note.trim()) { + toast("Pick a thumb or write a note first"); + return; + } + setSending(true); + const ok = await sendFeedback({ rating: rating ?? undefined, note: note.trim() || undefined, page }); + setSending(false); + if (ok) { + toast("Sent — thank you"); + setOpen(false); + setRating(null); + setNote(""); + } else { + toast("Couldn't send feedback — try again"); + } + } + + if (!open) { + return ( + + ); + } + + return ( +
+
+ Feedback + +
+ setRating(v === "up" ? 1 : -1)} /> +