---
title: How to build a Ko-fi clone with Next.js and Whop
slug: build-kofi-clone
excerpt: "You can build a Ko-fi clone with tips, subscriptions, and a creator shop using Next.js and the Whop infrastructure for payments, user authentication, and more."
customExcerpt: "You can build a Ko-fi clone with tips, subscriptions, and a creator shop using Next.js and the Whop infrastructure for payments, user authentication, and more."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/06/HowToBuildAKofiClone.webp"
status: published
publishedAt: "2026-06-15T22:17:41.000Z"
updatedAt: "2026-06-16T11:01:03.000Z"
createdAt: "2026-06-10T17:36:18.118Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to build a Ko-fi clone with Next.js and Whop

## Key takeaways

- Whop's single SDK handles authentication, charges, fee splitting, and KYC payouts, eliminating four separate systems.

<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">✨</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>

Building a creator support platform like Ko-fi is now easier than ever with Next.js and Whop. Complex parts like charging fans, splitting a platform fee, paying creators out, and running KYC can seem hard, but each can be solved with a single integration.

The Ko-fi clone we're going to build in this tutorial is called Cuppa. Anyone can sign in with Whop and open a page, fans send one-time tips, join monthly memberships, and buy from creator shops. Creators publish posts that are public or supporter-only, set donation goals, watch a supporter wall fill up, and withdraw their earnings on-site.

We'll build all the pages, forms, and database models ourselves using Next.js, TypeScript, and Prisma. Whop will handle the user authentication and payment systems for us, which are the really challenging parts.

You can preview the live demo [here](https://kofi-clone-whop-tutorial.vercel.app), and find the full codebase in this [GitHub repository](https://github.com/whopio/whop-tutorials/tree/main/kofi-clone).

## Project overview

Cuppa has three main components: creator pages where fans can show their support, a dashboard where creators can manage everything, and a discovery feature that connects these two components. By the end of this tutorial, our project will include:

- One-time tips via embedded checkout (direct charge with an application fee).
- Recurring memberships (tiers as Whop plans).
- A shop of one-time digital or physical products.
- Posts with public, supporters-only, and tier-gated visibility.
- Donation goals with a live progress bar.
- A supporter wall built from real payment records.
- On-site payouts through Whop's embedded payout components, with inline KYC.
- Push notifications to creators when new support comes in.
- Light / dark / system theming plus a per-creator accent color that flows into the checkout and payout UI.

## Why Whop

In this project, there are four things we're going to need. We need to charge the fans, split a platform fee, pay out to creators with KYC, and turn payment events into database rows. This usually means we need to build four separate systems, but Whop is going to help us by handling all four in one SDK:

- Whop OAuth for user authentication
- Connected accounts and embedded checkouts for charges
- Signed webhooks for fulfillment
- Embedded payout portal with KYC for payouts

### Tech stack

- **Next.js** (App Router, Turbopack) for routing, Server Components, and Vercel-native deploy. The new framework convention is `proxy.ts`, not `middleware.ts`.
- **React 19** for Server Components and the dashboard's client islands.
- **Tailwind CSS v4** with CSS-first `@theme` blocks. No `tailwind.config.js`.
- **Prisma 5** with `@prisma/client` (not Prisma 7, which has breaking connection changes).
- **Neon Postgres** via the Vercel Marketplace.
- **iron-session** for encrypted-cookie sessions, no session table.
- **DM Sans** for body text and **Fraunces** for display, both via `next/font`.
- **Zod** for runtime validation at every API boundary, plus the env schema.
- A custom **light / dark / system** theme toggle plus a **per-creator accent color** that flows into the checkout and payout UI. No `next-themes`.
- **Whop SDK** (`@whop/sdk`), the checkout embed (`@whop/checkout`), React helpers (`@whop/react`), and the embedded payouts UI (`@whop/embedded-components-react-js` and `-vanilla-js`).
- **Vercel** for hosting plus `vercel.ts` for typed routing and CSP config.

### Pages

- `/` home page (signed-out marketing hero with example creators and a claim-your-page CTA, signed-in entry point)
- `/explore` discover creators
- `/feed` signed-in feed of creators with supporter counts and their latest posts
- `/features` marketing features page
- `/{username}` the core creator page: cover, header, tabs, about, donation goal, support widget, feed
- `/{username}/membership` membership tiers
- `/{username}/shop` product grid
- `/{username}/gallery` image gallery
- `/{username}/posts` the full post feed
- `/{username}/leaderboard` top supporters
- `/{username}/post/{id}` a single post with content gating
- `/dashboard` creator overview
- `/dashboard/start` become a creator and run KYC
- `/dashboard/posts` write and manage posts
- `/dashboard/tiers` membership tiers
- `/dashboard/shop` products
- `/dashboard/supporters` the supporter list
- `/dashboard/payouts` the embedded payout portal
- `/dashboard/settings` profile, page, and theming settings

### API routes

- Auth: `/api/auth/login`, `/oauth/callback`, `/api/auth/logout`, `/api/auth/me`
- Checkout: `/api/checkout`, `/api/checkout/confirm`
- Creator: `/api/creator`, `/api/creator/settings`, `/api/creator/goal`, `/api/creator/username`, `/api/creator/upload`
- Tiers: `/api/tiers`, `/api/tiers/[id]`
- Products: `/api/products`, `/api/products/[id]`
- Posts: `/api/posts`, `/api/posts/[id]`
- Engagement: `/api/follow`
- Payouts: `/api/payouts/token`
- Webhooks: `/api/webhooks/whop`

## Part 1: Project setup

Let's start by scaffolding a Next.js app. To do this, run the command below in the directory where you want the project to live:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest kofi-clone --typescript --tailwind --app --turbopack --eslint
cd kofi-clone</code></pre>
  </div>
</div>

Then, install the dependencies of the packages we'll use upfront using the command:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @prisma/client @whop/checkout @whop/embedded-components-react-js @whop/embedded-components-vanilla-js @whop/react @whop/sdk iron-session sharp zod
npm install -D prisma @vercel/config</code></pre>
  </div>
</div>

Our package.json scripts below run prisma generate on every install and build, and that command fails when no Prisma schema exists yet. Initialize the Prisma scaffolding now, we fill in the actual models when we design the database.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma init --datasource-provider postgresql</code></pre>
  </div>
</div>

The end result of our `package.json` should be like the example below. The key detail is that every Next.js script targets port 3005 and `build` runs `prisma generate` first so the Prisma client is always fresh on Vercel.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">package.json</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-json">{
  &quot;name&quot;: &quot;kofi-clone&quot;,
  &quot;version&quot;: &quot;0.1.0&quot;,
  &quot;private&quot;: true,
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev -p 3005&quot;,
    &quot;build&quot;: &quot;prisma generate &amp;&amp; next build&quot;,
    &quot;start&quot;: &quot;next start -p 3005&quot;,
    &quot;lint&quot;: &quot;eslint&quot;,
    &quot;postinstall&quot;: &quot;prisma generate&quot;,
    &quot;db:migrate&quot;: &quot;prisma migrate dev&quot;,
    &quot;db:push&quot;: &quot;prisma db push&quot;,
    &quot;db:studio&quot;: &quot;prisma studio&quot;
  },
  &quot;dependencies&quot;: {
    &quot;@prisma/client&quot;: &quot;^5.22.0&quot;,
    &quot;@whop/checkout&quot;: &quot;^0.0.54&quot;,
    &quot;@whop/embedded-components-react-js&quot;: &quot;^1.0.0&quot;,
    &quot;@whop/embedded-components-vanilla-js&quot;: &quot;^1.0.0&quot;,
    &quot;@whop/react&quot;: &quot;^0.3.2&quot;,
    &quot;@whop/sdk&quot;: &quot;^0.0.39&quot;,
    &quot;iron-session&quot;: &quot;^8.0.4&quot;,
    &quot;next&quot;: &quot;16.2.6&quot;,
    &quot;react&quot;: &quot;19.2.4&quot;,
    &quot;react-dom&quot;: &quot;19.2.4&quot;,
    &quot;sharp&quot;: &quot;^0.35.1&quot;,
    &quot;zod&quot;: &quot;^4.4.3&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@tailwindcss/postcss&quot;: &quot;^4&quot;,
    &quot;@types/node&quot;: &quot;^20&quot;,
    &quot;@types/react&quot;: &quot;^19&quot;,
    &quot;@types/react-dom&quot;: &quot;^19&quot;,
    &quot;@vercel/config&quot;: &quot;^0.5.1&quot;,
    &quot;eslint&quot;: &quot;^9&quot;,
    &quot;eslint-config-next&quot;: &quot;16.2.6&quot;,
    &quot;prisma&quot;: &quot;^5.22.0&quot;,
    &quot;tailwindcss&quot;: &quot;^4&quot;,
    &quot;typescript&quot;: &quot;^5&quot;
  }
}</code></pre>
  </div>
</div>

### Environment variables

The app reads its secrets and settings from environment variables. Create `.env.example` at the project root and copy it to `.env`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">DATABASE_URL=&quot;postgresql://USER:PASSWORD@HOST:5432/kofi_clone?schema=public&quot;

SESSION_SECRET=&quot;a-random-string-at-least-32-characters-long&quot;

NEXT_PUBLIC_APP_URL=&quot;http://localhost:3005&quot;

WHOP_SANDBOX=&quot;true&quot;

WHOP_PLATFORM_COMPANY_ID=&quot;biz_xxxxxxxxxxxxx&quot;
NEXT_PUBLIC_WHOP_COMPANY_ID=&quot;biz_xxxxxxxxxxxxx&quot;

WHOP_CLIENT_ID=&quot;app_xxxxxxxxxxxxx&quot;
NEXT_PUBLIC_WHOP_APP_ID=&quot;app_xxxxxxxxxxxxx&quot;
WHOP_CLIENT_SECRET=&quot;your-app-oauth-client-secret&quot;

WHOP_COMPANY_API_KEY=&quot;apik_xxxxxxxxxxxxx&quot;

WHOP_WEBHOOK_SECRET=&quot;&quot;

NEXT_PUBLIC_PLATFORM_FEE_PERCENT=&quot;5&quot;</code></pre>
  </div>
</div>

We'll get the `DATABASE_URL` when we add the database in Part 2, and `WHOP_WEBHOOK_SECRET` when we set up webhooks in Part 11. `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_PLATFORM_FEE_PERCENT` are already filled in. Generate `SESSION_SECRET` from any random 32+ character string by running:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">node -e &quot;console.log(require(&#039;crypto&#039;).randomBytes(32).toString(&#039;hex&#039;))&quot;</code></pre>
  </div>
</div>

The `WHOP_` values come from a Whop company and its app. We build against the sandbox, so create everything at `sandbox.whop.com` with the dashboard switched into sandbox mode, and keep `WHOP_SANDBOX="true"`:

- App credentials (Whop > Developer > Apps): create an app. Its Client ID is the `app_...` value, used for both `WHOP_CLIENT_ID` and `NEXT_PUBLIC_WHOP_APP_ID`. Copy the Client Secret from the app's OAuth settings into `WHOP_CLIENT_SECRET`, and enable the `oauth:token_exchange` permission while you are there.
- Company ID: your company's `biz_...` ID (shown in the dashboard URL, and under Settings) goes in both `WHOP_PLATFORM_COMPANY_ID` and `NEXT_PUBLIC_WHOP_COMPANY_ID`.
- Company API key (Whop > Developer > API Keys): create one and put it in `WHOP_COMPANY_API_KEY`. This key authorizes every server-side Whop call we make, so enable the scopes those calls need:
- - Connected creator accounts: `company:create_child`, `company:basic:read`
  - Checkout, plans, and products: `checkout_configuration:create`, `checkout_configuration:basic:read`, `plan:create`, `plan:basic:read`, `access_pass:create`, `access_pass:update`, `access_pass:basic:read`
  - Confirming tips and processing webhooks: `payment:basic:read`, `member:basic:read`, `member:email:read`
  - The payout portal (balance, KYC, withdrawals): `company:balance:read`, `payout:account:read`, `payout:account:update`, `payout:destination:read`, `payout:create_destination`, `payout:update_destination`, `payout:transfer:read`, `payout:transfer_funds`, `payout:withdraw_funds`, `payout:withdrawal:read`
  - Push notifications: `notification:create`

> 

### Fonts and Tailwind theme

We want the app to reflect Ko-fi's beautifully warm and friendly design. We load Fraunces and DM Sans in the root layout and define our colors as CSS variables in the global stylesheet.

The root layout loads our fonts and applies the saved light/dark theme before the page renders. Replace `app/layout.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-jsx">import type { Metadata } from &quot;next&quot;;
import { DM_Sans, Fraunces } from &quot;next/font/google&quot;;
import { WhopApp } from &quot;@whop/react/components&quot;;
import &quot;./globals.css&quot;;

const dmSans = DM_Sans({
  subsets: [&quot;latin&quot;],
  variable: &quot;--font-dm-sans&quot;,
  weight: [&quot;400&quot;, &quot;500&quot;, &quot;600&quot;, &quot;700&quot;],
});

const fraunces = Fraunces({
  subsets: [&quot;latin&quot;],
  variable: &quot;--font-fraunces&quot;,
  display: &quot;swap&quot;,
});

export const metadata: Metadata = {
  title: &quot;Cuppa — get paid by the people who love your work&quot;,
  description:
    &quot;Cuppa is where fans back the creators they love with tips, memberships, and shop purchases. Built with Next.js and Whop.&quot;,
};

const themeScript = `(function(){try{var t=localStorage.getItem(&#039;theme&#039;)||&#039;system&#039;;var d=t===&#039;dark&#039;||(t===&#039;system&#039;&amp;&amp;window.matchMedia(&#039;(prefers-color-scheme: dark)&#039;).matches);document.documentElement.classList.toggle(&#039;dark&#039;,d);}catch(e){}})();`;

export default function RootLayout({
  children,
}: Readonly&lt;{ children: React.ReactNode }&gt;) {
  return (
    &lt;html
      lang=&quot;en&quot;
      className={`${dmSans.variable} ${fraunces.variable}`}
      suppressHydrationWarning
    &gt;
      &lt;head&gt;
        &lt;script dangerouslySetInnerHTML={{ __html: themeScript }} /&gt;
      &lt;/head&gt;
      &lt;body className=&quot;min-h-screen antialiased&quot;&gt;
        &lt;WhopApp accentColor=&quot;blue&quot; grayColor=&quot;sand&quot; appearance=&quot;inherit&quot; hasBackground={false}&gt;
          {children}
        &lt;/WhopApp&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
  </div>
</div>

For the favicon, delete `app/favicon.ico` and save our coffee-cup logo as `app/icon.png` (grab it from the companion repo, or use your own square image). The global stylesheet holds our Ko-fi colors, fonts, and button styles. Replace `app/globals.css`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">globals.css</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-css">@layer theme, base, frosted, components, utilities;
@import &quot;tailwindcss&quot;;
@import &quot;@whop/react/styles.css&quot; layer(frosted);

@custom-variant dark (&amp;:where(.dark, .dark *));

:root {
  --page: #f4efe7;
  --surface: #ffffff;
  --surface-2: #ede6dd;
  --foreground: #192025;
  --muted: #6a6a6a;
  --line: #e6ddd0;
  --brand: #467ceb;
  --accent: #467ceb;
  --accent-strong: #305de0;
  --soft-blue: #c3d8fa;
  --btn-primary: #202020;
  --btn-primary-fg: #ffffff;
  --btn-primary-hover: #2a2625;
  --positive: #07b25d;
}

.dark {
  --page: #192025;
  --surface: #2b3a44;
  --surface-2: #324653;
  --foreground: #eef2f3;
  --muted: #8eacba;
  --line: #324653;
  --brand: #72a4f2;
  --accent: #72a4f2;
  --accent-strong: #467ceb;
  --soft-blue: #263da7;
  --btn-primary: #edebea;
  --btn-primary-fg: #192025;
  --btn-primary-hover: #d4cfcd;
  --positive: #10d773;
}

.fui-BaseButton {
  border-radius: 9999px;
}

@theme inline {
  --font-sans: var(--font-dm-sans), Nunito, ui-sans-serif, system-ui, sans-serif;
  --font-display: var(--font-fraunces), Georgia, &quot;Times New Roman&quot;, serif;
  --color-page: var(--page);
  --color-surface: var(--surface);
  --color-surface-2: var(--surface-2);
  --color-ink: var(--foreground);
  --color-muted: var(--muted);
  --color-line: var(--line);
  --color-brand: var(--brand);
  --color-soft-blue: var(--soft-blue);
  --color-positive: var(--positive);
  --radius-card: 18px;
}

@layer base {
  html {
    -webkit-text-size-adjust: 100%;
  }
  body {
    background: var(--page);
    color: var(--foreground);
    font-family: var(--font-dm-sans), Nunito, ui-sans-serif, system-ui, sans-serif;
  }
  button:not(:disabled),
  [role=&quot;button&quot;]:not([aria-disabled=&quot;true&quot;]),
  summary,
  label[for] {
    cursor: pointer;
  }
}

@layer components {
  h1,
  h2,
  h3,
  .font-display {
    font-family: var(--font-fraunces), Georgia, &quot;Times New Roman&quot;, serif;
    font-weight: 800;
    letter-spacing: -0.015em;
  }
  .kofi-card {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-card);
  }
  .btn-pill {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    border-radius: 9999px;
    font-weight: 700;
    line-height: 1;
    padding: 0.625rem 1.25rem;
    transition: filter 0.15s ease, background-color 0.15s ease, opacity 0.15s ease;
    cursor: pointer;
  }
  .btn-pill:disabled {
    opacity: 0.55;
    cursor: not-allowed;
  }
  .btn-primary {
    background: var(--btn-primary);
    color: var(--btn-primary-fg);
  }
  .btn-primary:hover:not(:disabled) {
    background: var(--btn-primary-hover);
  }
  .btn-soft {
    background: var(--soft-blue);
    color: var(--foreground);
  }
  .btn-soft:hover:not(:disabled) {
    filter: brightness(0.97);
  }
  .btn-accent {
    background: var(--accent);
    color: #ffffff;
  }
  .btn-accent:hover:not(:disabled) {
    filter: brightness(0.95);
  }
  .btn-secondary {
    background: color-mix(in srgb, var(--muted) 22%, transparent);
    color: var(--foreground);
  }
  .btn-secondary:hover:not(:disabled) {
    background: color-mix(in srgb, var(--muted) 32%, transparent);
  }
  .btn-outline {
    background: transparent;
    border: 1px solid var(--line);
    color: var(--foreground);
  }
  .text-muted {
    color: var(--muted);
  }
}</code></pre>
  </div>
</div>

### Next.js config

We want to be able to add Whop checkout and payout iframes, so we set the Turbopack root and add a Content Security Policy. Create a file called `next.config.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">script.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { NextConfig } from &quot;next&quot;;

const csp = [
  &quot;default-src &#039;self&#039;&quot;,
  &quot;script-src &#039;self&#039; &#039;unsafe-inline&#039; &#039;unsafe-eval&#039; https://js.whop.com https://*.whop.com&quot;,
  &quot;style-src &#039;self&#039; &#039;unsafe-inline&#039;&quot;,
  &quot;img-src &#039;self&#039; data: blob: https:&quot;,
  &quot;font-src &#039;self&#039; data: https:&quot;,
  &quot;frame-src https://*.whop.com&quot;,
  &quot;connect-src &#039;self&#039; https://*.whop.com wss://*.whop.com&quot;,
  &quot;frame-ancestors &#039;self&#039;&quot;,
].join(&quot;; &quot;);

const nextConfig: NextConfig = {
  turbopack: {
    root: __dirname,
  },
  async headers() {
    return [
      {
        source: &quot;/:path*&quot;,
        headers: [{ key: &quot;Content-Security-Policy&quot;, value: csp }],
      },
    ];
  },
};

export default nextConfig;</code></pre>
  </div>
</div>

### Run it

Start the dev server and confirm the port and theme.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm run dev</code></pre>
  </div>
</div>

The app starts at `http://localhost:3005` with our Ko-fi fonts and theme applied.

## Checkpoint

- `npm run dev` starts the app and it is reachable at `http://localhost:3005` (not 3000).
- `package.json` lists `@whop/sdk`, `@whop/checkout`, `@whop/react`, both `@whop/embedded-components-*` packages, `iron-session`, `zod`, `@prisma/client`, and `prisma` is pinned to `^5.22.0` (not 7).
- The page renders in DM Sans with Fraunces serif headings on a warm stone (`#f4efe7`) background.
- `.env.example` exists at the project root and you have copied it to `.env`; `.env` is git-ignored.
- No secret value uses the `NEXT_PUBLIC_` prefix.
- Buttons show a pointer cursor on hover.
- `next.config.ts` sets the Turbopack `root` and returns the Content-Security-Policy header.
- `npm run build` completes (it runs `prisma generate` first against the empty schema that `npx prisma init` scaffolded. Part 3 fills in the real models).

## Part 2: Deploy to Vercel and Neon Postgres

In this tutorial, we're going to follow a deploy-first approach so that the parts we build are tested against the environment. On top of this, Whop OAuth and webhooks require real and working URLs. Deploying first gives us the URLs we need.

### Push to a Git repository

We're going to deploy our project to Vercel, and connecting a GitHub repo to our project is going to make future updates much easier. Run the commands below and make sure to replace the `your-username` part with your actual GitHub username:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">git init
git add .
git commit -m &quot;Scaffold Cuppa: Next.js + Tailwind + DM Sans&quot;
git branch -M main
git remote add origin https://github.com/your-username/kofi-clone.git
git push -u origin main</code></pre>
  </div>
</div>

### Import the project into Vercel

Create the Vercel project from the pushed repository.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm i -g vercel
vercel link</code></pre>
  </div>
</div>

Then, follow the prompts to create a new project linked to this repo. Vercel detects Next.js automatically.

### Configure Vercel with vercel.ts

Now, let's create the `vercel.ts` file that basically tells Vercel how to build our app. Create `vercel.ts` at the project root with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">vercel.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { VercelConfig } from &quot;@vercel/config/v1&quot;;

export const config: VercelConfig = {
  framework: &quot;nextjs&quot;,
  buildCommand: &quot;npm run build&quot;,
};</code></pre>
  </div>
</div>

### Add Neon Postgres from the Vercel Marketplace

Now, let's add the Neon database to the Vercel project by following these steps:

- Go to your project on Vercel and click the Storage option on the left sidebar
- There, select the Create Database option on the top right and select Neon from the popup
- Follow the Neon creation steps (you don't need to change anything)

Neon is going to add the environment variables like `DATABASE_URL` to our project automatically.

### Add the rest of the environment variables

We also need to add the environment variables to the production environment so Vercel can actually build. Go to the settings of your Vercel project, then Environment Variables, and input the variables:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Environment variables</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">SESSION_SECRET
WHOP_SANDBOX
WHOP_PLATFORM_COMPANY_ID
NEXT_PUBLIC_WHOP_COMPANY_ID
WHOP_CLIENT_ID
NEXT_PUBLIC_WHOP_APP_ID
WHOP_CLIENT_SECRET
WHOP_COMPANY_API_KEY
WHOP_WEBHOOK_SECRET
NEXT_PUBLIC_PLATFORM_FEE_PERCENT</code></pre>
  </div>
</div>

Set `NEXT_PUBLIC_APP_URL` to your Vercel production URL (for example `https://cuppa.vercel.app`) in the Production environment, and keep it as `http://localhost:3005` locally.

### Pull the cloud environment to your machine

Now, let's pull the environment variables locally once to get Neon's connection string using the command below.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env pull .env</code></pre>
  </div>
</div>

> 

### Understand the Content Security Policy

A Content Security Policy tells the browser which outside sources our app is allowed to load, so we need to allow the Whop elements to load or they silently fail to load. We added this header in `next.config.ts` back in Part 1, and we keep it there so the same rule applies both locally and in production:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">next.config.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const csp = [
  &quot;default-src &#039;self&#039;&quot;,
  &quot;script-src &#039;self&#039; &#039;unsafe-inline&#039; &#039;unsafe-eval&#039; https://js.whop.com https://*.whop.com&quot;,
  &quot;style-src &#039;self&#039; &#039;unsafe-inline&#039;&quot;,
  &quot;img-src &#039;self&#039; data: blob: https:&quot;,
  &quot;font-src &#039;self&#039; data: https:&quot;,
  &quot;frame-src https://*.whop.com&quot;,
  &quot;connect-src &#039;self&#039; https://*.whop.com wss://*.whop.com&quot;,
  &quot;frame-ancestors &#039;self&#039;&quot;,
].join(&quot;; &quot;);</code></pre>
  </div>
</div>

### Deploy to production

With the database connected and the environment set, ship the first build using the command.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel --prod</code></pre>
  </div>
</div>

When it finishes, open the production URL. You should see the same DM Sans page from Part 1, now served from Vercel and backed by Neon.

## Checkpoint

- The scaffold is pushed to a Git repo and imported as a Vercel project.
- `vercel.ts` exists at the project root and sets `framework: "nextjs"` and `buildCommand: "npm run build"`.
- A Neon Postgres database is created from the Vercel Marketplace and connected to the project.
- `DATABASE_URL` in Vercel is set to Neon's pooled connection string (`POSTGRES_PRISMA_URL`).
- All Whop and session variables are present in the Vercel project's environment.
- `vercel env pull .env` succeeds and `.env` now contains a Neon `DATABASE_URL`.
- `.env` is git-ignored and no secret was committed.
- `vercel --prod` deploys successfully and the production URL renders the scaffold.
- The response includes the `Content-Security-Policy` header with `frame-src https://*.whop.com`.

## Part 3: Database schema

In this step, we're going to define the data model of the project. In the model, a user should be able to become a creator; a creator has tiers, products, posts, and goals; and supports, memberships, and orders are the three kinds of money flowing in.

### The Prisma scaffolding

We already ran `npx prisma init --datasource-provider postgresql` back in the project setup, so `prisma/schema.prisma` exists and points at Postgres. If you skipped that step, run it now.

### Define the schema

Now let's create the complete data model. Go to `prisma` and update the entire `schema.prisma` file with:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-prisma">generator client {
  provider = &quot;prisma-client-js&quot;
}

datasource db {
  provider = &quot;postgresql&quot;
  url      = env(&quot;DATABASE_URL&quot;)
}

model User {
  id           String   @id @default(cuid())
  whopUserId   String   @unique
  username     String
  email        String?
  name         String?
  avatarUrl    String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  creator      Creator?
  supports     Support[]    @relation(&quot;UserSupports&quot;)
  memberships  Membership[]
  orders       Order[]      @relation(&quot;UserOrders&quot;)
  follows      Follow[]     @relation(&quot;UserFollows&quot;)
}

model Creator {
  id            String   @id @default(cuid())
  userId        String   @unique
  user          User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  whopCompanyId String?  @unique
  whopOnboarded Boolean  @default(false)

  username      String   @unique
  displayName   String
  bio           String?
  coverImageUrl String?
  avatarUrl     String?
  accentColor   String   @default(&quot;sky&quot;)
  websiteUrl    String?
  socialLinks   Json?
  tags          String[]

  isActive      Boolean  @default(true)
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  tiers         Tier[]
  posts         Post[]
  products      Product[]
  goals         Goal[]
  supports      Support[]
  memberships   Membership[]
  orders        Order[]
  followers     Follow[]
}

model Tier {
  id           String   @id @default(cuid())
  creatorId    String
  creator      Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  whopPlanId   String?  @unique
  name         String
  description  String?
  priceCents   Int
  benefits     String[]
  order        Int      @default(0)
  isActive     Boolean  @default(true)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  memberships  Membership[]
  gatedPosts   Post[]   @relation(&quot;TierGatedPosts&quot;)
}

model Product {
  id           String      @id @default(cuid())
  creatorId    String
  creator      Creator     @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  whopPlanId   String?     @unique
  title        String
  description  String?
  priceCents   Int         @default(0)
  imageUrl     String?
  type         ProductType @default(DIGITAL)
  downloadUrl  String?
  salesCount   Int         @default(0)
  isActive     Boolean     @default(true)
  createdAt    DateTime    @default(now())
  updatedAt    DateTime    @updatedAt

  orders       Order[]
}

model Post {
  id             String     @id @default(cuid())
  creatorId      String
  creator        Creator    @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  title          String
  content        String
  imageUrl       String?
  visibility     Visibility @default(PUBLIC)
  minimumTierId  String?
  minimumTier    Tier?      @relation(&quot;TierGatedPosts&quot;, fields: [minimumTierId], references: [id])
  pinned         Boolean    @default(false)
  published      Boolean    @default(true)
  reactionsCount Int        @default(0)
  createdAt      DateTime   @default(now())
  updatedAt      DateTime   @updatedAt
}

model Goal {
  id           String    @id @default(cuid())
  creatorId    String
  creator      Creator   @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  title        String
  description  String?
  targetCents  Int
  isActive     Boolean   @default(true)
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt

  supports     Support[]
}

model Support {
  id              String        @id @default(cuid())
  creatorId       String
  creator         Creator       @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  supporterUserId String?
  supporter       User?         @relation(&quot;UserSupports&quot;, fields: [supporterUserId], references: [id])
  supporterName   String
  message         String?
  amountCents     Int
  coffees         Int           @default(1)
  isPublic        Boolean       @default(true)

  whopPaymentId   String?       @unique
  status          SupportStatus @default(PENDING)

  goalId          String?
  goal            Goal?         @relation(fields: [goalId], references: [id])

  createdAt       DateTime      @default(now())
}

model Membership {
  id               String           @id @default(cuid())
  creatorId        String
  creator          Creator          @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  userId           String
  user             User             @relation(fields: [userId], references: [id], onDelete: Cascade)

  tierId           String
  tier             Tier             @relation(fields: [tierId], references: [id])

  whopMembershipId String?          @unique
  status           MembershipStatus @default(ACTIVE)

  createdAt        DateTime         @default(now())
  updatedAt        DateTime         @updatedAt

  @@unique([userId, tierId])
}

model Order {
  id            String        @id @default(cuid())
  creatorId     String
  creator       Creator       @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  productId     String
  product       Product       @relation(fields: [productId], references: [id])

  buyerUserId   String?
  buyer         User?         @relation(&quot;UserOrders&quot;, fields: [buyerUserId], references: [id])
  buyerName     String
  amountCents   Int

  whopPaymentId String?       @unique
  status        SupportStatus @default(PENDING)

  createdAt     DateTime      @default(now())
}

model Follow {
  id        String   @id @default(cuid())
  creatorId String
  creator   Creator  @relation(fields: [creatorId], references: [id], onDelete: Cascade)

  userId    String
  user      User     @relation(&quot;UserFollows&quot;, fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())

  @@unique([creatorId, userId])
}

model ProcessedWebhook {
  id        String   @id
  type      String
  createdAt DateTime @default(now())
}

enum Visibility {
  PUBLIC
  SUPPORTERS
  TIER
}

enum SupportStatus {
  PENDING
  COMPLETED
  REFUNDED
  FAILED
}

enum MembershipStatus {
  ACTIVE
  CANCELING
  CANCELED
  PAST_DUE
  EXPIRED
}

enum ProductType {
  DIGITAL
  PHYSICAL
}</code></pre>
  </div>
</div>

### Migrate and generate the client

Now let's create the first migration to the Neon database and generate the Prisma client (the code we use to query the database).

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm run db:migrate -- --name init</code></pre>
  </div>
</div>

This writes the migration under `prisma/migrations/`, applies it to Postgres, and regenerates the Prisma client.

### A single Prisma client

While we're developing the app, Next.js reloads our code every time we make a save, and each `PrismaClient` opens its own connection to the database, so making a new one on every reload would stack up connections. To avoid this, we create one client and reuse it everywhere. Create `lib/prisma.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { PrismaClient } from &quot;@prisma/client&quot;;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === &quot;development&quot; ? [&quot;error&quot;, &quot;warn&quot;] : [&quot;error&quot;],
  });

if (process.env.NODE_ENV !== &quot;production&quot;) globalForPrisma.prisma = prisma;

export default prisma;</code></pre>
  </div>
</div>

## Checkpoint

- `npx prisma init` created `prisma/schema.prisma` and did **not** create a `prisma.config.ts`.
- `prisma/schema.prisma` contains all eleven models: `User`, `Creator`, `Tier`, `Product`, `Post`, `Goal`, `Support`, `Membership`, `Order`, `Follow`, and `ProcessedWebhook`.
- All four enums are present, and `MembershipStatus` includes `CANCELING`.
- Every Whop id field (`whopCompanyId`, `whopPlanId`, `whopMembershipId`, `whopPaymentId`) is `@unique`, and `whopPaymentId` is unique on both `Support` and `Order`.
- All money fields are integer cents.
- `npm run db:migrate -- --name init` applied a migration to the Neon database and a folder appeared under `prisma/migrations/`.
- `lib/prisma.ts` exports a single shared `PrismaClient` cached on `globalThis` outside production.
- `npm run db:studio` opens Prisma Studio and lists every table.

## Part 4: Authentication

In this section, we will build the user authentication system using Whop OAuth. The login flow will begin when the user clicks the "Sign in with Whop" button (or a similar one).

They will then be redirected to Whop's app approval screen. After they grant approval, we will check the tokens provided by Whop, read their profile, and store their credentials in the session. We store sessions in iron-session instead of the database because iron-session stores the session in an httpOnly cookie.

### Validated environment access

To build the validated environment access, let's go to the `lib` folder and create a file called `env.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

const serverSchema = z.object({
  DATABASE_URL: z.string().min(1),
  SESSION_SECRET: z.string().min(32, &quot;SESSION_SECRET must be at least 32 characters&quot;),
  WHOP_SANDBOX: z.string().optional().default(&quot;true&quot;),
  WHOP_PLATFORM_COMPANY_ID: z.string().min(1),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().optional().default(&quot;&quot;),
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_WHOP_APP_ID: z.string().min(1),
  NEXT_PUBLIC_WHOP_COMPANY_ID: z.string().min(1),
  NEXT_PUBLIC_PLATFORM_FEE_PERCENT: z.string().optional().default(&quot;5&quot;),
});

type ServerEnv = z.infer&lt;typeof serverSchema&gt;;

let cached: ServerEnv | null = null;

function validate(): ServerEnv {
  if (cached) return cached;
  const parsed = serverSchema.safeParse(process.env);
  if (!parsed.success) {
    console.error(&quot;Invalid environment variables:&quot;, parsed.error.flatten().fieldErrors);
    throw new Error(&quot;Invalid environment variables. Check your .env file.&quot;);
  }
  cached = parsed.data;
  return cached;
}

export const env = new Proxy({} as ServerEnv, {
  get(_target, prop: string) {
    return validate()[prop as keyof ServerEnv];
  },
});

export function isSandbox(): boolean {
  return env.WHOP_SANDBOX !== &quot;false&quot;;
}

export function whopApiBaseUrl(): string {
  return isSandbox()
    ? &quot;https://sandbox-api.whop.com/api/v1&quot;
    : &quot;https://api.whop.com/api/v1&quot;;
}

export function whopOAuthBaseUrl(): string {
  return isSandbox() ? &quot;https://sandbox-api.whop.com&quot; : &quot;https://api.whop.com&quot;;
}

export function platformFeePercent(): number {
  const n = Number(env.NEXT_PUBLIC_PLATFORM_FEE_PERCENT);
  return Number.isFinite(n) ? n : 5;
}</code></pre>
  </div>
</div>

### Session shape and cookie options

The session helps us remember the identity of the user who signed in. The session file we'll create now sets what we store (the user ID and logged-in flag) and how the cookie that carries it behaves. It also gives a name to a second cookie that holds a one-time PKCE value. Create `lib/session.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { SessionOptions } from &quot;iron-session&quot;;

export interface SessionData {
  userId?: string;
  whopUserId?: string;
  isLoggedIn: boolean;
}

export const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET as string,
  cookieName: &quot;kofi_session&quot;,
  cookieOptions: {
    secure: process.env.NODE_ENV === &quot;production&quot;,
    httpOnly: true,
    sameSite: &quot;lax&quot;,
    maxAge: 60 * 60 * 24 * 7,
    path: &quot;/&quot;,
  },
};

export const defaultSession: SessionData = {
  isLoggedIn: false,
};

export const PKCE_COOKIE = &quot;kofi_pkce&quot;;</code></pre>
  </div>
</div>

### Building the OAuth flow

Now, let's build the OAuth handshake itself. Create `lib/oauth.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">oauth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import crypto from &quot;crypto&quot;;
import { env, whopOAuthBaseUrl } from &quot;./env&quot;;

const REDIRECT_URI = `${env.NEXT_PUBLIC_APP_URL}/oauth/callback`;
const SCOPE = &quot;openid profile email&quot;;

function base64url(buf: Buffer): string {
  return buf.toString(&quot;base64url&quot;);
}

export function randomString(bytes = 32): string {
  return base64url(crypto.randomBytes(bytes));
}

export function codeChallengeS256(verifier: string): string {
  return base64url(crypto.createHash(&quot;sha256&quot;).update(verifier).digest());
}

export interface PkceState {
  verifier: string;
  state: string;
  nonce: string;
  returnTo?: string;
}

export function buildAuthorizeUrl(pkce: PkceState): string {
  const params = new URLSearchParams({
    response_type: &quot;code&quot;,
    client_id: env.WHOP_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPE,
    state: pkce.state,
    nonce: pkce.nonce,
    code_challenge: codeChallengeS256(pkce.verifier),
    code_challenge_method: &quot;S256&quot;,
  });
  return `${whopOAuthBaseUrl()}/oauth/authorize?${params.toString()}`;
}

export interface WhopTokens {
  access_token: string;
  refresh_token: string;
  id_token?: string;
  token_type: string;
  expires_in: number;
}

export async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string,
): Promise&lt;WhopTokens&gt; {
  const res = await fetch(`${whopOAuthBaseUrl()}/oauth/token`, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify({
      grant_type: &quot;authorization_code&quot;,
      code,
      redirect_uri: REDIRECT_URI,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });
  if (!res.ok) {
    const detail = await res.text().catch(() =&gt; &quot;&quot;);
    throw new Error(`Token exchange failed (${res.status}): ${detail}`);
  }
  return (await res.json()) as WhopTokens;
}

export interface WhopUserInfo {
  sub: string;
  preferred_username?: string;
  name?: string;
  picture?: string;
  email?: string;
  email_verified?: boolean;
}

export async function getUserInfo(accessToken: string): Promise&lt;WhopUserInfo&gt; {
  const res = await fetch(`${whopOAuthBaseUrl()}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) {
    throw new Error(`Failed to fetch userinfo (${res.status})`);
  }
  return (await res.json()) as WhopUserInfo;
}</code></pre>
  </div>
</div>

### Starting the login

When the user clicks sign in, the route we'll create now makes a fresh set of one-time PKCE values, saves them in a temporary cookie, and sends the user to Whop to log in. Create the login route at `app/api/auth/login/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { buildAuthorizeUrl, randomString } from &quot;@/lib/oauth&quot;;
import { PKCE_COOKIE } from &quot;@/lib/session&quot;;

export async function GET(req: NextRequest) {
  const params = req.nextUrl.searchParams;
  const handle = (params.get(&quot;handle&quot;) || &quot;&quot;).trim();
  const explicitReturnTo = params.get(&quot;returnTo&quot;);
  const returnTo = handle
    ? `/dashboard/start?handle=${encodeURIComponent(handle)}`
    : explicitReturnTo &amp;&amp; explicitReturnTo.startsWith(&quot;/&quot;)
      ? explicitReturnTo
      : &quot;&quot;;
  const pkce = {
    verifier: randomString(32),
    state: randomString(16),
    nonce: randomString(16),
    returnTo,
  };

  const res = NextResponse.redirect(buildAuthorizeUrl(pkce));
  res.cookies.set(PKCE_COOKIE, JSON.stringify(pkce), {
    httpOnly: true,
    secure: process.env.NODE_ENV === &quot;production&quot;,
    sameSite: &quot;lax&quot;,
    maxAge: 600,
    path: &quot;/&quot;,
  });
  return res;
}</code></pre>
  </div>
</div>

### Handling the callback

At this point, we complete the handshake. We verify the `state`, perform code exchange to obtain tokens, update the `User`, save the session, and route the user based on their account type. Create the OAuth callback at `app/oauth/callback/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { cookies } from &quot;next/headers&quot;;
import { exchangeCodeForTokens, getUserInfo, type PkceState } from &quot;@/lib/oauth&quot;;
import { PKCE_COOKIE } from &quot;@/lib/session&quot;;
import { getSession } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;

export async function GET(req: NextRequest) {
  const base = req.nextUrl.origin;
  const params = req.nextUrl.searchParams;
  const code = params.get(&quot;code&quot;);
  const state = params.get(&quot;state&quot;);
  const oauthError = params.get(&quot;error&quot;);

  if (oauthError) {
    return NextResponse.redirect(new URL(`/?authError=${encodeURIComponent(oauthError)}`, base));
  }
  if (!code || !state) {
    return NextResponse.redirect(new URL(&quot;/?authError=missing_code&quot;, base));
  }

  const cookieStore = await cookies();
  const pkceRaw = cookieStore.get(PKCE_COOKIE)?.value;
  if (!pkceRaw) {
    return NextResponse.redirect(new URL(&quot;/?authError=missing_pkce&quot;, base));
  }

  let pkce: PkceState;
  try {
    pkce = JSON.parse(pkceRaw) as PkceState;
  } catch {
    return NextResponse.redirect(new URL(&quot;/?authError=bad_pkce&quot;, base));
  }
  if (pkce.state !== state) {
    return NextResponse.redirect(new URL(&quot;/?authError=state_mismatch&quot;, base));
  }

  let accessToken: string;
  let info: Awaited&lt;ReturnType&lt;typeof getUserInfo&gt;&gt;;
  try {
    const tokens = await exchangeCodeForTokens(code, pkce.verifier);
    accessToken = tokens.access_token;
    info = await getUserInfo(accessToken);
  } catch (err: unknown) {
    console.error(&quot;OAuth callback failed:&quot;, err);
    return NextResponse.redirect(new URL(&quot;/?authError=token_exchange_failed&quot;, base));
  }

  const user = await prisma.user.upsert({
    where: { whopUserId: info.sub },
    update: {
      username: info.preferred_username ?? info.sub,
      name: info.name ?? null,
      email: info.email ?? null,
      avatarUrl: info.picture ?? null,
    },
    create: {
      whopUserId: info.sub,
      username: info.preferred_username ?? info.sub,
      name: info.name ?? null,
      email: info.email ?? null,
      avatarUrl: info.picture ?? null,
    },
  });

  const session = await getSession();
  session.userId = user.id;
  session.whopUserId = user.whopUserId;
  session.isLoggedIn = true;
  await session.save();

  cookieStore.delete(PKCE_COOKIE);

  let dest: string;
  if (pkce.returnTo &amp;&amp; pkce.returnTo.startsWith(&quot;/&quot;)) {
    dest = pkce.returnTo;
  } else {
    const creator = await prisma.creator.findUnique({
      where: { userId: user.id },
      select: { id: true },
    });
    dest = creator ? &quot;/dashboard&quot; : &quot;/feed&quot;;
  }
  return NextResponse.redirect(new URL(dest, base));
}</code></pre>
  </div>
</div>

### Logging out

Now let's build the logout route that destroys the session and sends the user home. Create the logout route at `app/api/auth/logout/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { getSession } from &quot;@/lib/auth&quot;;

async function destroy(req: NextRequest) {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL(&quot;/&quot;, req.nextUrl.origin));
}

export async function GET(req: NextRequest) {
  return destroy(req);
}

export async function POST(req: NextRequest) {
  return destroy(req);
}</code></pre>
  </div>
</div>

### Server-side auth helpers

Let's build the helpers that check the user's type and guard the pages with `requireAuth` and `requireCreator`. Create `lib/auth.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { cookies } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { getIronSession } from &quot;iron-session&quot;;
import { prisma } from &quot;./prisma&quot;;
import { sessionOptions, type SessionData } from &quot;./session&quot;;
import type { Creator, User } from &quot;@prisma/client&quot;;

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession&lt;SessionData&gt;(cookieStore, sessionOptions);
}

export type CurrentUser = User &amp; { creator: Creator | null };

export async function getCurrentUser(): Promise&lt;CurrentUser | null&gt; {
  const session = await getSession();
  if (!session.isLoggedIn || !session.userId) return null;
  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    include: { creator: true },
  });
  return user;
}

export async function requireAuth(): Promise&lt;CurrentUser&gt; {
  const user = await getCurrentUser();
  if (!user) redirect(&quot;/api/auth/login&quot;);
  return user;
}

export async function requireCreator(): Promise&lt;CurrentUser &amp; { creator: Creator }&gt; {
  const user = await requireAuth();
  if (!user.creator) redirect(&quot;/dashboard/start&quot;);
  return user as CurrentUser &amp; { creator: Creator };
}</code></pre>
  </div>
</div>

### A client-readable session endpoint

We'll have a bunch of client components that need to know who's logged in without reading the httpOnly cookie. Create the endpoint at `app/api/auth/me/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;

export async function GET() {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ user: null });

  return NextResponse.json({
    user: {
      id: user.id,
      username: user.username,
      name: user.name,
      avatarUrl: user.avatarUrl,
      isCreator: Boolean(user.creator),
      creatorUsername: user.creator?.username ?? null,
    },
  });
}</code></pre>
  </div>
</div>

### Protecting the dashboard

Finally, we put the signed-in areas behind a gate so a logged-out visitor is redirected to login before the page loads. Create `proxy.ts` at the project root:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">proxy.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;

const SESSION_COOKIE = &quot;kofi_session&quot;;

export function proxy(req: NextRequest) {
  if (!req.cookies.has(SESSION_COOKIE)) {
    const url = new URL(&quot;/api/auth/login&quot;, req.url);
    url.searchParams.set(&quot;returnTo&quot;, req.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: [&quot;/dashboard&quot;, &quot;/dashboard/:path*&quot;, &quot;/feed&quot;, &quot;/feed/:path*&quot;],
};</code></pre>
  </div>
</div>

### Register the redirect URI and try it

For the handshake to work, we need to add the callback of our app to the Whop app's OAuth callback URIs. Go to your Whop company dashboard, Developer, your app, and its OAuth tab. There, add the redirect URI `http://localhost:3005/oauth/callback` (and your production equivalent, `https://your-domain/oauth/callback`).

Then start the app and visit `http://localhost:3005/api/auth/login`. You should be sent to Whop, and after approving, land back on `/dashboard` signed in.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm run dev</code></pre>
  </div>
</div>

> 

## Checkpoint

- `lib/env.ts` validates the environment with Zod and exposes `isSandbox()`, `whopOAuthBaseUrl()`, and `whopApiBaseUrl()`.
- Visiting `/api/auth/login` redirects to Whop's authorize page (sandbox host while `WHOP_SANDBOX="true"`), and the URL includes `code_challenge`, `code_challenge_method=S256`, `state`, and `nonce`.
- A `kofi_pkce` httpOnly cookie is set on that redirect.
- After approving on Whop, the callback lands you on `/dashboard` (creators) or `/feed` (supporters) signed in, and a `kofi_session` cookie exists while `kofi_pkce` is gone.
- A `User` row was upserted from the Whop profile (check Prisma Studio).
- `GET /api/auth/me` returns your public user fields and never the access or refresh token.
- Visiting `/dashboard` while signed out redirects to login with a `returnTo` of `/dashboard`; after login you return there.
- `GET /api/auth/logout` clears the session and redirects home.
- The project has a `proxy.ts` (not a `middleware.ts`), and the dev server starts cleanly.
- The redirect URI `http://localhost:3005/oauth/callback` is registered on the Whop app and `oauth:token_exchange` is enabled.

## Part 5: Whop SDK and creator onboarding

The Whop company we created to get keys and secrets also acts as the parent company of every creator we onboard in the project. Their accounts become "connected accounts" under ours. All charges made, like tips and purchases, are direct charges on the creator's company with a small application fee routed to us.

In this part, we set up the Whop SDK, add the money and utility helpers, and build the creator onboarding flow that creates those connected accounts.

### Initializing the SDK once

We want a single configured `Whop` client that all server modules will use and that targets the sandbox. Create `lib/whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Whop } from &quot;@whop/sdk&quot;;
import { env, isSandbox, whopApiBaseUrl } from &quot;./env&quot;;

function webhookKey(): string | null {
  const secret = env.WHOP_WEBHOOK_SECRET;
  if (!secret) return null;
  return Buffer.from(secret).toString(&quot;base64&quot;);
}

export const whopsdk = new Whop({
  apiKey: env.WHOP_COMPANY_API_KEY,
  webhookKey: webhookKey(),
  ...(isSandbox() ? { baseURL: whopApiBaseUrl() } : {}),
});</code></pre>
  </div>
</div>

### Money helpers

Whop's checkout amounts are set in dollars, but we store everything in cents to avoid rounding issues. Additionally, the application fee must always be a positive number and must be less than the total payment. Create `lib/fees.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fees.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">function platformFeePercent(): number {
  const n = Number(process.env.NEXT_PUBLIC_PLATFORM_FEE_PERCENT);
  return Number.isFinite(n) &amp;&amp; n &gt;= 0 ? n : 5;
}

export function applicationFeeCents(amountCents: number): number {
  const pct = platformFeePercent();
  const fee = Math.round((amountCents * pct) / 100);
  if (amountCents &lt;= 0) return 0;
  return Math.min(Math.max(fee, 1), amountCents - 1);
}

export function centsToDollars(cents: number): number {
  return Math.round(cents) / 100;
}

export function dollarsToCents(dollars: number): number {
  return Math.round(dollars * 100);
}

export function formatUsd(cents: number): string {
  return new Intl.NumberFormat(&quot;en-US&quot;, {
    style: &quot;currency&quot;,
    currency: &quot;USD&quot;,
    minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
  }).format(cents / 100);
}</code></pre>
  </div>
</div>

### Accent palette

To give some personalization freedom to our users, we want each creator to be able to pick a page accent color, and we want it to carry into Whop's embedded checkout and payout widgets.

However, those widgets take a name from a pre-made list, not a custom color code, so we keep a list pairing each Whop palette name with the hex we render ourselves. It defaults to `sky`, the closest match to Ko-fi's cyan. Create `lib/accent.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">accent.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export const ACCENT_OPTIONS = [
  { name: &quot;sky&quot;, hex: &quot;#0ea5e9&quot; },
  { name: &quot;blue&quot;, hex: &quot;#3b82f6&quot; },
  { name: &quot;cyan&quot;, hex: &quot;#06b6d4&quot; },
  { name: &quot;teal&quot;, hex: &quot;#14b8a6&quot; },
  { name: &quot;jade&quot;, hex: &quot;#10b981&quot; },
  { name: &quot;green&quot;, hex: &quot;#22c55e&quot; },
  { name: &quot;grass&quot;, hex: &quot;#65a30d&quot; },
  { name: &quot;pink&quot;, hex: &quot;#ec4899&quot; },
  { name: &quot;crimson&quot;, hex: &quot;#e11d48&quot; },
  { name: &quot;tomato&quot;, hex: &quot;#ef4444&quot; },
  { name: &quot;orange&quot;, hex: &quot;#f97316&quot; },
  { name: &quot;amber&quot;, hex: &quot;#f59e0b&quot; },
  { name: &quot;purple&quot;, hex: &quot;#a855f7&quot; },
  { name: &quot;violet&quot;, hex: &quot;#8b5cf6&quot; },
  { name: &quot;indigo&quot;, hex: &quot;#6366f1&quot; },
  { name: &quot;gold&quot;, hex: &quot;#ca8a04&quot; },
] as const;

export type AccentName = (typeof ACCENT_OPTIONS)[number][&quot;name&quot;];

const HEX_BY_NAME = new Map&lt;string, string&gt;(ACCENT_OPTIONS.map((a) =&gt; [a.name, a.hex] as [string, string]));

export const DEFAULT_ACCENT: AccentName = &quot;sky&quot;;

export function accentHex(name: string | null | undefined): string {
  if (!name) return HEX_BY_NAME.get(DEFAULT_ACCENT)!;
  return HEX_BY_NAME.get(name) ?? HEX_BY_NAME.get(DEFAULT_ACCENT)!;
}

export function isAccentName(name: string): name is AccentName {
  return HEX_BY_NAME.has(name);
}</code></pre>
  </div>
</div>

### Rate limiting

All public routes from this point onward will use a simple rate limiter. These rate limiters will count the requests coming from visitors' IP addresses and block those that make too many requests within a short period of time. Create `lib/rate-limit.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">rate-limit.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">type Bucket = { count: number; resetAt: number };

const buckets = new Map&lt;string, Bucket&gt;();

export function rateLimit(key: string, limit = 20, windowMs = 60_000): boolean {
  const now = Date.now();
  const bucket = buckets.get(key);
  if (!bucket || now &gt; bucket.resetAt) {
    buckets.set(key, { count: 1, resetAt: now + windowMs });
    return true;
  }
  if (bucket.count &gt;= limit) return false;
  bucket.count += 1;
  return true;
}

export function clientIp(req: Request): string {
  const h = req.headers;
  const fwd = h.get(&quot;x-forwarded-for&quot;);
  if (fwd) return fwd.split(&quot;,&quot;)[0]!.trim();
  return h.get(&quot;x-real-ip&quot;) ?? &quot;local&quot;;
}</code></pre>
  </div>
</div>

### The Whop service layer

We have lots of Whop API calls so we put them in a single file. Onboarding only needs the first three calls, but we'll write the whole file now: the checkout, payment, notification, and webhook helpers below all get used in later parts.

In this part, we add the three calls that onboarding needs. Create `services/whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { whopsdk } from &quot;@/lib/whop&quot;;
import { env } from &quot;@/lib/env&quot;;
import { centsToDollars } from &quot;@/lib/fees&quot;;
import type { CheckoutKind } from &quot;@/constants&quot;;

export async function createConnectedCompany(params: {
  email: string;
  title: string;
  internalUserId: string;
}): Promise&lt;string&gt; {
  const company = await whopsdk.companies.create({
    email: params.email,
    parent_company_id: env.WHOP_PLATFORM_COMPANY_ID,
    title: params.title,
    metadata: { internal_user_id: params.internalUserId },
  });
  return company.id;
}

export async function createAccountLink(params: {
  companyId: string;
  useCase: &quot;account_onboarding&quot; | &quot;payouts_portal&quot;;
  returnUrl: string;
  refreshUrl: string;
}): Promise&lt;string&gt; {
  const link = await whopsdk.accountLinks.create({
    company_id: params.companyId,
    use_case: params.useCase,
    return_url: params.returnUrl,
    refresh_url: params.refreshUrl,
  });
  return link.url;
}

export async function createCompanyAccessToken(companyId: string): Promise&lt;string&gt; {
  const { token } = await whopsdk.accessTokens.create({ company_id: companyId });
  return token;
}

export interface PayoutSnapshot {
  activated: boolean;
  status: string | null;
  availableCents: number;
  pendingCents: number;
}

export async function getPayoutSnapshot(companyId: string): Promise&lt;PayoutSnapshot&gt; {
  const ledger = await whopsdk.ledgerAccounts.retrieve(companyId);
  const balance = ledger.balances?.find((b) =&gt; b.currency === &quot;usd&quot;) ?? ledger.balances?.[0];
  const status = ledger.payout_account_details?.status ?? null;
  return {
    activated: status === &quot;connected&quot;,
    status,
    availableCents: Math.round((balance?.balance ?? 0) * 100),
    pendingCents: Math.round((balance?.pending_balance ?? 0) * 100),
  };
}

export interface CheckoutResult {
  sessionId: string;
  planId: string;
  purchaseUrl: string;
}

export async function createCheckoutConfiguration(params: {
  connectedCompanyId: string;
  amountCents: number;
  applicationFeeCents: number;
  planType: &quot;one_time&quot; | &quot;renewal&quot;;
  title: string;
  redirectUrl: string;
  metadata: { kind: CheckoutKind } &amp; Record&lt;string, string&gt;;
}): Promise&lt;CheckoutResult&gt; {
  const amount = centsToDollars(params.amountCents);
  const fee = centsToDollars(params.applicationFeeCents);

  const cfg = await whopsdk.checkoutConfigurations.create({
    plan: {
      company_id: params.connectedCompanyId,
      currency: &quot;usd&quot;,
      plan_type: params.planType,
      application_fee_amount: fee,
      title: params.title,
      ...(params.planType === &quot;renewal&quot;
        ? { renewal_price: amount, billing_period: 30, initial_price: 0 }
        : { initial_price: amount }),
    },
    metadata: params.metadata,
    ...(params.redirectUrl.startsWith(&quot;https://&quot;) ? { redirect_url: params.redirectUrl } : {}),
  });

  const planId = cfg.plan?.id;
  if (!planId) throw new Error(&quot;Checkout configuration did not return a plan id&quot;);

  return { sessionId: cfg.id, planId, purchaseUrl: cfg.purchase_url };
}

export async function retrievePayment(paymentId: string) {
  return whopsdk.payments.retrieve(paymentId);
}

export async function notifyCreator(params: {
  companyId: string;
  title: string;
  subtitle?: string;
  content: string;
  restPath?: string;
  iconUserId?: string;
}): Promise&lt;boolean&gt; {
  try {
    await whopsdk.notifications.create({
      company_id: params.companyId,
      title: params.title,
      subtitle: params.subtitle,
      content: params.content,
      rest_path: params.restPath,
      icon_user_id: params.iconUserId,
    });
    return true;
  } catch (err: unknown) {
    console.error(&quot;notifyCreator failed:&quot;, err);
    return false;
  }
}

export async function createPaymentsWebhook(appUrl: string): Promise&lt;{ id: string; secret?: string }&gt; {
  const res = await whopsdk.webhooks.create({
    url: `${appUrl}/api/webhooks/whop`,
    events: [
      &quot;payment.succeeded&quot;,
      &quot;payment.failed&quot;,
      &quot;membership.activated&quot;,
      &quot;membership.deactivated&quot;,
      &quot;refund.created&quot;,
    ],
  } as Parameters&lt;typeof whopsdk.webhooks.create&gt;[0]);
  const anyRes = res as unknown as { id: string; webhook_secret?: string; secret?: string };
  return { id: anyRes.id, secret: anyRes.webhook_secret ?? anyRes.secret };
}</code></pre>
  </div>
</div>

### App constants

The app shares a handful of constants: the coffee unit price and tip limits that checkout enforces, the `CheckoutKindtype` that `services/whop.ts` imports, and the two option lists the onboarding form offers users (how they're planning to earn, and which categories describe their work).

We keep them in one file so the form and the API use the same values. Create `constants/index.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">index.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export const APP_NAME = &quot;Cuppa&quot;;

export const COFFEE_UNIT_CENTS = 500;
export const COFFEE_PRESETS = [1, 3, 5] as const;

export const MIN_TIP_CENTS = 100;
export const MAX_TIP_CENTS = 100_000;

export type CheckoutKind = &quot;tip&quot; | &quot;membership&quot; | &quot;shop&quot;;

export const PAGE_SIZE = 10;

export const EARN_GOALS = [
  &quot;Tips &amp; donations&quot;,
  &quot;Monthly memberships&quot;,
  &quot;Digital products&quot;,
  &quot;Physical products&quot;,
  &quot;Commissions&quot;,
] as const;

export const CREATOR_CATEGORIES = [
  &quot;Art &amp; Illustration&quot;,
  &quot;Music&quot;,
  &quot;Writing&quot;,
  &quot;Podcasts&quot;,
  &quot;Video &amp; Film&quot;,
  &quot;Photography&quot;,
  &quot;Gaming&quot;,
  &quot;Education&quot;,
  &quot;Technology&quot;,
  &quot;Crafts &amp; DIY&quot;,
  &quot;Comics &amp; Animation&quot;,
  &quot;Cooking&quot;,
  &quot;Fitness &amp; Health&quot;,
  &quot;Cosplay&quot;,
  &quot;Charity &amp; Causes&quot;,
] as const;</code></pre>
  </div>
</div>

### The "create page" API route

When a user becomes a creator, we also create a Whop connected account, then save the `Creator` row. We follow this order so that we never save a creator who has no payments account.

In the onboarding, a handle has to pass the same rules here and the availability check, so we keep it all in the same module. Create `lib/username.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">username.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

export const RESERVED_USERNAMES = new Set([
  &quot;dashboard&quot;, &quot;signin&quot;, &quot;api&quot;, &quot;oauth&quot;, &quot;explore&quot;, &quot;admin&quot;, &quot;settings&quot;, &quot;about&quot;,
  &quot;login&quot;, &quot;register&quot;, &quot;account&quot;, &quot;creator&quot;, &quot;support&quot;, &quot;help&quot;, &quot;terms&quot;, &quot;privacy&quot;,
  &quot;feed&quot;, &quot;features&quot;,
]);

export const usernameSchema = z
  .string()
  .min(3)
  .max(30)
  .regex(/^[a-z0-9_]+$/, &quot;Use lowercase letters, numbers, and underscores&quot;);

export type UsernameFormat = { ok: true } | { ok: false; reason: string };

export function checkUsernameFormat(value: string): UsernameFormat {
  const parsed = usernameSchema.safeParse(value);
  if (!parsed.success) {
    return { ok: false, reason: parsed.error.issues[0]?.message ?? &quot;Invalid username&quot; };
  }
  if (RESERVED_USERNAMES.has(value)) {
    return { ok: false, reason: &quot;That username is reserved&quot; };
  }
  return { ok: true };
}</code></pre>
  </div>
</div>

Now the route that creates the connected account and saves the creator's page. Create `app/api/creator/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { createConnectedCompany } from &quot;@/services/whop&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;
import { DEFAULT_ACCENT } from &quot;@/lib/accent&quot;;
import { CREATOR_CATEGORIES } from &quot;@/constants&quot;;
import { usernameSchema, RESERVED_USERNAMES } from &quot;@/lib/username&quot;;

const schema = z.object({
  username: usernameSchema,
  displayName: z.string().min(1).max(60),
  bio: z.string().max(500).optional(),
  tags: z.array(z.string().max(40)).max(8).optional(),
});

export async function POST(req: NextRequest) {
  if (!rateLimit(`creator:${clientIp(req)}`, 5, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  if (user.creator) return NextResponse.json({ error: &quot;You already have a page&quot; }, { status: 400 });

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const { username, displayName, bio, tags } = parsed.data;
  const allowed = new Set&lt;string&gt;(CREATOR_CATEGORIES);
  const cleanTags = (tags ?? []).filter((t) =&gt; allowed.has(t));

  if (RESERVED_USERNAMES.has(username)) {
    return NextResponse.json({ error: &quot;That username is reserved&quot; }, { status: 409 });
  }
  const existing = await prisma.creator.findUnique({ where: { username } });
  if (existing) {
    return NextResponse.json({ error: &quot;That username is taken&quot; }, { status: 409 });
  }

  let whopCompanyId: string | null = null;
  try {
    whopCompanyId = await createConnectedCompany({
      email: user.email ?? `${username}@example.com`,
      title: displayName,
      internalUserId: user.id,
    });
  } catch (err: unknown) {
    console.error(&quot;Failed to create connected company:&quot;, err);
    return NextResponse.json(
      { error: &quot;We couldn&#039;t set up your payments account. Please try again.&quot; },
      { status: 502 },
    );
  }

  const creator = await prisma.creator.create({
    data: {
      userId: user.id,
      username,
      displayName,
      bio: bio || null,
      tags: cleanTags,
      whopCompanyId,
      whopOnboarded: true,
      accentColor: DEFAULT_ACCENT,
    },
  });

  return NextResponse.json({ ok: true, username: creator.username });
}</code></pre>
  </div>
</div>

### Brand icons

Drop the eight files into `public/brand/ascoffee-cup.webp`, `paint-palette.webp`, `megaphone.webp`, `money-stack.webp`, `heart.webp`, `shopping-bag.webp`, `lock.webp`, and `confetti.webp` (grab them from the companion repo, or swap in your own square art).

One small component maps a short name to each file so every surface references them the same way. Create `components/BrandIcon.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">BrandIcon.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */

const BRAND_ICONS = {
  coffee: &quot;/brand/coffee-cup.webp&quot;,
  palette: &quot;/brand/paint-palette.webp&quot;,
  megaphone: &quot;/brand/megaphone.webp&quot;,
  money: &quot;/brand/money-stack.webp&quot;,
  heart: &quot;/brand/heart.webp&quot;,
  shop: &quot;/brand/shopping-bag.webp&quot;,
  lock: &quot;/brand/lock.webp&quot;,
  confetti: &quot;/brand/confetti.webp&quot;,
} as const;

export type BrandIconName = keyof typeof BRAND_ICONS;

export default function BrandIcon({
  name,
  className = &quot;h-6 w-6&quot;,
  alt = &quot;&quot;,
}: {
  name: BrandIconName;
  className?: string;
  alt?: string;
}) {
  return (
    &lt;img
      src={BRAND_ICONS[name]}
      alt={alt}
      aria-hidden={alt === &quot;&quot; ? true : undefined}
      draggable={false}
      className={`inline-block shrink-0 select-none object-contain ${className}`}
    /&gt;
  );
}</code></pre>
  </div>
</div>

### The icon set

Several components from here on use small icons. Create `components/Icons.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Icons.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">type IconProps = { className?: string };

function Svg({ className = &quot;h-4 w-4&quot;, children }: IconProps &amp; { children: React.ReactNode }) {
  return (
    &lt;svg
      viewBox=&quot;0 0 24 24&quot;
      fill=&quot;none&quot;
      stroke=&quot;currentColor&quot;
      strokeWidth=&quot;2&quot;
      strokeLinecap=&quot;round&quot;
      strokeLinejoin=&quot;round&quot;
      aria-hidden=&quot;true&quot;
      className={`inline-block shrink-0 ${className}`}
    &gt;
      {children}
    &lt;/svg&gt;
  );
}

export function Check({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M20 6 9 17l-5-5&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function X({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M18 6 6 18M6 6l12 12&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function ChevronLeft({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;m15 18-6-6 6-6&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function ChevronRight({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;m9 18 6-6-6-6&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function ArrowUpRight({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M7 17 17 7M7 7h10v10&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Pin({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M12 17v5&quot; /&gt;
      &lt;path d=&quot;M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Sun({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;4&quot; /&gt;
      &lt;path d=&quot;M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Moon({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Monitor({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;rect x=&quot;2&quot; y=&quot;3&quot; width=&quot;20&quot; height=&quot;14&quot; rx=&quot;2&quot; /&gt;
      &lt;path d=&quot;M8 21h8M12 17v4&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Home({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z&quot; /&gt;
      &lt;path d=&quot;M9 22V12h6v10&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function User({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2&quot; /&gt;
      &lt;circle cx=&quot;12&quot; cy=&quot;7&quot; r=&quot;4&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Users({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2&quot; /&gt;
      &lt;circle cx=&quot;9&quot; cy=&quot;7&quot; r=&quot;4&quot; /&gt;
      &lt;path d=&quot;M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Gear({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;3&quot; /&gt;
      &lt;path d=&quot;M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Wallet({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M19 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2&quot; /&gt;
      &lt;path d=&quot;M21 12a2 2 0 0 0-2-2h-4a2 2 0 0 0 0 4h4a2 2 0 0 0 2-2Z&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function FileText({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z&quot; /&gt;
      &lt;path d=&quot;M14 2v6h6M16 13H8M16 17H8M10 9H8&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Crown({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;m2 6 4 4 6-7 6 7 4-4-2 13H4z&quot; /&gt;
      &lt;path d=&quot;M5 21h14&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Bag({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z&quot; /&gt;
      &lt;path d=&quot;M3 6h18&quot; /&gt;
      &lt;path d=&quot;M16 10a4 4 0 0 1-8 0&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Share({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;circle cx=&quot;18&quot; cy=&quot;5&quot; r=&quot;3&quot; /&gt;
      &lt;circle cx=&quot;6&quot; cy=&quot;12&quot; r=&quot;3&quot; /&gt;
      &lt;circle cx=&quot;18&quot; cy=&quot;19&quot; r=&quot;3&quot; /&gt;
      &lt;path d=&quot;m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98&quot; /&gt;
    &lt;/Svg&gt;
  );
}

export function Link({ className }: IconProps) {
  return (
    &lt;Svg className={className}&gt;
      &lt;path d=&quot;M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71&quot; /&gt;
      &lt;path d=&quot;M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71&quot; /&gt;
    &lt;/Svg&gt;
  );
}</code></pre>
  </div>
</div>

### The onboarding form

Our onboarding form opens with a single question: we ask users if they're a supporter, which redirects them to their feed, or if they're a creator, which goes on with four more steps (earning goals, username, interests, etc.).

The username step checks availability as you type, so "Next" only turns on when the handle is valid and still free. That needs a tiny endpoint that reuses `checkUsernameFormat` and looks up the handle. Create `app/api/creator/username/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;
import { checkUsernameFormat } from &quot;@/lib/username&quot;;

export async function GET(req: NextRequest) {
  if (!rateLimit(`username:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const value = (req.nextUrl.searchParams.get(&quot;username&quot;) ?? &quot;&quot;).toLowerCase();

  const format = checkUsernameFormat(value);
  if (!format.ok) {
    return NextResponse.json({ available: false, reason: format.reason });
  }

  const existing = await prisma.creator.findUnique({
    where: { username: value },
    select: { id: true },
  });

  return NextResponse.json(
    existing ? { available: false, reason: &quot;That username is taken&quot; } : { available: true },
  );
}</code></pre>
  </div>
</div>

As the user types, the form cleans up the handle so it always fits the server's rules, waits until they pause before checking whether it's free, and only enables "Next" once it is. Create `components/OnboardingWizard.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">OnboardingWizard.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { EARN_GOALS, CREATOR_CATEGORIES } from &quot;@/constants&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { ChevronLeft } from &quot;@/components/Icons&quot;;

type Role = &quot;creator&quot; | &quot;supporter&quot;;

function pillClass(active: boolean) {
  return [
    &quot;rounded-full border px-4 py-2 text-sm font-semibold transition&quot;,
    active ? &quot;border-brand bg-brand text-white&quot; : &quot;border-line bg-surface hover:border-brand/60&quot;,
  ].join(&quot; &quot;);
}

function toggle(list: string[], value: string): string[] {
  return list.includes(value) ? list.filter((v) =&gt; v !== value) : [...list, value];
}

export default function OnboardingWizard({
  email,
  defaultName,
  defaultUsername,
}: {
  email?: string;
  defaultName?: string;
  defaultUsername?: string;
}) {
  const router = useRouter();
  const [step, setStep] = useState(1);
  const [earnGoals, setEarnGoals] = useState&lt;string[]&gt;([]);
  const [username, setUsername] = useState(
    (defaultUsername ?? &quot;&quot;).toLowerCase().replace(/[^a-z0-9_]/g, &quot;&quot;),
  );
  const [interests, setInterests] = useState&lt;string[]&gt;([]);
  const [displayName, setDisplayName] = useState(defaultName ?? &quot;&quot;);
  const [bio, setBio] = useState(&quot;&quot;);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [loading, setLoading] = useState(false);
  const [availability, setAvailability] = useState&lt;{
    status: &quot;idle&quot; | &quot;checking&quot; | &quot;available&quot; | &quot;unavailable&quot;;
    message?: string;
  }&gt;({ status: &quot;idle&quot; });

  useEffect(() =&gt; {
    if (step !== 3) return;
    const handle = username;
    if (handle.length &lt; 3) {
      setAvailability({ status: &quot;idle&quot; });
      return;
    }
    setAvailability({ status: &quot;checking&quot; });
    const controller = new AbortController();
    const timer = setTimeout(async () =&gt; {
      try {
        const res = await fetch(
          `/api/creator/username?username=${encodeURIComponent(handle)}`,
          { signal: controller.signal },
        );
        const data: { available?: boolean; reason?: string } = await res.json();
        setAvailability(
          data.available
            ? { status: &quot;available&quot; }
            : { status: &quot;unavailable&quot;, message: data.reason ?? &quot;That username is taken&quot; },
        );
      } catch {
        if (!controller.signal.aborted) setAvailability({ status: &quot;idle&quot; });
      }
    }, 400);
    return () =&gt; {
      clearTimeout(timer);
      controller.abort();
    };
  }, [username, step]);

  function chooseRole(role: Role) {
    if (role === &quot;supporter&quot;) {
      router.push(&quot;/feed&quot;);
      return;
    }
    setStep(2);
  }

  function goToInterests() {
    if (availability.status !== &quot;available&quot;) return;
    setError(null);
    setStep(4);
  }

  async function createPage() {
    setError(null);
    setLoading(true);
    try {
      const res = await fetch(&quot;/api/creator&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ username, displayName, bio, tags: interests }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Something went wrong&quot;);
        setLoading(false);
        return;
      }
      router.push(`/${data.username}`);
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setLoading(false);
    }
  }

  if (step === 1) {
    return (
      &lt;div className=&quot;kofi-card p-7&quot;&gt;
        &lt;h1 className=&quot;text-2xl&quot;&gt;I&amp;rsquo;m a&amp;hellip;&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          {email ? (
            &lt;&gt;
              Signing up as &lt;span className=&quot;font-semibold text-ink&quot;&gt;{email}&lt;/span&gt;.
            &lt;/&gt;
          ) : (
            &quot;Tell us how you’ll use Cuppa.&quot;
          )}
        &lt;/p&gt;
        &lt;div className=&quot;mt-6 grid gap-3&quot;&gt;
          &lt;button
            type=&quot;button&quot;
            onClick={() =&gt; chooseRole(&quot;creator&quot;)}
            className=&quot;flex items-center gap-4 rounded-2xl border border-muted/30 bg-surface p-5 text-left transition hover:border-brand hover:bg-surface-2&quot;
          &gt;
            &lt;span className=&quot;grid h-11 w-11 shrink-0 place-items-center rounded-full bg-surface-2&quot;&gt;
              &lt;BrandIcon name=&quot;palette&quot; className=&quot;h-7 w-7&quot; /&gt;
            &lt;/span&gt;
            &lt;span&gt;
              &lt;span className=&quot;block font-bold&quot;&gt;Creator&lt;/span&gt;
              &lt;span className=&quot;block text-sm text-muted&quot;&gt;
                I want a page to accept tips, memberships and sales.
              &lt;/span&gt;
            &lt;/span&gt;
          &lt;/button&gt;
          &lt;button
            type=&quot;button&quot;
            onClick={() =&gt; chooseRole(&quot;supporter&quot;)}
            className=&quot;flex items-center gap-4 rounded-2xl border border-muted/30 bg-surface p-5 text-left transition hover:border-brand hover:bg-surface-2&quot;
          &gt;
            &lt;span className=&quot;grid h-11 w-11 shrink-0 place-items-center rounded-full bg-surface-2&quot;&gt;
              &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-7 w-7&quot; /&gt;
            &lt;/span&gt;
            &lt;span&gt;
              &lt;span className=&quot;block font-bold&quot;&gt;Supporter&lt;/span&gt;
              &lt;span className=&quot;block text-sm text-muted&quot;&gt;
                I&amp;rsquo;m here to support and follow creators I love.
              &lt;/span&gt;
            &lt;/span&gt;
          &lt;/button&gt;
        &lt;/div&gt;
        &lt;a
          href=&quot;/api/auth/logout&quot;
          className=&quot;mt-6 block text-center text-sm text-muted hover:text-ink&quot;
        &gt;
          Start over
        &lt;/a&gt;
      &lt;/div&gt;
    );
  }

  if (step === 2) {
    return (
      &lt;div className=&quot;kofi-card p-7&quot;&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; setStep(1)}
          className=&quot;mb-4 inline-flex items-center gap-1 text-sm text-muted hover:text-ink&quot;
        &gt;
          &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Back
        &lt;/button&gt;
        &lt;h1 className=&quot;text-2xl&quot;&gt;How are you planning to earn?&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;Pick what fits. You can offer all of it later.&lt;/p&gt;
        &lt;div className=&quot;mt-6 flex flex-wrap gap-2&quot;&gt;
          {EARN_GOALS.map((g) =&gt; (
            &lt;button
              key={g}
              type=&quot;button&quot;
              onClick={() =&gt; setEarnGoals(toggle(earnGoals, g))}
              className={pillClass(earnGoals.includes(g))}
            &gt;
              {g}
            &lt;/button&gt;
          ))}
        &lt;/div&gt;
        &lt;Button onClick={() =&gt; setStep(3)} size=&quot;3&quot; variant=&quot;solid&quot; color=&quot;gray&quot; highContrast className=&quot;mt-8 w-full&quot;&gt;
          Continue
        &lt;/Button&gt;
      &lt;/div&gt;
    );
  }

  if (step === 3) {
    return (
      &lt;div className=&quot;kofi-card p-7&quot;&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; setStep(2)}
          className=&quot;mb-4 inline-flex items-center gap-1 text-sm text-muted hover:text-ink&quot;
        &gt;
          &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Back
        &lt;/button&gt;
        &lt;h1 className=&quot;text-2xl&quot;&gt;Pick a username&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;This is your page link. You can change it later.&lt;/p&gt;
        &lt;div className=&quot;mt-6 flex items-center rounded-xl border border-line bg-surface px-4 py-3 focus-within:border-brand&quot;&gt;
          &lt;span className=&quot;text-muted&quot;&gt;kofi-clone-whop-tutorial.vercel.app/&lt;/span&gt;
          &lt;input
            autoFocus
            value={username}
            onChange={(e) =&gt;
              setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, &quot;&quot;))
            }
            placeholder=&quot;yourname&quot;
            className=&quot;flex-1 bg-transparent outline-none&quot;
          /&gt;
        &lt;/div&gt;
        &lt;p className=&quot;mt-2 min-h-[1.25rem] text-sm&quot;&gt;
          {username.length &gt; 0 &amp;&amp; username.length &lt; 3 ? (
            &lt;span className=&quot;text-muted&quot;&gt;At least 3 characters.&lt;/span&gt;
          ) : availability.status === &quot;checking&quot; ? (
            &lt;span className=&quot;text-muted&quot;&gt;Checking availability&amp;hellip;&lt;/span&gt;
          ) : availability.status === &quot;available&quot; ? (
            &lt;span className=&quot;text-positive&quot;&gt;kofi-clone-whop-tutorial.vercel.app/{username} is available&lt;/span&gt;
          ) : availability.status === &quot;unavailable&quot; ? (
            &lt;span className=&quot;text-red-600&quot;&gt;{availability.message}&lt;/span&gt;
          ) : null}
        &lt;/p&gt;
        &lt;Button
          onClick={goToInterests}
          disabled={availability.status !== &quot;available&quot;}
          size=&quot;3&quot;
          variant=&quot;solid&quot;
          color=&quot;gray&quot;
          highContrast
          className=&quot;mt-8 w-full&quot;
        &gt;
          Next
        &lt;/Button&gt;
      &lt;/div&gt;
    );
  }

  if (step === 4) {
    return (
      &lt;div className=&quot;kofi-card p-7&quot;&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; setStep(3)}
          className=&quot;mb-4 inline-flex items-center gap-1 text-sm text-muted hover:text-ink&quot;
        &gt;
          &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Back
        &lt;/button&gt;
        &lt;h1 className=&quot;text-2xl&quot;&gt;Choose your interests&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          We use these to describe your page. Pick a few that match your work.
        &lt;/p&gt;
        &lt;div className=&quot;mt-6 flex flex-wrap gap-2&quot;&gt;
          {CREATOR_CATEGORIES.map((c) =&gt; (
            &lt;button
              key={c}
              type=&quot;button&quot;
              onClick={() =&gt; setInterests(toggle(interests, c))}
              className={pillClass(interests.includes(c))}
            &gt;
              {c}
            &lt;/button&gt;
          ))}
        &lt;/div&gt;
        &lt;Button onClick={() =&gt; setStep(5)} size=&quot;3&quot; variant=&quot;solid&quot; color=&quot;gray&quot; highContrast className=&quot;mt-8 w-full&quot;&gt;
          Next
        &lt;/Button&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className=&quot;kofi-card p-7&quot;&gt;
      &lt;button
        type=&quot;button&quot;
        onClick={() =&gt; setStep(4)}
        className=&quot;mb-4 text-sm text-muted hover:text-ink&quot;
      &gt;
        &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Back
      &lt;/button&gt;
      &lt;h1 className=&quot;text-2xl&quot;&gt;About you&lt;/h1&gt;
      &lt;div className=&quot;mt-6 space-y-4&quot;&gt;
        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;displayName&quot;&gt;
            Display name
          &lt;/label&gt;
          &lt;input
            id=&quot;displayName&quot;
            value={displayName}
            onChange={(e) =&gt; setDisplayName(e.target.value)}
            placeholder=&quot;Anje&#039;s Art&quot;
            className=&quot;w-full rounded-xl border border-line bg-surface px-4 py-2.5 outline-none focus:border-brand&quot;
          /&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;bio&quot;&gt;
            Bio
          &lt;/label&gt;
          &lt;textarea
            id=&quot;bio&quot;
            value={bio}
            onChange={(e) =&gt; setBio(e.target.value)}
            rows={4}
            placeholder=&quot;Introduce yourself so others can get to know you…&quot;
            className=&quot;w-full resize-none rounded-xl border border-line bg-surface px-4 py-2.5 outline-none focus:border-brand&quot;
          /&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      {error ? (
        &lt;p className=&quot;mt-3 text-sm text-red-600&quot;&gt;
          {error} {error.toLowerCase().includes(&quot;username&quot;) ? &quot;Go back to change it.&quot; : null}
        &lt;/p&gt;
      ) : null}
      &lt;Button
        onClick={createPage}
        disabled={loading || !displayName.trim()}
        size=&quot;3&quot;
        variant=&quot;solid&quot;
        color=&quot;gray&quot;
        highContrast
        className=&quot;mt-8 w-full&quot;
      &gt;
        {loading ? &quot;Setting up your page…&quot; : &quot;Next&quot;}
      &lt;/Button&gt;
      &lt;p className=&quot;mt-3 text-center text-xs text-muted&quot;&gt;
        We create a secure payments account so supporters pay you directly.
      &lt;/p&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The start page

Finally, the page that hosts the form. It requires a signed-in user, sends anyone who already has a page to their dashboard, and pre-fills the username from any handle the homepage passed through login. Create `app/dashboard/start/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { redirect } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import OnboardingWizard from &quot;@/components/OnboardingWizard&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

export default async function StartPage({
  searchParams,
}: {
  searchParams: Promise&lt;{ handle?: string }&gt;;
}) {
  const user = await requireAuth();
  if (user.creator) redirect(&quot;/dashboard&quot;);

  const { handle } = await searchParams;
  const suggestedUsername = (handle ?? &quot;&quot;).toLowerCase().replace(/[^a-z0-9_]/g, &quot;&quot;);

  return (
    &lt;main className=&quot;min-h-dvh bg-page&quot;&gt;
      &lt;div className=&quot;mx-auto max-w-lg px-5 py-12&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;mb-8 flex items-center justify-center gap-2&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
          &lt;span className=&quot;font-display text-2xl font-extrabold&quot;&gt;Cuppa&lt;/span&gt;
        &lt;/Link&gt;
        &lt;OnboardingWizard
          email={user.email ?? undefined}
          defaultName={user.name ?? undefined}
          defaultUsername={suggestedUsername || undefined}
        /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

## Checkpoint

- Run `npm run dev`, sign in, and open `/dashboard/start`. The onboarding form loads on the "I'm a..." step.
- Choose **creator** and continue to the username step.
- Type a taken or reserved handle (try `features`) and confirm the form marks it unavailable and keeps "Next" disabled.
- Change it to a free, valid handle and confirm "Next" enables as you type.
- Finish the interests and "about you" steps and submit. (It redirects to a page we build in a later part, so verify the result with the next two steps.)
- Open your Whop **sandbox dashboard** and confirm a new connected company appears under your platform company, created with your real email (not `@example.com`).
- Run `npm run db:studio` and confirm there is a `Creator` row whose `whopCompanyId` matches that new company.
- Visit `/dashboard/start` again. Because you now have a page, you are redirected away from onboarding.

## Part 6: Creator page UI

The creator page `/{username}` is the main landing point of this project. We build that page in this part, and the support widget and checkout come in Part 7, so we drop in its component now and flesh it out next.

### Cached creator lookup and viewer context

All creator routes need two things: the creator record and the viewer's status to that creator (owner, supporter, follower, etc.). The record is cached so the layout and the entire page share just one query. Create `lib/creator.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">creator.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { cache } from &quot;react&quot;;
import { prisma } from &quot;./prisma&quot;;
import { getCurrentUser } from &quot;./auth&quot;;

export const getCreatorLite = cache(async (username: string) =&gt; {
  return prisma.creator.findUnique({
    where: { username },
    select: {
      id: true,
      userId: true,
      username: true,
      displayName: true,
      bio: true,
      avatarUrl: true,
      coverImageUrl: true,
      accentColor: true,
      isActive: true,
    },
  });
});

export interface ViewerContext {
  userId: string | null;
  isOwner: boolean;
  isSupporter: boolean;
  activeTierIds: string[];
  isFollowing: boolean;
}

export async function getViewerContext(creatorId: string, creatorUserId?: string): Promise&lt;ViewerContext&gt; {
  const user = await getCurrentUser();
  if (!user) {
    return { userId: null, isOwner: false, isSupporter: false, activeTierIds: [], isFollowing: false };
  }

  const [memberships, follow] = await Promise.all([
    prisma.membership.findMany({
      where: { creatorId, userId: user.id, status: { in: [&quot;ACTIVE&quot;, &quot;CANCELING&quot;] } },
      select: { tierId: true },
    }),
    prisma.follow.findUnique({
      where: { creatorId_userId: { creatorId, userId: user.id } },
      select: { id: true },
    }),
  ]);

  return {
    userId: user.id,
    isOwner: creatorUserId ? user.id === creatorUserId : Boolean(user.creator &amp;&amp; user.creator.id === creatorId),
    isSupporter: memberships.length &gt; 0,
    activeTierIds: memberships.map((m) =&gt; m.tierId),
    isFollowing: Boolean(follow),
  };
}

export function canViewPost(
  post: { visibility: &quot;PUBLIC&quot; | &quot;SUPPORTERS&quot; | &quot;TIER&quot;; minimumTierId: string | null },
  viewer: ViewerContext,
): boolean {
  if (post.visibility === &quot;PUBLIC&quot;) return true;
  if (viewer.isOwner) return true;
  if (post.visibility === &quot;SUPPORTERS&quot;) return viewer.isSupporter;
  if (post.visibility === &quot;TIER&quot;) {
    return post.minimumTierId ? viewer.activeTierIds.includes(post.minimumTierId) : viewer.isSupporter;
  }
  return false;
}</code></pre>
  </div>
</div>

### Small reusable components

The creator page's header and layout use a few small components, so we build those first.

First, the theme toggle. Create `components/ThemeToggle.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ThemeToggle.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { Monitor, Sun, Moon } from &quot;@/components/Icons&quot;;

type Theme = &quot;system&quot; | &quot;light&quot; | &quot;dark&quot;;

function apply(theme: Theme) {
  const dark =
    theme === &quot;dark&quot; ||
    (theme === &quot;system&quot; &amp;&amp; window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches);
  document.documentElement.classList.toggle(&quot;dark&quot;, dark);
}

export default function ThemeToggle() {
  const [theme, setTheme] = useState&lt;Theme&gt;(&quot;system&quot;);

  useEffect(() =&gt; {
    const stored = (localStorage.getItem(&quot;theme&quot;) as Theme) || &quot;system&quot;;
    setTheme(stored);
    const mq = window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;);
    const onChange = () =&gt; {
      if ((localStorage.getItem(&quot;theme&quot;) as Theme) === &quot;system&quot;) apply(&quot;system&quot;);
    };
    mq.addEventListener(&quot;change&quot;, onChange);
    return () =&gt; mq.removeEventListener(&quot;change&quot;, onChange);
  }, []);

  function cycle() {
    const next: Theme = theme === &quot;system&quot; ? &quot;light&quot; : theme === &quot;light&quot; ? &quot;dark&quot; : &quot;system&quot;;
    setTheme(next);
    localStorage.setItem(&quot;theme&quot;, next);
    apply(next);
  }

  const Icon = theme === &quot;system&quot; ? Monitor : theme === &quot;light&quot; ? Sun : Moon;
  const label = theme === &quot;system&quot; ? &quot;System theme&quot; : theme === &quot;light&quot; ? &quot;Light theme&quot; : &quot;Dark theme&quot;;

  return (
    &lt;button
      onClick={cycle}
      title={label}
      aria-label={label}
      className=&quot;grid h-9 w-9 place-items-center rounded-full border border-line text-muted&quot;
    &gt;
      &lt;Icon className=&quot;h-[18px] w-[18px]&quot; /&gt;
    &lt;/button&gt;
  );
}</code></pre>
  </div>
</div>

Next, the tab bar. Create `components/creator/CreatorTabs.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CreatorTabs.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import Link from &quot;next/link&quot;;
import { usePathname } from &quot;next/navigation&quot;;

const TABS = [
  { label: &quot;Home&quot;, path: &quot;&quot; },
  { label: &quot;Membership&quot;, path: &quot;/membership&quot; },
  { label: &quot;Shop&quot;, path: &quot;/shop&quot; },
  { label: &quot;Gallery&quot;, path: &quot;/gallery&quot; },
  { label: &quot;Posts&quot;, path: &quot;/posts&quot; },
];

export default function CreatorTabs({ username }: { username: string }) {
  const pathname = usePathname();
  const base = `/${username}`;

  return (
    &lt;div className=&quot;no-scrollbar overflow-x-auto border-b border-line&quot;&gt;
      &lt;div className=&quot;mx-auto flex max-w-5xl gap-6 px-5&quot;&gt;
        {TABS.map((tab) =&gt; {
          const href = `${base}${tab.path}`;
          const active = tab.path === &quot;&quot; ? pathname === base : pathname === href;
          return (
            &lt;Link
              key={tab.label}
              href={href}
              className={`whitespace-nowrap border-b-2 py-3 text-sm font-semibold transition ${
                active
                  ? &quot;border-[var(--accent)] text-ink&quot;
                  : &quot;border-transparent text-muted hover:text-ink&quot;
              }`}
            &gt;
              {tab.label}
            &lt;/Link&gt;
          );
        })}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Then the follow button. Create `components/creator/FollowButton.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">FollowButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { Button } from &quot;@whop/react/components&quot;;

export default function FollowButton({
  username,
  initialFollowing,
  isLoggedIn,
}: {
  username: string;
  initialFollowing: boolean;
  isLoggedIn: boolean;
}) {
  const [following, setFollowing] = useState(initialFollowing);
  const [loading, setLoading] = useState(false);

  async function toggle() {
    if (!isLoggedIn) {
      window.location.href = `/api/auth/login?returnTo=/${username}`;
      return;
    }
    setLoading(true);
    const optimistic = !following;
    setFollowing(optimistic);
    try {
      const res = await fetch(&quot;/api/follow&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ username }),
      });
      const data = await res.json();
      if (typeof data.following === &quot;boolean&quot;) setFollowing(data.following);
    } catch {
      setFollowing(!optimistic);
    } finally {
      setLoading(false);
    }
  }

  return (
    &lt;Button onClick={toggle} disabled={loading} size=&quot;2&quot; variant=&quot;soft&quot; color=&quot;gray&quot;&gt;
      {following ? &quot;Following&quot; : &quot;Follow&quot;}
    &lt;/Button&gt;
  );
}</code></pre>
  </div>
</div>

The button posts to a route that toggles the `Follow` row: it deletes the row if one exists, or creates one if it doesn't. Create `app/api/follow/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const schema = z.object({ username: z.string().min(1) });

export async function POST(req: NextRequest) {
  if (!rateLimit(`follow:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: &quot;Invalid input&quot; }, { status: 400 });

  const creator = await prisma.creator.findUnique({
    where: { username: parsed.data.username },
    select: { id: true },
  });
  if (!creator) return NextResponse.json({ error: &quot;Not found&quot; }, { status: 404 });

  const existing = await prisma.follow.findUnique({
    where: { creatorId_userId: { creatorId: creator.id, userId: user.id } },
  });

  if (existing) {
    await prisma.follow.delete({ where: { id: existing.id } });
    return NextResponse.json({ following: false });
  }
  await prisma.follow.create({ data: { creatorId: creator.id, userId: user.id } });
  return NextResponse.json({ following: true });
}</code></pre>
  </div>
</div>

### The profile header

The profile header has a cover, avatar, supporters count, and the Follow, Tip, and Edit actions, with the tab bar beneath. It also fetches the supporters count and viewer context. Create `components/creator/CreatorProfileHeader.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CreatorProfileHeader.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getCreatorLite, getViewerContext } from &quot;@/lib/creator&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import CreatorTabs from &quot;@/components/creator/CreatorTabs&quot;;
import FollowButton from &quot;@/components/creator/FollowButton&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

export default async function CreatorProfileHeader({ username }: { username: string }) {
  const creator = await getCreatorLite(username);
  if (!creator) return null;

  const [supportersCount, viewer] = await Promise.all([
    prisma.support.count({ where: { creatorId: creator.id, status: &quot;COMPLETED&quot; } }),
    getViewerContext(creator.id, creator.userId),
  ]);

  const accent = accentHex(creator.accentColor);

  return (
    &lt;&gt;
      &lt;div
        className=&quot;h-40 w-full sm:h-56&quot;
        style={{
          background: creator.coverImageUrl
            ? `url(${creator.coverImageUrl}) center/cover`
            : `linear-gradient(120deg, ${accent}, ${accent}99)`,
        }}
      /&gt;
      &lt;div className=&quot;mx-auto max-w-5xl px-5&quot;&gt;
        &lt;div className=&quot;-mt-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between&quot;&gt;
          &lt;div className=&quot;flex items-end gap-4&quot;&gt;
            {creator.avatarUrl ? (
              &lt;img
                src={creator.avatarUrl}
                alt={creator.displayName}
                className=&quot;h-24 w-24 rounded-full border-4 border-surface object-cover&quot;
              /&gt;
            ) : (
              &lt;div
                className=&quot;grid h-24 w-24 place-items-center rounded-full border-4 border-surface text-4xl text-white&quot;
                style={{ background: accent }}
              &gt;
                {creator.displayName.charAt(0)}
              &lt;/div&gt;
            )}
            &lt;div className=&quot;pb-1&quot;&gt;
              &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;{creator.displayName}&lt;/h1&gt;
              &lt;p className=&quot;mt-0.5 flex items-center gap-1.5 text-sm text-muted&quot;&gt;
                &lt;BrandIcon name=&quot;heart&quot; className=&quot;h-4 w-4&quot; /&gt;
                {supportersCount} {supportersCount === 1 ? &quot;supporter&quot; : &quot;supporters&quot;}
              &lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;div className=&quot;flex items-center gap-2 pb-1&quot;&gt;
            &lt;FollowButton username={username} initialFollowing={viewer.isFollowing} isLoggedIn={Boolean(viewer.userId)} /&gt;
            &lt;a href={`/${username}#support`} className=&quot;btn-pill btn-accent text-sm sm:hidden&quot;&gt;
              Tip
            &lt;/a&gt;
            {viewer.isOwner ? (
              &lt;Link href=&quot;/dashboard&quot; className=&quot;btn-pill btn-outline text-sm&quot;&gt;
                Edit page
              &lt;/Link&gt;
            ) : null}
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div className=&quot;mt-5&quot;&gt;
        &lt;CreatorTabs username={username} /&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The creator layout

The creator layout wraps every `/{username}/*` route. It sets the creator-set accent as the `--accent` CSS variable, renders the top navigation bar, and adds the footer. Create `app/[username]/layout.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { getCreatorLite } from &quot;@/lib/creator&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

export default async function CreatorLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise&lt;{ username: string }&gt;;
}) {
  const { username } = await params;
  const [creator, user] = await Promise.all([getCreatorLite(username), getCurrentUser()]);
  const accent = accentHex(creator?.accentColor);
  const isOwner = Boolean(user?.creator &amp;&amp; user.creator.username === username);

  return (
    &lt;div style={{ [&quot;--accent&quot; as string]: accent } as React.CSSProperties} className=&quot;min-h-screen&quot;&gt;
      &lt;header className=&quot;sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur&quot;&gt;
        &lt;div className=&quot;mx-auto flex max-w-5xl items-center justify-between px-5 py-2.5&quot;&gt;
          &lt;Link href={`/${username}`} className=&quot;flex items-center gap-2 font-bold&quot;&gt;
            {creator?.avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              &lt;img src={creator.avatarUrl} alt=&quot;&quot; className=&quot;h-7 w-7 rounded-full object-cover&quot; /&gt;
            ) : (
              &lt;span className=&quot;grid h-7 w-7 place-items-center rounded-full bg-surface-2&quot;&gt;
                &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-6 w-6&quot; /&gt;
              &lt;/span&gt;
            )}
            &lt;span className=&quot;truncate&quot;&gt;{creator?.displayName ?? &quot;Cuppa&quot;}&lt;/span&gt;
          &lt;/Link&gt;

          &lt;div className=&quot;flex items-center gap-2&quot;&gt;
            &lt;ThemeToggle /&gt;
            {user ? (
              &lt;Link href={isOwner ? &quot;/dashboard&quot; : &quot;/dashboard&quot;} className=&quot;btn-pill btn-secondary text-sm&quot;&gt;
                {isOwner ? &quot;My dashboard&quot; : &quot;Dashboard&quot;}
              &lt;/Link&gt;
            ) : (
              &lt;a href={`/api/auth/login?returnTo=/${username}`} className=&quot;btn-pill btn-secondary text-sm&quot;&gt;
                Log in
              &lt;/a&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/header&gt;

      {children}

      &lt;footer className=&quot;mt-16 border-t border-line py-10 text-center text-sm text-muted&quot;&gt;
        &lt;p&gt;
          Creating something worth supporting?{&quot; &quot;}
          &lt;a href=&quot;/api/auth/login?returnTo=/dashboard&quot; className=&quot;font-semibold text-ink underline&quot;&gt;
            Start your own page
          &lt;/a&gt;
        &lt;/p&gt;
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The creator home page

The page's sidebar renders the support widget, which is the centerpiece of the next part. So that the page compiles now, create placeholder `components/creator/SupportWidget.tsx` that the next part replaces with the real one:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">SupportWidget.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export default function SupportWidget(_props: {
  creatorUsername: string;
  creatorDisplayName: string;
  accentColor: string;
  sandbox: boolean;
  hasMemberships: boolean;
}) {
  return null;
}</code></pre>
  </div>
</div>

The creator home page has the creator's active tiers, recent products, current goal, public supporter activity, published posts, and more. Create `app/[username]/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import { notFound } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getViewerContext, canViewPost } from &quot;@/lib/creator&quot;;
import { isSandbox } from &quot;@/lib/env&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import SupportWidget from &quot;@/components/creator/SupportWidget&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { Pin } from &quot;@/components/Icons&quot;;

function timeAgo(date: Date): string {
  const s = Math.floor((Date.now() - date.getTime()) / 1000);
  if (s &lt; 60) return &quot;just now&quot;;
  const m = Math.floor(s / 60);
  if (m &lt; 60) return `${m}m`;
  const h = Math.floor(m / 60);
  if (h &lt; 24) return `${h}h`;
  return `${Math.floor(h / 24)}d`;
}

export default async function CreatorPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    include: {
      tiers: { where: { isActive: true }, orderBy: { priceCents: &quot;asc&quot; } },
      products: { where: { isActive: true }, orderBy: { createdAt: &quot;desc&quot; }, take: 3 },
      goals: { where: { isActive: true }, orderBy: { createdAt: &quot;desc&quot; }, take: 1 },
    },
  });
  if (!creator || !creator.isActive) notFound();

  const [supports, posts, sumAgg, viewer] = await Promise.all([
    prisma.support.findMany({
      where: { creatorId: creator.id, status: &quot;COMPLETED&quot;, isPublic: true },
      orderBy: { createdAt: &quot;desc&quot; },
      take: 20,
      include: { supporter: { select: { username: true, avatarUrl: true } } },
    }),
    prisma.post.findMany({
      where: { creatorId: creator.id, published: true },
      orderBy: [{ pinned: &quot;desc&quot; }, { createdAt: &quot;desc&quot; }],
      take: 10,
      include: { minimumTier: { select: { id: true, name: true } } },
    }),
    prisma.support.aggregate({ where: { creatorId: creator.id, status: &quot;COMPLETED&quot; }, _sum: { amountCents: true } }),
    getViewerContext(creator.id, creator.userId),
  ]);

  const accent = accentHex(creator.accentColor);
  const goal = creator.goals[0];
  const raised = sumAgg._sum.amountCents ?? 0;
  const goalPct = goal ? Math.min(100, Math.round((raised / goal.targetCents) * 100)) : null;

  const pinned = posts.filter((p) =&gt; p.pinned);
  const feed = [
    ...posts.filter((p) =&gt; !p.pinned).map((p) =&gt; ({ kind: &quot;post&quot; as const, date: p.createdAt, post: p })),
    ...supports.map((s) =&gt; ({ kind: &quot;support&quot; as const, date: s.createdAt, support: s })),
  ].sort((a, b) =&gt; b.date.getTime() - a.date.getTime());

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;div className=&quot;grid gap-6 lg:grid-cols-[1fr_360px]&quot;&gt;
          &lt;div className=&quot;order-2 space-y-6 lg:order-1&quot;&gt;
            {goal ? (
              &lt;div className=&quot;kofi-card p-5&quot;&gt;
                &lt;div className=&quot;flex items-center justify-between&quot;&gt;
                  &lt;h2 className=&quot;font-bold&quot;&gt;{goal.title}&lt;/h2&gt;
                  &lt;span className=&quot;text-sm font-semibold text-muted&quot;&gt;{goalPct}%&lt;/span&gt;
                &lt;/div&gt;
                &lt;div className=&quot;mt-3 h-2.5 w-full overflow-hidden rounded-full bg-surface-2&quot;&gt;
                  &lt;div className=&quot;h-full rounded-full bg-positive&quot; style={{ width: `${goalPct}%` }} /&gt;
                &lt;/div&gt;
                &lt;p className=&quot;mt-2 text-sm text-muted&quot;&gt;
                  {formatUsd(raised)} of {formatUsd(goal.targetCents)}
                &lt;/p&gt;
                {goal.description ? &lt;p className=&quot;mt-3 text-sm&quot;&gt;{goal.description}&lt;/p&gt; : null}
              &lt;/div&gt;
            ) : null}

            {creator.bio ? (
              &lt;div className=&quot;kofi-card p-5&quot;&gt;
                &lt;h2 className=&quot;mb-2 font-bold&quot;&gt;About {creator.displayName}&lt;/h2&gt;
                &lt;p className=&quot;whitespace-pre-wrap text-sm leading-relaxed&quot;&gt;{creator.bio}&lt;/p&gt;
                {creator.tags.length ? (
                  &lt;div className=&quot;mt-4 flex flex-wrap gap-2&quot;&gt;
                    {creator.tags.map((t) =&gt; (
                      &lt;span key={t} className=&quot;rounded-full bg-surface-2 px-3 py-1 text-xs font-medium text-muted&quot;&gt;
                        {t}
                      &lt;/span&gt;
                    ))}
                  &lt;/div&gt;
                ) : null}
              &lt;/div&gt;
            ) : null}

            &lt;div className=&quot;kofi-card p-5&quot;&gt;
              &lt;h2 className=&quot;mb-4 font-bold&quot;&gt;Recent activity&lt;/h2&gt;
              {pinned.length === 0 &amp;&amp; feed.length === 0 ? (
                &lt;p className=&quot;text-sm text-muted&quot;&gt;No activity yet. Be the first to show support!&lt;/p&gt;
              ) : (
                &lt;div className=&quot;space-y-4&quot;&gt;
                  {pinned.map((post) =&gt; (
                    &lt;PostItem key={post.id} post={post} canView={canViewPost(post, viewer)} username={username} pinned /&gt;
                  ))}
                  {feed.map((item) =&gt;
                    item.kind === &quot;post&quot; ? (
                      &lt;PostItem key={`p-${item.post.id}`} post={item.post} canView={canViewPost(item.post, viewer)} username={username} /&gt;
                    ) : (
                      &lt;SupportItem key={`s-${item.support.id}`} support={item.support} /&gt;
                    ),
                  )}
                &lt;/div&gt;
              )}
            &lt;/div&gt;
          &lt;/div&gt;

          &lt;aside className=&quot;order-1 space-y-6 lg:order-2&quot;&gt;
            &lt;div id=&quot;support&quot;&gt;
              &lt;SupportWidget
                creatorUsername={username}
                creatorDisplayName={creator.displayName}
                accentColor={creator.accentColor}
                sandbox={isSandbox()}
                hasMemberships={creator.tiers.length &gt; 0}
              /&gt;
            &lt;/div&gt;

            {creator.tiers.length &gt; 0 ? (
              &lt;div className=&quot;kofi-card p-5&quot;&gt;
                &lt;h3 className=&quot;font-bold&quot;&gt;Become a regular&lt;/h3&gt;
                &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;Monthly support from {formatUsd(creator.tiers[0].priceCents)}/mo&lt;/p&gt;
                &lt;Link href={`/${username}/membership`} className=&quot;btn-pill btn-outline mt-3 w-full text-sm&quot;&gt;
                  See membership options
                &lt;/Link&gt;
              &lt;/div&gt;
            ) : null}

            {creator.products.length &gt; 0 ? (
              &lt;div className=&quot;kofi-card p-5&quot;&gt;
                &lt;div className=&quot;mb-3 flex items-center justify-between&quot;&gt;
                  &lt;h3 className=&quot;font-bold&quot;&gt;Shop&lt;/h3&gt;
                  &lt;Link href={`/${username}/shop`} className=&quot;text-sm font-semibold&quot; style={{ color: accent }}&gt;
                    Go to shop
                  &lt;/Link&gt;
                &lt;/div&gt;
                &lt;div className=&quot;space-y-3&quot;&gt;
                  {creator.products.map((p) =&gt; (
                    &lt;Link key={p.id} href={`/${username}/shop`} className=&quot;flex items-center gap-3&quot;&gt;
                      &lt;div className=&quot;h-12 w-12 overflow-hidden rounded-lg bg-surface-2&quot;&gt;
                        {p.imageUrl ? &lt;img src={p.imageUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt; : null}
                      &lt;/div&gt;
                      &lt;div className=&quot;min-w-0 flex-1&quot;&gt;
                        &lt;p className=&quot;truncate text-sm font-semibold&quot;&gt;{p.title}&lt;/p&gt;
                        &lt;p className=&quot;text-xs text-muted&quot;&gt;{p.priceCents === 0 ? &quot;Free&quot; : formatUsd(p.priceCents)}&lt;/p&gt;
                      &lt;/div&gt;
                    &lt;/Link&gt;
                  ))}
                &lt;/div&gt;
              &lt;/div&gt;
            ) : null}
          &lt;/aside&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}

type PostWithTier = {
  id: string;
  title: string;
  content: string;
  visibility: &quot;PUBLIC&quot; | &quot;SUPPORTERS&quot; | &quot;TIER&quot;;
  minimumTierId: string | null;
  createdAt: Date;
  minimumTier: { id: string; name: string } | null;
};

function PostItem({ post, canView, username, pinned }: { post: PostWithTier; canView: boolean; username: string; pinned?: boolean }) {
  return (
    &lt;div className=&quot;rounded-xl border border-line p-4&quot;&gt;
      &lt;div className=&quot;mb-1 flex items-center gap-2 text-xs text-muted&quot;&gt;
        {pinned ? (
          &lt;span className=&quot;inline-flex items-center gap-1 font-semibold&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
            &lt;Pin className=&quot;h-3.5 w-3.5&quot; /&gt; Pinned
          &lt;/span&gt;
        ) : null}
        &lt;span&gt;{timeAgo(post.createdAt)}&lt;/span&gt;
        {post.visibility !== &quot;PUBLIC&quot; ? (
          &lt;span className=&quot;inline-flex items-center gap-1&quot;&gt;
            · &lt;BrandIcon name=&quot;lock&quot; className=&quot;h-3.5 w-3.5&quot; /&gt;
            {post.minimumTier?.name ?? &quot;Supporters&quot;}
          &lt;/span&gt;
        ) : null}
      &lt;/div&gt;
      &lt;Link href={`/${username}/post/${post.id}`} className=&quot;font-semibold hover:underline&quot;&gt;
        {post.title}
      &lt;/Link&gt;
      {canView ? (
        &lt;p className=&quot;mt-1 line-clamp-3 text-sm text-muted&quot;&gt;{post.content}&lt;/p&gt;
      ) : (
        &lt;p className=&quot;mt-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted&quot;&gt;
          &lt;BrandIcon name=&quot;lock&quot; className=&quot;mr-1 h-4 w-4 align-text-bottom&quot; /&gt;This post is for supporters.{&quot; &quot;}
          &lt;a href=&quot;#support&quot; className=&quot;font-semibold&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
            Unlock it
          &lt;/a&gt;
        &lt;/p&gt;
      )}
    &lt;/div&gt;
  );
}

function SupportItem({
  support,
}: {
  support: { id: string; supporterName: string; message: string | null; coffees: number; createdAt: Date; supporter: { username: string; avatarUrl: string | null } | null };
}) {
  return (
    &lt;div className=&quot;flex gap-3&quot;&gt;
      &lt;div className=&quot;grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 text-sm&quot;&gt;
        {support.supporter?.avatarUrl ? (
          &lt;img src={support.supporter.avatarUrl} alt=&quot;&quot; className=&quot;h-full w-full rounded-full object-cover&quot; /&gt;
        ) : (
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-7 w-7&quot; /&gt;
        )}
      &lt;/div&gt;
      &lt;div className=&quot;min-w-0 flex-1&quot;&gt;
        &lt;p className=&quot;text-sm&quot;&gt;
          &lt;span className=&quot;font-semibold&quot;&gt;{support.supporterName}&lt;/span&gt;{&quot; &quot;}
          &lt;span className=&quot;text-muted&quot;&gt;
            bought {support.coffees} {support.coffees === 1 ? &quot;coffee&quot; : &quot;coffees&quot;} · {timeAgo(support.createdAt)}
          &lt;/span&gt;
        &lt;/p&gt;
        {support.message ? &lt;p className=&quot;mt-0.5 text-sm&quot;&gt;{support.message}&lt;/p&gt; : null}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

## Checkpoint

- `lib/creator.ts` caches the creator lookup with React `cache` and computes a `ViewerContext` (owner, supporter, tiers, following) plus a `canViewPost` gate.
- `components/ThemeToggle.tsx` cycles system, light, and dark, persisting to `localStorage` and toggling the `dark` class.
- `components/creator/CreatorTabs.tsx` renders an underline tab bar with the active tab derived from the pathname.
- `components/creator/FollowButton.tsx` toggles optimistically and reconciles with the server; unauthenticated clicks go to login.
- `app/api/follow/route.ts` toggles the `Follow` row, returning `{ following }`.
- `components/creator/CreatorProfileHeader.tsx` renders the cover, overlapping avatar, supporters count, and Follow/Tip/Edit actions, then the tabs.
- `app/[username]/layout.tsx` injects the per-creator accent as `--accent` and renders the sticky bar and footer.
- `app/[username]/page.tsx` loads the creator with tiers, products, and goal, then fetches supports, posts, total raised, and viewer context in parallel.
- The goal card shows a green progress bar against the total raised, and the feed interleaves pinned posts, posts, and supporter activity by date.
- Supporters-only posts render a locked placeholder unless `canViewPost` returns true for the viewer.

## Part 7: Tips and embedded checkout

In this part, we're going to start setting up the money flow. Supporters go to a creator's page, pick an amount, and we turn that into a real charge that lands on the creator's company. The entire flow looks like this:

- The supporter chooses an amount in the support widget.
- We save the tip in our database as pending, then ask Whop to set up the charge on the creator's account, with our fee included and a tag linking back to that pending tip.
- Whop's checkout opens right on the page, and the supporter pays without ever leaving our app.
- Once the payment clears, we mark the tip completed. In production a Whop webhook does this (we build it in later parts). On localhost, where Whop can't reach us, a small confirm step on the way back does the same job.

### The checkout configuration call

Back in Part 5 we wrote a helper, `createCheckoutConfiguration`, that sets up a charge on the creator's account and hands back the two ids the embedded checkout needs. Here it is again, in `services/whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export interface CheckoutResult {
  sessionId: string;
  planId: string;
  purchaseUrl: string;
}

export async function createCheckoutConfiguration(params: {
  connectedCompanyId: string;
  amountCents: number;
  applicationFeeCents: number;
  planType: &quot;one_time&quot; | &quot;renewal&quot;;
  title: string;
  redirectUrl: string;
  metadata: { kind: CheckoutKind } &amp; Record&lt;string, string&gt;;
}): Promise&lt;CheckoutResult&gt; {
  const amount = centsToDollars(params.amountCents);
  const fee = centsToDollars(params.applicationFeeCents);

  const cfg = await whopsdk.checkoutConfigurations.create({
    plan: {
      company_id: params.connectedCompanyId,
      currency: &quot;usd&quot;,
      plan_type: params.planType,
      application_fee_amount: fee,
      title: params.title,
      ...(params.planType === &quot;renewal&quot;
        ? { renewal_price: amount, billing_period: 30, initial_price: 0 }
        : { initial_price: amount }),
    },
    metadata: params.metadata,
    ...(params.redirectUrl.startsWith(&quot;https://&quot;) ? { redirect_url: params.redirectUrl } : {}),
  });

  const planId = cfg.plan?.id;
  if (!planId) throw new Error(&quot;Checkout configuration did not return a plan id&quot;);

  return { sessionId: cfg.id, planId, purchaseUrl: cfg.purchase_url };
}</code></pre>
  </div>
</div>

### The checkout API route

We have a single route that handles all three kinds of checkout: tips, memberships, and shop. We validate each one with its own Zod rules.

The tip branch creates the `PENDING` `Support` first and attaches a `ref` that the webhook and confirm endpoint use later to find it. A coffee is 500 cents, and tips are capped between `MIN_TIP_CENTS` and `MAX_TIP_CENTS`. Create `app/api/checkout/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import crypto from &quot;crypto&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { env } from &quot;@/lib/env&quot;;
import { applicationFeeCents } from &quot;@/lib/fees&quot;;
import { createCheckoutConfiguration } from &quot;@/services/whop&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;
import { COFFEE_UNIT_CENTS, MIN_TIP_CENTS, MAX_TIP_CENTS } from &quot;@/constants&quot;;

const schema = z.discriminatedUnion(&quot;kind&quot;, [
  z.object({
    kind: z.literal(&quot;tip&quot;),
    creatorUsername: z.string().min(1),
    amountCents: z.number().int().min(MIN_TIP_CENTS).max(MAX_TIP_CENTS),
    supporterName: z.string().max(60).optional(),
    message: z.string().max(500).optional(),
    isPublic: z.boolean().optional().default(true),
  }),
  z.object({
    kind: z.literal(&quot;membership&quot;),
    creatorUsername: z.string().min(1),
    tierId: z.string().min(1),
  }),
  z.object({
    kind: z.literal(&quot;shop&quot;),
    creatorUsername: z.string().min(1),
    productId: z.string().min(1),
  }),
]);

export async function POST(req: NextRequest) {
  if (!rateLimit(`checkout:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; }, { status: 400 });
  }
  const input = parsed.data;

  const creator = await prisma.creator.findUnique({
    where: { username: input.creatorUsername },
    select: {
      id: true,
      displayName: true,
      whopCompanyId: true,
      goals: { where: { isActive: true }, take: 1, select: { id: true } },
    },
  });
  if (!creator || !creator.whopCompanyId) {
    return NextResponse.json({ error: &quot;Creator not found or not ready for payments&quot; }, { status: 404 });
  }

  const user = await getCurrentUser();
  const appUrl = env.NEXT_PUBLIC_APP_URL;
  const returnUrl = `${appUrl}/${input.creatorUsername}?status=success`;

  let amountCents: number;
  let planType: &quot;one_time&quot; | &quot;renewal&quot;;
  let title: string;
  let metadata: Record&lt;string, string&gt;;

  if (input.kind === &quot;tip&quot;) {
    amountCents = input.amountCents;
    planType = &quot;one_time&quot;;
    title = `Tip for ${creator.displayName}`;
    const displayName = (input.supporterName?.trim() || user?.name || user?.username || &quot;Someone&quot;).slice(0, 60);
    const coffees = Math.max(1, Math.round(amountCents / COFFEE_UNIT_CENTS));
    const support = await prisma.support.create({
      data: {
        creatorId: creator.id,
        supporterUserId: user?.id ?? null,
        supporterName: displayName,
        message: input.message?.trim() || null,
        amountCents,
        coffees,
        isPublic: input.isPublic,
        status: &quot;PENDING&quot;,
        goalId: creator.goals[0]?.id ?? null,
      },
    });
    metadata = { kind: &quot;tip&quot;, ref: support.id, supportId: support.id, creatorId: creator.id };
  } else if (input.kind === &quot;shop&quot;) {
    const product = await prisma.product.findFirst({
      where: { id: input.productId, creatorId: creator.id, isActive: true },
    });
    if (!product) return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
    amountCents = product.priceCents;
    planType = &quot;one_time&quot;;
    title = product.title;
    const buyerName = (user?.name || user?.username || &quot;Someone&quot;).slice(0, 60);
    const order = await prisma.order.create({
      data: {
        creatorId: creator.id,
        productId: product.id,
        buyerUserId: user?.id ?? null,
        buyerName,
        amountCents,
        status: &quot;PENDING&quot;,
      },
    });
    if (amountCents &lt;= 0) {
      await prisma.order.update({ where: { id: order.id }, data: { status: &quot;COMPLETED&quot; } });
      await prisma.product.update({ where: { id: product.id }, data: { salesCount: { increment: 1 } } });
      return NextResponse.json({ free: true, downloadUrl: product.downloadUrl ?? null });
    }
    metadata = { kind: &quot;shop&quot;, ref: order.id, orderId: order.id, creatorId: creator.id };
  } else {
    if (!user) return NextResponse.json({ error: &quot;login_required&quot; }, { status: 401 });
    const tier = await prisma.tier.findFirst({
      where: { id: input.tierId, creatorId: creator.id, isActive: true },
    });
    if (!tier) return NextResponse.json({ error: &quot;Tier not found&quot; }, { status: 404 });
    amountCents = tier.priceCents;
    planType = &quot;renewal&quot;;
    title = `${tier.name} — ${creator.displayName}`;
    metadata = {
      kind: &quot;membership&quot;,
      ref: crypto.randomUUID(),
      creatorId: creator.id,
      tierId: tier.id,
      userId: user.id,
    };
  }

  try {
    const checkout = await createCheckoutConfiguration({
      connectedCompanyId: creator.whopCompanyId,
      amountCents,
      applicationFeeCents: applicationFeeCents(amountCents),
      planType,
      title,
      redirectUrl: returnUrl,
      metadata: metadata as { kind: &quot;tip&quot; | &quot;membership&quot; | &quot;shop&quot; } &amp; Record&lt;string, string&gt;,
    });
    return NextResponse.json({ ...checkout, ref: metadata.ref });
  } catch (err: unknown) {
    console.error(&quot;Checkout creation failed:&quot;, err);
    return NextResponse.json({ error: &quot;Could not start checkout&quot; }, { status: 502 });
  }
}</code></pre>
  </div>
</div>

### The support widget

Now, let's build the signature component, the box where supporters pick an amount and send their tip. Replace the placeholder `components/creator/SupportWidget.tsx` with the real thing:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">SupportWidget.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useRef, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;
import type { AccentColor } from &quot;@whop/checkout/react&quot;;
import { Button, TextField, TextArea } from &quot;@whop/react/components&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { ChevronLeft } from &quot;@/components/Icons&quot;;

const COFFEE_UNIT_CENTS = 500;
const PRESETS = [1, 3, 5];

type Step = &quot;form&quot; | &quot;checkout&quot; | &quot;done&quot;;

export default function SupportWidget({
  creatorUsername,
  creatorDisplayName,
  accentColor,
  sandbox,
  hasMemberships,
}: {
  creatorUsername: string;
  creatorDisplayName: string;
  accentColor: string;
  sandbox: boolean;
  hasMemberships: boolean;
}) {
  const router = useRouter();
  const [mode, setMode] = useState&lt;&quot;once&quot; | &quot;membership&quot;&gt;(&quot;once&quot;);
  const [coffees, setCoffees] = useState(1);
  const [custom, setCustom] = useState(&quot;&quot;);
  const [name, setName] = useState(&quot;&quot;);
  const [message, setMessage] = useState(&quot;&quot;);
  const [step, setStep] = useState&lt;Step&gt;(&quot;form&quot;);
  const [checkout, setCheckout] = useState&lt;{ sessionId: string; planId: string; ref: string } | null&gt;(null);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [loading, setLoading] = useState(false);
  const [theme, setTheme] = useState&lt;&quot;light&quot; | &quot;dark&quot;&gt;(&quot;light&quot;);
  const confirmTriedRef = useRef(false);

  useEffect(() =&gt; {
    setTheme(document.documentElement.classList.contains(&quot;dark&quot;) ? &quot;dark&quot; : &quot;light&quot;);
  }, []);

  const customCents = custom ? Math.round(parseFloat(custom) * 100) : 0;
  const amountCents = customCents &gt; 0 ? customCents : coffees * COFFEE_UNIT_CENTS;
  const amountLabel = `$${(amountCents / 100).toFixed(amountCents % 100 === 0 ? 0 : 2)}`;

  async function startCheckout() {
    setError(null);
    if (amountCents &lt; 100) {
      setError(&quot;Please choose an amount of at least $1.&quot;);
      return;
    }
    setLoading(true);
    try {
      const res = await fetch(&quot;/api/checkout&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          creatorUsername,
          kind: &quot;tip&quot;,
          amountCents,
          supporterName: name || undefined,
          message: message || undefined,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not start checkout&quot;);
        setLoading(false);
        return;
      }
      setCheckout({ sessionId: data.sessionId, planId: data.planId, ref: data.ref });
      setStep(&quot;checkout&quot;);
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
    } finally {
      setLoading(false);
    }
  }

  async function onComplete() {
    if (!checkout || confirmTriedRef.current) return;
    confirmTriedRef.current = true;
    setStep(&quot;done&quot;);
    for (let i = 0; i &lt; 5; i++) {
      try {
        const res = await fetch(&quot;/api/checkout/confirm&quot;, {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify({ ref: checkout.ref, creatorUsername }),
        });
        const data = await res.json();
        if (data.ok) break;
      } catch {
      }
      await new Promise((r) =&gt; setTimeout(r, 1500));
    }
    router.refresh();
  }

  if (step === &quot;done&quot;) {
    return (
      &lt;div className=&quot;kofi-card p-6 text-center&quot;&gt;
        &lt;div className=&quot;mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-positive/15&quot;&gt;
          &lt;BrandIcon name=&quot;confetti&quot; className=&quot;h-8 w-8&quot; /&gt;
        &lt;/div&gt;
        &lt;h3 className=&quot;text-lg font-bold&quot;&gt;Thank you!&lt;/h3&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Your support means a lot to {creatorDisplayName}.
        &lt;/p&gt;
        &lt;Button
          size=&quot;2&quot;
          variant=&quot;soft&quot;
          color=&quot;gray&quot;
          className=&quot;mt-4&quot;
          onClick={() =&gt; {
            setStep(&quot;form&quot;);
            setCheckout(null);
            confirmTriedRef.current = false;
            setCustom(&quot;&quot;);
            setMessage(&quot;&quot;);
          }}
        &gt;
          Send another
        &lt;/Button&gt;
      &lt;/div&gt;
    );
  }

  if (step === &quot;checkout&quot; &amp;&amp; checkout) {
    return (
      &lt;div className=&quot;kofi-card overflow-hidden p-4&quot;&gt;
        &lt;button className=&quot;mb-2 inline-flex items-center gap-1 text-sm font-semibold text-muted&quot; onClick={() =&gt; setStep(&quot;form&quot;)}&gt;
          &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Back
        &lt;/button&gt;
        &lt;WhopCheckoutEmbed
          sessionId={checkout.sessionId}
          planId={checkout.planId}
          theme={theme}
          themeOptions={{ accentColor: accentColor as AccentColor }}
          environment={sandbox ? &quot;sandbox&quot; : &quot;production&quot;}
          returnUrl={`${typeof window !== &quot;undefined&quot; ? window.location.origin : &quot;&quot;}/${creatorUsername}?status=success`}
          onComplete={onComplete}
          fallback={&lt;div className=&quot;py-10 text-center text-sm text-muted&quot;&gt;Loading secure checkout…&lt;/div&gt;}
        /&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className=&quot;kofi-card p-5&quot;&gt;
      &lt;h2 className=&quot;text-lg font-bold&quot;&gt;Show {creatorDisplayName} some love&lt;/h2&gt;

      &lt;div className=&quot;mt-3 grid grid-cols-2 gap-1 rounded-full bg-surface-2 p-1 text-sm font-semibold&quot;&gt;
        &lt;button
          onClick={() =&gt; setMode(&quot;once&quot;)}
          className={`rounded-full py-2 transition ${mode === &quot;once&quot; ? &quot;bg-surface shadow-sm&quot; : &quot;text-muted&quot;}`}
        &gt;
          One time
        &lt;/button&gt;
        &lt;button
          onClick={() =&gt; setMode(&quot;membership&quot;)}
          className={`rounded-full py-2 transition ${mode === &quot;membership&quot; ? &quot;bg-surface shadow-sm&quot; : &quot;text-muted&quot;}`}
        &gt;
          Membership
        &lt;/button&gt;
      &lt;/div&gt;

      {mode === &quot;membership&quot; ? (
        &lt;div className=&quot;mt-5 text-center&quot;&gt;
          &lt;p className=&quot;text-sm text-muted&quot;&gt;
            {hasMemberships
              ? &quot;Join a monthly membership for exclusive perks.&quot;
              : `${creatorDisplayName} hasn&#039;t set up memberships yet.`}
          &lt;/p&gt;
          {hasMemberships ? (
            &lt;Link href={`/${creatorUsername}/membership`} className=&quot;btn-pill btn-accent mt-4 w-full&quot;&gt;
              See membership options
            &lt;/Link&gt;
          ) : null}
        &lt;/div&gt;
      ) : (
        &lt;&gt;
          &lt;p className=&quot;mt-4 text-sm font-semibold&quot;&gt;Choose amount&lt;/p&gt;
          &lt;div className=&quot;mt-2 grid grid-cols-3 gap-2&quot;&gt;
            {PRESETS.map((n) =&gt; {
              const active = customCents === 0 &amp;&amp; coffees === n;
              return (
                &lt;button
                  key={n}
                  onClick={() =&gt; {
                    setCoffees(n);
                    setCustom(&quot;&quot;);
                  }}
                  className=&quot;btn-pill inline-flex items-center justify-center gap-1.5 border text-sm&quot;
                  style={
                    active
                      ? { background: &quot;var(--accent)&quot;, color: &quot;#fff&quot;, borderColor: &quot;var(--accent)&quot; }
                      : { borderColor: &quot;var(--line)&quot; }
                  }
                &gt;
                  &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-5 w-5&quot; /&gt;
                  &lt;span&gt;${(n * COFFEE_UNIT_CENTS) / 100}&lt;/span&gt;
                &lt;/button&gt;
              );
            })}
          &lt;/div&gt;

          &lt;div className=&quot;mt-3 flex items-center gap-2&quot;&gt;
            &lt;span className=&quot;text-sm text-muted&quot;&gt;or enter an amount&lt;/span&gt;
            &lt;div className=&quot;flex flex-1 items-center rounded-full border border-line px-3 py-1.5&quot;&gt;
              &lt;span className=&quot;text-muted&quot;&gt;$&lt;/span&gt;
              &lt;input
                type=&quot;number&quot;
                min={1}
                value={custom}
                onChange={(e) =&gt; setCustom(e.target.value)}
                placeholder=&quot;10&quot;
                className=&quot;w-full bg-transparent pl-1 outline-none&quot;
              /&gt;
            &lt;/div&gt;
          &lt;/div&gt;

          &lt;TextField.Root size=&quot;3&quot; className=&quot;mt-3&quot;&gt;
            &lt;TextField.Input
              value={name}
              onChange={(e) =&gt; setName(e.target.value)}
              placeholder=&quot;Your name (optional)&quot;
            /&gt;
          &lt;/TextField.Root&gt;
          &lt;TextArea
            className=&quot;mt-2&quot;
            size=&quot;3&quot;
            value={message}
            onChange={(e) =&gt; setMessage(e.target.value)}
            rows={2}
            placeholder=&quot;Say something nice (optional)&quot;
          /&gt;

          {error ? &lt;p className=&quot;mt-2 text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}

          &lt;Button onClick={startCheckout} disabled={loading} size=&quot;3&quot; variant=&quot;solid&quot; className=&quot;mt-3 w-full&quot;&gt;
            {loading ? &quot;Starting…&quot; : `Support ${amountLabel}`}
          &lt;/Button&gt;
          &lt;p className=&quot;mt-2 text-center text-xs text-muted&quot;&gt;
            Every payment goes straight to {creatorDisplayName}.
          &lt;/p&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The confirm endpoint (local fallback)

In the production environment, the `payment.succeeded` webhook completes the support, but webhooks can't reach localhost so if we don't build a fallback, every tip will sit `PENDING` in development.

The confirm endpoint closes that gap by finding the creator's recent payment with our `ref` and runs the same fulfillment as the webhook. Create `app/api/checkout/confirm/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { whopsdk } from &quot;@/lib/whop&quot;;
import { fulfillFromMetadata } from &quot;@/lib/fulfillment&quot;;

const schema = z.object({ ref: z.string().min(1), creatorUsername: z.string().min(1) });

export async function POST(req: NextRequest) {
  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) return NextResponse.json({ error: &quot;Invalid input&quot; }, { status: 400 });

  const creator = await prisma.creator.findUnique({
    where: { username: parsed.data.creatorUsername },
    select: { whopCompanyId: true },
  });
  if (!creator?.whopCompanyId) return NextResponse.json({ ok: false }, { status: 400 });

  let matched: { id: string; metadata?: Record&lt;string, unknown&gt; | null } | null = null;
  try {
    let scanned = 0;
    for await (const payment of whopsdk.payments.list({ company_id: creator.whopCompanyId, direction: &quot;desc&quot; })) {
      const p = payment as unknown as {
        id: string;
        status?: string;
        substatus?: string;
        metadata?: Record&lt;string, unknown&gt; | null;
      };
      const settled = p.status === &quot;paid&quot; || p.substatus === &quot;succeeded&quot;;
      if (p.metadata?.ref === parsed.data.ref &amp;&amp; settled) {
        matched = { id: p.id, metadata: p.metadata };
        break;
      }
      if (++scanned &gt;= 40) break;
    }
  } catch (err: unknown) {
    console.error(&quot;payments.list failed during confirm:&quot;, err);
  }

  if (!matched) return NextResponse.json({ ok: false, pending: true });

  await fulfillFromMetadata(matched.metadata, matched.id);
  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

### Fulfillment

When a payment goes through, we have to record it in our database and notify the creator about it. There are two things that can tell us it succeeded: the confirm step step we just built for local development, and the Whop webhook. So instead of writing it twice, let's put it in one file so they both call `lib/fulfillment.ts`.

> 

For a tip, it marks the tip as paid and notifies the creator, and it is safe to run twice in case both triggers fire for the same payment. The membership and shop helpers live here too; we revisit memberships in the next part. Create `lib/fulfillment.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfillment.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { prisma } from &quot;./prisma&quot;;
import { formatUsd } from &quot;./fees&quot;;
import { notifyCreator } from &quot;@/services/whop&quot;;

type CreatorLike = { whopCompanyId: string | null };

async function notify(creator: CreatorLike, n: { title: string; subtitle?: string; content: string; iconUserId?: string }) {
  if (!creator.whopCompanyId) return;
  await notifyCreator({ companyId: creator.whopCompanyId, restPath: &quot;/dashboard&quot;, ...n });
}

export async function markSupportCompleted(supportId: string, whopPaymentId: string) {
  const support = await prisma.support.findUnique({ where: { id: supportId }, include: { creator: true } });
  if (!support || support.status === &quot;COMPLETED&quot;) return support;
  const updated = await prisma.support.update({
    where: { id: supportId },
    data: { status: &quot;COMPLETED&quot;, whopPaymentId },
  });
  const word = support.coffees === 1 ? &quot;coffee&quot; : &quot;coffees&quot;;
  await notify(support.creator, {
    title: &quot;New supporter&quot;,
    subtitle: `${support.supporterName} bought you ${support.coffees} ${word}`,
    content: support.message?.trim()
      ? `&quot;${support.message.trim()}&quot; — ${formatUsd(support.amountCents)}`
      : `You received ${formatUsd(support.amountCents)}!`,
  });
  return updated;
}

export async function markSupportRefunded(whopPaymentId: string) {
  const support = await prisma.support.findUnique({ where: { whopPaymentId } });
  if (support &amp;&amp; support.status !== &quot;REFUNDED&quot;) {
    await prisma.support.update({ where: { id: support.id }, data: { status: &quot;REFUNDED&quot; } });
  }
}

export async function completeOrder(orderId: string, whopPaymentId: string) {
  const order = await prisma.order.findUnique({ where: { id: orderId }, include: { creator: true, product: true } });
  if (!order || order.status === &quot;COMPLETED&quot;) return;
  await prisma.$transaction([
    prisma.order.update({ where: { id: orderId }, data: { status: &quot;COMPLETED&quot;, whopPaymentId } }),
    prisma.product.update({ where: { id: order.productId }, data: { salesCount: { increment: 1 } } }),
  ]);
  await notify(order.creator, {
    title: &quot;New sale&quot;,
    subtitle: order.product.title,
    content: `${order.buyerName} bought ${order.product.title} for ${formatUsd(order.amountCents)}`,
  });
}

export async function activateMembership(params: {
  creatorId: string;
  userId: string;
  tierId: string;
  whopMembershipId?: string;
}) {
  const tier = await prisma.tier.findUnique({ where: { id: params.tierId }, include: { creator: true } });
  if (!tier) return;
  const existing = await prisma.membership.findUnique({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
  });
  await prisma.membership.upsert({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
    update: { status: &quot;ACTIVE&quot;, whopMembershipId: params.whopMembershipId ?? undefined },
    create: {
      creatorId: params.creatorId,
      userId: params.userId,
      tierId: params.tierId,
      status: &quot;ACTIVE&quot;,
      whopMembershipId: params.whopMembershipId ?? null,
    },
  });
  if (!existing) {
    await notify(tier.creator, {
      title: &quot;New member&quot;,
      subtitle: tier.name,
      content: `Someone just joined your &quot;${tier.name}&quot; tier (${formatUsd(tier.priceCents)}/mo)`,
    });
  }
}

export async function deactivateMembership(whopMembershipId: string) {
  const m = await prisma.membership.findUnique({ where: { whopMembershipId } });
  if (m &amp;&amp; m.status !== &quot;CANCELED&quot; &amp;&amp; m.status !== &quot;EXPIRED&quot;) {
    await prisma.membership.update({ where: { id: m.id }, data: { status: &quot;CANCELED&quot; } });
  }
}

export async function fulfillFromMetadata(
  meta: Record&lt;string, unknown&gt; | null | undefined,
  paymentId: string,
) {
  if (!meta) return;
  const kind = meta.kind;
  if (kind === &quot;tip&quot; &amp;&amp; typeof meta.supportId === &quot;string&quot;) {
    return markSupportCompleted(meta.supportId, paymentId);
  }
  if (kind === &quot;shop&quot; &amp;&amp; typeof meta.orderId === &quot;string&quot;) {
    return completeOrder(meta.orderId, paymentId);
  }
  if (
    kind === &quot;membership&quot; &amp;&amp;
    typeof meta.creatorId === &quot;string&quot; &amp;&amp;
    typeof meta.userId === &quot;string&quot; &amp;&amp;
    typeof meta.tierId === &quot;string&quot;
  ) {
    return activateMembership({ creatorId: meta.creatorId, userId: meta.userId, tierId: meta.tierId });
  }
}</code></pre>
  </div>
</div>

With this in place, a tip opens an inline checkout, the payment lands on the creator's connected company with our fee deducted, and the support flips to `COMPLETED` and appears in the feed. The webhook does this in production, the confirm endpoint does it locally.

## Checkpoint

- Sign in, then open a creator page at `/{username}`. The support widget shows coffee presets, a custom amount, and optional name and message fields.
- Pick an amount and click **Support**. The Whop checkout opens inline on the page, not as a redirect.
- Pay with the test card `4242 4242 4242 4242` (any future expiry date, any CVC).
- The page refreshes and your tip appears in the creator's recent activity.
- Run `npm run db:studio` and confirm the `Support` row flipped from `PENDING` to `COMPLETED` and now has a `whopPaymentId`.
- In the creator's connected company in your Whop **sandbox dashboard**, confirm the payment landed with your application fee deducted.

## Part 8: Memberships

In this part, we're going to build the recurring side of our project. Creators can publish monthly tiers with benefit lists, and supporters subscribe to unlock supporter-only content. Since we built the checkout in the previous part, a tier is just a database row.

When a supporter subscribes to a creator, we send the price to Whop as a recurring charge and Whop sets up the subscription. When a payment is made, we save a membership record that grants access using the same webhook steps from the previous part.

So, let's build the join modal, the public membership page, the creator's tier-management dashboard, and the `activateMembership` step that ties it together.

### A reusable checkout modal

First, let's create the component that puts the checkout in a modal, so any buy button on the membership and shop pages can open it. Create `components/checkout/CheckoutModal.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CheckoutModal.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useRef, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;
import type { AccentColor } from &quot;@whop/checkout/react&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { X, Check } from &quot;@/components/Icons&quot;;

type Step = &quot;loading&quot; | &quot;checkout&quot; | &quot;free&quot; | &quot;done&quot; | &quot;error&quot;;

export default function CheckoutModal({
  open,
  onClose,
  body,
  creatorUsername,
  creatorDisplayName,
  accentColor,
  sandbox,
}: {
  open: boolean;
  onClose: () =&gt; void;
  body: Record&lt;string, unknown&gt;;
  creatorUsername: string;
  creatorDisplayName: string;
  accentColor: string;
  sandbox: boolean;
}) {
  const router = useRouter();
  const [step, setStep] = useState&lt;Step&gt;(&quot;loading&quot;);
  const [checkout, setCheckout] = useState&lt;{ sessionId: string; planId: string; ref: string } | null&gt;(null);
  const [downloadUrl, setDownloadUrl] = useState&lt;string | null&gt;(null);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [theme, setTheme] = useState&lt;&quot;light&quot; | &quot;dark&quot;&gt;(&quot;light&quot;);
  const startedRef = useRef(false);
  const confirmTriedRef = useRef(false);

  useEffect(() =&gt; {
    setTheme(document.documentElement.classList.contains(&quot;dark&quot;) ? &quot;dark&quot; : &quot;light&quot;);
  }, []);

  useEffect(() =&gt; {
    if (!open) {
      startedRef.current = false;
      confirmTriedRef.current = false;
      setStep(&quot;loading&quot;);
      setCheckout(null);
      setDownloadUrl(null);
      setError(null);
      return;
    }
    if (startedRef.current) return;
    startedRef.current = true;

    (async () =&gt; {
      try {
        const res = await fetch(&quot;/api/checkout&quot;, {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify(body),
        });
        const data = await res.json();
        if (!res.ok) {
          setError(data.error ?? &quot;Could not start checkout&quot;);
          setStep(&quot;error&quot;);
          return;
        }
        if (data.free) {
          setDownloadUrl(data.downloadUrl ?? null);
          setStep(&quot;free&quot;);
          return;
        }
        setCheckout({ sessionId: data.sessionId, planId: data.planId, ref: data.ref });
        setStep(&quot;checkout&quot;);
      } catch {
        setError(&quot;Network error. Please try again.&quot;);
        setStep(&quot;error&quot;);
      }
    })();
  }, [open, body]);

  async function onComplete() {
    if (!checkout || confirmTriedRef.current) return;
    confirmTriedRef.current = true;
    setStep(&quot;done&quot;);
    for (let i = 0; i &lt; 5; i++) {
      try {
        const res = await fetch(&quot;/api/checkout/confirm&quot;, {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify({ ref: checkout.ref, creatorUsername }),
        });
        const data = await res.json();
        if (data.ok) break;
      } catch {
      }
      await new Promise((r) =&gt; setTimeout(r, 1500));
    }
    router.refresh();
  }

  if (!open) return null;

  return (
    &lt;div className=&quot;fixed inset-0 z-50 grid place-items-center bg-black/50 p-4&quot;&gt;
      &lt;div className=&quot;kofi-card w-full max-w-md p-4&quot;&gt;
        &lt;div className=&quot;mb-2 flex items-center justify-between&quot;&gt;
          &lt;h3 className=&quot;text-base font-bold&quot;&gt;
            {step === &quot;done&quot; ? &quot;Thank you!&quot; : step === &quot;free&quot; ? &quot;You&#039;re all set&quot; : `Support ${creatorDisplayName}`}
          &lt;/h3&gt;
          &lt;button
            onClick={onClose}
            aria-label=&quot;Close&quot;
            className=&quot;grid h-8 w-8 place-items-center rounded-full text-muted hover:bg-surface-2&quot;
          &gt;
            &lt;X className=&quot;h-4 w-4&quot; /&gt;
          &lt;/button&gt;
        &lt;/div&gt;

        {step === &quot;loading&quot; ? (
          &lt;div className=&quot;py-10 text-center text-sm text-muted&quot;&gt;Starting secure checkout…&lt;/div&gt;
        ) : null}

        {step === &quot;error&quot; ? (
          &lt;div className=&quot;py-8 text-center&quot;&gt;
            &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt;
            &lt;Button size=&quot;2&quot; variant=&quot;soft&quot; color=&quot;gray&quot; className=&quot;mt-4&quot; onClick={onClose}&gt;
              Close
            &lt;/Button&gt;
          &lt;/div&gt;
        ) : null}

        {step === &quot;free&quot; ? (
          &lt;div className=&quot;py-6 text-center&quot;&gt;
            &lt;div className=&quot;mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-positive/15 text-positive&quot;&gt;
              &lt;Check className=&quot;h-7 w-7&quot; /&gt;
            &lt;/div&gt;
            &lt;p className=&quot;text-sm font-semibold&quot;&gt;You&amp;apos;re all set — download ready&lt;/p&gt;
            {downloadUrl ? (
              &lt;a
                href={downloadUrl}
                target=&quot;_blank&quot;
                rel=&quot;noopener noreferrer&quot;
                className=&quot;btn-pill btn-accent mt-4 w-full&quot;
              &gt;
                Download
              &lt;/a&gt;
            ) : (
              &lt;p className=&quot;mt-2 text-sm text-muted&quot;&gt;Check your account for delivery details.&lt;/p&gt;
            )}
            &lt;Button size=&quot;3&quot; variant=&quot;soft&quot; color=&quot;gray&quot; className=&quot;mt-3 w-full&quot; onClick={onClose}&gt;
              Close
            &lt;/Button&gt;
          &lt;/div&gt;
        ) : null}

        {step === &quot;done&quot; ? (
          &lt;div className=&quot;py-6 text-center&quot;&gt;
            &lt;div className=&quot;mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-positive/15&quot;&gt;
              &lt;BrandIcon name=&quot;confetti&quot; className=&quot;h-8 w-8&quot; /&gt;
            &lt;/div&gt;
            &lt;p className=&quot;text-sm text-muted&quot;&gt;Your support means a lot to {creatorDisplayName}.&lt;/p&gt;
            &lt;Button size=&quot;3&quot; variant=&quot;solid&quot; className=&quot;mt-4 w-full&quot; onClick={onClose}&gt;
              Done
            &lt;/Button&gt;
          &lt;/div&gt;
        ) : null}

        {step === &quot;checkout&quot; &amp;&amp; checkout ? (
          &lt;div className=&quot;overflow-hidden&quot;&gt;
            &lt;WhopCheckoutEmbed
              sessionId={checkout.sessionId}
              planId={checkout.planId}
              theme={theme === &quot;dark&quot; ? &quot;dark&quot; : &quot;light&quot;}
              themeOptions={{ accentColor: accentColor as AccentColor }}
              environment={sandbox ? &quot;sandbox&quot; : &quot;production&quot;}
              onComplete={onComplete}
              fallback={&lt;div className=&quot;py-10 text-center text-sm text-muted&quot;&gt;Loading secure checkout…&lt;/div&gt;}
            /&gt;
          &lt;/div&gt;
        ) : null}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The tier list

The public tier cards have a join button and clicking the Join button opens a modal unless the user is signed out. If that's the case, we send them through login first. Create `components/creator/MembershipTiers.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">MembershipTiers.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useMemo, useState } from &quot;react&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import CheckoutModal from &quot;@/components/checkout/CheckoutModal&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import { Check } from &quot;@/components/Icons&quot;;

type Tier = {
  id: string;
  name: string;
  description: string | null;
  priceCents: number;
  benefits: string[];
  memberCount?: number;
};

export default function MembershipTiers({
  tiers,
  creatorUsername,
  creatorDisplayName,
  accentColor,
  sandbox,
  isLoggedIn,
}: {
  tiers: Tier[];
  creatorUsername: string;
  creatorDisplayName: string;
  accentColor: string;
  sandbox: boolean;
  isLoggedIn: boolean;
}) {
  const [activeTierId, setActiveTierId] = useState&lt;string | null&gt;(null);

  const body = useMemo(
    () =&gt; ({ kind: &quot;membership&quot;, creatorUsername, tierId: activeTierId }),
    [creatorUsername, activeTierId],
  );

  function join(tierId: string) {
    if (!isLoggedIn) {
      window.location.href = `/api/auth/login?returnTo=/${creatorUsername}/membership`;
      return;
    }
    setActiveTierId(tierId);
  }

  return (
    &lt;&gt;
      &lt;div className=&quot;space-y-4&quot;&gt;
        {tiers.map((tier) =&gt; (
          &lt;div key={tier.id} className=&quot;kofi-card p-5&quot;&gt;
            &lt;div className=&quot;flex items-start justify-between gap-4&quot;&gt;
              &lt;div className=&quot;min-w-0&quot;&gt;
                &lt;h3 className=&quot;text-lg font-bold&quot;&gt;{tier.name}&lt;/h3&gt;
                &lt;p className=&quot;mt-0.5 text-sm font-semibold&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
                  {formatUsd(tier.priceCents)}/mo
                &lt;/p&gt;
                {typeof tier.memberCount === &quot;number&quot; ? (
                  &lt;p className=&quot;mt-0.5 text-xs text-muted&quot;&gt;
                    {tier.memberCount} {tier.memberCount === 1 ? &quot;member&quot; : &quot;members&quot;}
                  &lt;/p&gt;
                ) : null}
              &lt;/div&gt;
              &lt;Button onClick={() =&gt; join(tier.id)} size=&quot;2&quot; variant=&quot;solid&quot; className=&quot;shrink-0&quot;&gt;
                Join
              &lt;/Button&gt;
            &lt;/div&gt;

            {tier.description ? (
              &lt;p className=&quot;mt-3 whitespace-pre-wrap text-sm text-muted&quot;&gt;{tier.description}&lt;/p&gt;
            ) : null}

            {tier.benefits.length &gt; 0 ? (
              &lt;ul className=&quot;mt-4 space-y-2&quot;&gt;
                {tier.benefits.map((benefit, i) =&gt; (
                  &lt;li key={i} className=&quot;flex items-start gap-2 text-sm&quot;&gt;
                    &lt;span className=&quot;mt-0.5 shrink-0&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
                      &lt;Check className=&quot;h-4 w-4&quot; /&gt;
                    &lt;/span&gt;
                    &lt;span&gt;{benefit}&lt;/span&gt;
                  &lt;/li&gt;
                ))}
              &lt;/ul&gt;
            ) : null}
          &lt;/div&gt;
        ))}
      &lt;/div&gt;

      &lt;CheckoutModal
        open={activeTierId !== null}
        onClose={() =&gt; setActiveTierId(null)}
        body={body}
        creatorUsername={creatorUsername}
        creatorDisplayName={creatorDisplayName}
        accentColor={accentColor}
        sandbox={sandbox}
      /&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The membership page

The membership page route loads the creator's active tiers and counts the members of each, so that every card on the profile can show a total. Create `app/[username]/membership/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;
import { isSandbox } from &quot;@/lib/env&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;
import MembershipTiers from &quot;@/components/creator/MembershipTiers&quot;;

export default async function MembershipPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: {
      id: true,
      displayName: true,
      accentColor: true,
      isActive: true,
      tiers: {
        where: { isActive: true },
        orderBy: { priceCents: &quot;asc&quot; },
        select: { id: true, name: true, description: true, priceCents: true, benefits: true },
      },
    },
  });
  if (!creator || !creator.isActive) notFound();

  const [user, memberCounts] = await Promise.all([
    getCurrentUser(),
    Promise.all(
      creator.tiers.map((tier) =&gt;
        prisma.membership.count({
          where: { tierId: tier.id, status: { in: [&quot;ACTIVE&quot;, &quot;CANCELING&quot;] } },
        }),
      ),
    ),
  ]);

  const tiers = creator.tiers.map((tier, i) =&gt; ({ ...tier, memberCount: memberCounts[i] }));

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;h1 className=&quot;text-xl font-bold&quot;&gt;Become a regular&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Pick a monthly membership to back {creator.displayName} and get member-only perks.
        &lt;/p&gt;

        &lt;div className=&quot;mt-5&quot;&gt;
          {tiers.length === 0 ? (
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;{creator.displayName} hasn&amp;apos;t set up any membership tiers yet.&lt;/p&gt;
            &lt;/div&gt;
          ) : (
            &lt;MembershipTiers
              tiers={tiers}
              creatorUsername={username}
              creatorDisplayName={creator.displayName}
              accentColor={creator.accentColor}
              sandbox={isSandbox()}
              isLoggedIn={Boolean(user)}
            /&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### Creating tiers

Now, let's build the route that allows creators to add a new tier. Create `app/api/tiers/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const schema = z.object({
  name: z.string().min(1).max(60),
  priceCents: z.number().int().min(1).max(1_000_000),
  description: z.string().max(500).optional(),
  benefits: z.array(z.string().min(1).max(120)).max(20).default([]),
});

export async function POST(req: NextRequest) {
  if (!rateLimit(`tiers:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const { name, priceCents, description, benefits } = parsed.data;

  const count = await prisma.tier.count({ where: { creatorId: creator.id } });

  const tier = await prisma.tier.create({
    data: {
      creatorId: creator.id,
      name,
      priceCents,
      description: description?.trim() || null,
      benefits: benefits.map((b) =&gt; b.trim()).filter(Boolean),
      order: count,
    },
  });

  return NextResponse.json({ ok: true, id: tier.id });
}</code></pre>
  </div>
</div>

### Deleting a tier

And now create the route that handles tier deletions. Create `app/api/tiers/[id]/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { Prisma } from &quot;@prisma/client&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

export async function DELETE(
  req: NextRequest,
  { params }: { params: Promise&lt;{ id: string }&gt; },
) {
  if (!rateLimit(`tiers-del:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();
  const { id } = await params;

  const tier = await prisma.tier.findUnique({
    where: { id },
    select: { id: true, creatorId: true },
  });
  if (!tier || tier.creatorId !== creator.id) {
    return NextResponse.json({ error: &quot;Not found&quot; }, { status: 404 });
  }

  try {
    await prisma.tier.delete({ where: { id: tier.id } });
  } catch (err: unknown) {
    if (err instanceof Prisma.PrismaClientKnownRequestError &amp;&amp; err.code === &quot;P2003&quot;) {
      return NextResponse.json(
        { error: &quot;This tier has members or gated posts. Deactivate it instead of deleting.&quot; },
        { status: 409 },
      );
    }
    throw err;
  }

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

### The tier manager UI

We want the creators to be able to manage their tiers on the dashboard so let's build the tier manager UI. Create `components/dashboard/TierManager.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">TierManager.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button, TextField, TextArea } from &quot;@whop/react/components&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import { Check } from &quot;@/components/Icons&quot;;

export interface TierRow {
  id: string;
  name: string;
  description: string | null;
  priceCents: number;
  benefits: string[];
  memberCount?: number;
}

export default function TierManager({ tiers }: { tiers: TierRow[] }) {
  const router = useRouter();
  const [name, setName] = useState(&quot;&quot;);
  const [price, setPrice] = useState(&quot;&quot;);
  const [description, setDescription] = useState(&quot;&quot;);
  const [benefitsText, setBenefitsText] = useState(&quot;&quot;);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [saving, setSaving] = useState(false);
  const [deletingId, setDeletingId] = useState&lt;string | null&gt;(null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);

    const priceDollars = Number(price);
    if (!Number.isFinite(priceDollars) || priceDollars &lt;= 0) {
      setError(&quot;Enter a monthly price greater than $0.&quot;);
      return;
    }

    const benefits = benefitsText
      .split(&quot;\n&quot;)
      .map((line) =&gt; line.trim())
      .filter(Boolean);

    setSaving(true);
    try {
      const res = await fetch(&quot;/api/tiers&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          name,
          priceCents: Math.round(priceDollars * 100),
          description: description || undefined,
          benefits,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not create tier&quot;);
        setSaving(false);
        return;
      }
      setName(&quot;&quot;);
      setPrice(&quot;&quot;);
      setDescription(&quot;&quot;);
      setBenefitsText(&quot;&quot;);
      setSaving(false);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setSaving(false);
    }
  }

  async function onDelete(id: string) {
    setDeletingId(id);
    try {
      const res = await fetch(`/api/tiers/${id}`, { method: &quot;DELETE&quot; });
      if (!res.ok) {
        const data = await res.json().catch(() =&gt; ({}));
        setError(data.error ?? &quot;Could not delete tier&quot;);
        setDeletingId(null);
        return;
      }
      setDeletingId(null);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setDeletingId(null);
    }
  }

  return (
    &lt;div className=&quot;space-y-6&quot;&gt;
      {tiers.length &gt; 0 ? (
        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          {tiers.map((tier) =&gt; (
            &lt;div key={tier.id} className=&quot;kofi-card flex flex-col p-5&quot;&gt;
              &lt;div className=&quot;flex items-start justify-between gap-3&quot;&gt;
                &lt;div className=&quot;min-w-0&quot;&gt;
                  &lt;h3 className=&quot;font-semibold&quot;&gt;{tier.name}&lt;/h3&gt;
                  &lt;p className=&quot;text-sm text-muted&quot;&gt;{formatUsd(tier.priceCents)}/mo&lt;/p&gt;
                &lt;/div&gt;
                {typeof tier.memberCount === &quot;number&quot; ? (
                  &lt;span className=&quot;shrink-0 rounded-full bg-surface-2 px-2.5 py-1 text-xs text-muted&quot;&gt;
                    {tier.memberCount} {tier.memberCount === 1 ? &quot;member&quot; : &quot;members&quot;}
                  &lt;/span&gt;
                ) : null}
              &lt;/div&gt;

              {tier.description ? (
                &lt;p className=&quot;mt-2 text-sm text-muted&quot;&gt;{tier.description}&lt;/p&gt;
              ) : null}

              {tier.benefits.length &gt; 0 ? (
                &lt;ul className=&quot;mt-3 space-y-1 text-sm&quot;&gt;
                  {tier.benefits.map((benefit, i) =&gt; (
                    &lt;li key={i} className=&quot;flex gap-2&quot;&gt;
                      &lt;Check className=&quot;h-4 w-4 shrink-0 text-positive&quot; /&gt;
                      &lt;span&gt;{benefit}&lt;/span&gt;
                    &lt;/li&gt;
                  ))}
                &lt;/ul&gt;
              ) : null}

              &lt;div className=&quot;mt-4 self-start&quot;&gt;
                &lt;Button
                  type=&quot;button&quot;
                  size=&quot;2&quot;
                  variant=&quot;surface&quot;
                  color=&quot;gray&quot;
                  onClick={() =&gt; onDelete(tier.id)}
                  disabled={deletingId === tier.id}
                &gt;
                  {deletingId === tier.id ? &quot;Deleting…&quot; : &quot;Delete&quot;}
                &lt;/Button&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;kofi-card p-6 text-sm text-muted&quot;&gt;
          No tiers yet. Add your first membership tier below.
        &lt;/div&gt;
      )}

      &lt;form onSubmit={onSubmit} className=&quot;kofi-card space-y-4 p-6&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;Add a tier&lt;/h2&gt;

        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          &lt;div&gt;
            &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;tier-name&quot;&gt;
              Tier name
            &lt;/label&gt;
            &lt;TextField.Root size=&quot;3&quot;&gt;
              &lt;TextField.Input
                id=&quot;tier-name&quot;
                value={name}
                onChange={(e) =&gt; setName(e.target.value)}
                placeholder=&quot;Coffee Club&quot;
                required
                maxLength={60}
              /&gt;
            &lt;/TextField.Root&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;tier-price&quot;&gt;
              Monthly price (USD)
            &lt;/label&gt;
            &lt;TextField.Root size=&quot;3&quot;&gt;
              &lt;TextField.Slot&gt;$&lt;/TextField.Slot&gt;
              &lt;TextField.Input
                id=&quot;tier-price&quot;
                type=&quot;number&quot;
                min=&quot;1&quot;
                step=&quot;0.01&quot;
                value={price}
                onChange={(e) =&gt; setPrice(e.target.value)}
                placeholder=&quot;5&quot;
                required
              /&gt;
            &lt;/TextField.Root&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;tier-description&quot;&gt;
            Description &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextArea
            id=&quot;tier-description&quot;
            size=&quot;3&quot;
            value={description}
            onChange={(e) =&gt; setDescription(e.target.value)}
            rows={2}
            maxLength={500}
            placeholder=&quot;What this tier is about.&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;tier-benefits&quot;&gt;
            Benefits &lt;span className=&quot;font-normal text-muted&quot;&gt;(one per line)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextArea
            id=&quot;tier-benefits&quot;
            size=&quot;3&quot;
            value={benefitsText}
            onChange={(e) =&gt; setBenefitsText(e.target.value)}
            rows={4}
            placeholder={&quot;Supporter-only posts\nDiscord access\nMonthly wallpaper&quot;}
          /&gt;
        &lt;/div&gt;

        {error ? &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}

        &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; disabled={saving}&gt;
          {saving ? &quot;Adding…&quot; : &quot;Add tier&quot;}
        &lt;/Button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The dashboard tiers page

The dashboard page loads the creator's tiers with an active-membership count for each. Create `app/dashboard/tiers/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import TierManager from &quot;@/components/dashboard/TierManager&quot;;

export default async function DashboardTiersPage() {
  const { creator } = await requireCreator();

  const tiers = await prisma.tier.findMany({
    where: { creatorId: creator.id },
    orderBy: [{ order: &quot;asc&quot; }, { priceCents: &quot;asc&quot; }],
    include: {
      _count: { select: { memberships: { where: { status: &quot;ACTIVE&quot; } } } },
    },
  });

  const initialTiers = tiers.map((tier) =&gt; ({
    id: tier.id,
    name: tier.name,
    description: tier.description,
    priceCents: tier.priceCents,
    benefits: tier.benefits,
    memberCount: tier._count.memberships,
  }));

  return (
    &lt;div className=&quot;space-y-8&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Membership tiers&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Offer monthly memberships with their own benefits and supporter-only access.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;TierManager tiers={initialTiers} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Activating a membership

When a membership payment is made, we'll save the supporter as an active member of that tier. If they're currently renewing a membership, we keep their existing one instead of adding a duplicate and only notify the creator if someone new joins. Here it is again, in `lib/fulfillment.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfillment.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export async function activateMembership(params: {
  creatorId: string;
  userId: string;
  tierId: string;
  whopMembershipId?: string;
}) {
  const tier = await prisma.tier.findUnique({ where: { id: params.tierId }, include: { creator: true } });
  if (!tier) return;
  const existing = await prisma.membership.findUnique({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
  });
  await prisma.membership.upsert({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
    update: { status: &quot;ACTIVE&quot;, whopMembershipId: params.whopMembershipId ?? undefined },
    create: {
      creatorId: params.creatorId,
      userId: params.userId,
      tierId: params.tierId,
      status: &quot;ACTIVE&quot;,
      whopMembershipId: params.whopMembershipId ?? null,
    },
  });
  if (!existing) {
    await notify(tier.creator, {
      title: &quot;New member&quot;,
      subtitle: tier.name,
      content: `Someone just joined your &quot;${tier.name}&quot; tier (${formatUsd(tier.priceCents)}/mo)`,
    });
  }
}</code></pre>
  </div>
</div>

## Checkpoint

- Open a creator's page and go to the **Membership** tab (`/{username}/membership`). The tier cards show prices, benefits, and a member count.
- Click **Join** on a tier and pay with the test card `4242 4242 4242 4242` (any future expiry, any CVC).
- Run `npm run db:studio` and confirm a `Membership` row exists for your user and that tier, with status `ACTIVE`.
- Reload the membership page and confirm the tier's member count went up.
- As the creator, open `/dashboard/tiers`, add a tier (benefits one per line, price in dollars), and confirm it appears on the public membership page.
- Try to delete a tier that already has a member; confirm you get a "deactivate it instead" message rather than an error.

## Part 9: Shop

In this part, we're going to work on the third way supporters pay a creator, the Shop. One-time purchases of products. What's new here is creating and deleting products, the public shop grid, and a free product path, which skips payment entirely.

### The shop branch of the checkout route

The shop branch lives in the checkout route we built before and it's similar to the tip flow. It saves a pending order and then sets up a charge with Whop.

What's different now is the free product flow. When the price of a product is zero, we skip Whop entirely and mark the order complete. Here is the `shop` branch inside `app/api/checkout/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">} else if (input.kind === &quot;shop&quot;) {
  const product = await prisma.product.findFirst({
    where: { id: input.productId, creatorId: creator.id, isActive: true },
  });
  if (!product) return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  amountCents = product.priceCents;
  planType = &quot;one_time&quot;;
  title = product.title;
  const buyerName = (user?.name || user?.username || &quot;Someone&quot;).slice(0, 60);
  const order = await prisma.order.create({
    data: {
      creatorId: creator.id,
      productId: product.id,
      buyerUserId: user?.id ?? null,
      buyerName,
      amountCents,
      status: &quot;PENDING&quot;,
    },
  });
  if (amountCents &lt;= 0) {
    await prisma.order.update({ where: { id: order.id }, data: { status: &quot;COMPLETED&quot; } });
    await prisma.product.update({ where: { id: product.id }, data: { salesCount: { increment: 1 } } });
    return NextResponse.json({ free: true, downloadUrl: product.downloadUrl ?? null });
  }
  metadata = { kind: &quot;shop&quot;, ref: order.id, orderId: order.id, creatorId: creator.id };
}</code></pre>
  </div>
</div>

### Creating products

Now, let's build the route that creates a product. Create `app/api/products/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const schema = z.object({
  title: z.string().min(1).max(120),
  description: z.string().max(1000).optional(),
  priceCents: z.number().int().min(0).max(10_000_000),
  imageUrl: z.string().url().max(2000).optional(),
  type: z.enum([&quot;DIGITAL&quot;, &quot;PHYSICAL&quot;]),
  downloadUrl: z.string().url().max(2000).optional(),
});

export async function POST(req: NextRequest) {
  if (!rateLimit(`products:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const { title, description, priceCents, imageUrl, type, downloadUrl } = parsed.data;

  const product = await prisma.product.create({
    data: {
      creatorId: creator.id,
      title,
      description: description?.trim() || null,
      priceCents,
      imageUrl: imageUrl || null,
      type,
      downloadUrl: downloadUrl || null,
    },
  });

  return NextResponse.json({ ok: true, id: product.id });
}</code></pre>
  </div>
</div>

### Deleting a product

Deleting a product has the same difference we had before with tiers: if it already has orders, we do not delete it but instead turn the database error into a "deactivate it instead" message. Create `app/api/products/[id]/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { Prisma } from &quot;@prisma/client&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

export async function DELETE(
  req: NextRequest,
  { params }: { params: Promise&lt;{ id: string }&gt; },
) {
  if (!rateLimit(`products-del:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();
  const { id } = await params;

  const product = await prisma.product.findUnique({
    where: { id },
    select: { id: true, creatorId: true },
  });
  if (!product || product.creatorId !== creator.id) {
    return NextResponse.json({ error: &quot;Not found&quot; }, { status: 404 });
  }

  try {
    await prisma.product.delete({ where: { id: product.id } });
  } catch (err: unknown) {
    if (err instanceof Prisma.PrismaClientKnownRequestError &amp;&amp; err.code === &quot;P2003&quot;) {
      return NextResponse.json(
        { error: &quot;This product has orders and can&#039;t be deleted. Deactivate it instead.&quot; },
        { status: 409 },
      );
    }
    throw err;
  }

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

### The product manager UI

The dashboard component lists existing products and includes a form to add a new one. Create `components/dashboard/ProductManager.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ProductManager.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

/* eslint-disable @next/next/no-img-element */
import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button, TextField, TextArea, Select } from &quot;@whop/react/components&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

type ProductType = &quot;DIGITAL&quot; | &quot;PHYSICAL&quot;;

export interface ProductRow {
  id: string;
  title: string;
  description: string | null;
  priceCents: number;
  imageUrl: string | null;
  type: ProductType;
  salesCount: number;
}

export default function ProductManager({ products }: { products: ProductRow[] }) {
  const router = useRouter();
  const [title, setTitle] = useState(&quot;&quot;);
  const [description, setDescription] = useState(&quot;&quot;);
  const [price, setPrice] = useState(&quot;&quot;);
  const [imageUrl, setImageUrl] = useState(&quot;&quot;);
  const [type, setType] = useState&lt;ProductType&gt;(&quot;DIGITAL&quot;);
  const [downloadUrl, setDownloadUrl] = useState(&quot;&quot;);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [saving, setSaving] = useState(false);
  const [deletingId, setDeletingId] = useState&lt;string | null&gt;(null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);

    const priceDollars = price.trim() === &quot;&quot; ? 0 : Number(price);
    if (!Number.isFinite(priceDollars) || priceDollars &lt; 0) {
      setError(&quot;Enter a valid price ($0 or more).&quot;);
      return;
    }

    setSaving(true);
    try {
      const res = await fetch(&quot;/api/products&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          title,
          description: description || undefined,
          priceCents: Math.round(priceDollars * 100),
          imageUrl: imageUrl || undefined,
          type,
          downloadUrl: downloadUrl || undefined,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not create product&quot;);
        setSaving(false);
        return;
      }
      setTitle(&quot;&quot;);
      setDescription(&quot;&quot;);
      setPrice(&quot;&quot;);
      setImageUrl(&quot;&quot;);
      setType(&quot;DIGITAL&quot;);
      setDownloadUrl(&quot;&quot;);
      setSaving(false);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setSaving(false);
    }
  }

  async function onDelete(id: string) {
    setDeletingId(id);
    try {
      const res = await fetch(`/api/products/${id}`, { method: &quot;DELETE&quot; });
      if (!res.ok) {
        const data = await res.json().catch(() =&gt; ({}));
        setError(data.error ?? &quot;Could not delete product&quot;);
        setDeletingId(null);
        return;
      }
      setDeletingId(null);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setDeletingId(null);
    }
  }

  return (
    &lt;div className=&quot;space-y-6&quot;&gt;
      {products.length &gt; 0 ? (
        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          {products.map((product) =&gt; (
            &lt;div key={product.id} className=&quot;kofi-card flex gap-4 p-4&quot;&gt;
              {product.imageUrl ? (
                &lt;img
                  src={product.imageUrl}
                  alt=&quot;&quot;
                  className=&quot;h-20 w-20 shrink-0 rounded-xl border border-line object-cover&quot;
                /&gt;
              ) : (
                &lt;div className=&quot;grid h-20 w-20 shrink-0 place-items-center rounded-xl border border-line bg-surface-2&quot;&gt;
                  &lt;BrandIcon name=&quot;shop&quot; className=&quot;h-12 w-12&quot; /&gt;
                &lt;/div&gt;
              )}
              &lt;div className=&quot;flex min-w-0 flex-1 flex-col&quot;&gt;
                &lt;div className=&quot;flex items-start justify-between gap-2&quot;&gt;
                  &lt;h3 className=&quot;truncate font-semibold&quot;&gt;{product.title}&lt;/h3&gt;
                  &lt;span className=&quot;shrink-0 font-semibold&quot;&gt;
                    {product.priceCents === 0 ? &quot;Free&quot; : formatUsd(product.priceCents)}
                  &lt;/span&gt;
                &lt;/div&gt;
                &lt;p className=&quot;mt-0.5 text-xs text-muted&quot;&gt;
                  {product.type === &quot;DIGITAL&quot; ? &quot;Digital&quot; : &quot;Physical&quot;} · {product.salesCount} sold
                &lt;/p&gt;
                {product.description ? (
                  &lt;p className=&quot;mt-1 line-clamp-2 text-sm text-muted&quot;&gt;{product.description}&lt;/p&gt;
                ) : null}
                &lt;div className=&quot;mt-auto self-start pt-2&quot;&gt;
                  &lt;Button
                    type=&quot;button&quot;
                    size=&quot;2&quot;
                    variant=&quot;surface&quot;
                    color=&quot;gray&quot;
                    onClick={() =&gt; onDelete(product.id)}
                    disabled={deletingId === product.id}
                  &gt;
                    {deletingId === product.id ? &quot;Deleting…&quot; : &quot;Delete&quot;}
                  &lt;/Button&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;kofi-card p-6 text-sm text-muted&quot;&gt;
          No products yet. Add your first item below.
        &lt;/div&gt;
      )}

      &lt;form onSubmit={onSubmit} className=&quot;kofi-card space-y-4 p-6&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;Add a product&lt;/h2&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-title&quot;&gt;
            Title
          &lt;/label&gt;
          &lt;TextField.Root size=&quot;3&quot;&gt;
            &lt;TextField.Input
              id=&quot;product-title&quot;
              value={title}
              onChange={(e) =&gt; setTitle(e.target.value)}
              placeholder=&quot;High-res wallpaper pack&quot;
              required
              maxLength={120}
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-description&quot;&gt;
            Description &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextArea
            id=&quot;product-description&quot;
            size=&quot;3&quot;
            value={description}
            onChange={(e) =&gt; setDescription(e.target.value)}
            rows={2}
            maxLength={1000}
            placeholder=&quot;What&#039;s included.&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          &lt;div&gt;
            &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-price&quot;&gt;
              Price (USD) &lt;span className=&quot;font-normal text-muted&quot;&gt;— 0 for free&lt;/span&gt;
            &lt;/label&gt;
            &lt;TextField.Root size=&quot;3&quot;&gt;
              &lt;TextField.Slot&gt;$&lt;/TextField.Slot&gt;
              &lt;TextField.Input
                id=&quot;product-price&quot;
                type=&quot;number&quot;
                min=&quot;0&quot;
                step=&quot;0.01&quot;
                value={price}
                onChange={(e) =&gt; setPrice(e.target.value)}
                placeholder=&quot;0&quot;
              /&gt;
            &lt;/TextField.Root&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-type&quot;&gt;
              Type
            &lt;/label&gt;
            &lt;Select.Root value={type} onValueChange={(v) =&gt; setType(v as ProductType)}&gt;
              &lt;Select.Trigger id=&quot;product-type&quot; className=&quot;w-full&quot; /&gt;
              &lt;Select.Content&gt;
                &lt;Select.Item value=&quot;DIGITAL&quot;&gt;Digital&lt;/Select.Item&gt;
                &lt;Select.Item value=&quot;PHYSICAL&quot;&gt;Physical&lt;/Select.Item&gt;
              &lt;/Select.Content&gt;
            &lt;/Select.Root&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-image&quot;&gt;
            Image URL &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextField.Root size=&quot;3&quot;&gt;
            &lt;TextField.Input
              id=&quot;product-image&quot;
              type=&quot;url&quot;
              value={imageUrl}
              onChange={(e) =&gt; setImageUrl(e.target.value)}
              placeholder=&quot;https://…&quot;
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;product-download&quot;&gt;
            Download URL &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional, for digital)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextField.Root size=&quot;3&quot;&gt;
            &lt;TextField.Input
              id=&quot;product-download&quot;
              type=&quot;url&quot;
              value={downloadUrl}
              onChange={(e) =&gt; setDownloadUrl(e.target.value)}
              placeholder=&quot;https://…&quot;
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/div&gt;

        {error ? &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}

        &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; disabled={saving}&gt;
          {saving ? &quot;Adding…&quot; : &quot;Add product&quot;}
        &lt;/Button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The dashboard shop page

The dashboard page loads the creator's products newest first and gives them to the manager. Create `app/dashboard/shop/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import ProductManager from &quot;@/components/dashboard/ProductManager&quot;;

export default async function DashboardShopPage() {
  const { creator } = await requireCreator();

  const products = await prisma.product.findMany({
    where: { creatorId: creator.id },
    orderBy: { createdAt: &quot;desc&quot; },
  });

  const initialProducts = products.map((product) =&gt; ({
    id: product.id,
    title: product.title,
    description: product.description,
    priceCents: product.priceCents,
    imageUrl: product.imageUrl,
    type: product.type,
    salesCount: product.salesCount,
  }));

  return (
    &lt;div className=&quot;space-y-8&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Shop&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Sell digital downloads or physical goods. Set a price of $0 to offer something for free.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;ProductManager products={initialProducts} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The public shop grid

The grid shown to supporters displays each product with a Buy button. Clicking it opens the shared `CheckoutModal` we built in the previous part. Create `components/creator/ShopGrid.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ShopGrid.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
&quot;use client&quot;;

import { useMemo, useState } from &quot;react&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import CheckoutModal from &quot;@/components/checkout/CheckoutModal&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

type Product = {
  id: string;
  title: string;
  description: string | null;
  priceCents: number;
  imageUrl: string | null;
  salesCount: number;
};

export default function ShopGrid({
  products,
  creatorUsername,
  creatorDisplayName,
  accentColor,
  sandbox,
}: {
  products: Product[];
  creatorUsername: string;
  creatorDisplayName: string;
  accentColor: string;
  sandbox: boolean;
}) {
  const [activeProductId, setActiveProductId] = useState&lt;string | null&gt;(null);

  const body = useMemo(
    () =&gt; ({ kind: &quot;shop&quot;, creatorUsername, productId: activeProductId }),
    [creatorUsername, activeProductId],
  );

  return (
    &lt;&gt;
      &lt;div className=&quot;grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3&quot;&gt;
        {products.map((product) =&gt; {
          const isFree = product.priceCents === 0;
          return (
            &lt;div key={product.id} className=&quot;kofi-card flex flex-col overflow-hidden&quot;&gt;
              &lt;div className=&quot;aspect-square w-full bg-surface-2&quot;&gt;
                {product.imageUrl ? (
                  &lt;img src={product.imageUrl} alt={product.title} className=&quot;h-full w-full object-cover&quot; /&gt;
                ) : (
                  &lt;div className=&quot;grid h-full w-full place-items-center&quot;&gt;&lt;BrandIcon name=&quot;shop&quot; className=&quot;h-20 w-20&quot; /&gt;&lt;/div&gt;
                )}
              &lt;/div&gt;
              &lt;div className=&quot;flex flex-1 flex-col p-4&quot;&gt;
                &lt;h3 className=&quot;font-semibold&quot;&gt;{product.title}&lt;/h3&gt;
                {product.description ? (
                  &lt;p className=&quot;mt-1 line-clamp-2 text-sm text-muted&quot;&gt;{product.description}&lt;/p&gt;
                ) : null}
                &lt;div className=&quot;mt-3 flex items-center justify-between gap-2&quot;&gt;
                  &lt;div&gt;
                    &lt;p className=&quot;text-sm font-bold&quot;&gt;{isFree ? &quot;Free&quot; : formatUsd(product.priceCents)}&lt;/p&gt;
                    &lt;p className=&quot;text-xs text-muted&quot;&gt;
                      {product.salesCount} {product.salesCount === 1 ? &quot;sold&quot; : &quot;sold&quot;}
                    &lt;/p&gt;
                  &lt;/div&gt;
                  &lt;Button
                    onClick={() =&gt; setActiveProductId(product.id)}
                    size=&quot;2&quot;
                    variant=&quot;solid&quot;
                    className=&quot;shrink-0&quot;
                  &gt;
                    {isFree ? &quot;Get&quot; : &quot;Buy&quot;}
                  &lt;/Button&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          );
        })}
      &lt;/div&gt;

      &lt;CheckoutModal
        open={activeProductId !== null}
        onClose={() =&gt; setActiveProductId(null)}
        body={body}
        creatorUsername={creatorUsername}
        creatorDisplayName={creatorDisplayName}
        accentColor={accentColor}
        sandbox={sandbox}
      /&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The public shop page

The shop route loads the creator's active products and reuses the same `CreatorProfileHeader` as every other creator tab. Create `app/[username]/shop/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { isSandbox } from &quot;@/lib/env&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;
import ShopGrid from &quot;@/components/creator/ShopGrid&quot;;

export default async function ShopPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: {
      id: true,
      displayName: true,
      accentColor: true,
      isActive: true,
      products: {
        where: { isActive: true },
        orderBy: { createdAt: &quot;desc&quot; },
        select: { id: true, title: true, description: true, priceCents: true, imageUrl: true, salesCount: true },
      },
    },
  });
  if (!creator || !creator.isActive) notFound();

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;h1 className=&quot;text-xl font-bold&quot;&gt;Shop&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;Grab something from {creator.displayName}&amp;rsquo;s shop and back their work.&lt;/p&gt;

        &lt;div className=&quot;mt-5&quot;&gt;
          {creator.products.length === 0 ? (
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;{creator.displayName} hasn&amp;apos;t added any products yet.&lt;/p&gt;
            &lt;/div&gt;
          ) : (
            &lt;ShopGrid
              products={creator.products}
              creatorUsername={username}
              creatorDisplayName={creator.displayName}
              accentColor={creator.accentColor}
              sandbox={isSandbox()}
            /&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

A paid order doesn't need new code since it finishes through the same Part 7 fulfillment as a tip. So a paid purchase lands on the creator's connected company with our fee deducted, and free products skip straight to the download.

## Checkpoint

- As a creator, open `/dashboard/shop` and add two products: one with a price (say $5) and one priced at `0`.
- Open the creator's **Shop** tab (`/{username}/shop`). Both products show, and the free one has a **Get** button instead of **Buy**.
- Buy the paid product with the test card `4242 4242 4242 4242`. After it completes, its sales count goes up.
- Run `npm run db:studio` and confirm the `Order` row is `COMPLETED` with a `whopPaymentId`.
- Click **Get** on the free product and confirm you get the download right away, with no checkout step.
- Try to delete a product that already has an order; confirm you get a "deactivate it instead" message rather than an error.

## Part 10: Posts and content gating

In this part, we're going to build the post-related routes like creating and deleting, then we'll build the dashboard posts, public posts, single post, and other pages. The gate that decides who can see each post we built back in Part 6, so we just reuse it here.

### Creating posts

The create route adds two checks the other routes did not need: it requires a tier to be chosen when the post is locked to one, and it confirms that tier belongs to this creator. Create `app/api/posts/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const schema = z
  .object({
    title: z.string().min(1).max(140),
    content: z.string().min(1).max(10000),
    imageUrl: z.string().url().max(2000).optional(),
    visibility: z.enum([&quot;PUBLIC&quot;, &quot;SUPPORTERS&quot;, &quot;TIER&quot;]),
    minimumTierId: z.string().min(1).optional(),
    pinned: z.boolean().optional().default(false),
  })
  .refine((data) =&gt; data.visibility !== &quot;TIER&quot; || Boolean(data.minimumTierId), {
    message: &quot;Pick a tier for tier-gated posts&quot;,
    path: [&quot;minimumTierId&quot;],
  });

export async function POST(req: NextRequest) {
  if (!rateLimit(`posts:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const { title, content, imageUrl, visibility, minimumTierId, pinned } = parsed.data;

  let resolvedTierId: string | null = null;
  if (visibility === &quot;TIER&quot;) {
    const tier = await prisma.tier.findFirst({
      where: { id: minimumTierId, creatorId: creator.id },
      select: { id: true },
    });
    if (!tier) {
      return NextResponse.json({ error: &quot;Tier not found&quot; }, { status: 404 });
    }
    resolvedTierId = tier.id;
  }

  const post = await prisma.post.create({
    data: {
      creatorId: creator.id,
      title,
      content,
      imageUrl: imageUrl || null,
      visibility,
      minimumTierId: resolvedTierId,
      pinned,
    },
  });

  return NextResponse.json({ ok: true, id: post.id });
}</code></pre>
  </div>
</div>

### Deleting a post

Now, let's build the post deletion route. Create `app/api/posts/[id]/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

export async function DELETE(
  req: NextRequest,
  { params }: { params: Promise&lt;{ id: string }&gt; },
) {
  if (!rateLimit(`posts-del:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();
  const { id } = await params;

  const post = await prisma.post.findUnique({
    where: { id },
    select: { id: true, creatorId: true },
  });
  if (!post || post.creatorId !== creator.id) {
    return NextResponse.json({ error: &quot;Not found&quot; }, { status: 404 });
  }

  await prisma.post.delete({ where: { id: post.id } });

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

### The post manager UI

The post manager dashboard component lists existing posts. Create `components/dashboard/PostManager.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PostManager.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button, TextField, TextArea, Select, Switch } from &quot;@whop/react/components&quot;;
import { Pin } from &quot;@/components/Icons&quot;;

type Visibility = &quot;PUBLIC&quot; | &quot;SUPPORTERS&quot; | &quot;TIER&quot;;

interface TierOption {
  id: string;
  name: string;
}

export interface PostRow {
  id: string;
  title: string;
  visibility: Visibility;
  minimumTierName: string | null;
  pinned: boolean;
  createdAt: string;
}

const VISIBILITY_LABEL: Record&lt;Visibility, string&gt; = {
  PUBLIC: &quot;Public&quot;,
  SUPPORTERS: &quot;Supporters&quot;,
  TIER: &quot;Tier&quot;,
};

export default function PostManager({
  posts,
  tiers,
}: {
  posts: PostRow[];
  tiers: TierOption[];
}) {
  const router = useRouter();
  const [title, setTitle] = useState(&quot;&quot;);
  const [content, setContent] = useState(&quot;&quot;);
  const [imageUrl, setImageUrl] = useState(&quot;&quot;);
  const [visibility, setVisibility] = useState&lt;Visibility&gt;(&quot;PUBLIC&quot;);
  const [minimumTierId, setMinimumTierId] = useState(&quot;&quot;);
  const [pinned, setPinned] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [saving, setSaving] = useState(false);
  const [deletingId, setDeletingId] = useState&lt;string | null&gt;(null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);

    if (visibility === &quot;TIER&quot; &amp;&amp; !minimumTierId) {
      setError(&quot;Pick a tier for tier-gated posts.&quot;);
      return;
    }

    setSaving(true);
    try {
      const res = await fetch(&quot;/api/posts&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          title,
          content,
          imageUrl: imageUrl || undefined,
          visibility,
          minimumTierId: visibility === &quot;TIER&quot; ? minimumTierId : undefined,
          pinned,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not create post&quot;);
        setSaving(false);
        return;
      }
      setTitle(&quot;&quot;);
      setContent(&quot;&quot;);
      setImageUrl(&quot;&quot;);
      setVisibility(&quot;PUBLIC&quot;);
      setMinimumTierId(&quot;&quot;);
      setPinned(false);
      setSaving(false);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setSaving(false);
    }
  }

  async function onDelete(id: string) {
    setDeletingId(id);
    try {
      const res = await fetch(`/api/posts/${id}`, { method: &quot;DELETE&quot; });
      if (!res.ok) {
        const data = await res.json().catch(() =&gt; ({}));
        setError(data.error ?? &quot;Could not delete post&quot;);
        setDeletingId(null);
        return;
      }
      setDeletingId(null);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setDeletingId(null);
    }
  }

  return (
    &lt;div className=&quot;space-y-6&quot;&gt;
      {posts.length &gt; 0 ? (
        &lt;ul className=&quot;space-y-3&quot;&gt;
          {posts.map((post) =&gt; (
            &lt;li key={post.id} className=&quot;kofi-card flex items-center justify-between gap-4 p-4&quot;&gt;
              &lt;div className=&quot;min-w-0&quot;&gt;
                &lt;div className=&quot;flex items-center gap-2&quot;&gt;
                  {post.pinned ? &lt;span title=&quot;Pinned&quot; className=&quot;text-muted&quot;&gt;&lt;Pin className=&quot;h-4 w-4&quot; /&gt;&lt;/span&gt; : null}
                  &lt;h3 className=&quot;truncate font-semibold&quot;&gt;{post.title}&lt;/h3&gt;
                &lt;/div&gt;
                &lt;p className=&quot;mt-0.5 text-xs text-muted&quot;&gt;
                  &lt;span className=&quot;rounded-full bg-surface-2 px-2 py-0.5&quot;&gt;
                    {post.visibility === &quot;TIER&quot; &amp;&amp; post.minimumTierName
                      ? post.minimumTierName
                      : VISIBILITY_LABEL[post.visibility]}
                  &lt;/span&gt;{&quot; &quot;}
                  · {new Date(post.createdAt).toLocaleDateString()}
                &lt;/p&gt;
              &lt;/div&gt;
              &lt;Button
                type=&quot;button&quot;
                size=&quot;2&quot;
                variant=&quot;surface&quot;
                color=&quot;gray&quot;
                className=&quot;shrink-0&quot;
                onClick={() =&gt; onDelete(post.id)}
                disabled={deletingId === post.id}
              &gt;
                {deletingId === post.id ? &quot;Deleting…&quot; : &quot;Delete&quot;}
              &lt;/Button&gt;
            &lt;/li&gt;
          ))}
        &lt;/ul&gt;
      ) : (
        &lt;div className=&quot;kofi-card p-6 text-sm text-muted&quot;&gt;
          No posts yet. Write your first update below.
        &lt;/div&gt;
      )}

      &lt;form onSubmit={onSubmit} className=&quot;kofi-card space-y-4 p-6&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;Write a post&lt;/h2&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;post-title&quot;&gt;
            Title
          &lt;/label&gt;
          &lt;TextField.Root size=&quot;3&quot;&gt;
            &lt;TextField.Input
              id=&quot;post-title&quot;
              value={title}
              onChange={(e) =&gt; setTitle(e.target.value)}
              placeholder=&quot;A new piece is up!&quot;
              required
              maxLength={140}
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;post-content&quot;&gt;
            Content
          &lt;/label&gt;
          &lt;TextArea
            id=&quot;post-content&quot;
            size=&quot;3&quot;
            value={content}
            onChange={(e) =&gt; setContent(e.target.value)}
            rows={5}
            required
            maxLength={10000}
            placeholder=&quot;Share what you&#039;ve been working on…&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;post-image&quot;&gt;
            Image URL &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;TextField.Root size=&quot;3&quot;&gt;
            &lt;TextField.Input
              id=&quot;post-image&quot;
              type=&quot;url&quot;
              value={imageUrl}
              onChange={(e) =&gt; setImageUrl(e.target.value)}
              placeholder=&quot;https://…&quot;
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/div&gt;

        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          &lt;div&gt;
            &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;post-visibility&quot;&gt;
              Who can see it
            &lt;/label&gt;
            &lt;Select.Root value={visibility} onValueChange={(v) =&gt; setVisibility(v as Visibility)}&gt;
              &lt;Select.Trigger id=&quot;post-visibility&quot; className=&quot;w-full&quot; /&gt;
              &lt;Select.Content&gt;
                &lt;Select.Item value=&quot;PUBLIC&quot;&gt;Public — everyone&lt;/Select.Item&gt;
                &lt;Select.Item value=&quot;SUPPORTERS&quot;&gt;Supporters only&lt;/Select.Item&gt;
                &lt;Select.Item value=&quot;TIER&quot;&gt;Specific tier&lt;/Select.Item&gt;
              &lt;/Select.Content&gt;
            &lt;/Select.Root&gt;
          &lt;/div&gt;

          {visibility === &quot;TIER&quot; ? (
            &lt;div&gt;
              &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;post-tier&quot;&gt;
                Minimum tier
              &lt;/label&gt;
              &lt;Select.Root
                value={minimumTierId || undefined}
                onValueChange={(v) =&gt; setMinimumTierId(v)}
              &gt;
                &lt;Select.Trigger id=&quot;post-tier&quot; className=&quot;w-full&quot; placeholder=&quot;Select a tier…&quot; /&gt;
                &lt;Select.Content&gt;
                  {tiers.map((tier) =&gt; (
                    &lt;Select.Item key={tier.id} value={tier.id}&gt;
                      {tier.name}
                    &lt;/Select.Item&gt;
                  ))}
                &lt;/Select.Content&gt;
              &lt;/Select.Root&gt;
              {tiers.length === 0 ? (
                &lt;p className=&quot;mt-1 text-xs text-muted&quot;&gt;Create a tier first to gate by tier.&lt;/p&gt;
              ) : null}
            &lt;/div&gt;
          ) : null}
        &lt;/div&gt;

        &lt;label className=&quot;flex items-center gap-2 text-sm&quot;&gt;
          &lt;Switch checked={pinned} onCheckedChange={setPinned} /&gt;
          Pin this post to the top of my page
        &lt;/label&gt;

        {error ? &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}

        &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; disabled={saving}&gt;
          {saving ? &quot;Publishing…&quot; : &quot;Publish post&quot;}
        &lt;/Button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The dashboard posts page

Next, the dashboard posts page. Create `app/dashboard/posts/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import PostManager from &quot;@/components/dashboard/PostManager&quot;;

export default async function DashboardPostsPage() {
  const { creator } = await requireCreator();

  const [posts, tiers] = await Promise.all([
    prisma.post.findMany({
      where: { creatorId: creator.id },
      orderBy: [{ pinned: &quot;desc&quot; }, { createdAt: &quot;desc&quot; }],
      include: { minimumTier: { select: { name: true } } },
    }),
    prisma.tier.findMany({
      where: { creatorId: creator.id, isActive: true },
      orderBy: [{ order: &quot;asc&quot; }, { priceCents: &quot;asc&quot; }],
      select: { id: true, name: true },
    }),
  ]);

  const initialPosts = posts.map((post) =&gt; ({
    id: post.id,
    title: post.title,
    visibility: post.visibility,
    minimumTierName: post.minimumTier?.name ?? null,
    pinned: post.pinned,
    createdAt: post.createdAt.toISOString(),
  }));

  return (
    &lt;div className=&quot;space-y-8&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Posts&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Share updates with everyone, your supporters, or a specific tier.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;PostManager posts={initialPosts} tiers={tiers} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The public posts page

Then the public posts tab. It shows the full post to anyone allowed to see it, and a locked placeholder to the rest. Create `app/[username]/posts/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { notFound } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getViewerContext, canViewPost } from &quot;@/lib/creator&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { Pin } from &quot;@/components/Icons&quot;;

function formatDate(date: Date): string {
  return new Intl.DateTimeFormat(&quot;en-US&quot;, { month: &quot;short&quot;, day: &quot;numeric&quot;, year: &quot;numeric&quot; }).format(date);
}

function visibilityLabel(visibility: &quot;PUBLIC&quot; | &quot;SUPPORTERS&quot; | &quot;TIER&quot;, tierName: string | null): string {
  if (visibility === &quot;PUBLIC&quot;) return &quot;Public&quot;;
  if (visibility === &quot;TIER&quot;) return tierName ?? &quot;Members&quot;;
  return &quot;Supporters&quot;;
}

export default async function PostsPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: { id: true, userId: true, isActive: true },
  });
  if (!creator || !creator.isActive) notFound();

  const [posts, viewer] = await Promise.all([
    prisma.post.findMany({
      where: { creatorId: creator.id, published: true },
      orderBy: [{ pinned: &quot;desc&quot; }, { createdAt: &quot;desc&quot; }],
      include: { minimumTier: { select: { id: true, name: true } } },
    }),
    getViewerContext(creator.id, creator.userId),
  ]);

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;h1 className=&quot;text-xl font-bold&quot;&gt;Posts&lt;/h1&gt;

        &lt;div className=&quot;mt-5 space-y-4&quot;&gt;
          {posts.length === 0 ? (
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;No posts yet.&lt;/p&gt;
            &lt;/div&gt;
          ) : (
            posts.map((post) =&gt; {
              const canView = canViewPost(post, viewer);
              return (
                &lt;div key={post.id} className=&quot;kofi-card p-5&quot;&gt;
                  &lt;div className=&quot;mb-1 flex flex-wrap items-center gap-2 text-xs text-muted&quot;&gt;
                    {post.pinned ? (
                      &lt;span className=&quot;inline-flex items-center gap-1 font-semibold&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
                        &lt;Pin className=&quot;h-3.5 w-3.5&quot; /&gt; Pinned
                      &lt;/span&gt;
                    ) : null}
                    &lt;span&gt;{formatDate(post.createdAt)}&lt;/span&gt;
                    &lt;span className=&quot;rounded-full bg-surface-2 px-2 py-0.5 font-medium&quot;&gt;
                      {visibilityLabel(post.visibility, post.minimumTier?.name ?? null)}
                    &lt;/span&gt;
                  &lt;/div&gt;

                  {canView ? (
                    &lt;&gt;
                      &lt;Link
                        href={`/${username}/post/${post.id}`}
                        className=&quot;text-lg font-bold hover:underline&quot;
                      &gt;
                        {post.title}
                      &lt;/Link&gt;
                      &lt;p className=&quot;mt-1 line-clamp-3 text-sm text-muted&quot;&gt;{post.content}&lt;/p&gt;
                      &lt;Link
                        href={`/${username}/post/${post.id}`}
                        className=&quot;mt-2 inline-block text-sm font-semibold&quot;
                        style={{ color: &quot;var(--accent)&quot; }}
                      &gt;
                        Read more
                      &lt;/Link&gt;
                    &lt;/&gt;
                  ) : (
                    &lt;&gt;
                      &lt;h2 className=&quot;text-lg font-bold&quot;&gt;{post.title}&lt;/h2&gt;
                      &lt;p className=&quot;mt-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted&quot;&gt;
                        &lt;BrandIcon name=&quot;lock&quot; className=&quot;mr-1 h-4 w-4 align-text-bottom&quot; /&gt;This post is for supporters.{&quot; &quot;}
                        &lt;Link href={`/${username}#support`} className=&quot;font-semibold&quot; style={{ color: &quot;var(--accent)&quot; }}&gt;
                          Unlock it
                        &lt;/Link&gt;
                      &lt;/p&gt;
                    &lt;/&gt;
                  )}
                &lt;/div&gt;
              );
            })
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The single post page

We also want each post to have its own page. Create `app/[username]/post/[id]/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import { notFound } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getViewerContext, canViewPost } from &quot;@/lib/creator&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { ChevronLeft } from &quot;@/components/Icons&quot;;

function formatDate(date: Date): string {
  return new Intl.DateTimeFormat(&quot;en-US&quot;, { month: &quot;short&quot;, day: &quot;numeric&quot;, year: &quot;numeric&quot; }).format(date);
}

export default async function PostPage({ params }: { params: Promise&lt;{ username: string; id: string }&gt; }) {
  const { username, id } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: { id: true, userId: true, displayName: true, isActive: true },
  });
  if (!creator || !creator.isActive) notFound();

  const post = await prisma.post.findUnique({
    where: { id },
    include: { minimumTier: { select: { id: true, name: true } } },
  });
  if (!post || post.creatorId !== creator.id || !post.published) notFound();

  const viewer = await getViewerContext(creator.id, creator.userId);
  const canView = canViewPost(post, viewer);

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;article className=&quot;kofi-card mx-auto max-w-2xl p-6&quot;&gt;
          &lt;Link href={`/${username}/posts`} className=&quot;inline-flex items-center gap-1 text-sm font-semibold text-muted hover:text-ink&quot;&gt;
            &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; All posts
          &lt;/Link&gt;

          &lt;div className=&quot;mt-3 flex flex-wrap items-center gap-2 text-xs text-muted&quot;&gt;
            &lt;span&gt;{formatDate(post.createdAt)}&lt;/span&gt;
            {post.visibility !== &quot;PUBLIC&quot; ? (
              &lt;span className=&quot;inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 font-medium&quot;&gt;
                &lt;BrandIcon name=&quot;lock&quot; className=&quot;h-3.5 w-3.5&quot; /&gt;
                {post.minimumTier?.name ?? &quot;Supporters&quot;}
              &lt;/span&gt;
            ) : null}
          &lt;/div&gt;

          &lt;h1 className=&quot;mt-2 text-2xl font-bold&quot;&gt;{post.title}&lt;/h1&gt;

          {canView ? (
            &lt;&gt;
              {post.imageUrl ? (
                &lt;img src={post.imageUrl} alt=&quot;&quot; className=&quot;mt-4 w-full rounded-xl object-cover&quot; /&gt;
              ) : null}
              &lt;div className=&quot;mt-4 whitespace-pre-wrap text-[15px] leading-relaxed&quot;&gt;{post.content}&lt;/div&gt;
            &lt;/&gt;
          ) : (
            &lt;div className=&quot;mt-6 rounded-xl border border-line bg-surface-2 p-6 text-center&quot;&gt;
              &lt;div className=&quot;mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-surface&quot;&gt;
                &lt;BrandIcon name=&quot;lock&quot; className=&quot;h-8 w-8&quot; /&gt;
              &lt;/div&gt;
              &lt;p className=&quot;text-sm font-semibold&quot;&gt;This post is for supporters&lt;/p&gt;
              &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
                Back {creator.displayName} to unlock this post and more.
              &lt;/p&gt;
              &lt;Link href={`/${username}#support`} className=&quot;btn-pill btn-accent mt-4&quot;&gt;
                Unlock
              &lt;/Link&gt;
            &lt;/div&gt;
          )}
        &lt;/article&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The gallery tab

The gallery tab is where the users see a grid of every post that has an image. Create `app/[username]/gallery/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import { notFound } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;

export default async function GalleryPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: { id: true, isActive: true },
  });
  if (!creator || !creator.isActive) notFound();

  const posts = await prisma.post.findMany({
    where: { creatorId: creator.id, published: true, imageUrl: { not: null } },
    orderBy: [{ pinned: &quot;desc&quot; }, { createdAt: &quot;desc&quot; }],
    select: { id: true, title: true, imageUrl: true },
  });

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;h1 className=&quot;text-xl font-bold&quot;&gt;Gallery&lt;/h1&gt;

        &lt;div className=&quot;mt-5&quot;&gt;
          {posts.length === 0 ? (
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;No images yet.&lt;/p&gt;
            &lt;/div&gt;
          ) : (
            &lt;div className=&quot;grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4&quot;&gt;
              {posts.map((post) =&gt; (
                &lt;Link
                  key={post.id}
                  href={`/${username}/post/${post.id}`}
                  className=&quot;kofi-card group block aspect-square overflow-hidden&quot;
                &gt;
                  &lt;img
                    src={post.imageUrl as string}
                    alt={post.title}
                    className=&quot;h-full w-full object-cover transition group-hover:scale-105&quot;
                  /&gt;
                &lt;/Link&gt;
              ))}
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

### The leaderboard tab

Finally, let's build the leaderboard tab that ranks the top supporters by how much they have given. Create `app/[username]/leaderboard/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import CreatorProfileHeader from &quot;@/components/creator/CreatorProfileHeader&quot;;

export default async function LeaderboardPage({ params }: { params: Promise&lt;{ username: string }&gt; }) {
  const { username } = await params;

  const creator = await prisma.creator.findUnique({
    where: { username },
    select: { id: true, displayName: true, isActive: true },
  });
  if (!creator || !creator.isActive) notFound();

  const grouped = await prisma.support.groupBy({
    by: [&quot;supporterName&quot;],
    where: { creatorId: creator.id, status: &quot;COMPLETED&quot;, isPublic: true },
    _sum: { amountCents: true, coffees: true },
    orderBy: { _sum: { amountCents: &quot;desc&quot; } },
    take: 25,
  });

  const rows = grouped.map((row) =&gt; ({
    name: row.supporterName,
    totalCents: row._sum.amountCents ?? 0,
    coffees: row._sum.coffees ?? 0,
  }));

  return (
    &lt;&gt;
      &lt;CreatorProfileHeader username={username} /&gt;

      &lt;div className=&quot;mx-auto max-w-5xl px-5 py-6&quot;&gt;
        &lt;h1 className=&quot;text-xl font-bold&quot;&gt;Top supporters&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;The biggest supporters of {creator.displayName}.&lt;/p&gt;

        &lt;div className=&quot;mt-5&quot;&gt;
          {rows.length === 0 ? (
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;No supporters yet. Be the first!&lt;/p&gt;
            &lt;/div&gt;
          ) : (
            &lt;div className=&quot;kofi-card divide-y divide-line overflow-hidden&quot;&gt;
              {rows.map((row, i) =&gt; (
                &lt;div key={`${row.name}-${i}`} className=&quot;flex items-center gap-4 px-5 py-3&quot;&gt;
                  &lt;span
                    className=&quot;grid h-8 w-8 shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-bold&quot;
                    style={i &lt; 3 ? { background: &quot;var(--accent)&quot;, color: &quot;#fff&quot; } : undefined}
                  &gt;
                    {i + 1}
                  &lt;/span&gt;
                  &lt;div className=&quot;min-w-0 flex-1&quot;&gt;
                    &lt;p className=&quot;truncate font-semibold&quot;&gt;{row.name}&lt;/p&gt;
                    &lt;p className=&quot;text-xs text-muted&quot;&gt;
                      {row.coffees} {row.coffees === 1 ? &quot;coffee&quot; : &quot;coffees&quot;}
                    &lt;/p&gt;
                  &lt;/div&gt;
                  &lt;span className=&quot;shrink-0 font-bold&quot;&gt;{formatUsd(row.totalCents)}&lt;/span&gt;
                &lt;/div&gt;
              ))}
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

## Checkpoint

- As a creator, open `/dashboard/posts` and publish three posts: one public, one supporters-only, and one locked to a specific tier.
- Open the creator's **Posts** tab (`/{username}/posts`) while signed out or as a non-member. The public post is readable; the other two show a locked placeholder.
- Join a tier (from Part 8) and reload. The supporters-only post unlocks, and the tier post unlocks too if you joined that tier.
- Open a locked post's page directly by its URL and confirm the body is still hidden while the title and date show.
- Add an image to a post and confirm it appears on the **Gallery** tab.
- Send a few tips under different names, then open the **Leaderboard** tab and confirm the top supporters are ranked by total.

## Part 11: Webhooks and the money flow

In this part, we're going to build the webhook, the piece that records payments. When a payment settles, Whop sends us an event, and that's what we trust to mark a tip, membership, or order as paid.

### The money flow

Since we're charging directly on each creator's connected company, no money passes through a platform balance. It goes directly to the creator minus fees. Our webhook reacts to four events: `payment.succeeded`, `membership.activated`, `membership.deactivated`, and `refund.created`.

### Creating the webhook in the dashboard

First of all, we need to get the webhook secret from Whop. Go to your Whop company, then to its dashboard, then Developer > Webhooks and add a webhook pointing at `https://<your-app>/api/webhooks/whop`, on API version v1.

Enable connected account actions as well, since every payment in this app happens on a creator's connected company and the webhook only receives those events with that toggle on.

Subscribe to the payment, membership, and refund events, then copy the signing secret into the `WHOP_WEBHOOK_SECRET` environment variable.

> 

### The webhook handler

Let's build the webhook handler. It checks the signature, skips events it has already handled, routes each one to the right fulfillment. Create `app/api/webhooks/whop/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { whopsdk } from &quot;@/lib/whop&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import {
  fulfillFromMetadata,
  activateMembership,
  deactivateMembership,
  markSupportRefunded,
} from &quot;@/lib/fulfillment&quot;;

type WebhookEvent = { id?: string; type: string; data: Record&lt;string, unknown&gt; };

export async function POST(req: NextRequest) {
  const bodyText = await req.text();
  const headers = Object.fromEntries(req.headers);

  let event: WebhookEvent;
  try {
    event = whopsdk.webhooks.unwrap(bodyText, { headers }) as unknown as WebhookEvent;
  } catch (err: unknown) {
    console.error(&quot;Webhook signature verification failed:&quot;, err);
    return new NextResponse(&quot;invalid signature&quot;, { status: 401 });
  }

  const eventId = event.id ?? headers[&quot;webhook-id&quot;];
  if (eventId) {
    const seen = await prisma.processedWebhook.findUnique({ where: { id: eventId } }).catch(() =&gt; null);
    if (seen) return new NextResponse(&quot;ok&quot;, { status: 200 });
    await prisma.processedWebhook.create({ data: { id: eventId, type: event.type } }).catch(() =&gt; {});
  }

  try {
    const data = event.data;
    switch (event.type) {
      case &quot;payment.succeeded&quot;: {
        const id = String(data.id ?? &quot;&quot;);
        const metadata = (data.metadata ?? null) as Record&lt;string, unknown&gt; | null;
        if (id) await fulfillFromMetadata(metadata, id);
        break;
      }
      case &quot;membership.activated&quot;: {
        const meta = (data.metadata ?? {}) as Record&lt;string, unknown&gt;;
        if (
          typeof meta.creatorId === &quot;string&quot; &amp;&amp;
          typeof meta.userId === &quot;string&quot; &amp;&amp;
          typeof meta.tierId === &quot;string&quot;
        ) {
          await activateMembership({
            creatorId: meta.creatorId,
            userId: meta.userId,
            tierId: meta.tierId,
            whopMembershipId: String(data.id ?? &quot;&quot;),
          });
        }
        break;
      }
      case &quot;membership.deactivated&quot;: {
        const id = String(data.id ?? &quot;&quot;);
        if (id) await deactivateMembership(id);
        break;
      }
      case &quot;refund.created&quot;: {
        const paymentId = String(data.payment_id ?? (data.payment as { id?: string } | undefined)?.id ?? &quot;&quot;);
        if (paymentId) await markSupportRefunded(paymentId);
        break;
      }
      default:
        break;
    }
  } catch (err: unknown) {
    console.error(`Webhook handler error for ${event.type}:`, err);
  }

  return new NextResponse(&quot;ok&quot;, { status: 200 });
}</code></pre>
  </div>
</div>

### The fulfillment functions the webhook drives

The `deactivateMembership` helper marks a membership canceled, unless it already is. We already wrote it in `lib/fulfillment.ts`; here it is again:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfillment.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export async function deactivateMembership(whopMembershipId: string) {
  const m = await prisma.membership.findUnique({ where: { whopMembershipId } });
  if (m &amp;&amp; m.status !== &quot;CANCELED&quot; &amp;&amp; m.status !== &quot;EXPIRED&quot;) {
    await prisma.membership.update({ where: { id: m.id }, data: { status: &quot;CANCELED&quot; } });
  }
}</code></pre>
  </div>
</div>

`markSupportRefunded` marks a support refunded, again only if it is not already, so a refund reverses a recorded tip. It is already in `lib/fulfillment.ts` too:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfillment.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export async function markSupportRefunded(whopPaymentId: string) {
  const support = await prisma.support.findUnique({ where: { whopPaymentId } });
  if (support &amp;&amp; support.status !== &quot;REFUNDED&quot;) {
    await prisma.support.update({ where: { id: support.id }, data: { status: &quot;REFUNDED&quot; } });
  }
}</code></pre>
  </div>
</div>

### How the confirm endpoint relates to the webhook

Now, there are two things that record payments: the webhook, and the confirm endpoint we built in Part 7. They never step on each other. Recording a payment is safe to repeat: the first one to run does the work, and anything that runs after just sees it is already done and stops.

And that makes the whole money flow end to end. When a supporter pays, the money lands on the creator minus our fee, and one of these parts records the sale and notifies the creator.

## Checkpoint

- The webhook is registered in the Whop dashboard (Developer > Webhooks, API version v1, connected account actions enabled) pointing at your `/api/webhooks/whop`, with its signing secret in `WHOP_WEBHOOK_SECRET`.
- Send a sandbox tip with the test card `4242 4242 4242 4242`. In local dev the Part 7 confirm endpoint completes it, so the `Support` row still flips from `PENDING` to `COMPLETED`.
- To exercise the real webhook locally, expose your dev server with a tunnel (ngrok) and point the webhook at it; the same tip now completes through the webhook instead.
- Refund that payment from the Whop dashboard and confirm the support flips to `REFUNDED` and the creator's total and goal drop on the next load.

## Part 12: On-site payouts and the creator dashboard

In this part, we're going to build the on-site payouts so that creators can withdraw earnings directly on the project without leaving for Whop.com, and a couple of dashboard additions.

### The payout token endpoint

We need to give the payout portal a short-lived token from Whop so that it can load the creator's balance. So, let's build the endpoint for it. Create `app/api/payouts/token/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { createCompanyAccessToken } from &quot;@/services/whop&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

export async function GET(req: NextRequest) {
  if (!rateLimit(`payouts-token:${clientIp(req)}`, 30, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const companyId = req.nextUrl.searchParams.get(&quot;companyId&quot;);
  if (!companyId) {
    return NextResponse.json({ error: &quot;Missing companyId&quot; }, { status: 400 });
  }

  if (!creator.whopCompanyId || companyId !== creator.whopCompanyId) {
    return NextResponse.json({ error: &quot;Forbidden&quot; }, { status: 403 });
  }

  try {
    const token = await createCompanyAccessToken(companyId);
    return NextResponse.json({ token });
  } catch (err: unknown) {
    console.error(&quot;Payout token creation failed:&quot;, err);
    return NextResponse.json({ error: &quot;Could not create payout token&quot; }, { status: 502 });
  }
}</code></pre>
  </div>
</div>

### The payout portal component

Then, let's create the payout portal component where the creator sees their balance, and either an Activate-payouts button (if they haven't set up payouts yet) or the withdraw controls.

Create `components/payouts/PayoutsPortal.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PayoutsPortal.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useRef, useState } from &quot;react&quot;;
import {
  Elements,
  PayoutsSession,
  usePayoutsSession,
  StatusBannerElement,
  BalanceElement,
  WithdrawalsElement,
} from &quot;@whop/embedded-components-react-js&quot;;
import { loadWhopElements } from &quot;@whop/embedded-components-vanilla-js&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

const elementsByEnv: Partial&lt;Record&lt;&quot;production&quot; | &quot;sandbox&quot;, ReturnType&lt;typeof loadWhopElements&gt;&gt;&gt; = {};
function getElements(environment: &quot;production&quot; | &quot;sandbox&quot;) {
  return (elementsByEnv[environment] ??= loadWhopElements({ environment }));
}

type ThemeAccent = NonNullable&lt;
  NonNullable&lt;Parameters&lt;typeof Elements&gt;[0][&quot;appearance&quot;]&gt;[&quot;theme&quot;]
&gt;[&quot;accentColor&quot;];

type PortalProps = {
  companyId: string;
  accentColor: string;
  sandbox: boolean;
  earnedCents: number;
  activated: boolean;
  availableCents: number;
  pendingCents: number;
};

function Loading({ height }: { height: number }) {
  return (
    &lt;div className=&quot;grid place-items-center text-sm text-muted&quot; style={{ height }}&gt;
      Loading…
    &lt;/div&gt;
  );
}

function Card({ title, children }: { title?: string; children: React.ReactNode }) {
  return (
    &lt;section className=&quot;kofi-card p-5&quot;&gt;
      {title ? (
        &lt;h2 className=&quot;mb-3 text-sm font-bold uppercase tracking-wide text-muted&quot;&gt;{title}&lt;/h2&gt;
      ) : null}
      {children}
    &lt;/section&gt;
  );
}

function BalanceSummary({
  earnedCents,
  activated,
  availableCents,
  pendingCents,
  sandbox,
}: {
  earnedCents: number;
  activated: boolean;
  availableCents: number;
  pendingCents: number;
  sandbox: boolean;
}) {
  return (
    &lt;Card title=&quot;Balance&quot;&gt;
      &lt;div className=&quot;flex items-end justify-between gap-4&quot;&gt;
        &lt;div&gt;
          &lt;p className=&quot;text-3xl font-bold&quot;&gt;{formatUsd(earnedCents)}&lt;/p&gt;
          &lt;p className=&quot;text-sm text-muted&quot;&gt;Total earned from supporters&lt;/p&gt;
        &lt;/div&gt;
        {activated ? (
          &lt;div className=&quot;text-right&quot;&gt;
            &lt;p className=&quot;text-lg font-semibold&quot;&gt;{formatUsd(availableCents)}&lt;/p&gt;
            &lt;p className=&quot;text-xs text-muted&quot;&gt;
              Available to withdraw{pendingCents &gt; 0 ? ` · ${formatUsd(pendingCents)} pending` : &quot;&quot;}
            &lt;/p&gt;
          &lt;/div&gt;
        ) : null}
      &lt;/div&gt;
      {sandbox ? (
        &lt;p className=&quot;mt-3 border-t border-line pt-3 text-xs text-muted&quot;&gt;
          You&amp;rsquo;re in sandbox mode, so your withdrawable Whop balance stays $0 even after a
          test payment. On production, settled payments appear here once your payout account is
          verified.
        &lt;/p&gt;
      ) : null}
    &lt;/Card&gt;
  );
}

function ActivatePayouts() {
  const session = usePayoutsSession();
  return (
    &lt;div className=&quot;flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between&quot;&gt;
      &lt;div className=&quot;flex items-start gap-4&quot;&gt;
        &lt;span className=&quot;grid h-11 w-11 shrink-0 place-items-center rounded-full bg-surface-2&quot;&gt;
          &lt;BrandIcon name=&quot;money&quot; className=&quot;h-7 w-7&quot; /&gt;
        &lt;/span&gt;
        &lt;div&gt;
          &lt;h3 className=&quot;font-bold&quot;&gt;Activate payouts to withdraw&lt;/h3&gt;
          &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
            Verify your identity and add a bank account or PayPal. It takes a few minutes, and you
            only do it once.
          &lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;Button
        onClick={() =&gt; session?.showVerifyModal({})}
        size=&quot;3&quot;
        variant=&quot;solid&quot;
        className=&quot;w-full shrink-0 sm:w-auto&quot;
      &gt;
        Activate payouts
      &lt;/Button&gt;
    &lt;/div&gt;
  );
}

function EmbeddedPortal({ companyId, accentColor, sandbox, activated }: {
  companyId: string;
  accentColor: string;
  sandbox: boolean;
  activated: boolean;
}) {
  const [dark, setDark] = useState(false);
  const [failed, setFailed] = useState(false);
  const containerRef = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    const el = document.documentElement;
    const sync = () =&gt; setDark(el.classList.contains(&quot;dark&quot;));
    sync();
    const observer = new MutationObserver(sync);
    observer.observe(el, { attributes: true, attributeFilter: [&quot;class&quot;] });
    return () =&gt; observer.disconnect();
  }, []);

  useEffect(() =&gt; {
    const t = setTimeout(() =&gt; {
      if (!containerRef.current?.querySelector(&quot;iframe&quot;)) setFailed(true);
    }, 8000);
    return () =&gt; clearTimeout(t);
  }, []);

  const origin = typeof window !== &quot;undefined&quot; ? window.location.origin : &quot;&quot;;
  const elements = getElements(sandbox ? &quot;sandbox&quot; : &quot;production&quot;);

  return (
    &lt;Card title={activated ? &quot;Withdraw&quot; : &quot;Payouts&quot;}&gt;
      {failed ? (
        &lt;p className=&quot;rounded-xl border border-line bg-surface-2 px-4 py-3 text-sm text-muted&quot;&gt;
          We couldn&amp;apos;t load the live payout portal. Refresh the page to try again.
        &lt;/p&gt;
      ) : null}
      &lt;div ref={containerRef} className={failed ? &quot;hidden&quot; : undefined}&gt;
        &lt;Elements
          elements={elements}
          appearance={{ theme: { appearance: dark ? &quot;dark&quot; : &quot;light&quot;, accentColor: accentColor as ThemeAccent } }}
        &gt;
          &lt;PayoutsSession
            token={() =&gt;
              fetch(`/api/payouts/token?companyId=${companyId}`)
                .then((r) =&gt; r.json())
                .then((d) =&gt; d.token as string)
            }
            companyId={companyId}
            currency=&quot;usd&quot;
            redirectUrl={`${origin}/dashboard/payouts`}
          &gt;
            &lt;div className=&quot;grid gap-4&quot;&gt;
              &lt;StatusBannerElement fallback={&lt;Loading height={0} /&gt;} style={{ width: &quot;100%&quot; }} /&gt;
              {activated ? (
                &lt;&gt;
                  &lt;div style={{ position: &quot;relative&quot;, width: &quot;100%&quot;, height: &quot;95.5px&quot; }}&gt;
                    &lt;BalanceElement fallback={&lt;Loading height={96} /&gt;} /&gt;
                  &lt;/div&gt;
                  &lt;WithdrawalsElement fallback={&lt;Loading height={120} /&gt;} /&gt;
                &lt;/&gt;
              ) : (
                &lt;ActivatePayouts /&gt;
              )}
            &lt;/div&gt;
          &lt;/PayoutsSession&gt;
        &lt;/Elements&gt;
      &lt;/div&gt;
    &lt;/Card&gt;
  );
}

export default function PayoutsPortal({
  companyId,
  accentColor,
  sandbox,
  earnedCents,
  activated,
  availableCents,
  pendingCents,
}: PortalProps) {
  return (
    &lt;div className=&quot;grid gap-5&quot;&gt;
      &lt;BalanceSummary
        earnedCents={earnedCents}
        activated={activated}
        availableCents={availableCents}
        pendingCents={pendingCents}
        sandbox={sandbox}
      /&gt;
      &lt;EmbeddedPortal companyId={companyId} accentColor={accentColor} sandbox={sandbox} activated={activated} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Reconciling pending tips

A tip can settle on Whop while our record stays `PENDING`, for example when a supporter pays but closes the tab before the confirm step runs and the webhook delivery fails too.

Before the payouts page adds up earnings, we sweep the creator's recent payments on Whop and complete any pending tip that has actually settled. The sweep calls the Whop SDK, so the imports change as well. Update `lib/fulfillment.ts` with the following content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfillment.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-html">&lt;div class=&quot;ucb-box&quot;&gt;
  &lt;div class=&quot;ucb-header&quot;&gt;
    &lt;span class=&quot;ucb-title&quot;&gt;fulfillment.ts&lt;/span&gt;
    &lt;button class=&quot;ucb-copy&quot; onclick=&quot;
      const code = this.closest(&#039;.ucb-box&#039;).querySelector(&#039;code&#039;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &#039;Copied!&#039;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    &quot;&gt;Copy&lt;/button&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ucb-content&quot;&gt;
    &lt;pre class=&quot;ucb-pre&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { prisma } from &amp;quot;./prisma&amp;quot;;
import { whopsdk } from &amp;quot;./whop&amp;quot;;
import { formatUsd } from &amp;quot;./fees&amp;quot;;
import { notifyCreator } from &amp;quot;@/services/whop&amp;quot;;

type CreatorLike = { whopCompanyId: string | null };

async function notify(creator: CreatorLike, n: { title: string; subtitle?: string; content: string; iconUserId?: string }) {
  if (!creator.whopCompanyId) return;
  await notifyCreator({ companyId: creator.whopCompanyId, restPath: &amp;quot;/dashboard&amp;quot;, ...n });
}

export async function markSupportCompleted(supportId: string, whopPaymentId: string) {
  const support = await prisma.support.findUnique({ where: { id: supportId }, include: { creator: true } });
  if (!support || support.status === &amp;quot;COMPLETED&amp;quot;) return support;
  const updated = await prisma.support.update({
    where: { id: supportId },
    data: { status: &amp;quot;COMPLETED&amp;quot;, whopPaymentId },
  });
  const word = support.coffees === 1 ? &amp;quot;coffee&amp;quot; : &amp;quot;coffees&amp;quot;;
  await notify(support.creator, {
    title: &amp;quot;New supporter&amp;quot;,
    subtitle: `${support.supporterName} bought you ${support.coffees} ${word}`,
    content: support.message?.trim()
      ? `&amp;quot;${support.message.trim()}&amp;quot; — ${formatUsd(support.amountCents)}`
      : `You received ${formatUsd(support.amountCents)}!`,
  });
  return updated;
}

export async function reconcilePendingSupports(creatorId: string, whopCompanyId: string) {
  const pending = await prisma.support.findMany({
    where: { creatorId, status: &amp;quot;PENDING&amp;quot; },
    select: { id: true },
  });
  if (pending.length === 0) return;
  const pendingIds = new Set(pending.map((s) =&amp;gt; s.id));

  try {
    let scanned = 0;
    for await (const payment of whopsdk.payments.list({ company_id: whopCompanyId, direction: &amp;quot;desc&amp;quot; })) {
      const p = payment as unknown as {
        id: string;
        status?: string;
        substatus?: string;
        metadata?: Record&amp;lt;string, unknown&amp;gt; | null;
      };
      const ref = typeof p.metadata?.ref === &amp;quot;string&amp;quot; ? p.metadata.ref : null;
      const settled = p.status === &amp;quot;paid&amp;quot; || p.substatus === &amp;quot;succeeded&amp;quot;;
      if (ref &amp;amp;&amp;amp; settled &amp;amp;&amp;amp; pendingIds.has(ref)) {
        await markSupportCompleted(ref, p.id);
        pendingIds.delete(ref);
        if (pendingIds.size === 0) break;
      }
      if (++scanned &amp;gt;= 60) break;
    }
  } catch (err: unknown) {
    console.error(&amp;quot;reconcilePendingSupports failed:&amp;quot;, err);
  }
}

export async function markSupportRefunded(whopPaymentId: string) {
  const support = await prisma.support.findUnique({ where: { whopPaymentId } });
  if (support &amp;amp;&amp;amp; support.status !== &amp;quot;REFUNDED&amp;quot;) {
    await prisma.support.update({ where: { id: support.id }, data: { status: &amp;quot;REFUNDED&amp;quot; } });
  }
}

export async function completeOrder(orderId: string, whopPaymentId: string) {
  const order = await prisma.order.findUnique({ where: { id: orderId }, include: { creator: true, product: true } });
  if (!order || order.status === &amp;quot;COMPLETED&amp;quot;) return;
  await prisma.$transaction([
    prisma.order.update({ where: { id: orderId }, data: { status: &amp;quot;COMPLETED&amp;quot;, whopPaymentId } }),
    prisma.product.update({ where: { id: order.productId }, data: { salesCount: { increment: 1 } } }),
  ]);
  await notify(order.creator, {
    title: &amp;quot;New sale&amp;quot;,
    subtitle: order.product.title,
    content: `${order.buyerName} bought ${order.product.title} for ${formatUsd(order.amountCents)}`,
  });
}

export async function activateMembership(params: {
  creatorId: string;
  userId: string;
  tierId: string;
  whopMembershipId?: string;
}) {
  const tier = await prisma.tier.findUnique({ where: { id: params.tierId }, include: { creator: true } });
  if (!tier) return;
  const existing = await prisma.membership.findUnique({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
  });
  await prisma.membership.upsert({
    where: { userId_tierId: { userId: params.userId, tierId: params.tierId } },
    update: { status: &amp;quot;ACTIVE&amp;quot;, whopMembershipId: params.whopMembershipId ?? undefined },
    create: {
      creatorId: params.creatorId,
      userId: params.userId,
      tierId: params.tierId,
      status: &amp;quot;ACTIVE&amp;quot;,
      whopMembershipId: params.whopMembershipId ?? null,
    },
  });
  if (!existing) {
    await notify(tier.creator, {
      title: &amp;quot;New member&amp;quot;,
      subtitle: tier.name,
      content: `Someone just joined your &amp;quot;${tier.name}&amp;quot; tier (${formatUsd(tier.priceCents)}/mo)`,
    });
  }
}

export async function deactivateMembership(whopMembershipId: string) {
  const m = await prisma.membership.findUnique({ where: { whopMembershipId } });
  if (m &amp;amp;&amp;amp; m.status !== &amp;quot;CANCELED&amp;quot; &amp;amp;&amp;amp; m.status !== &amp;quot;EXPIRED&amp;quot;) {
    await prisma.membership.update({ where: { id: m.id }, data: { status: &amp;quot;CANCELED&amp;quot; } });
  }
}

export async function fulfillFromMetadata(
  meta: Record&amp;lt;string, unknown&amp;gt; | null | undefined,
  paymentId: string,
) {
  if (!meta) return;
  const kind = meta.kind;
  if (kind === &amp;quot;tip&amp;quot; &amp;amp;&amp;amp; typeof meta.supportId === &amp;quot;string&amp;quot;) {
    return markSupportCompleted(meta.supportId, paymentId);
  }
  if (kind === &amp;quot;shop&amp;quot; &amp;amp;&amp;amp; typeof meta.orderId === &amp;quot;string&amp;quot;) {
    return completeOrder(meta.orderId, paymentId);
  }
  if (
    kind === &amp;quot;membership&amp;quot; &amp;amp;&amp;amp;
    typeof meta.creatorId === &amp;quot;string&amp;quot; &amp;amp;&amp;amp;
    typeof meta.userId === &amp;quot;string&amp;quot; &amp;amp;&amp;amp;
    typeof meta.tierId === &amp;quot;string&amp;quot;
  ) {
    return activateMembership({ creatorId: meta.creatorId, userId: meta.userId, tierId: meta.tierId });
  }
}&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
  </div>
</div>

### The payouts page

The payouts page sends creators who have not set up payments back to onboarding. If they did, it re-checks any tips that have settled without a webhook and adds up their earnings for the balance component.

Then, it reads the connected company's payout status and live balance from Whop. Create `app/dashboard/payouts/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { isSandbox } from &quot;@/lib/env&quot;;
import { reconcilePendingSupports } from &quot;@/lib/fulfillment&quot;;
import { getPayoutSnapshot, type PayoutSnapshot } from &quot;@/services/whop&quot;;
import PayoutsPortal from &quot;@/components/payouts/PayoutsPortal&quot;;

export default async function PayoutsPage() {
  const { creator } = await requireCreator();

  if (creator.whopCompanyId) {
    await reconcilePendingSupports(creator.id, creator.whopCompanyId);
  }

  const earned = await prisma.support.aggregate({
    where: { creatorId: creator.id, status: &quot;COMPLETED&quot; },
    _sum: { amountCents: true },
  });
  const earnedCents = earned._sum.amountCents ?? 0;

  let snapshot: PayoutSnapshot = { activated: false, status: null, availableCents: 0, pendingCents: 0 };
  if (creator.whopCompanyId) {
    try {
      snapshot = await getPayoutSnapshot(creator.whopCompanyId);
    } catch (err: unknown) {
      console.error(&quot;getPayoutSnapshot failed:&quot;, err);
    }
  }

  return (
    &lt;div className=&quot;space-y-6&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Payouts&lt;/h1&gt;
        &lt;p className=&quot;mt-1 max-w-2xl text-sm text-muted&quot;&gt;
          Your earnings come straight from the supporters who tip you, join your
          memberships, and buy from your shop. Withdraw your available balance to
          your bank account or PayPal whenever you like.
        &lt;/p&gt;
      &lt;/div&gt;

      {!creator.whopCompanyId ? (
        &lt;div className=&quot;kofi-card p-6&quot;&gt;
          &lt;h2 className=&quot;text-lg font-bold&quot;&gt;Finish setting up payments&lt;/h2&gt;
          &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
            We need to connect your payout account before you can withdraw. Complete
            your creator setup to start receiving and withdrawing support.
          &lt;/p&gt;
          &lt;Link href=&quot;/dashboard/start&quot; className=&quot;btn-pill btn-accent mt-4&quot;&gt;
            Finish setup
          &lt;/Link&gt;
        &lt;/div&gt;
      ) : (
        &lt;&gt;
          &lt;PayoutsPortal
            companyId={creator.whopCompanyId}
            accentColor={creator.accentColor}
            sandbox={isSandbox()}
            earnedCents={earnedCents}
            activated={snapshot.activated}
            availableCents={snapshot.availableCents}
            pendingCents={snapshot.pendingCents}
          /&gt;
          &lt;p className=&quot;text-xs text-muted&quot;&gt;
            Identity verification (KYC) is handled securely inside the portal the first
            time you withdraw. You only need to do it once.
          &lt;/p&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The dashboard shell

The dashboard shell we'll build now wraps all dashboard pages with a sidebar shell. Create `app/dashboard/layout.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import DashboardNav from &quot;@/components/dashboard/DashboardNav&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import ShareButton from &quot;@/components/dashboard/ShareButton&quot;;

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await requireAuth();

  if (!user.creator) {
    return &lt;&gt;{children}&lt;/&gt;;
  }

  const creator = user.creator;

  return (
    &lt;div className=&quot;min-h-dvh bg-page md:flex&quot;&gt;
      &lt;aside className=&quot;border-line bg-surface md:sticky md:top-0 md:flex md:h-dvh md:w-64 md:flex-col md:border-r&quot;&gt;
        &lt;div className=&quot;flex items-center justify-between gap-2 border-b border-line px-5 py-4&quot;&gt;
          &lt;Link href=&quot;/dashboard&quot; className=&quot;flex items-center gap-2 text-lg font-bold&quot;&gt;
            &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
            Cuppa
          &lt;/Link&gt;
          &lt;div className=&quot;md:hidden&quot;&gt;
            &lt;ThemeToggle /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;DashboardNav username={creator.username} /&gt;

        &lt;div className=&quot;hidden px-4 pb-2 md:block&quot;&gt;
          &lt;ShareButton username={creator.username} /&gt;
        &lt;/div&gt;

        &lt;div className=&quot;hidden border-t border-line px-3 py-4 md:block&quot;&gt;
          &lt;div className=&quot;flex items-center justify-between px-2&quot;&gt;
            &lt;span className=&quot;truncate text-sm text-muted&quot;&gt;{creator.displayName}&lt;/span&gt;
            &lt;ThemeToggle /&gt;
          &lt;/div&gt;
          &lt;a
            href=&quot;/api/auth/logout&quot;
            className=&quot;mt-3 block rounded-xl px-3 py-2 text-sm text-muted transition hover:bg-surface-2 hover:text-ink&quot;
          &gt;
            Log out
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/aside&gt;

      &lt;main className=&quot;flex-1&quot;&gt;
        &lt;div className=&quot;mx-auto max-w-5xl px-5 py-8 md:py-10&quot;&gt;{children}&lt;/div&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### Small dashboard components

And now we'll build a few components for the dashboard. First, the Share button, which opens a modal to copy the page link or share it to Facebook, WhatsApp, Twitter, Email, etc. Create `components/dashboard/ShareButton.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ShareButton.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { Share, Link as LinkIcon, Check, X } from &quot;@/components/Icons&quot;;

export default function ShareButton({ username }: { username: string }) {
  const [open, setOpen] = useState(false);
  return (
    &lt;&gt;
      &lt;button
        type=&quot;button&quot;
        onClick={() =&gt; setOpen(true)}
        className=&quot;btn-pill btn-accent flex w-full items-center justify-center gap-2&quot;
      &gt;
        &lt;Share className=&quot;h-4 w-4&quot; /&gt; Share
      &lt;/button&gt;
      {open ? &lt;ShareDialog username={username} onClose={() =&gt; setOpen(false)} /&gt; : null}
    &lt;/&gt;
  );
}

function ShareDialog({ username, onClose }: { username: string; onClose: () =&gt; void }) {
  const [origin, setOrigin] = useState(&quot;&quot;);
  const [copied, setCopied] = useState(false);
  const [canShare, setCanShare] = useState(false);

  useEffect(() =&gt; {
    setOrigin(window.location.origin);
    setCanShare(typeof navigator !== &quot;undefined&quot; &amp;&amp; typeof navigator.share === &quot;function&quot;);
  }, []);

  const url = origin ? `${origin}/${username}` : &quot;&quot;;

  async function copyLink() {
    if (!url) return;
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() =&gt; setCopied(false), 1500);
    } catch {
    }
  }

  async function nativeShare() {
    if (!url) return;
    try {
      await navigator.share({ title: &quot;Cuppa&quot;, text: &quot;Support me on Cuppa&quot;, url });
    } catch {
    }
  }

  const enc = encodeURIComponent(url);
  const text = encodeURIComponent(&quot;Support me on Cuppa&quot;);
  const socials = [
    { label: &quot;X&quot;, href: `https://twitter.com/intent/tweet?text=${text}&amp;url=${enc}` },
    { label: &quot;Facebook&quot;, href: `https://www.facebook.com/sharer/sharer.php?u=${enc}` },
    { label: &quot;WhatsApp&quot;, href: `https://wa.me/?text=${text}%20${enc}` },
    { label: &quot;Email&quot;, href: `mailto:?subject=${text}&amp;body=${enc}` },
  ];

  return (
    &lt;div
      className=&quot;fixed inset-0 z-[60] grid place-items-center bg-black/60 p-4&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      onClick={onClose}
    &gt;
      &lt;div className=&quot;kofi-card w-full max-w-md p-6&quot; onClick={(e) =&gt; e.stopPropagation()}&gt;
        &lt;div className=&quot;flex items-start justify-between gap-4&quot;&gt;
          &lt;h3 className=&quot;text-lg font-bold&quot;&gt;Share your page&lt;/h3&gt;
          &lt;button type=&quot;button&quot; onClick={onClose} aria-label=&quot;Close&quot; className=&quot;text-muted hover:text-ink&quot;&gt;
            &lt;X className=&quot;h-5 w-5&quot; /&gt;
          &lt;/button&gt;
        &lt;/div&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;Share this link anywhere to bring in supporters.&lt;/p&gt;

        &lt;div className=&quot;mt-4 flex items-center gap-2 rounded-xl border border-line bg-surface px-3 py-2&quot;&gt;
          &lt;span className=&quot;min-w-0 flex-1 truncate text-sm text-muted&quot;&gt;kofi-clone-whop-tutorial.vercel.app/{username}&lt;/span&gt;
          &lt;button
            type=&quot;button&quot;
            onClick={copyLink}
            className=&quot;inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line px-3 py-1.5 text-sm font-semibold transition hover:bg-surface-2&quot;
          &gt;
            {copied ? &lt;Check className=&quot;h-4 w-4 text-positive&quot; /&gt; : &lt;LinkIcon className=&quot;h-4 w-4&quot; /&gt;}
            {copied ? &quot;Copied&quot; : &quot;Copy&quot;}
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div className=&quot;mt-4 grid grid-cols-2 gap-2&quot;&gt;
          {socials.map((s) =&gt; (
            &lt;a
              key={s.label}
              href={url ? s.href : undefined}
              target=&quot;_blank&quot;
              rel=&quot;noopener noreferrer&quot;
              className=&quot;rounded-full border border-line px-3 py-2 text-center text-sm font-semibold transition hover:bg-surface-2&quot;
            &gt;
              {s.label}
            &lt;/a&gt;
          ))}
        &lt;/div&gt;

        {canShare ? (
          &lt;button type=&quot;button&quot; onClick={nativeShare} className=&quot;btn-pill btn-secondary mt-3 w-full&quot;&gt;
            More sharing options
          &lt;/button&gt;
        ) : null}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Next, let's create the sidebar navigation, which highlights the section you're on and links out to the public page. Create `components/dashboard/DashboardNav.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">DashboardNav.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import Link from &quot;next/link&quot;;
import { usePathname } from &quot;next/navigation&quot;;
import { Home, User, Gear, Users, Wallet, FileText, Crown, Bag } from &quot;@/components/Icons&quot;;

type IconType = React.ComponentType&lt;{ className?: string }&gt;;
type NavItem = { label: string; href: string; icon: IconType };
type NavGroup = { label?: string; items: NavItem[] };

export default function DashboardNav({ username }: { username: string }) {
  const pathname = usePathname();

  const groups: NavGroup[] = [
    {
      items: [
        { label: &quot;Home&quot;, href: &quot;/dashboard&quot;, icon: Home },
        { label: &quot;Your page&quot;, href: `/${username}`, icon: User },
        { label: &quot;Settings&quot;, href: &quot;/dashboard/settings&quot;, icon: Gear },
      ],
    },
    {
      label: &quot;Earnings&quot;,
      items: [
        { label: &quot;Supporters&quot;, href: &quot;/dashboard/supporters&quot;, icon: Users },
        { label: &quot;Payouts&quot;, href: &quot;/dashboard/payouts&quot;, icon: Wallet },
      ],
    },
    {
      label: &quot;Grow your page&quot;,
      items: [
        { label: &quot;Posts&quot;, href: &quot;/dashboard/posts&quot;, icon: FileText },
        { label: &quot;Memberships&quot;, href: &quot;/dashboard/tiers&quot;, icon: Crown },
        { label: &quot;Shop&quot;, href: &quot;/dashboard/shop&quot;, icon: Bag },
      ],
    },
  ];

  function isActive(href: string) {
    if (href === &quot;/dashboard&quot;) return pathname === &quot;/dashboard&quot;;
    if (href === `/${username}`) return false;
    return pathname === href || pathname.startsWith(`${href}/`);
  }

  return (
    &lt;nav className=&quot;no-scrollbar flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:gap-0.5 md:overflow-visible md:py-4&quot;&gt;
      {groups.map((group, gi) =&gt; (
        &lt;div key={gi} className=&quot;contents md:block&quot;&gt;
          {group.label ? (
            &lt;p className=&quot;hidden px-3 pb-1 pt-4 text-xs font-bold uppercase tracking-wide text-muted md:block&quot;&gt;
              {group.label}
            &lt;/p&gt;
          ) : null}
          {group.items.map((item) =&gt; {
            const Icon = item.icon;
            const active = isActive(item.href);
            return (
              &lt;Link
                key={item.href}
                href={item.href}
                className={`flex items-center gap-2.5 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-semibold transition ${
                  active ? &quot;bg-surface-2 text-ink&quot; : &quot;text-muted hover:bg-surface-2 hover:text-ink&quot;
                }`}
              &gt;
                &lt;Icon className=&quot;h-[18px] w-[18px]&quot; /&gt;
                {item.label}
              &lt;/Link&gt;
            );
          })}
        &lt;/div&gt;
      ))}

      &lt;a
        href=&quot;/api/auth/logout&quot;
        className=&quot;flex items-center gap-2.5 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-semibold text-muted transition hover:bg-surface-2 hover:text-ink md:hidden&quot;
      &gt;
        Log out
      &lt;/a&gt;
    &lt;/nav&gt;
  );
}</code></pre>
  </div>
</div>

Then the share card for the dashboard home, a simpler copy-and-share card for the page link. Create `components/dashboard/ShareCard.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ShareCard.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { Link as LinkIcon, Check } from &quot;@/components/Icons&quot;;

export default function ShareCard({ username }: { username: string }) {
  const [origin, setOrigin] = useState(&quot;&quot;);
  const [copied, setCopied] = useState(false);

  useEffect(() =&gt; setOrigin(window.location.origin), []);

  const url = origin ? `${origin}/${username}` : &quot;&quot;;

  async function copyLink() {
    if (!url) return;
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() =&gt; setCopied(false), 1500);
    } catch {
    }
  }

  const enc = encodeURIComponent(url);
  const xShare = `https://twitter.com/intent/tweet?text=${encodeURIComponent(&quot;Support me on Cuppa&quot;)}&amp;url=${enc}`;
  const fbShare = `https://www.facebook.com/sharer/sharer.php?u=${enc}`;

  return (
    &lt;section className=&quot;kofi-card flex flex-col p-5&quot;&gt;
      &lt;h2 className=&quot;text-sm font-bold uppercase tracking-wide text-muted&quot;&gt;Share your link&lt;/h2&gt;
      &lt;p className=&quot;mt-2 text-sm text-muted&quot;&gt;
        Add this link to your social bios and share it with followers. Creators who share regularly
        earn more.
      &lt;/p&gt;
      &lt;div className=&quot;mt-3 flex items-center rounded-xl border border-line bg-surface px-3 py-2 text-sm&quot;&gt;
        &lt;span className=&quot;truncate text-muted&quot;&gt;kofi-clone-whop-tutorial.vercel.app/{username}&lt;/span&gt;
      &lt;/div&gt;
      &lt;div className=&quot;mt-3 flex items-center gap-2&quot;&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={copyLink}
          className=&quot;inline-flex items-center gap-2 rounded-full border border-line px-3 py-1.5 text-sm font-semibold transition hover:bg-surface-2&quot;
        &gt;
          {copied ? &lt;Check className=&quot;h-4 w-4 text-positive&quot; /&gt; : &lt;LinkIcon className=&quot;h-4 w-4&quot; /&gt;}
          {copied ? &quot;Copied!&quot; : &quot;Copy link&quot;}
        &lt;/button&gt;
        &lt;a
          href={url ? xShare : undefined}
          target=&quot;_blank&quot;
          rel=&quot;noopener noreferrer&quot;
          aria-label=&quot;Share on X&quot;
          className=&quot;grid h-9 w-9 place-items-center rounded-full border border-line text-sm font-bold transition hover:bg-surface-2&quot;
        &gt;
          X
        &lt;/a&gt;
        &lt;a
          href={url ? fbShare : undefined}
          target=&quot;_blank&quot;
          rel=&quot;noopener noreferrer&quot;
          aria-label=&quot;Share on Facebook&quot;
          className=&quot;grid h-9 w-9 place-items-center rounded-full border border-line text-sm font-bold transition hover:bg-surface-2&quot;
        &gt;
          f
        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  );
}</code></pre>
  </div>
</div>

### The dashboard home

The home is the creator's landing screen and onboarding checklist, followed by profile statistics, recent payments, the share card, and the recommendations list. Create `app/dashboard/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import ShareCard from &quot;@/components/dashboard/ShareCard&quot;;
import { Check, Gear, ArrowUpRight } from &quot;@/components/Icons&quot;;

function timeAgo(date: Date): string {
  const s = Math.floor((Date.now() - date.getTime()) / 1000);
  if (s &lt; 60) return &quot;just now&quot;;
  const m = Math.floor(s / 60);
  if (m &lt; 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h &lt; 24) return `${h}h ago`;
  return `${Math.floor(h / 24)}d ago`;
}

export default async function DashboardHomePage() {
  const user = await requireCreator();
  const creator = user.creator;

  const [totals, followingCount, followerCount, tierCount, productCount, goalCount, recentSupports, recentOrders] =
    await Promise.all([
      prisma.support.aggregate({
        where: { creatorId: creator.id, status: &quot;COMPLETED&quot; },
        _sum: { coffees: true, amountCents: true },
        _count: true,
      }),
      prisma.follow.count({ where: { userId: creator.userId } }),
      prisma.follow.count({ where: { creatorId: creator.id } }),
      prisma.tier.count({ where: { creatorId: creator.id, isActive: true } }),
      prisma.product.count({ where: { creatorId: creator.id, isActive: true } }),
      prisma.goal.count({ where: { creatorId: creator.id, isActive: true } }),
      prisma.support.findMany({
        where: { creatorId: creator.id, status: &quot;COMPLETED&quot; },
        orderBy: { createdAt: &quot;desc&quot; },
        take: 6,
      }),
      prisma.order.findMany({
        where: { creatorId: creator.id, status: &quot;COMPLETED&quot; },
        orderBy: { createdAt: &quot;desc&quot; },
        take: 6,
        include: { product: true },
      }),
    ]);

  const coffees = totals._sum.coffees ?? 0;
  const raised = totals._sum.amountCents ?? 0;
  const supporterCount = totals._count;

  const activity = [
    ...recentSupports.map((s) =&gt; ({
      id: s.id,
      name: s.supporterName,
      label: `${s.coffees} ${s.coffees === 1 ? &quot;coffee&quot; : &quot;coffees&quot;}`,
      amountCents: s.amountCents,
      at: s.createdAt,
    })),
    ...recentOrders.map((o) =&gt; ({
      id: o.id,
      name: o.buyerName,
      label: o.product.title,
      amountCents: o.amountCents,
      at: o.createdAt,
    })),
  ]
    .sort((a, b) =&gt; b.at.getTime() - a.at.getTime())
    .slice(0, 6);

  const steps = [
    {
      key: &quot;connect&quot;,
      title: &quot;Set up payouts&quot;,
      desc: &quot;Link an account so your earnings have somewhere to land.&quot;,
      done: !!creator.whopCompanyId,
      href: &quot;/dashboard/payouts&quot;,
      action: &quot;Set up&quot;,
    },
    {
      key: &quot;profile&quot;,
      title: &quot;Make the page yours&quot;,
      desc: &quot;Add a photo and a line about what you do.&quot;,
      done: !!creator.bio &amp;&amp; !!(creator.avatarUrl || creator.coverImageUrl),
      href: &quot;/dashboard/settings&quot;,
      action: &quot;Edit&quot;,
    },
    {
      key: &quot;earn&quot;,
      title: &quot;Give people a way to chip in&quot;,
      desc: &quot;Add a membership, list something in your shop, or set a goal.&quot;,
      done: tierCount + productCount + goalCount &gt; 0,
      href: &quot;/dashboard/tiers&quot;,
      action: &quot;Add&quot;,
    },
    {
      key: &quot;share&quot;,
      title: &quot;Put it out there&quot;,
      desc: &quot;Share your link and land your first cup.&quot;,
      done: supporterCount &gt; 0,
      href: `/${creator.username}`,
      action: &quot;Share&quot;,
    },
  ];
  const progress = Math.round((steps.filter((s) =&gt; s.done).length / steps.length) * 100);

  const avatarUrl = creator.avatarUrl ?? user.avatarUrl;

  const suggestions = [
    {
      icon: &quot;palette&quot; as const,
      title: &quot;Add a cover image&quot;,
      desc: &quot;A banner up top makes the page feel like yours.&quot;,
      href: &quot;/dashboard/settings&quot;,
      action: &quot;Add a cover&quot;,
    },
    {
      icon: &quot;money&quot; as const,
      title: &quot;Set a goal&quot;,
      desc: &quot;Show people what their support is building toward.&quot;,
      href: &quot;/dashboard/settings&quot;,
      action: &quot;Set a goal&quot;,
    },
    {
      icon: &quot;megaphone&quot; as const,
      title: &quot;Post an update&quot;,
      desc: &quot;Share what you&#039;re working on so people have a reason to come back.&quot;,
      href: &quot;/dashboard/posts&quot;,
      action: &quot;Write a post&quot;,
    },
    {
      icon: &quot;heart&quot; as const,
      title: &quot;Add a membership&quot;,
      desc: &quot;Let your regulars back you every month for perks.&quot;,
      href: &quot;/dashboard/tiers&quot;,
      action: &quot;Add a membership&quot;,
    },
    {
      icon: &quot;shop&quot; as const,
      title: &quot;Stock your shop&quot;,
      desc: &quot;Sell downloads or physical goods, no listing fees.&quot;,
      href: &quot;/dashboard/shop&quot;,
      action: &quot;Add a product&quot;,
    },
    {
      icon: &quot;confetti&quot; as const,
      title: &quot;Spread the word&quot;,
      desc: &quot;Send your page to a few people and see who chips in.&quot;,
      href: `/${creator.username}`,
      action: &quot;Share your page&quot;,
    },
  ];

  return (
    &lt;div className=&quot;space-y-6&quot;&gt;
      &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Home&lt;/h1&gt;

      &lt;section className=&quot;kofi-card p-6&quot;&gt;
        &lt;h2 className=&quot;text-lg font-bold&quot;&gt;Get your page ready&lt;/h2&gt;
        &lt;p className=&quot;text-sm text-muted&quot;&gt;A few quick things before you share it.&lt;/p&gt;
        &lt;div className=&quot;mt-3 h-2 overflow-hidden rounded-full bg-surface-2&quot;&gt;
          &lt;div className=&quot;h-full rounded-full bg-positive transition-all&quot; style={{ width: `${progress}%` }} /&gt;
        &lt;/div&gt;
        &lt;div className=&quot;mt-5 space-y-3&quot;&gt;
          {steps.map((step) =&gt; (
            &lt;div key={step.key} className=&quot;flex items-center gap-3 rounded-2xl border border-line p-4&quot;&gt;
              &lt;span
                className={
                  step.done
                    ? &quot;grid h-6 w-6 shrink-0 place-items-center rounded-full bg-positive text-white&quot;
                    : &quot;h-6 w-6 shrink-0 rounded-full border-2 border-muted/40&quot;
                }
              &gt;
                {step.done ? &lt;Check className=&quot;h-4 w-4&quot; /&gt; : null}
              &lt;/span&gt;
              &lt;div className=&quot;min-w-0 flex-1&quot;&gt;
                &lt;p className=&quot;font-semibold&quot;&gt;{step.title}&lt;/p&gt;
                &lt;p className=&quot;text-sm text-muted&quot;&gt;{step.desc}&lt;/p&gt;
              &lt;/div&gt;
              {step.done ? (
                &lt;span className=&quot;shrink-0 text-sm font-semibold text-muted&quot;&gt;Done&lt;/span&gt;
              ) : (
                &lt;Link href={step.href} className=&quot;btn-pill btn-secondary shrink-0 text-sm&quot;&gt;
                  {step.action}
                &lt;/Link&gt;
              )}
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;kofi-card p-6&quot;&gt;
        &lt;div className=&quot;flex flex-wrap items-center justify-between gap-4&quot;&gt;
          &lt;div className=&quot;flex items-center gap-4&quot;&gt;
            {avatarUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              &lt;img src={avatarUrl} alt=&quot;&quot; className=&quot;h-14 w-14 rounded-full object-cover&quot; /&gt;
            ) : (
              &lt;span className=&quot;grid h-14 w-14 place-items-center rounded-full bg-surface-2 text-lg font-bold&quot;&gt;
                {creator.displayName.charAt(0).toUpperCase()}
              &lt;/span&gt;
            )}
            &lt;div&gt;
              &lt;p className=&quot;text-lg font-bold&quot;&gt;{creator.displayName}&lt;/p&gt;
              &lt;p className=&quot;text-sm text-muted&quot;&gt;kofi-clone-whop-tutorial.vercel.app/{creator.username}&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;div className=&quot;flex items-center gap-2&quot;&gt;
            &lt;Link href=&quot;/dashboard/settings&quot; className=&quot;btn-pill btn-secondary text-sm&quot;&gt;
              Edit profile
            &lt;/Link&gt;
            &lt;Link
              href=&quot;/dashboard/settings&quot;
              aria-label=&quot;Settings&quot;
              className=&quot;grid h-9 w-9 place-items-center rounded-full border border-line text-muted transition hover:bg-surface-2 hover:text-ink&quot;
            &gt;
              &lt;Gear className=&quot;h-4 w-4&quot; /&gt;
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div className=&quot;mt-5 grid grid-cols-3 gap-4 border-t border-line pt-4&quot;&gt;
          &lt;div&gt;
            &lt;p className=&quot;text-lg font-bold&quot;&gt;{coffees.toLocaleString()}&lt;/p&gt;
            &lt;p className=&quot;text-xs text-muted&quot;&gt;Coffees&lt;/p&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;p className=&quot;text-lg font-bold&quot;&gt;{followingCount.toLocaleString()}&lt;/p&gt;
            &lt;p className=&quot;text-xs text-muted&quot;&gt;Following&lt;/p&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;p className=&quot;text-lg font-bold&quot;&gt;{followerCount.toLocaleString()}&lt;/p&gt;
            &lt;p className=&quot;text-xs text-muted&quot;&gt;Followers&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;kofi-card p-6&quot;&gt;
        &lt;div className=&quot;flex items-center justify-between gap-3&quot;&gt;
          &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;Latest support&lt;/h2&gt;
          &lt;Link href=&quot;/dashboard/supporters&quot; className=&quot;text-sm font-semibold text-muted hover:text-ink&quot;&gt;
            View all &lt;ArrowUpRight className=&quot;inline h-4 w-4 align-text-bottom&quot; /&gt;
          &lt;/Link&gt;
        &lt;/div&gt;
        {activity.length === 0 ? (
          &lt;p className=&quot;mt-3 text-sm text-muted&quot;&gt;Nothing&amp;rsquo;s come in yet. Share your link and that&amp;rsquo;ll change.&lt;/p&gt;
        ) : (
          &lt;ul className=&quot;mt-4 divide-y divide-line&quot;&gt;
            {activity.map((a) =&gt; (
              &lt;li key={a.id} className=&quot;flex items-center justify-between gap-4 py-3&quot;&gt;
                &lt;div className=&quot;min-w-0&quot;&gt;
                  &lt;p className=&quot;truncate font-semibold&quot;&gt;{a.name}&lt;/p&gt;
                  &lt;p className=&quot;text-xs text-muted&quot;&gt;
                    {a.label} · {timeAgo(a.at)}
                  &lt;/p&gt;
                &lt;/div&gt;
                &lt;span className=&quot;shrink-0 font-semibold text-positive&quot;&gt;{formatUsd(a.amountCents)}&lt;/span&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        )}
      &lt;/section&gt;

      &lt;div className=&quot;grid gap-6 md:grid-cols-2&quot;&gt;
        &lt;ShareCard username={creator.username} /&gt;
        &lt;section className=&quot;kofi-card p-5&quot;&gt;
          &lt;h2 className=&quot;text-sm font-bold uppercase tracking-wide text-muted&quot;&gt;At a glance&lt;/h2&gt;
          &lt;div className=&quot;mt-3 grid grid-cols-2 gap-4&quot;&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold&quot;&gt;{formatUsd(raised)}&lt;/p&gt;
              &lt;p className=&quot;text-xs text-muted&quot;&gt;Raised&lt;/p&gt;
            &lt;/div&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold&quot;&gt;{supporterCount.toLocaleString()}&lt;/p&gt;
              &lt;p className=&quot;text-xs text-muted&quot;&gt;Supporters&lt;/p&gt;
            &lt;/div&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold&quot;&gt;{coffees.toLocaleString()}&lt;/p&gt;
              &lt;p className=&quot;text-xs text-muted&quot;&gt;Coffees&lt;/p&gt;
            &lt;/div&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-2xl font-bold&quot;&gt;{followerCount.toLocaleString()}&lt;/p&gt;
              &lt;p className=&quot;text-xs text-muted&quot;&gt;Followers&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/section&gt;
      &lt;/div&gt;

      &lt;section&gt;
        &lt;h2 className=&quot;mb-4 text-lg font-semibold&quot;&gt;Things to try&lt;/h2&gt;
        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          {suggestions.map((s) =&gt; (
            &lt;div key={s.title} className=&quot;kofi-card flex flex-col p-5&quot;&gt;
              &lt;div className=&quot;flex items-center gap-2&quot;&gt;
                &lt;BrandIcon name={s.icon} className=&quot;h-7 w-7&quot; /&gt;
                &lt;h3 className=&quot;font-bold&quot;&gt;{s.title}&lt;/h3&gt;
              &lt;/div&gt;
              &lt;p className=&quot;mt-2 flex-1 text-sm text-muted&quot;&gt;{s.desc}&lt;/p&gt;
              &lt;Link href={s.href} className=&quot;btn-pill btn-outline mt-4 self-start text-sm&quot;&gt;
                {s.action}
              &lt;/Link&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

### The supporters page

The supporters page lists the creator's completed tips and their active members, with each member's tier. Create `app/dashboard/supporters/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatUsd } from &quot;@/lib/fees&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

function formatDate(date: Date): string {
  return date.toLocaleDateString(&quot;en-US&quot;, { month: &quot;short&quot;, day: &quot;numeric&quot;, year: &quot;numeric&quot; });
}

export default async function DashboardSupportersPage() {
  const { creator } = await requireCreator();

  const [supports, members] = await Promise.all([
    prisma.support.findMany({
      where: { creatorId: creator.id, status: &quot;COMPLETED&quot; },
      orderBy: { createdAt: &quot;desc&quot; },
      take: 100,
    }),
    prisma.membership.findMany({
      where: { creatorId: creator.id, status: &quot;ACTIVE&quot; },
      orderBy: { createdAt: &quot;desc&quot; },
      include: {
        tier: { select: { name: true } },
        user: { select: { name: true, username: true } },
      },
    }),
  ]);

  return (
    &lt;div className=&quot;space-y-10&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Supporters&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;Everyone who has tipped or joined a membership.&lt;/p&gt;
      &lt;/div&gt;

      &lt;section className=&quot;space-y-3&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;One-time support&lt;/h2&gt;
        &lt;div className=&quot;kofi-card overflow-hidden&quot;&gt;
          {supports.length === 0 ? (
            &lt;p className=&quot;p-6 text-sm text-muted&quot;&gt;No one-time support yet.&lt;/p&gt;
          ) : (
            &lt;div className=&quot;overflow-x-auto&quot;&gt;
              &lt;table className=&quot;w-full text-sm&quot;&gt;
                &lt;thead&gt;
                  &lt;tr className=&quot;border-b border-line text-left text-muted&quot;&gt;
                    &lt;th className=&quot;px-4 py-3 font-semibold&quot;&gt;Name&lt;/th&gt;
                    &lt;th className=&quot;px-4 py-3 font-semibold&quot;&gt;Coffees&lt;/th&gt;
                    &lt;th className=&quot;px-4 py-3 font-semibold&quot;&gt;Amount&lt;/th&gt;
                    &lt;th className=&quot;px-4 py-3 font-semibold&quot;&gt;Message&lt;/th&gt;
                    &lt;th className=&quot;px-4 py-3 font-semibold&quot;&gt;Date&lt;/th&gt;
                  &lt;/tr&gt;
                &lt;/thead&gt;
                &lt;tbody&gt;
                  {supports.map((support) =&gt; (
                    &lt;tr key={support.id} className=&quot;border-b border-line last:border-0&quot;&gt;
                      &lt;td className=&quot;px-4 py-3 font-medium&quot;&gt;{support.supporterName}&lt;/td&gt;
                      &lt;td className=&quot;px-4 py-3 text-muted&quot;&gt;
                        &lt;span className=&quot;inline-flex items-center gap-1.5&quot;&gt;
                          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-4 w-4&quot; /&gt;
                          {support.coffees}
                        &lt;/span&gt;
                      &lt;/td&gt;
                      &lt;td className=&quot;px-4 py-3 font-semibold text-positive&quot;&gt;
                        {formatUsd(support.amountCents)}
                      &lt;/td&gt;
                      &lt;td className=&quot;max-w-xs px-4 py-3 text-muted&quot;&gt;
                        {support.message ? support.message : &lt;span className=&quot;opacity-50&quot;&gt;—&lt;/span&gt;}
                      &lt;/td&gt;
                      &lt;td className=&quot;whitespace-nowrap px-4 py-3 text-muted&quot;&gt;
                        {formatDate(support.createdAt)}
                      &lt;/td&gt;
                    &lt;/tr&gt;
                  ))}
                &lt;/tbody&gt;
              &lt;/table&gt;
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;space-y-3&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold&quot;&gt;Active members&lt;/h2&gt;
        &lt;div className=&quot;kofi-card overflow-hidden&quot;&gt;
          {members.length === 0 ? (
            &lt;p className=&quot;p-6 text-sm text-muted&quot;&gt;No active members yet.&lt;/p&gt;
          ) : (
            &lt;ul className=&quot;divide-y divide-line&quot;&gt;
              {members.map((member) =&gt; (
                &lt;li key={member.id} className=&quot;flex items-center justify-between gap-4 px-4 py-3&quot;&gt;
                  &lt;div className=&quot;min-w-0&quot;&gt;
                    &lt;p className=&quot;font-medium&quot;&gt;
                      {member.user.name ?? member.user.username}
                    &lt;/p&gt;
                    &lt;p className=&quot;text-xs text-muted&quot;&gt;{member.tier.name}&lt;/p&gt;
                  &lt;/div&gt;
                  &lt;span className=&quot;shrink-0 text-xs text-muted&quot;&gt;
                    since {formatDate(member.createdAt)}
                  &lt;/span&gt;
                &lt;/li&gt;
              ))}
            &lt;/ul&gt;
          )}
        &lt;/div&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

## Checkpoint

- Open `/dashboard` as a creator. You get the sidebar shell with Home, Supporters, and Payouts, plus a Share button.
- The home shows your onboarding checklist, profile stats, and a feed of your recent payments and orders.
- Open `/dashboard/payouts`. You see your earned total (from completed supports) and, if payouts aren't set up yet, an "Activate payouts" button.
- Click **Activate payouts** and confirm Whop's identity-verification (KYC) modal opens. In the sandbox, Whop's own balance reads $0 even after a test tip; the headline earned total still reflects your sales.
- Open `/dashboard/supporters` and confirm your completed tips and active members are listed.
- Click **Share** and confirm the modal copies your page link and offers the share options.

## Part 13: Homepage, discovery, and theming

In this part, we're going to build the app's entry point, the landing and explore pages. We're also going to implement the light and dark themes and the creator-customized accent colors.

### The homepage

The homepage displays newly onboarded creators along with their supporter counts and generates the marketing page. Since the homepage and features pages share the same footer, let’s build the footer as a single component. Create `components/SiteFooter.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">SiteFooter.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

export default function SiteFooter() {
  return (
    &lt;footer className=&quot;bg-page&quot;&gt;
      &lt;div className=&quot;mx-auto max-w-6xl px-5 py-12&quot;&gt;
        &lt;div className=&quot;kofi-card grid grid-cols-2 gap-8 p-8 sm:grid-cols-4&quot;&gt;
          &lt;div&gt;
            &lt;p className=&quot;font-bold&quot;&gt;Features&lt;/p&gt;
            &lt;ul className=&quot;mt-3 space-y-2 text-sm text-muted&quot;&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;Tips&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;Memberships&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;Shop&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;Posts&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;Goals&lt;/Link&gt;&lt;/li&gt;
            &lt;/ul&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;p className=&quot;font-bold&quot;&gt;Discover&lt;/p&gt;
            &lt;ul className=&quot;mt-3 space-y-2 text-sm text-muted&quot;&gt;
              &lt;li&gt;&lt;Link href=&quot;/explore&quot; className=&quot;hover:text-ink&quot;&gt;Explore creators&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/features&quot; className=&quot;hover:text-ink&quot;&gt;How it works&lt;/Link&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/#faq&quot; className=&quot;hover:text-ink&quot;&gt;FAQ&lt;/Link&gt;&lt;/li&gt;
            &lt;/ul&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;p className=&quot;font-bold&quot;&gt;Account&lt;/p&gt;
            &lt;ul className=&quot;mt-3 space-y-2 text-sm text-muted&quot;&gt;
              &lt;li&gt;&lt;a href=&quot;/api/auth/login&quot; className=&quot;hover:text-ink&quot;&gt;Log in&lt;/a&gt;&lt;/li&gt;
              &lt;li&gt;&lt;a href=&quot;/api/auth/login?returnTo=/dashboard/start&quot; className=&quot;hover:text-ink&quot;&gt;Get started&lt;/a&gt;&lt;/li&gt;
              &lt;li&gt;&lt;Link href=&quot;/dashboard&quot; className=&quot;hover:text-ink&quot;&gt;Dashboard&lt;/Link&gt;&lt;/li&gt;
            &lt;/ul&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;p className=&quot;font-bold&quot;&gt;About Cuppa&lt;/p&gt;
            &lt;ul className=&quot;mt-3 space-y-2 text-sm text-muted&quot;&gt;
              &lt;li&gt;&lt;a href=&quot;https://whop.com&quot; target=&quot;_blank&quot; rel=&quot;noreferrer&quot; className=&quot;hover:text-ink&quot;&gt;Powered by Whop&lt;/a&gt;&lt;/li&gt;
              &lt;li&gt;&lt;a href=&quot;https://nextjs.org&quot; target=&quot;_blank&quot; rel=&quot;noreferrer&quot; className=&quot;hover:text-ink&quot;&gt;Built with Next.js&lt;/a&gt;&lt;/li&gt;
              &lt;li&gt;Made for creators&lt;/li&gt;
            &lt;/ul&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div className=&quot;mt-8 flex flex-col items-center justify-between gap-4 sm:flex-row&quot;&gt;
          &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2&quot;&gt;
            &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-8 w-8&quot; /&gt;
            &lt;span className=&quot;font-display text-lg font-extrabold&quot;&gt;Cuppa&lt;/span&gt;
          &lt;/Link&gt;
          &lt;p className=&quot;text-sm text-muted&quot;&gt;© 2026 Cuppa. Built with Next.js and Whop.&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/footer&gt;
  );
}</code></pre>
  </div>
</div>

We want the featured creators section to be able to filter creators by tag using the category pills. Now, let's build the pill row only from tags that actually have creators, so no pill is ever a dead end. Create `components/CreatorCategories.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CreatorCategories.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

/* eslint-disable @next/next/no-img-element */
import { useState } from &quot;react&quot;;
import Link from &quot;next/link&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

export type FeaturedCreator = {
  username: string;
  displayName: string;
  avatarUrl: string | null;
  accent: string;
  supporters: number;
  tags: string[];
};

const ALL = &quot;All&quot;;

export default function CreatorCategories({ creators }: { creators: FeaturedCreator[] }) {
  const categories = Array.from(new Set(creators.flatMap((c) =&gt; c.tags))).slice(0, 9);
  const [active, setActive] = useState(ALL);

  const shown = active === ALL ? creators : creators.filter((c) =&gt; c.tags.includes(active));
  const tabs = [ALL, ...categories];

  return (
    &lt;&gt;
      {categories.length &gt; 0 ? (
        &lt;div className=&quot;mb-8 flex flex-wrap justify-center gap-2.5&quot;&gt;
          {tabs.map((tab) =&gt; {
            const isActive = tab === active;
            return (
              &lt;button
                key={tab}
                type=&quot;button&quot;
                onClick={() =&gt; setActive(tab)}
                aria-pressed={isActive}
                className={[
                  &quot;rounded-full border-2 px-5 py-2.5 text-sm font-semibold transition&quot;,
                  isActive
                    ? &quot;border-ink bg-surface text-ink&quot;
                    : &quot;border-transparent bg-surface-2 text-ink hover:brightness-95&quot;,
                ].join(&quot; &quot;)}
              &gt;
                {tab}
              &lt;/button&gt;
            );
          })}
        &lt;/div&gt;
      ) : null}

      &lt;div className=&quot;grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6&quot;&gt;
        {shown.map((c) =&gt; (
          &lt;Link
            key={c.username}
            href={`/${c.username}`}
            className=&quot;kofi-card flex flex-col items-center p-5 text-center transition-[filter] hover:brightness-[0.98]&quot;
          &gt;
            &lt;div
              className={`grid h-16 w-16 place-items-center overflow-hidden rounded-full ${c.avatarUrl ? &quot;&quot; : &quot;bg-surface-2&quot;}`}
              style={c.avatarUrl ? { background: c.accent } : undefined}
            &gt;
              {c.avatarUrl ? (
                &lt;img src={c.avatarUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
              ) : (
                &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-10 w-10&quot; /&gt;
              )}
            &lt;/div&gt;
            &lt;p className=&quot;mt-3 w-full truncate font-semibold&quot;&gt;{c.displayName}&lt;/p&gt;
            &lt;p className=&quot;mt-0.5 text-xs text-muted&quot;&gt;
              {c.supporters} {c.supporters === 1 ? &quot;supporter&quot; : &quot;supporters&quot;}
            &lt;/p&gt;
          &lt;/Link&gt;
        ))}
      &lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
  </div>
</div>

Now the homepage itself, create `app/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import BrandIcon, { type BrandIconName } from &quot;@/components/BrandIcon&quot;;
import CreatorCategories, { type FeaturedCreator } from &quot;@/components/CreatorCategories&quot;;
import SiteFooter from &quot;@/components/SiteFooter&quot;;
import { Button } from &quot;@whop/react/components&quot;;

const AUTH_ERRORS: Record&lt;string, string&gt; = {
  token_exchange_failed: &quot;We couldn&#039;t complete sign in with Whop. Please try again.&quot;,
  state_mismatch: &quot;Your sign in session expired. Please try again.&quot;,
  missing_pkce: &quot;Your sign in session expired. Please try again.&quot;,
  bad_pkce: &quot;Your sign in session expired. Please try again.&quot;,
  missing_code: &quot;Sign in was canceled.&quot;,
  access_denied: &quot;Sign in was canceled.&quot;,
};

async function getFeaturedCreators(): Promise&lt;FeaturedCreator[]&gt; {
  const creators = await prisma.creator.findMany({
    where: { isActive: true, whopOnboarded: true },
    orderBy: { createdAt: &quot;desc&quot; },
    take: 12,
    select: {
      id: true,
      username: true,
      displayName: true,
      avatarUrl: true,
      accentColor: true,
      tags: true,
    },
  });
  if (creators.length === 0) return [];

  const counts = await prisma.support.groupBy({
    by: [&quot;creatorId&quot;],
    where: { creatorId: { in: creators.map((c) =&gt; c.id) }, status: &quot;COMPLETED&quot; },
    _count: { _all: true },
  });
  const countByCreator = new Map(counts.map((c) =&gt; [c.creatorId, c._count._all]));

  return creators.map((c) =&gt; ({
    username: c.username,
    displayName: c.displayName,
    avatarUrl: c.avatarUrl,
    accent: accentHex(c.accentColor),
    supporters: countByCreator.get(c.id) ?? 0,
    tags: c.tags,
  }));
}

const STEPS: { icon: BrandIconName; title: string; body: string }[] = [
  { icon: &quot;palette&quot;, title: &quot;Create your page&quot;, body: &quot;Sign up in seconds and set up a page that looks just how you want.&quot; },
  { icon: &quot;megaphone&quot;, title: &quot;Share it with fans&quot;, body: &quot;Drop your link anywhere — your bio, your stream, your newsletter.&quot; },
  { icon: &quot;money&quot;, title: &quot;Get paid directly &amp; instantly&quot;, body: &quot;Tips, memberships, and sales land in your account right away.&quot; },
];

const FAQS = [
  {
    q: &quot;What is Cuppa?&quot;,
    a: &quot;Cuppa is the easiest way to support creators. Fans send one-time tips, join monthly memberships, and buy from your shop, all from one simple page.&quot;,
  },
  {
    q: &quot;How does Cuppa work?&quot;,
    a: &quot;Create your page, share your link, and start earning. You decide what to offer: accept tips, set up membership tiers, sell digital or physical products, and publish posts for your supporters.&quot;,
  },
  {
    q: &quot;Does Cuppa take a fee?&quot;,
    a: &quot;No monthly fees. We take a small platform fee of up to 5% on what you earn, and nothing when you are not earning.&quot;,
  },
  {
    q: &quot;Can I use Cuppa if I&#039;m just starting out?&quot;,
    a: &quot;Absolutely. Cuppa is free to set up and works whether you have five fans or fifty thousand. There is nothing to lose by starting today.&quot;,
  },
  {
    q: &quot;How do I get paid on Cuppa?&quot;,
    a: &quot;You get paid directly. Payments land in your own connected account powered by Whop, and you withdraw your earnings right here on Cuppa whenever you like.&quot;,
  },
  {
    q: &quot;How is Cuppa different from other services?&quot;,
    a: &quot;Cuppa is built for simplicity: one page for tips, memberships, and a shop, with payments going directly to you. No complicated setup, no waiting to get paid.&quot;,
  },
];

export default async function HomePage({
  searchParams,
}: {
  searchParams: Promise&lt;{ authError?: string }&gt;;
}) {
  const sp = await searchParams;
  const authError = sp.authError
    ? AUTH_ERRORS[sp.authError] ?? &quot;Sign in didn&#039;t complete. Please try again.&quot;
    : null;

  const creators = await getFeaturedCreators();

  return (
    &lt;main className=&quot;min-h-screen&quot;&gt;
      &lt;header className=&quot;sticky top-0 z-50 backdrop-blur-lg&quot;&gt;
        &lt;nav className=&quot;mx-auto flex max-w-6xl items-center justify-between px-5 py-4&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
          &lt;span className=&quot;font-display text-2xl font-extrabold&quot;&gt;Cuppa&lt;/span&gt;
        &lt;/Link&gt;
        &lt;div className=&quot;flex items-center gap-3&quot;&gt;
          &lt;a href=&quot;#how-it-works&quot; className=&quot;hidden text-sm font-semibold sm:block&quot;&gt;
            How it works
          &lt;/a&gt;
          &lt;ThemeToggle /&gt;
          &lt;a href=&quot;/api/auth/login&quot; className=&quot;text-sm font-semibold&quot;&gt;
            Log in
          &lt;/a&gt;
          &lt;a href=&quot;/api/auth/login?returnTo=/dashboard/start&quot; className=&quot;btn-pill btn-primary text-sm&quot;&gt;
            Sign up free
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;/nav&gt;
      &lt;/header&gt;

      {authError ? (
        &lt;div className=&quot;mx-auto max-w-6xl px-5&quot;&gt;
          &lt;p className=&quot;rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-950/40&quot;&gt;
            {authError}
          &lt;/p&gt;
        &lt;/div&gt;
      ) : null}

      &lt;section className=&quot;mx-auto max-w-3xl px-5 pb-10 pt-16 text-center sm:pt-24&quot;&gt;
        &lt;h1 className=&quot;text-balance text-5xl leading-[1.05] sm:text-6xl&quot;&gt;
          Let the people who love your work chip in
        &lt;/h1&gt;
        &lt;p className=&quot;mx-auto mt-6 max-w-md text-lg text-muted&quot;&gt;
          Set up a page in minutes and start taking tips, memberships, and shop orders from the people who follow you.
        &lt;/p&gt;
        &lt;div className=&quot;mt-8 flex justify-center&quot;&gt;
          &lt;a
            href=&quot;/api/auth/login?returnTo=/dashboard/start&quot;
            className=&quot;btn-pill btn-soft px-7 py-3 text-base&quot;
          &gt;
            Get started
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div className=&quot;mt-12 flex justify-center gap-4&quot; aria-hidden=&quot;true&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-14 w-14&quot; /&gt;
          &lt;BrandIcon name=&quot;palette&quot; className=&quot;h-14 w-14&quot; /&gt;
          &lt;BrandIcon name=&quot;money&quot; className=&quot;h-14 w-14&quot; /&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;mx-auto max-w-2xl px-5 py-10&quot;&gt;
        &lt;div className=&quot;kofi-card p-8 text-center sm:p-10&quot;&gt;
          &lt;h2 className=&quot;text-3xl sm:text-4xl&quot;&gt;Payday your way&lt;/h2&gt;
          &lt;p className=&quot;mx-auto mt-3 max-w-md text-muted&quot;&gt;
            Decide how you want to earn, set your own terms, and get paid directly. All
            from one place, at your own pace.
          &lt;/p&gt;
          &lt;div className=&quot;mt-6 flex justify-center&quot;&gt;
            &lt;Link href=&quot;/features&quot; className=&quot;btn-pill btn-primary text-sm&quot;&gt;
              Learn how Cuppa works
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      {creators.length &gt; 0 ? (
        &lt;section className=&quot;mx-auto max-w-6xl px-5 py-12&quot;&gt;
          &lt;div className=&quot;mb-8 text-center&quot;&gt;
            &lt;h2 className=&quot;text-3xl sm:text-4xl&quot;&gt;Creators of all kinds&lt;/h2&gt;
            &lt;p className=&quot;mt-2 text-muted&quot;&gt;
              Artists, writers, musicians, streamers, and podcasters. If people love what you make, Cuppa gives them an easy way to support it.
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;CreatorCategories creators={creators} /&gt;
        &lt;/section&gt;
      ) : null}

      &lt;section id=&quot;how-it-works&quot; className=&quot;mx-auto max-w-5xl scroll-mt-8 px-5 py-16&quot;&gt;
        &lt;h2 className=&quot;mb-10 text-center text-3xl sm:text-4xl&quot;&gt;How it works&lt;/h2&gt;
        &lt;div className=&quot;grid gap-8 md:grid-cols-3&quot;&gt;
          {STEPS.map((step, i) =&gt; (
            &lt;div key={step.title} className=&quot;text-center&quot;&gt;
              &lt;div className=&quot;mx-auto grid h-20 w-20 place-items-center rounded-2xl bg-surface-2&quot;&gt;
                &lt;BrandIcon name={step.icon} className=&quot;h-12 w-12&quot; /&gt;
              &lt;/div&gt;
              &lt;h3 className=&quot;mt-4 text-lg font-bold&quot;&gt;
                {i + 1}. {step.title}
              &lt;/h3&gt;
              &lt;p className=&quot;mx-auto mt-2 max-w-xs text-sm text-muted&quot;&gt;{step.body}&lt;/p&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section id=&quot;faq&quot; className=&quot;scroll-mt-8 bg-soft-blue&quot;&gt;
        &lt;div className=&quot;relative mx-auto max-w-5xl px-5 py-16&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;absolute left-2 top-24 hidden h-20 w-20 -rotate-12 lg:block&quot; /&gt;
          &lt;BrandIcon name=&quot;money&quot; className=&quot;absolute right-2 top-16 hidden h-20 w-20 rotate-12 lg:block&quot; /&gt;
          &lt;div className=&quot;relative z-10 mx-auto max-w-2xl rounded-[28px] border border-line bg-surface p-6 shadow-sm sm:p-10&quot;&gt;
            &lt;h2 className=&quot;mb-8 text-3xl sm:text-4xl&quot;&gt;Frequently asked questions&lt;/h2&gt;
            &lt;div className=&quot;space-y-4&quot;&gt;
              {FAQS.map((faq) =&gt; (
                &lt;details
                  key={faq.q}
                  className=&quot;group rounded-2xl border-2 border-ink bg-soft-blue px-5 py-4 dark:border-white/25&quot;
                &gt;
                  &lt;summary className=&quot;flex items-center justify-between gap-4 font-bold text-ink marker:content-[&#039;&#039;] [&amp;::-webkit-details-marker]:hidden&quot;&gt;
                    {faq.q}
                    &lt;span className=&quot;text-2xl leading-none transition-transform group-open:rotate-45&quot;&gt;+&lt;/span&gt;
                  &lt;/summary&gt;
                  &lt;p className=&quot;mt-3 text-sm leading-relaxed text-ink/80&quot;&gt;{faq.a}&lt;/p&gt;
                &lt;/details&gt;
              ))}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;relative overflow-hidden bg-soft-blue&quot;&gt;
        &lt;div className=&quot;mx-auto max-w-3xl px-5 py-16 text-center sm:py-20&quot;&gt;
          &lt;h2 className=&quot;text-3xl text-ink sm:text-5xl&quot;&gt;Grab your Cuppa page&lt;/h2&gt;
          &lt;div className=&quot;relative mx-auto mt-8 max-w-xl&quot;&gt;
            &lt;BrandIcon name=&quot;palette&quot; className=&quot;absolute -left-24 -top-7 hidden h-24 w-24 -rotate-12 lg:block&quot; /&gt;
            &lt;BrandIcon name=&quot;money&quot; className=&quot;absolute -right-24 -top-3 hidden h-24 w-24 rotate-12 lg:block&quot; /&gt;
            &lt;form
              action=&quot;/api/auth/login&quot;
              method=&quot;get&quot;
              className=&quot;flex items-center gap-2 rounded-full bg-surface p-2 pl-5 shadow-sm&quot;
            &gt;
              &lt;span className=&quot;shrink-0 font-semibold text-ink&quot;&gt;kofi-clone-whop-tutorial.vercel.app/&lt;/span&gt;
              &lt;input
                name=&quot;handle&quot;
                placeholder=&quot;yourname&quot;
                autoComplete=&quot;off&quot;
                aria-label=&quot;Choose your Cuppa page URL&quot;
                className=&quot;min-w-0 flex-1 bg-transparent py-2 text-ink outline-none placeholder:text-muted&quot;
              /&gt;
              &lt;input type=&quot;hidden&quot; name=&quot;returnTo&quot; value=&quot;/dashboard/start&quot; /&gt;
              &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; color=&quot;gray&quot; highContrast className=&quot;shrink-0&quot;&gt;
                Claim
              &lt;/Button&gt;
            &lt;/form&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;SiteFooter /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### The features page

The "Learn how Cuppa works" button is going to redirect users to the features page. Create `app/features/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import type { Metadata } from &quot;next&quot;;
import Link from &quot;next/link&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import SiteFooter from &quot;@/components/SiteFooter&quot;;

export const metadata: Metadata = {
  title: &quot;Features&quot;,
  description: &quot;Everything you need to earn and grow on Cuppa.&quot;,
};

function Check({ children }: { children: React.ReactNode }) {
  return (
    &lt;li className=&quot;flex items-center gap-2 text-ink&quot;&gt;
      &lt;svg
        viewBox=&quot;0 0 20 20&quot;
        className=&quot;h-5 w-5 shrink-0 text-positive&quot;
        fill=&quot;none&quot;
        stroke=&quot;currentColor&quot;
        strokeWidth=&quot;2.5&quot;
        strokeLinecap=&quot;round&quot;
        strokeLinejoin=&quot;round&quot;
        aria-hidden=&quot;true&quot;
      &gt;
        &lt;path d=&quot;M4 10.5l4 4 8-9&quot; /&gt;
      &lt;/svg&gt;
      &lt;span&gt;{children}&lt;/span&gt;
    &lt;/li&gt;
  );
}

function PageMock() {
  return (
    &lt;div className=&quot;w-full max-w-[260px] overflow-hidden rounded-2xl border border-line bg-surface shadow-sm&quot;&gt;
      &lt;div className=&quot;h-16 bg-brand&quot; /&gt;
      &lt;div className=&quot;px-4 pb-4&quot;&gt;
        &lt;div className=&quot;-mt-6 flex items-end gap-2&quot;&gt;
          &lt;div className=&quot;grid h-12 w-12 place-items-center overflow-hidden rounded-full border-4 border-surface bg-surface-2&quot;&gt;
            &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-7 w-7&quot; /&gt;
          &lt;/div&gt;
          &lt;span className=&quot;btn-pill btn-accent ml-auto px-4 py-1.5 text-xs&quot;&gt;Tip&lt;/span&gt;
        &lt;/div&gt;
        &lt;div className=&quot;mt-2 h-3 w-24 rounded bg-surface-2&quot; /&gt;
        &lt;div className=&quot;mt-1.5 h-2 w-32 rounded bg-surface-2&quot; /&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}

function CustomizeMock() {
  const colors = [&quot;#467ceb&quot;, &quot;#e8634f&quot;, &quot;#07b25d&quot;, &quot;#9b6cf0&quot;, &quot;#202020&quot;];
  return (
    &lt;div className=&quot;flex flex-col items-center gap-4&quot;&gt;
      &lt;BrandIcon name=&quot;palette&quot; className=&quot;h-24 w-24&quot; /&gt;
      &lt;div className=&quot;flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-2 shadow-sm&quot;&gt;
        {colors.map((c) =&gt; (
          &lt;span key={c} className=&quot;h-6 w-6 rounded-full ring-2 ring-surface&quot; style={{ background: c }} /&gt;
        ))}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}

function ControlMock() {
  return (
    &lt;div className=&quot;flex w-full max-w-[240px] flex-col gap-3&quot;&gt;
      &lt;span className=&quot;self-start rounded-full bg-ink px-4 py-2 text-sm font-semibold text-surface&quot;&gt;Direct messages&lt;/span&gt;
      &lt;span className=&quot;self-center rounded-full bg-brand px-4 py-2 text-sm font-semibold text-white&quot;&gt;Manage delivery&lt;/span&gt;
      &lt;span className=&quot;self-end rounded-full bg-[#9b6cf0] px-4 py-2 text-sm font-semibold text-white&quot;&gt;Withdraw earnings&lt;/span&gt;
    &lt;/div&gt;
  );
}

function FeatureBlock({
  heading,
  reverse,
  tint,
  graphic,
  children,
}: {
  heading: string;
  reverse?: boolean;
  tint: string;
  graphic: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    &lt;div className=&quot;grid items-stretch gap-4 md:grid-cols-2&quot;&gt;
      &lt;div
        className={`flex flex-col justify-center rounded-3xl border border-line bg-surface p-8 sm:p-10 ${
          reverse ? &quot;md:order-2&quot; : &quot;&quot;
        }`}
      &gt;
        &lt;h2 className=&quot;text-3xl sm:text-4xl&quot;&gt;{heading}&lt;/h2&gt;
        &lt;div className=&quot;mt-4 space-y-3 text-muted&quot;&gt;{children}&lt;/div&gt;
      &lt;/div&gt;
      &lt;div className={`grid min-h-[260px] place-items-center rounded-3xl p-8 ${tint} ${reverse ? &quot;md:order-1&quot; : &quot;&quot;}`}&gt;
        {graphic}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}

export default function FeaturesPage() {
  return (
    &lt;main className=&quot;min-h-screen&quot;&gt;
      &lt;header className=&quot;sticky top-0 z-50 backdrop-blur-lg&quot;&gt;
        &lt;nav className=&quot;mx-auto flex max-w-6xl items-center justify-between px-5 py-4&quot;&gt;
          &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2&quot;&gt;
            &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
            &lt;span className=&quot;font-display text-2xl font-extrabold&quot;&gt;Cuppa&lt;/span&gt;
          &lt;/Link&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            &lt;Link href=&quot;/explore&quot; className=&quot;hidden text-sm font-semibold sm:block&quot;&gt;
              Explore
            &lt;/Link&gt;
            &lt;ThemeToggle /&gt;
            &lt;a href=&quot;/api/auth/login&quot; className=&quot;text-sm font-semibold&quot;&gt;
              Log in
            &lt;/a&gt;
            &lt;a href=&quot;/api/auth/login?returnTo=/dashboard/start&quot; className=&quot;btn-pill btn-primary text-sm&quot;&gt;
              Sign up free
            &lt;/a&gt;
          &lt;/div&gt;
        &lt;/nav&gt;
      &lt;/header&gt;

      &lt;section className=&quot;mx-auto max-w-5xl px-5 pb-8 pt-14 text-center sm:pt-20&quot;&gt;
        &lt;h1 className=&quot;text-balance text-5xl sm:text-6xl&quot;&gt;Payday your way&lt;/h1&gt;
        &lt;p className=&quot;mx-auto mt-5 max-w-xl text-lg text-muted&quot;&gt;
          Cuppa gives you the tools to earn money directly from the people who love your work. Here is
          everything you get.
        &lt;/p&gt;
        &lt;div className=&quot;mt-7 flex justify-center&quot;&gt;
          &lt;a href=&quot;/api/auth/login?returnTo=/dashboard/start&quot; className=&quot;btn-pill btn-soft px-7 py-3 text-base&quot;&gt;
            Get started
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;div className=&quot;mx-auto max-w-5xl space-y-4 px-5 py-8&quot;&gt;
        &lt;FeatureBlock heading=&quot;Create your free page&quot; tint=&quot;bg-soft-blue&quot; graphic={&lt;PageMock /&gt;}&gt;
          &lt;p&gt;
            Take the first step to give your fans a way to support your creative work. Setting up your
            Cuppa page takes a couple of minutes.
          &lt;/p&gt;
        &lt;/FeatureBlock&gt;

        &lt;FeatureBlock
          heading=&quot;Get paid directly&quot;
          reverse
          tint=&quot;bg-[#d6f0dd] dark:bg-[#26352b]&quot;
          graphic={&lt;BrandIcon name=&quot;money&quot; className=&quot;h-32 w-32&quot; /&gt;}
        &gt;
          &lt;p&gt;
            Payments land in &lt;strong className=&quot;text-ink&quot;&gt;your own connected account&lt;/strong&gt;, powered by
            Whop. Cuppa never holds your money or pays you out on a schedule.
          &lt;/p&gt;
          &lt;p&gt;Withdraw your balance right here on Cuppa whenever you like.&lt;/p&gt;
        &lt;/FeatureBlock&gt;

        &lt;FeatureBlock heading=&quot;Make it your own&quot; tint=&quot;bg-[#e7defb] dark:bg-[#2f2a3e]&quot; graphic={&lt;CustomizeMock /&gt;}&gt;
          &lt;p&gt;
            Choose your own cover image, avatar, and accent color. Switch between light and dark. Your
            page, your vibe.
          &lt;/p&gt;
        &lt;/FeatureBlock&gt;

        &lt;FeatureBlock
          heading=&quot;Share with your audience&quot;
          reverse
          tint=&quot;bg-[#f8dbd5] dark:bg-[#3a2a2e]&quot;
          graphic={&lt;BrandIcon name=&quot;megaphone&quot; className=&quot;h-32 w-32&quot; /&gt;}
        &gt;
          &lt;p&gt;
            Cuppa gives you the tools, but you bring the supporters. There is no algorithm deciding who
            sees your page. You control how and where you promote it.
          &lt;/p&gt;
        &lt;/FeatureBlock&gt;

        &lt;FeatureBlock heading=&quot;Everything you need&quot; tint=&quot;bg-[#fbecb0] dark:bg-[#3a3526]&quot; graphic={&lt;ControlMock /&gt;}&gt;
          &lt;p&gt;Cuppa gives you full control:&lt;/p&gt;
          &lt;ul className=&quot;space-y-2&quot;&gt;
            &lt;Check&gt;Set your own pricing and terms&lt;/Check&gt;
            &lt;Check&gt;Tips, memberships, a shop, and posts&lt;/Check&gt;
            &lt;Check&gt;Message supporters and manage delivery&lt;/Check&gt;
            &lt;Check&gt;Withdraw your earnings on-site&lt;/Check&gt;
          &lt;/ul&gt;
        &lt;/FeatureBlock&gt;
      &lt;/div&gt;

      &lt;section className=&quot;mx-auto max-w-5xl px-5 py-12&quot;&gt;
        &lt;div className=&quot;relative overflow-hidden rounded-[32px] bg-soft-blue px-6 py-16 text-center&quot;&gt;
          &lt;h2 className=&quot;text-4xl sm:text-5xl&quot;&gt;Get started&lt;/h2&gt;
          &lt;p className=&quot;mx-auto mt-3 max-w-md text-ink/80&quot;&gt;
            Set up your page in minutes. It is free, and you only pay a small fee when you earn.
          &lt;/p&gt;
          &lt;div className=&quot;mt-7 flex justify-center&quot;&gt;
            &lt;a
              href=&quot;/api/auth/login?returnTo=/dashboard/start&quot;
              className=&quot;btn-pill btn-primary px-8 py-3.5 text-base&quot;
            &gt;
              Sign up free
            &lt;/a&gt;
          &lt;/div&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;absolute -left-3 bottom-1 hidden h-24 w-24 -rotate-12 sm:block&quot; /&gt;
          &lt;BrandIcon name=&quot;palette&quot; className=&quot;absolute -right-3 top-3 hidden h-24 w-24 rotate-12 sm:block&quot; /&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;SiteFooter /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### Finding a creator

Ko-fi doesn't have a global creator search feature; supporters find creator pages by knowing their handles.

We'll follow this system and use handles to redirect users to creator pages. We'll add this component to the "Explore" header and the supporter feed. Create `components/CreatorSearch.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">CreatorSearch.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button } from &quot;@whop/react/components&quot;;

export default function CreatorSearch({ placeholder = &quot;creator-handle&quot; }: { placeholder?: string }) {
  const router = useRouter();
  const [handle, setHandle] = useState(&quot;&quot;);

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const clean = handle.trim().toLowerCase().replace(/[^a-z0-9_]/g, &quot;&quot;);
    if (clean) router.push(`/${clean}`);
  }

  return (
    &lt;form
      onSubmit={onSubmit}
      className=&quot;flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 focus-within:border-brand&quot;
    &gt;
      &lt;span className=&quot;shrink-0 text-sm text-muted&quot;&gt;kofi-clone-whop-tutorial.vercel.app/&lt;/span&gt;
      &lt;input
        value={handle}
        onChange={(e) =&gt; setHandle(e.target.value)}
        placeholder={placeholder}
        autoComplete=&quot;off&quot;
        aria-label=&quot;Find a creator by their handle&quot;
        className=&quot;min-w-0 flex-1 bg-transparent text-sm outline-none&quot;
      /&gt;
      &lt;Button type=&quot;submit&quot; size=&quot;2&quot; variant=&quot;solid&quot; color=&quot;gray&quot; highContrast className=&quot;shrink-0&quot;&gt;
        Go
      &lt;/Button&gt;
    &lt;/form&gt;
  );
}</code></pre>
  </div>
</div>

### The explore page

The explore page is the same list of creators as the homepage, but paginated. Create `app/explore/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import Link from &quot;next/link&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import CreatorSearch from &quot;@/components/CreatorSearch&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;
import { ChevronLeft, ChevronRight } from &quot;@/components/Icons&quot;;

const PAGE_SIZE = 12;

type ExploreCreator = {
  username: string;
  displayName: string;
  bio: string | null;
  avatarUrl: string | null;
  accentColor: string;
  supporters: number;
};

function parsePage(raw: string | undefined): number {
  const n = Number.parseInt(raw ?? &quot;1&quot;, 10);
  if (!Number.isFinite(n) || n &lt; 1) return 1;
  return n;
}

export default async function ExplorePage({
  searchParams,
}: {
  searchParams: Promise&lt;{ page?: string }&gt;;
}) {
  const sp = await searchParams;
  const page = parsePage(sp.page);
  const skip = (page - 1) * PAGE_SIZE;

  const where = { isActive: true, whopOnboarded: true } as const;

  const [total, creators] = await Promise.all([
    prisma.creator.count({ where }),
    prisma.creator.findMany({
      where,
      orderBy: { createdAt: &quot;desc&quot; },
      skip,
      take: PAGE_SIZE,
      select: { id: true, username: true, displayName: true, bio: true, avatarUrl: true, accentColor: true },
    }),
  ]);

  const counts =
    creators.length &gt; 0
      ? await prisma.support.groupBy({
          by: [&quot;creatorId&quot;],
          where: { creatorId: { in: creators.map((c) =&gt; c.id) }, status: &quot;COMPLETED&quot; },
          _count: { _all: true },
        })
      : [];
  const countByCreator = new Map(counts.map((c) =&gt; [c.creatorId, c._count._all]));

  const rows: ExploreCreator[] = creators.map((c) =&gt; ({
    username: c.username,
    displayName: c.displayName,
    bio: c.bio,
    avatarUrl: c.avatarUrl,
    accentColor: c.accentColor,
    supporters: countByCreator.get(c.id) ?? 0,
  }));

  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
  const hasPrev = page &gt; 1;
  const hasNext = page &lt; totalPages;

  return (
    &lt;main className=&quot;min-h-screen&quot;&gt;
      &lt;nav className=&quot;mx-auto flex max-w-6xl items-center justify-between px-5 py-4&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2 text-xl font-bold&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
          Cuppa
        &lt;/Link&gt;
        &lt;div className=&quot;flex items-center gap-3&quot;&gt;
          &lt;ThemeToggle /&gt;
          &lt;a href=&quot;/api/auth/login&quot; className=&quot;text-sm font-semibold&quot;&gt;
            Log in
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/nav&gt;

      &lt;section className=&quot;mx-auto max-w-6xl px-5 py-10&quot;&gt;
        &lt;div className=&quot;mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between&quot;&gt;
          &lt;div&gt;
            &lt;h1 className=&quot;text-4xl font-bold tracking-tight&quot;&gt;Explore creators&lt;/h1&gt;
            &lt;p className=&quot;mt-2 text-muted&quot;&gt;
              Discover creators to support, or jump straight to a handle you know.
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;div className=&quot;w-full sm:w-80&quot;&gt;
            &lt;CreatorSearch /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        {rows.length === 0 ? (
          &lt;div className=&quot;kofi-card p-12 text-center&quot;&gt;
            &lt;div className=&quot;mx-auto grid h-14 w-14 place-items-center rounded-full bg-surface-2&quot;&gt;
              &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-10 w-10&quot; /&gt;
            &lt;/div&gt;
            &lt;h2 className=&quot;mt-4 font-bold&quot;&gt;No creators yet&lt;/h2&gt;
            &lt;p className=&quot;mx-auto mt-1 max-w-sm text-sm text-muted&quot;&gt;
              Be the first to set up a page and start earning from your fans.
            &lt;/p&gt;
            &lt;a href=&quot;/api/auth/login?returnTo=/dashboard/start&quot; className=&quot;btn-pill btn-accent mt-5 text-sm&quot;&gt;
              Get started
            &lt;/a&gt;
          &lt;/div&gt;
        ) : (
          &lt;&gt;
            &lt;div className=&quot;grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3&quot;&gt;
              {rows.map((c) =&gt; {
                const accent = accentHex(c.accentColor);
                return (
                  &lt;Link
                    key={c.username}
                    href={`/${c.username}`}
                    className=&quot;kofi-card overflow-hidden transition-[filter] hover:brightness-[0.98]&quot;
                  &gt;
                    &lt;div className=&quot;h-20&quot; style={{ background: accent }} /&gt;
                    &lt;div className=&quot;px-5 pb-5&quot;&gt;
                      &lt;div className=&quot;-mt-8 grid h-16 w-16 place-items-center overflow-hidden rounded-full border-4 border-surface bg-surface-2&quot;&gt;
                        {c.avatarUrl ? (
                          &lt;img src={c.avatarUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
                        ) : (
                          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-10 w-10&quot; /&gt;
                        )}
                      &lt;/div&gt;
                      &lt;p className=&quot;mt-3 truncate text-lg font-bold&quot;&gt;{c.displayName}&lt;/p&gt;
                      &lt;p className=&quot;text-sm text-muted&quot;&gt;
                        {c.supporters} {c.supporters === 1 ? &quot;supporter&quot; : &quot;supporters&quot;}
                      &lt;/p&gt;
                      {c.bio ? &lt;p className=&quot;mt-2 line-clamp-2 text-sm text-muted&quot;&gt;{c.bio}&lt;/p&gt; : null}
                    &lt;/div&gt;
                  &lt;/Link&gt;
                );
              })}
            &lt;/div&gt;

            {totalPages &gt; 1 ? (
              &lt;div className=&quot;mt-10 flex items-center justify-center gap-3&quot;&gt;
                {hasPrev ? (
                  &lt;Link href={`/explore?page=${page - 1}`} className=&quot;btn-pill btn-outline text-sm&quot;&gt;
                    &lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Previous
                  &lt;/Link&gt;
                ) : (
                  &lt;span className=&quot;btn-pill btn-outline cursor-not-allowed text-sm opacity-50&quot;&gt;&lt;ChevronLeft className=&quot;h-4 w-4&quot; /&gt; Previous&lt;/span&gt;
                )}
                &lt;span className=&quot;text-sm text-muted&quot;&gt;
                  Page {page} of {totalPages}
                &lt;/span&gt;
                {hasNext ? (
                  &lt;Link href={`/explore?page=${page + 1}`} className=&quot;btn-pill btn-outline text-sm&quot;&gt;
                    Next &lt;ChevronRight className=&quot;h-4 w-4&quot; /&gt;
                  &lt;/Link&gt;
                ) : (
                  &lt;span className=&quot;btn-pill btn-outline cursor-not-allowed text-sm opacity-50&quot;&gt;Next &lt;ChevronRight className=&quot;h-4 w-4&quot; /&gt;&lt;/span&gt;
                )}
              &lt;/div&gt;
            ) : null}
          &lt;/&gt;
        )}
      &lt;/section&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### The supporter feed

The supporter feed page is at `/feed`, which lists the creators they're supporting and their posts. We only want to display the public posts so the gated content doesn't leak.

If the user isn't supporting anyone yet, we fall back to the most-supported creators so the page doesn't feel dead. Create `app/feed/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">/* eslint-disable @next/next/no-img-element */
import Link from &quot;next/link&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { accentHex } from &quot;@/lib/accent&quot;;
import ThemeToggle from &quot;@/components/ThemeToggle&quot;;
import CreatorSearch from &quot;@/components/CreatorSearch&quot;;
import BrandIcon from &quot;@/components/BrandIcon&quot;;

type CreatorRow = {
  id: string;
  username: string;
  displayName: string;
  bio: string | null;
  avatarUrl: string | null;
  accentColor: string;
};

type FeedCreator = CreatorRow &amp; {
  supporters: number;
  latestPost: { id: string; title: string } | null;
};

const CREATOR_SELECT = {
  id: true,
  username: true,
  displayName: true,
  bio: true,
  avatarUrl: true,
  accentColor: true,
} as const;

async function decorate(rows: CreatorRow[]): Promise&lt;FeedCreator[]&gt; {
  if (rows.length === 0) return [];
  const ids = rows.map((c) =&gt; c.id);
  const [counts, posts] = await Promise.all([
    prisma.support.groupBy({
      by: [&quot;creatorId&quot;],
      where: { creatorId: { in: ids }, status: &quot;COMPLETED&quot; },
      _count: { _all: true },
    }),
    prisma.post.findMany({
      where: { creatorId: { in: ids }, published: true, visibility: &quot;PUBLIC&quot; },
      orderBy: { createdAt: &quot;desc&quot; },
      select: { id: true, title: true, creatorId: true },
    }),
  ]);
  const countByCreator = new Map(counts.map((c) =&gt; [c.creatorId, c._count._all]));
  const latestByCreator = new Map&lt;string, { id: string; title: string }&gt;();
  for (const p of posts) {
    if (!latestByCreator.has(p.creatorId)) latestByCreator.set(p.creatorId, { id: p.id, title: p.title });
  }
  return rows.map((c) =&gt; ({
    ...c,
    supporters: countByCreator.get(c.id) ?? 0,
    latestPost: latestByCreator.get(c.id) ?? null,
  }));
}

export default async function FeedPage() {
  const user = await requireAuth();

  const [memberships, supports, follows] = await Promise.all([
    prisma.membership.findMany({
      where: { userId: user.id, status: &quot;ACTIVE&quot; },
      select: { creatorId: true },
    }),
    prisma.support.findMany({
      where: { supporterUserId: user.id, status: &quot;COMPLETED&quot; },
      select: { creatorId: true },
    }),
    prisma.follow.findMany({
      where: { userId: user.id },
      select: { creatorId: true },
    }),
  ]);
  const supportedIds = Array.from(
    new Set([...memberships, ...supports, ...follows].map((r) =&gt; r.creatorId)),
  );

  let supported: FeedCreator[] = [];
  if (supportedIds.length &gt; 0) {
    const rows = await prisma.creator.findMany({
      where: { id: { in: supportedIds } },
      orderBy: { createdAt: &quot;desc&quot; },
      select: CREATOR_SELECT,
    });
    supported = await decorate(rows);
  }

  let suggestions: FeedCreator[] = [];
  if (supported.length === 0) {
    const rows = await prisma.creator.findMany({
      where: {
        isActive: true,
        whopOnboarded: true,
        ...(user.creator ? { id: { not: user.creator.id } } : {}),
      },
      select: CREATOR_SELECT,
    });
    const decorated = await decorate(rows);
    suggestions = decorated.sort((a, b) =&gt; b.supporters - a.supporters).slice(0, 3);
  }

  return (
    &lt;main className=&quot;min-h-screen&quot;&gt;
      &lt;nav className=&quot;mx-auto flex max-w-4xl items-center justify-between px-5 py-4&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2 text-xl font-bold&quot;&gt;
          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
          Cuppa
        &lt;/Link&gt;
        &lt;div className=&quot;flex items-center gap-3 text-sm font-semibold&quot;&gt;
          &lt;Link href=&quot;/explore&quot; className=&quot;hidden sm:block hover:text-muted&quot;&gt;
            Explore
          &lt;/Link&gt;
          {user.creator ? (
            &lt;Link href=&quot;/dashboard&quot; className=&quot;hover:text-muted&quot;&gt;
              Dashboard
            &lt;/Link&gt;
          ) : (
            &lt;Link href=&quot;/dashboard/start&quot; className=&quot;hover:text-muted&quot;&gt;
              Become a creator
            &lt;/Link&gt;
          )}
          &lt;ThemeToggle /&gt;
          &lt;a href=&quot;/api/auth/logout&quot; className=&quot;text-muted hover:text-ink&quot;&gt;
            Log out
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/nav&gt;

      &lt;section className=&quot;mx-auto max-w-4xl px-5 py-10&quot;&gt;
        &lt;div className=&quot;mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between&quot;&gt;
          &lt;div&gt;
            &lt;h1 className=&quot;text-4xl font-bold tracking-tight&quot;&gt;Your feed&lt;/h1&gt;
            &lt;p className=&quot;mt-2 text-muted&quot;&gt;The creators you support, all in one place.&lt;/p&gt;
          &lt;/div&gt;
          &lt;div className=&quot;w-full sm:w-80&quot;&gt;
            &lt;CreatorSearch /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        {supported.length &gt; 0 ? (
          &lt;div className=&quot;grid grid-cols-1 gap-5 sm:grid-cols-2&quot;&gt;
            {supported.map((c) =&gt; {
              const accent = accentHex(c.accentColor);
              return (
                &lt;div key={c.username} className=&quot;kofi-card overflow-hidden&quot;&gt;
                  &lt;Link href={`/${c.username}`} className=&quot;block transition-[filter] hover:brightness-[0.98]&quot;&gt;
                    &lt;div className=&quot;h-16&quot; style={{ background: accent }} /&gt;
                    &lt;div className=&quot;px-5&quot;&gt;
                      &lt;div className=&quot;-mt-8 grid h-16 w-16 place-items-center overflow-hidden rounded-full border-4 border-surface bg-surface-2&quot;&gt;
                        {c.avatarUrl ? (
                          &lt;img src={c.avatarUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
                        ) : (
                          &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-10 w-10&quot; /&gt;
                        )}
                      &lt;/div&gt;
                      &lt;p className=&quot;mt-3 truncate text-lg font-bold&quot;&gt;{c.displayName}&lt;/p&gt;
                      &lt;p className=&quot;text-sm text-muted&quot;&gt;
                        {c.supporters} {c.supporters === 1 ? &quot;supporter&quot; : &quot;supporters&quot;}
                      &lt;/p&gt;
                    &lt;/div&gt;
                  &lt;/Link&gt;
                  &lt;div className=&quot;px-5 pb-5 pt-3&quot;&gt;
                    {c.latestPost ? (
                      &lt;Link
                        href={`/${c.username}/post/${c.latestPost.id}`}
                        className=&quot;block rounded-xl bg-surface-2 px-4 py-3 text-sm transition hover:brightness-[0.98]&quot;
                      &gt;
                        &lt;span className=&quot;text-muted&quot;&gt;Latest post&lt;/span&gt;
                        &lt;span className=&quot;mt-0.5 block truncate font-semibold&quot;&gt;{c.latestPost.title}&lt;/span&gt;
                      &lt;/Link&gt;
                    ) : c.bio ? (
                      &lt;p className=&quot;line-clamp-2 text-sm text-muted&quot;&gt;{c.bio}&lt;/p&gt;
                    ) : null}
                  &lt;/div&gt;
                &lt;/div&gt;
              );
            })}
          &lt;/div&gt;
        ) : (
          &lt;div&gt;
            &lt;div className=&quot;kofi-card p-8 text-center&quot;&gt;
              &lt;div className=&quot;mx-auto grid h-14 w-14 place-items-center rounded-full bg-surface-2&quot;&gt;
                &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-10 w-10&quot; /&gt;
              &lt;/div&gt;
              &lt;h2 className=&quot;mt-4 text-xl font-bold&quot;&gt;You&amp;rsquo;re not supporting anyone yet&lt;/h2&gt;
              &lt;p className=&quot;mx-auto mt-1 max-w-sm text-sm text-muted&quot;&gt;
                Follow or tip a creator and they&amp;rsquo;ll show up here. Here are a few to get you started.
              &lt;/p&gt;
            &lt;/div&gt;

            {suggestions.length &gt; 0 ? (
              &lt;div className=&quot;mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3&quot;&gt;
                {suggestions.map((c) =&gt; {
                  const accent = accentHex(c.accentColor);
                  return (
                    &lt;Link
                      key={c.username}
                      href={`/${c.username}`}
                      className=&quot;kofi-card overflow-hidden transition-[filter] hover:brightness-[0.98]&quot;
                    &gt;
                      &lt;div className=&quot;h-16&quot; style={{ background: accent }} /&gt;
                      &lt;div className=&quot;px-5 pb-5&quot;&gt;
                        &lt;div className=&quot;-mt-8 grid h-14 w-14 place-items-center overflow-hidden rounded-full border-4 border-surface bg-surface-2&quot;&gt;
                          {c.avatarUrl ? (
                            &lt;img src={c.avatarUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
                          ) : (
                            &lt;BrandIcon name=&quot;coffee&quot; className=&quot;h-9 w-9&quot; /&gt;
                          )}
                        &lt;/div&gt;
                        &lt;p className=&quot;mt-3 truncate font-bold&quot;&gt;{c.displayName}&lt;/p&gt;
                        &lt;p className=&quot;text-sm text-muted&quot;&gt;
                          {c.supporters} {c.supporters === 1 ? &quot;supporter&quot; : &quot;supporters&quot;}
                        &lt;/p&gt;
                      &lt;/div&gt;
                    &lt;/Link&gt;
                  );
                })}
              &lt;/div&gt;
            ) : null}

            &lt;div className=&quot;mt-6 text-center&quot;&gt;
              &lt;Link href=&quot;/explore&quot; className=&quot;btn-pill btn-outline text-sm&quot;&gt;
                Browse all creators
              &lt;/Link&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        )}
      &lt;/section&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

### Choosing the accent in settings

We let the creators edit their profiles and pick their accent colors on the dashboard settings page. Create `app/dashboard/settings/page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { requireCreator } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import SettingsForm from &quot;@/components/dashboard/SettingsForm&quot;;
import GoalForm from &quot;@/components/dashboard/GoalForm&quot;;

export default async function DashboardSettingsPage() {
  const { creator } = await requireCreator();

  const goal = await prisma.goal.findFirst({
    where: { creatorId: creator.id, isActive: true },
  });

  return (
    &lt;div className=&quot;space-y-8&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-bold&quot;&gt;Settings&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Your page is live at{&quot; &quot;}
          &lt;a
            href={`/${creator.username}`}
            className=&quot;font-semibold text-brand&quot;
            target=&quot;_blank&quot;
            rel=&quot;noreferrer&quot;
          &gt;
            http://kofi-clone-whop-tutorial.vercel.app/{creator.username}
          &lt;/a&gt;
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;SettingsForm
        creator={{
          displayName: creator.displayName,
          bio: creator.bio ?? &quot;&quot;,
          coverImageUrl: creator.coverImageUrl ?? &quot;&quot;,
          avatarUrl: creator.avatarUrl ?? &quot;&quot;,
          tags: creator.tags,
          accentColor: creator.accentColor,
        }}
      /&gt;

      &lt;GoalForm
        goal={
          goal
            ? { title: goal.title, description: goal.description ?? &quot;&quot;, targetCents: goal.targetCents }
            : null
        }
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Before we let users upload avatars and cover images, we need to plug in Whop's files API. In development, we use a resized inline data URL.

The route checks auth, rate-limits, and validates the image's type and size first, and switches paths on the `WHOP_SANDBOX` flag, so going live needs no code change. Create `app/api/creator/upload/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import sharp from &quot;sharp&quot;;
import { whopsdk } from &quot;@/lib/whop&quot;;
import { isSandbox } from &quot;@/lib/env&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;, &quot;image/gif&quot;];

export async function POST(req: NextRequest) {
  if (!rateLimit(`creator-upload:${clientIp(req)}`, 10, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  await requireCreator();

  let file: File | null = null;
  try {
    const form = await req.formData();
    const value = form.get(&quot;file&quot;);
    if (value instanceof File) file = value;
  } catch {
    return NextResponse.json({ error: &quot;Invalid upload&quot; }, { status: 400 });
  }

  if (!file) {
    return NextResponse.json({ error: &quot;No file provided&quot; }, { status: 400 });
  }
  if (!ALLOWED.includes(file.type)) {
    return NextResponse.json({ error: &quot;Only JPG, PNG, WEBP or GIF images are allowed&quot; }, { status: 400 });
  }
  if (file.size &gt; MAX_BYTES) {
    return NextResponse.json({ error: &quot;Image must be under 5 MB&quot; }, { status: 400 });
  }

  try {
    if (isSandbox()) {
      const input = Buffer.from(await file.arrayBuffer());
      const out = await sharp(input)
        .rotate()
        .resize(800, 800, { fit: &quot;inside&quot;, withoutEnlargement: true })
        .webp({ quality: 78 })
        .toBuffer();
      return NextResponse.json({ url: `data:image/webp;base64,${out.toString(&quot;base64&quot;)}` });
    }

    const uploaded = await whopsdk.files.upload(file, { filename: file.name || &quot;upload&quot; });
    if (!uploaded.url) {
      return NextResponse.json({ error: &quot;Upload did not finish&quot; }, { status: 502 });
    }
    return NextResponse.json({ url: uploaded.url });
  } catch (err: unknown) {
    console.error(&quot;File upload failed:&quot;, err);
    return NextResponse.json({ error: &quot;Could not upload the image&quot; }, { status: 502 });
  }
}</code></pre>
  </div>
</div>

### The image upload field

Now a reusable image upload field that previews the file and saves it right away. Create `components/dashboard/ImageUploadField.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">ImageUploadField.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useRef, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button } from &quot;@whop/react/components&quot;;
import { Check } from &quot;@/components/Icons&quot;;

const MAX_BYTES = 5 * 1024 * 1024;

export default function ImageUploadField({
  label,
  field,
  value,
  onChange,
  round,
}: {
  label: string;
  field: &quot;coverImageUrl&quot; | &quot;avatarUrl&quot;;
  value: string;
  onChange: (url: string) =&gt; void;
  round?: boolean;
}) {
  const router = useRouter();
  const inputRef = useRef&lt;HTMLInputElement&gt;(null);
  const [busy, setBusy] = useState(false);
  const [saved, setSaved] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);

  async function persist(url: string) {
    await fetch(&quot;/api/creator/settings&quot;, {
      method: &quot;PATCH&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ [field]: url }),
    });
    onChange(url);
    setSaved(true);
    setTimeout(() =&gt; setSaved(false), 2000);
    router.refresh();
  }

  async function onPick(e: React.ChangeEvent&lt;HTMLInputElement&gt;) {
    const file = e.target.files?.[0];
    e.target.value = &quot;&quot;;
    if (!file) return;
    if (!file.type.startsWith(&quot;image/&quot;)) {
      setError(&quot;Please choose an image file.&quot;);
      return;
    }
    if (file.size &gt; MAX_BYTES) {
      setError(&quot;Image must be under 5 MB.&quot;);
      return;
    }

    setError(null);
    setBusy(true);
    try {
      const body = new FormData();
      body.append(&quot;file&quot;, file);
      const res = await fetch(&quot;/api/creator/upload&quot;, { method: &quot;POST&quot;, body });
      const data = await res.json();
      if (!res.ok || !data.url) {
        setError(data.error ?? &quot;Upload failed&quot;);
        setBusy(false);
        return;
      }
      await persist(data.url);
    } catch {
      setError(&quot;Upload failed. Please try again.&quot;);
    }
    setBusy(false);
  }

  async function onRemove() {
    setBusy(true);
    setError(null);
    try {
      await persist(&quot;&quot;);
    } catch {
      setError(&quot;Could not remove the image&quot;);
    }
    setBusy(false);
  }

  return (
    &lt;div&gt;
      &lt;span className=&quot;mb-1 block text-sm font-semibold&quot;&gt;{label}&lt;/span&gt;
      &lt;div className=&quot;flex items-center gap-4&quot;&gt;
        &lt;div
          className={`grid shrink-0 place-items-center overflow-hidden border border-line bg-surface-2 ${
            round ? &quot;h-16 w-16 rounded-full&quot; : &quot;h-16 w-28 rounded-xl&quot;
          }`}
        &gt;
          {value ? (
            // eslint-disable-next-line @next/next/no-img-element
            &lt;img src={value} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
          ) : (
            &lt;span className=&quot;px-2 text-center text-xs text-muted&quot;&gt;No image&lt;/span&gt;
          )}
        &lt;/div&gt;
        &lt;div className=&quot;flex flex-col items-start gap-1.5&quot;&gt;
          &lt;Button
            type=&quot;button&quot;
            size=&quot;2&quot;
            variant=&quot;surface&quot;
            color=&quot;gray&quot;
            disabled={busy}
            onClick={() =&gt; inputRef.current?.click()}
          &gt;
            {busy ? &quot;Uploading…&quot; : value ? &quot;Replace&quot; : &quot;Upload&quot;}
          &lt;/Button&gt;
          {value ? (
            &lt;button
              type=&quot;button&quot;
              onClick={onRemove}
              disabled={busy}
              className=&quot;text-xs text-muted hover:text-ink&quot;
            &gt;
              Remove
            &lt;/button&gt;
          ) : null}
        &lt;/div&gt;
        &lt;input ref={inputRef} type=&quot;file&quot; accept=&quot;image/*&quot; onChange={onPick} className=&quot;hidden&quot; /&gt;
        {saved ? (
          &lt;span className=&quot;inline-flex items-center gap-1 text-sm text-positive&quot;&gt;
            &lt;Check className=&quot;h-4 w-4&quot; /&gt; Saved
          &lt;/span&gt;
        ) : null}
      &lt;/div&gt;
      {error ? &lt;p className=&quot;mt-1 text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Now the settings form itself, where a creator edits their name, bio, images, tags, and accent color. Create `components/dashboard/SettingsForm.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">SettingsForm.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button, TextField, TextArea } from &quot;@whop/react/components&quot;;
import ImageUploadField from &quot;@/components/dashboard/ImageUploadField&quot;;
import { ACCENT_OPTIONS } from &quot;@/lib/accent&quot;;

interface CreatorSettings {
  displayName: string;
  bio: string;
  coverImageUrl: string;
  avatarUrl: string;
  tags: string[];
  accentColor: string;
}

export default function SettingsForm({ creator }: { creator: CreatorSettings }) {
  const router = useRouter();
  const [displayName, setDisplayName] = useState(creator.displayName);
  const [bio, setBio] = useState(creator.bio);
  const [coverImageUrl, setCoverImageUrl] = useState(creator.coverImageUrl);
  const [avatarUrl, setAvatarUrl] = useState(creator.avatarUrl);
  const [tagsText, setTagsText] = useState(creator.tags.join(&quot;, &quot;));
  const [accentColor, setAccentColor] = useState(creator.accentColor);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [saved, setSaved] = useState(false);
  const [saving, setSaving] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setSaved(false);

    const tags = tagsText
      .split(&quot;,&quot;)
      .map((tag) =&gt; tag.trim())
      .filter(Boolean);

    setSaving(true);
    try {
      const res = await fetch(&quot;/api/creator/settings&quot;, {
        method: &quot;PATCH&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          displayName,
          bio,
          coverImageUrl,
          avatarUrl,
          tags,
          accentColor,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not save settings&quot;);
        setSaving(false);
        return;
      }
      setSaved(true);
      setSaving(false);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setSaving(false);
    }
  }

  return (
    &lt;form onSubmit={onSubmit} className=&quot;kofi-card space-y-5 p-6&quot;&gt;
      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;settings-name&quot;&gt;
          Display name
        &lt;/label&gt;
        &lt;TextField.Root size=&quot;3&quot;&gt;
          &lt;TextField.Input
            id=&quot;settings-name&quot;
            value={displayName}
            onChange={(e) =&gt; setDisplayName(e.target.value)}
            required
            maxLength={60}
          /&gt;
        &lt;/TextField.Root&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;settings-bio&quot;&gt;
          Bio
        &lt;/label&gt;
        &lt;TextArea
          id=&quot;settings-bio&quot;
          size=&quot;3&quot;
          value={bio}
          onChange={(e) =&gt; setBio(e.target.value)}
          rows={4}
          maxLength={1000}
          placeholder=&quot;Tell supporters what you create.&quot;
        /&gt;
      &lt;/div&gt;

      &lt;div className=&quot;grid gap-5 sm:grid-cols-2&quot;&gt;
        &lt;ImageUploadField label=&quot;Cover image&quot; field=&quot;coverImageUrl&quot; value={coverImageUrl} onChange={setCoverImageUrl} /&gt;
        &lt;ImageUploadField label=&quot;Avatar&quot; field=&quot;avatarUrl&quot; value={avatarUrl} onChange={setAvatarUrl} round /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;settings-tags&quot;&gt;
          Tags &lt;span className=&quot;font-normal text-muted&quot;&gt;(comma-separated)&lt;/span&gt;
        &lt;/label&gt;
        &lt;TextField.Root size=&quot;3&quot;&gt;
          &lt;TextField.Input
            id=&quot;settings-tags&quot;
            value={tagsText}
            onChange={(e) =&gt; setTagsText(e.target.value)}
            placeholder=&quot;illustration, comics, tutorials&quot;
          /&gt;
        &lt;/TextField.Root&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;span className=&quot;mb-2 block text-sm font-semibold&quot;&gt;Accent color&lt;/span&gt;
        &lt;div className=&quot;flex flex-wrap gap-2&quot;&gt;
          {ACCENT_OPTIONS.map((option) =&gt; {
            const selected = accentColor === option.name;
            return (
              &lt;button
                key={option.name}
                type=&quot;button&quot;
                onClick={() =&gt; setAccentColor(option.name)}
                title={option.name}
                aria-label={option.name}
                aria-pressed={selected}
                className={`h-9 w-9 rounded-full border-2 transition ${
                  selected ? &quot;border-ink scale-110&quot; : &quot;border-transparent hover:scale-105&quot;
                }`}
                style={{ backgroundColor: option.hex }}
              /&gt;
            );
          })}
        &lt;/div&gt;
        &lt;p className=&quot;mt-2 text-xs text-muted capitalize&quot;&gt;Selected: {accentColor}&lt;/p&gt;
      &lt;/div&gt;

      {error ? &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}
      {saved ? &lt;p className=&quot;text-sm text-positive&quot;&gt;Saved.&lt;/p&gt; : null}

      &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; disabled={saving}&gt;
        {saving ? &quot;Saving…&quot; : &quot;Save changes&quot;}
      &lt;/Button&gt;
    &lt;/form&gt;
  );
}</code></pre>
  </div>
</div>

The save endpoint validates every field with Zod, and updates just the fields that were sent. Create `app/api/creator/settings/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { Prisma } from &quot;@prisma/client&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;
import { isAccentName } from &quot;@/lib/accent&quot;;

const imageValue = z.union([
  z.string().url().max(2000),
  z.string().regex(/^data:image\/(png|jpeg|webp|gif);base64,/).max(3_000_000),
  z.literal(&quot;&quot;),
]);

const schema = z.object({
  displayName: z.string().min(1).max(60).optional(),
  bio: z.string().max(1000).optional(),
  coverImageUrl: imageValue.optional(),
  avatarUrl: imageValue.optional(),
  tags: z.array(z.string().min(1).max(40)).max(20).optional(),
  accentColor: z.string().refine(isAccentName, &quot;Invalid accent color&quot;).optional(),
});

export async function PATCH(req: NextRequest) {
  if (!rateLimit(`creator-settings:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const input = parsed.data;

  const data: Prisma.CreatorUpdateInput = {};
  if (input.displayName !== undefined) data.displayName = input.displayName;
  if (input.bio !== undefined) data.bio = input.bio.trim() || null;
  if (input.coverImageUrl !== undefined) data.coverImageUrl = input.coverImageUrl || null;
  if (input.avatarUrl !== undefined) data.avatarUrl = input.avatarUrl || null;
  if (input.tags !== undefined) data.tags = input.tags.map((t) =&gt; t.trim()).filter(Boolean);
  if (input.accentColor !== undefined) data.accentColor = input.accentColor;

  await prisma.creator.update({ where: { id: creator.id }, data });

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

> 

### The donation goal

Creators can run donations goals but only one at a time , so the settings form either sets the first goal or updates the existing one, and a Remove button retires it. Create `components/dashboard/GoalForm.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">GoalForm.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Button, TextField, TextArea } from &quot;@whop/react/components&quot;;

interface CreatorGoal {
  title: string;
  description: string;
  targetCents: number;
}

export default function GoalForm({ goal }: { goal: CreatorGoal | null }) {
  const router = useRouter();
  const [title, setTitle] = useState(goal?.title ?? &quot;&quot;);
  const [description, setDescription] = useState(goal?.description ?? &quot;&quot;);
  const [target, setTarget] = useState(goal ? String(goal.targetCents / 100) : &quot;&quot;);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [saved, setSaved] = useState(false);
  const [saving, setSaving] = useState(false);
  const [removing, setRemoving] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setSaved(false);

    const targetCents = Math.round(Number(target) * 100);
    if (!title.trim()) {
      setError(&quot;Give your goal a title.&quot;);
      return;
    }
    if (!Number.isFinite(targetCents) || targetCents &lt; 100) {
      setError(&quot;Set a target of at least $1.&quot;);
      return;
    }

    setSaving(true);
    try {
      const res = await fetch(&quot;/api/creator/goal&quot;, {
        method: &quot;PATCH&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({
          title: title.trim(),
          description: description.trim() || undefined,
          targetCents,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? &quot;Could not save goal&quot;);
        setSaving(false);
        return;
      }
      setSaved(true);
      setSaving(false);
      router.refresh();
    } catch {
      setError(&quot;Network error. Please try again.&quot;);
      setSaving(false);
    }
  }

  async function onRemove() {
    setRemoving(true);
    setError(null);
    try {
      await fetch(&quot;/api/creator/goal&quot;, { method: &quot;DELETE&quot; });
      setTitle(&quot;&quot;);
      setDescription(&quot;&quot;);
      setTarget(&quot;&quot;);
      setSaved(false);
      router.refresh();
    } catch {
      setError(&quot;Could not remove goal&quot;);
    }
    setRemoving(false);
  }

  return (
    &lt;form onSubmit={onSubmit} className=&quot;kofi-card space-y-5 p-6&quot;&gt;
      &lt;div&gt;
        &lt;h2 className=&quot;text-lg font-bold&quot;&gt;Donation goal&lt;/h2&gt;
        &lt;p className=&quot;mt-1 text-sm text-muted&quot;&gt;
          Show supporters what you&amp;rsquo;re raising money for. Your progress is your total raised
          against the target, and a progress bar appears on your page.
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;goal-title&quot;&gt;
          Title
        &lt;/label&gt;
        &lt;TextField.Root size=&quot;3&quot;&gt;
          &lt;TextField.Input
            id=&quot;goal-title&quot;
            value={title}
            onChange={(e) =&gt; setTitle(e.target.value)}
            maxLength={80}
            placeholder=&quot;A new drawing tablet&quot;
          /&gt;
        &lt;/TextField.Root&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;goal-target&quot;&gt;
          Target amount &lt;span className=&quot;font-normal text-muted&quot;&gt;(USD)&lt;/span&gt;
        &lt;/label&gt;
        &lt;TextField.Root size=&quot;3&quot;&gt;
          &lt;TextField.Input
            id=&quot;goal-target&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            step=&quot;1&quot;
            inputMode=&quot;decimal&quot;
            value={target}
            onChange={(e) =&gt; setTarget(e.target.value)}
            placeholder=&quot;500&quot;
          /&gt;
        &lt;/TextField.Root&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label className=&quot;mb-1 block text-sm font-semibold&quot; htmlFor=&quot;goal-desc&quot;&gt;
          Description &lt;span className=&quot;font-normal text-muted&quot;&gt;(optional)&lt;/span&gt;
        &lt;/label&gt;
        &lt;TextArea
          id=&quot;goal-desc&quot;
          size=&quot;3&quot;
          value={description}
          onChange={(e) =&gt; setDescription(e.target.value)}
          rows={3}
          maxLength={500}
          placeholder=&quot;Tell supporters why it matters.&quot;
        /&gt;
      &lt;/div&gt;

      {error ? &lt;p className=&quot;text-sm text-red-600&quot;&gt;{error}&lt;/p&gt; : null}
      {saved ? &lt;p className=&quot;text-sm text-positive&quot;&gt;Saved.&lt;/p&gt; : null}

      &lt;div className=&quot;flex items-center gap-3&quot;&gt;
        &lt;Button type=&quot;submit&quot; size=&quot;3&quot; variant=&quot;solid&quot; disabled={saving}&gt;
          {saving ? &quot;Saving…&quot; : goal ? &quot;Update goal&quot; : &quot;Set goal&quot;}
        &lt;/Button&gt;
        {goal ? (
          &lt;Button type=&quot;button&quot; size=&quot;3&quot; variant=&quot;soft&quot; color=&quot;gray&quot; disabled={removing} onClick={onRemove}&gt;
            {removing ? &quot;Removing…&quot; : &quot;Remove&quot;}
          &lt;/Button&gt;
        ) : null}
      &lt;/div&gt;
    &lt;/form&gt;
  );
}</code></pre>
  </div>
</div>

Now the route the goal form posts to: it saves the creator's goal, creating it the first time, and removes it when they clear it. Create `app/api/creator/goal/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireCreator } from &quot;@/lib/auth&quot;;
import { rateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const schema = z.object({
  title: z.string().min(1).max(80),
  description: z.string().max(500).optional(),
  targetCents: z.number().int().min(100).max(10_000_000),
});

export async function PATCH(req: NextRequest) {
  if (!rateLimit(`creator-goal:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();

  const body: unknown = await req.json().catch(() =&gt; null);
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid input&quot; },
      { status: 400 },
    );
  }
  const input = parsed.data;

  const data = {
    title: input.title.trim(),
    description: input.description?.trim() || null,
    targetCents: input.targetCents,
    isActive: true,
  };

  const existing = await prisma.goal.findFirst({
    where: { creatorId: creator.id, isActive: true },
  });
  if (existing) {
    await prisma.goal.update({ where: { id: existing.id }, data });
  } else {
    await prisma.goal.create({ data: { creatorId: creator.id, ...data } });
  }

  return NextResponse.json({ ok: true });
}

export async function DELETE(req: NextRequest) {
  if (!rateLimit(`creator-goal:${clientIp(req)}`, 20, 60_000)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const { creator } = await requireCreator();
  await prisma.goal.updateMany({
    where: { creatorId: creator.id, isActive: true },
    data: { isActive: false },
  });

  return NextResponse.json({ ok: true });
}</code></pre>
  </div>
</div>

## Checkpoint

- Open `/` signed out. The homepage shows a featured strip of real creators, the category pills filter it, and the FAQ and claim-your-page sections render.
- Open `/explore` and page through the creators; the pager works and each shows a supporter count.
- Use the handle search and confirm it takes you to `/<handle>` (try a mixed-case handle, it still lands).
- Sign in and open `/feed`. It shows the creators you follow, tip, or subscribe to (or the top creators if you back none yet).
- Toggle the theme through system, light, and dark. The whole app reflows, and a reload keeps your choice with no flash of the wrong theme.
- On `/dashboard/settings`, upload a cover and avatar, edit your profile, and pick an accent color. Save, then confirm your creator page uses the new accent, including inside the support widget's checkout.
- Set a donation goal and confirm the progress bar appears on your creator page.

## Part 14: Go live

Now the final part, switching from sandbox to the live Whop.com environment. This doesn't include code rewrites or deep changes. Though it's not just switching `WHOP_SANDBOX` to `false`.

### Step 1: Switch the flag and the keys

The sandbox keys and secrets we got at the start are not going to mirror to the live Whop.com environment, so we need to get new ones, and set `WHOP_SANDBOX` to `false` in Vercel.

- `WHOP_PLATFORM_COMPANY_ID` and `NEXT_PUBLIC_WHOP_COMPANY_ID` > your production platform (on Whop.com, not Sandbox.Whop.com) company ID (`biz_...`), the parent for real connected creator accounts.
- `WHOP_CLIENT_ID` and `NEXT_PUBLIC_WHOP_APP_ID` > the production app ID (`app_...`).
- `WHOP_COMPANY_API_KEY` > the production Company API key (`apik_...`) from Developer > API Keys. This is a brand-new key, so enable the same scopes we listed in Part 1, or the live app fails silently the same way the sandbox would.
- `WHOP_CLIENT_SECRET` > the production app OAuth client secret from the app's OAuth settings.

These go into Vercel, never a committed file. Set them on the Production environment with the CLI or the dashboard:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env add WHOP_SANDBOX production
vercel env add WHOP_PLATFORM_COMPANY_ID production
vercel env add NEXT_PUBLIC_WHOP_COMPANY_ID production
vercel env add WHOP_CLIENT_ID production
vercel env add NEXT_PUBLIC_WHOP_APP_ID production
vercel env add WHOP_COMPANY_API_KEY production
vercel env add WHOP_CLIENT_SECRET production</code></pre>
  </div>
</div>

### Step 2: Register the production OAuth redirect URI

OAuth requires the redirect URI we send to match a URI registered on the app exactly. In development that was `http://localhost:3005/oauth/callback`.

For production, open the app's OAuth settings in the Whop dashboard and add your deployed callback, for example `https://your-domain.com/oauth/callback`.

### Step 3: Create the production webhook

The webhook we made earlier only exists in the sandbox, so we need to create a fresh one for the production company. In the Whop dashboard of your company created at live Whop.com, open Developer > Webhooks, and add a webhook:

- Point it at `https://your-domain.com/api/webhooks/whop`.
- Set the API version to **v1**.
- Enable connected account actions.
- Subscribe to `payment.succeeded`, `payment.failed`, `membership.activated`, `membership.deactivated`, and `refund.created`.

Then copy its signing secret into `WHOP_WEBHOOK_SECRET` on Vercel:

```bash
vercel env add WHOP_WEBHOOK_SECRET production

```

### Step 4: Use sandbox keys on preview

Vercel gives every branch a Preview environment. We keep Preview on sandbox so a preview deployment can never move real money.

Set `WHOP_SANDBOX="true"` and the sandbox company ID, app ID, Company API key, and client secret on Preview, with a sandbox webhook pointed at a stable preview URL or a tunnel. Only Production carries production keys.

> 

### The environment variable checklist

Every variable from `.env.example`, with what it should be in Production versus Preview. Set these on the matching Vercel environment; never commit the real values.

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Production</th>
      <th>Preview</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>DATABASE_URL</code></td>
      <td>Production Neon (pooled) URL</td>
      <td>Sandbox/dev DB or a preview Neon branch</td>
      <td>Provisioned via the Vercel Neon integration (Part 2); apply migrations with <code>prisma migrate deploy</code>.</td>
    </tr>
    <tr>
      <td><code>SESSION_SECRET</code></td>
      <td>32+ char random string</td>
      <td>32+ char random string</td>
      <td>iron-session cookie key; can differ per environment.</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_APP_URL</code></td>
      <td><code>https://your-domain.com</code></td>
      <td>The preview deployment origin</td>
      <td>Must be the real origin so OAuth builds the correct redirect.</td>
    </tr>
    <tr>
      <td><code>WHOP_SANDBOX</code></td>
      <td><code>"false"</code></td>
      <td><code>"true"</code></td>
      <td>The master switch; <code>"false"</code> only in Production.</td>
    </tr>
    <tr>
      <td><code>WHOP_PLATFORM_COMPANY_ID</code></td>
      <td>Production <code>biz_...</code></td>
      <td>Sandbox <code>biz_...</code></td>
      <td>Parent company for connected accounts.</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_WHOP_COMPANY_ID</code></td>
      <td>Production <code>biz_...</code></td>
      <td>Sandbox <code>biz_...</code></td>
      <td>Public mirror of the platform company id.</td>
    </tr>
    <tr>
      <td><code>WHOP_CLIENT_ID</code></td>
      <td>Production <code>app_...</code></td>
      <td>Sandbox <code>app_...</code></td>
      <td>OAuth client id.</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_WHOP_APP_ID</code></td>
      <td>Production <code>app_...</code></td>
      <td>Sandbox <code>app_...</code></td>
      <td>Public mirror of the app id.</td>
    </tr>
    <tr>
      <td><code>WHOP_CLIENT_SECRET</code></td>
      <td>Production OAuth client secret</td>
      <td>Sandbox OAuth client secret</td>
      <td>Required even with PKCE; server-only.</td>
    </tr>
    <tr>
      <td><code>WHOP_COMPANY_API_KEY</code></td>
      <td>Production Company API key</td>
      <td>Sandbox Company API key</td>
      <td>Server-only; powers all SDK calls.</td>
    </tr>
    <tr>
      <td><code>WHOP_WEBHOOK_SECRET</code></td>
      <td>Production webhook secret</td>
      <td>Sandbox webhook secret</td>
      <td>The production webhook's own secret, no trailing newline.</td>
    </tr>
    <tr>
      <td><code>NEXT_PUBLIC_PLATFORM_FEE_PERCENT</code></td>
      <td>Your fee, e.g. <code>"5"</code></td>
      <td>Same</td>
      <td>Application fee percent (0–100).</td>
    </tr>
  </tbody>
</table>

## Checkpoint

- `WHOP_SANDBOX=false` is set on the Production environment only; Preview stays `"true"`.
- Production `WHOP_PLATFORM_COMPANY_ID` / `NEXT_PUBLIC_WHOP_COMPANY_ID`, `WHOP_CLIENT_ID` / `NEXT_PUBLIC_WHOP_APP_ID`, `WHOP_COMPANY_API_KEY`, and `WHOP_CLIENT_SECRET` are all the production values.
- The production OAuth redirect URI `https://<domain>/oauth/callback` is registered on the Whop app with an exact-match URL.
- `NEXT_PUBLIC_APP_URL` is the production origin so OAuth builds the correct redirect.
- A production payments webhook points at `https://<domain>/api/webhooks/whop` and subscribes to `payment.succeeded`, `payment.failed`, `membership.activated`, `membership.deactivated`, and `refund.created`.
- The production webhook's signing secret is in `WHOP_WEBHOOK_SECRET` with no trailing newline.
- `DATABASE_URL` resolves to the production Neon connection, and migrations have been applied with `prisma migrate deploy`.
- Preview keeps sandbox keys and `WHOP_SANDBOX="true"` so previews never touch real money.
- Every variable in the checklist table is set on the matching Vercel environment, and the app has been redeployed to production.
- A `4242 4242 4242 4242` test charge drives the full loop (webhook > record > goal > notification), and `4000 0000 0000 0002` is declined with no record, before switching to real cards.
- You can sign in, become a creator, complete KYC, receive a tip, and withdraw earnings end to end on the live domain.

## Ready to build your own platform with Whop?

In this tutorial we've built a fully functioning Ko-fi clone with connected accounts, embedded checkouts, and on-site payouts. We used Whop for some of the most crucial parts of the build like user authentication, notifications, and payments.

There are a lot more projects you can work on with Whop like building a [Gumroad](https://whop.com/blog/build-gumroad-clone/) or a [Medium clone](https://whop.com/blog/build-medium-clone/), or even adding [user authentication](https://whop.com/blog/add-user-authentication/) to your existing SaaS. If you want to learn more about how Whop can help you build or improve projects, check out our other [tutorials](https://whop.com/blog/t/tutorials/) and read the [Whop developer docs](https://docs.whop.com/).

**[Go to Whop developer docs](https://docs.whop.com/)**
