Topic Details

https://whop.com/blog/rss/

Last successful fetch
Thu, 09 Apr 2026 17:23:35 +0000
Last ping
Thu, 09 Apr 2026 17:01:16 +0000
Last fetch error
Fri, 06 Mar 2026 14:46:43 +0000 (HTTP 504)
Aggregate statistics
0 fetch request(s) per second to whop.com, 0% errors, based on latest 300 seconds

Last item retrieved

Content received
Thu, 09 Apr 2026 17:23:35 +0000
<item><title><![CDATA[How to build a Gumroad clone using Next.js and Whop]]></title><description><![CDATA[You can build a Gumroad clone using Next.js and Whop's infrastructure and it's easier than ever. In this tutorial, we'll walk you through building a marketplace with file uploads via UploadThing, payments via Whop Payments Network, user authentication with Whop OAuth, and more.]]></description><link>https://whop.com/blog/build-gumroad-clone/</link><guid isPermaLink="false">69d323ee5018870001858966</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[Engineering]]></category><dc:creator><![CDATA[East]]></dc:creator><pubDate>Thu, 09 Apr 2026 14:18:32 GMT</pubDate><media:content url="https://whop.com/blog/content/images/2026/04/how-to-build-a-gumroad-clone.webp" medium="image"/><content:encoded><![CDATA[
<!--kg-card-begin: html-->
<div class="kg-card kg-cta-card kg-cta-minimal whop-key-takeaways" data-whop-key-takeaways="2026-04-09T17:00:48.405Z" data-layout="minimal">
	<div class="kg-cta-content">
		<div class="kg-cta-content-inner">
			<div class="kg-cta-text">
				<img src="https://whop.com/blog/content/images/2026/04/how-to-build-a-gumroad-clone.webp" alt="How to build a Gumroad clone using Next.js and Whop"><p><strong>Key takeaways</strong></p>
				<ul><li>Next.js combined with Whop&apos;s payment network and OAuth enables building a full multi-seller digital marketplace.</li><li>Whop handles both seller onboarding with connected accounts and buyer payments with automatic platform fee splits.</li><li>A deploy-first approach with Vercel and Neon integration streamlines environment setup and database management.</li></ul>
			</div>
		</div>
	</div>
</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">&#x2728;</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>
<!--kg-card-end: html-->
<p>Building a Gumroad clone where users can browse the marketplace and buy files and links from creators, or become a seller themselves and earn money, can be done by using Next.js and Whop infrastructure.</p><p>In this tutorial, we&apos;re going to build such clone (which we&apos;ll call Shelfie). A marketplace where users sign up, become sellers, upload digital products, set their own prices, and publish to the marketplace.</p><p>Buyers in the platform can browse, purchase, and download products they bought. Our platform is also going to take a 5% cut of every sale.</p><p>You can preview the <a href="https://shelfie-rust.vercel.app/" rel="noopener nofollow">demo of our project here</a>, and see the full <a href="https://github.com/whopio/whop-tutorials/tree/main/gumroad-clone">GitHub repository here</a>.</p><h2 id="project-overview">Project overview</h2><p>Before we dive deep into coding, let&apos;s take a general look at our project:</p><ul><li><strong>Multi-seller marketplace</strong> where any user can become a seller through Whop&apos;s connected account flow</li><li><strong>Product creation with file uploads</strong> where sellers upload files via UploadThing, add descriptions, set prices, and publish when ready</li><li><strong>Marketplace discovery</strong> with search, category filters, pagination, and trending products</li><li><strong>One-time purchases</strong> where sellers set a price and buyers pay through the <a href="https://whop.com/network/" rel="noopener nofollow">Whop Payments Network</a></li><li><strong>Access-gated downloads</strong> where buyers get instant access to files, text content, and external links after purchase</li><li><strong>Rating system</strong> where buyers rate products on a 1-5 cookie scale</li><li><strong>Seller and buyer dashboards</strong> with earnings, product management, bio editing, and purchase history</li></ul><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
            <div class="kg-toggle-heading">
                <h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">Tech Stack</span></h4>
                <button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
                    <svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                        <path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
                    </svg>
                </button>
            </div>
            <div class="kg-toggle-content"><ul><li value="1"><b><strong style="white-space: pre-wrap;">Next.js -</strong></b><span style="white-space: pre-wrap;"> Server Components, API routes, and Vercel deployment in one framework</span></li><li value="2"><b><strong style="white-space: pre-wrap;">React -</strong></b><span style="white-space: pre-wrap;"> Server Components for data fetching, Client Components for interactivity</span></li><li value="3"><b><strong style="white-space: pre-wrap;">Tailwind CSS -</strong></b><span style="white-space: pre-wrap;"> CSS-first configuration with </span><code spellcheck="false" style="white-space: pre-wrap;"><span>@theme</span></code><span style="white-space: pre-wrap;"> blocks, no config file</span></li><li value="4"><b><strong style="white-space: pre-wrap;">Whop OAuth -</strong></b><span style="white-space: pre-wrap;"> Sign-in and identity for both sellers and buyers</span></li><li value="5"><b><strong style="white-space: pre-wrap;">Whop Payments Network -</strong></b><span style="white-space: pre-wrap;"> Connected accounts for seller onboarding, direct charges with application fees for payment splits</span></li><li value="6"><b><strong style="white-space: pre-wrap;">Neon -</strong></b><span style="white-space: pre-wrap;"> Serverless Postgres via the Vercel integration. Auto-populated connection strings</span></li><li value="7"><b><strong style="white-space: pre-wrap;">Prisma -</strong></b><span style="white-space: pre-wrap;"> ESM-only ORM with </span><code spellcheck="false" style="white-space: pre-wrap;"><span>@prisma/adapter-pg</span></code><span style="white-space: pre-wrap;"> for Neon compatibility. Client generated into </span><code spellcheck="false" style="white-space: pre-wrap;"><span>src/generated/prisma</span></code></li><li value="8"><b><strong style="white-space: pre-wrap;">UploadThing -</strong></b><span style="white-space: pre-wrap;"> File uploads with typed routes, auth middleware, and CDN delivery</span></li><li value="9"><b><strong style="white-space: pre-wrap;">Zod -</strong></b><span style="white-space: pre-wrap;"> Runtime validation for env vars, API inputs, and form data</span></li><li value="10"><b><strong style="white-space: pre-wrap;">iron-session -</strong></b><span style="white-space: pre-wrap;"> Encrypted cookie sessions. No session store, no Redis</span></li><li value="11"><b><strong style="white-space: pre-wrap;">Vercel -</strong></b><span style="white-space: pre-wrap;"> Deployment with automatic builds from GitHub</span></li></ul></div>
        </div><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
            <div class="kg-toggle-heading">
                <h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">Pages</span></h4>
                <button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
                    <svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                        <path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
                    </svg>
                </button>
            </div>
            <div class="kg-toggle-content"><ul><li value="1"><code spellcheck="false" style="white-space: pre-wrap;"><span>/</span></code><span style="white-space: pre-wrap;"> - Landing page (hero, search, trending products, categories, seller CTA)</span></li><li value="2"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sign-in</span></code><span style="white-space: pre-wrap;"> - Sign-in card with Whop button</span></li><li value="3"><code spellcheck="false" style="white-space: pre-wrap;"><span>/products</span></code><span style="white-space: pre-wrap;"> - Browse/search products with category filter, pagination</span></li><li value="4"><code spellcheck="false" style="white-space: pre-wrap;"><span>/products/[slug]</span></code><span style="white-space: pre-wrap;"> - Product detail: description, file list, seller info, purchase card, cookie ratings</span></li><li value="5"><code spellcheck="false" style="white-space: pre-wrap;"><span>/products/[slug]/download</span></code><span style="white-space: pre-wrap;"> - Post-purchase download page (access-gated)</span></li><li value="6"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sellers/[username]</span></code><span style="white-space: pre-wrap;"> &#x2014; Seller profile: bio, stats, published products</span></li><li value="7"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sell</span></code><span style="white-space: pre-wrap;"> - Become a seller: pitch + connect Whop account</span></li><li value="8"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sell/kyc-return</span></code><span style="white-space: pre-wrap;"> - KYC completion handler (redirects to dashboard)</span></li><li value="9"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sell/dashboard</span></code><span style="white-space: pre-wrap;"> - Seller dashboard: products, earnings, bio editing, payout portal</span></li><li value="10"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sell/products/new</span></code><span style="white-space: pre-wrap;"> - Create new product form</span></li><li value="11"><code spellcheck="false" style="white-space: pre-wrap;"><span>/sell/products/[productId]/edit</span></code><span style="white-space: pre-wrap;"> - Edit product: info, files, thumbnail, publish/unpublish/delete</span></li><li value="12"><code spellcheck="false" style="white-space: pre-wrap;"><span>/dashboard</span></code><span style="white-space: pre-wrap;"> - Buyer dashboard: purchased products, download links</span></li></ul></div>
        </div><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
            <div class="kg-toggle-heading">
                <h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">API routes</span></h4>
                <button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
                    <svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                        <path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
                    </svg>
                </button>
            </div>
            <div class="kg-toggle-content"><ul><li value="1"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/auth/login</span></code><span style="white-space: pre-wrap;"> - OAuth initiation (PKCE)</span></li><li value="2"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/auth/callback</span></code><span style="white-space: pre-wrap;"> - OAuth callback + user upsert</span></li><li value="3"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/auth/logout</span></code><span style="white-space: pre-wrap;"> - Session destroy</span></li><li value="4"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/onboard</span></code><span style="white-space: pre-wrap;"> - Create connected account Company + KYC (sandbox: auto-complete)</span></li><li value="5"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/complete-kyc</span></code><span style="white-space: pre-wrap;"> - Mark KYC as complete (called from kyc-return page)</span></li><li value="6"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/profile</span></code><span style="white-space: pre-wrap;"> - PATCH: update seller headline and bio</span></li><li value="7"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/products</span></code><span style="white-space: pre-wrap;"> - POST: create product</span></li><li value="8"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/products/[productId]</span></code><span style="white-space: pre-wrap;"> - PATCH: update (with file add/remove), DELETE: remove product</span></li><li value="9"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/products/[productId]/publish</span></code><span style="white-space: pre-wrap;"> - POST: publish product (create Whop checkout config)</span></li><li value="10"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/sell/products/[productId]/unpublish</span></code><span style="white-space: pre-wrap;"> - POST: revert to draft</span></li><li value="11"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/products/[productId]/purchase</span></code><span style="white-space: pre-wrap;"> - POST: free product purchase</span></li><li value="12"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/products/[productId]/like</span></code><span style="white-space: pre-wrap;"> - POST: toggle like</span></li><li value="13"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/products/[productId]/rate</span></code><span style="white-space: pre-wrap;"> - POST: cookie rating (0.5-5)</span></li><li value="14"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/uploadthing</span></code><span style="white-space: pre-wrap;"> - UploadThing file upload endpoint</span></li><li value="15"><code spellcheck="false" style="white-space: pre-wrap;"><span>/api/webhooks/whop</span></code><span style="white-space: pre-wrap;"> - POST: Whop payment webhooks</span></li></ul></div>
        </div><div class="kg-card kg-toggle-card" data-kg-toggle-state="close">
            <div class="kg-toggle-heading">
                <h4 class="kg-toggle-heading-text"><span style="white-space: pre-wrap;">Payment flow</span></h4>
                <button class="kg-toggle-card-icon" aria-label="Expand toggle to read content">
                    <svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
                        <path class="cls-1" d="M23.25,7.311,12.53,18.03a.749.749,0,0,1-1.06,0L.75,7.311"/>
                    </svg>
                </button>
            </div>
            <div class="kg-toggle-content"><ol><li value="1"><span style="white-space: pre-wrap;">Seller clicks &quot;Get Started&quot; and creates a connected account through Whop&apos;s hosted KYC flow</span></li><li value="2"><span style="white-space: pre-wrap;">Seller publishes a product. The app creates a Whop product and checkout configuration with a 5% application fee</span></li><li value="3"><span style="white-space: pre-wrap;">Buyer clicks &quot;Buy Now&quot; and pays through Whop&apos;s hosted checkout</span></li><li value="4"><span style="white-space: pre-wrap;">Whop fires a </span><code spellcheck="false" style="white-space: pre-wrap;"><span>payment.succeeded</span></code><span style="white-space: pre-wrap;"> webhook. The app creates a Purchase record</span></li><li value="5"><span style="white-space: pre-wrap;">Seller manages payouts through Whop&apos;s dashboard</span></li></ol></div>
        </div><h3 id="why-we-use-whop">Why we use Whop</h3><p>While building this project, we&apos;re going to face two important problems to solve: the payments system and the user authentication - Whop is going to help us solve them easily:</p><ul><li>For the <strong>payments</strong>, we&apos;re going to use the <strong>Whop Payments Network</strong> for the out-of-the-box solution for marketplace payments.</li><li>For <strong>user authentication</strong>, we&apos;re going to use <strong>Whop OAuth</strong> to integrate a user authentication system for both sellers and buyers, allowing us to focus on building instead of authentication security and credential storage.</li></ul><h3 id="what-you-need-to-start">What you need to start</h3><p>Before starting this project, you need:</p><ul><li>A Whop sandbox account (create at sandbox.whop.com)</li><li>A Vercel account</li><li>Working familiarity with Next.js and React</li><li>An UploadThing account</li></ul><h2 id="part-1-scaffold-deploy-and-authenticate">Part 1: Scaffold, deploy, and authenticate</h2><p>In this project, we&apos;re going to take a deploy-first approach. This means we get a real production URL, which we&apos;ll use later. First, let&apos;s scaffold a new Next.js project.</p><h3 id="scaffold-a-nextjs-project">Scaffold a Next.js project</h3><p>Go to the directory you want to build your project in and run the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest shelfie --ts --tailwind --eslint --app --src-dir --turbopack --import-alias &quot;@/*&quot;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, install all dependencies we&apos;ll use in the project:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk @prisma/client @prisma/adapter-pg pg iron-session zod lucide-react next-themes clsx tailwind-merge dotenv uploadthing @uploadthing/react
npm install -D prisma @types/pg@8.11.11</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="deploy-to-vercel">Deploy to Vercel</h3><p>Now, let&apos;s deploy everything to GitHub and connect to Vercel:</p><ol><li>Use the command <code>git init &amp;&amp; git add . &amp;&amp; git commit -m &quot;scaffold&quot;</code> (you can make a private repo if you want) and push to a new GitHub repository</li><li>Import the repo at vercel.com/new and take note of your production URL</li><li>Set <code>NEXT_PUBLIC_APP_URL</code> to your Vercel URL (e.g. <code>https://shelfie-xyz.vercel.app</code>)</li></ol><p>The first deploy will succeed with the default Next.js starter. You&apos;ll add the real environment variables and redeploy as you go.</p><h3 id="add-the-neon-database">Add the Neon database</h3><p>We&apos;re going to use the Neon integration on Vercel so that we don&apos;t have to locally set up Postgres, &#xA0;connection strings auto-populate in every Vercel environment (dev, preview, production), and preview deployments get their own database branches.</p><p>Add the Neon integration from Vercel&apos;s marketplace (Settings &gt; Integrations &gt; Browse Marketplace &gt; Neon). It auto-populates <code>DATABASE_URL</code> and <code>DATABASE_URL_UNPOOLED</code> across all environments.</p><p>Once the Neon setup is done, use the commands below to pull the environment variables locally:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel link
vercel env pull .env.local</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="setting-up-a-whop-app">Setting up a Whop app</h3><p>We&apos;re going to use Whop Sandbox (at sandbox.whop.com) for the development phase of this project.</p><p>It&apos;s a separate environment that allows us to simulate money movements without touching the live version of the site or real money. To create a Whop app, follow the steps below:</p><ol><li>Go to sandbox.whop.com and create a whop</li><li>Go to the Developer page of your whop and click the <strong>Create app</strong> button under the Apps section</li><li>In the App details tab, copy the Client ID and Client Secret keys. We&apos;ll use them later</li><li>Go to the OAuth tab and add the redirect URIs:<ol><li><code>http://localhost:3000/api/auth/callback</code></li><li><code>https://your-vercel-url.vercel.app/api/auth/callback</code></li></ol></li></ol><p>For now, we only need the OAuth client ID and client secret. We&apos;ll grab the company API key and company ID later when we build seller onboarding.</p><h3 id="environment-variables">Environment variables</h3><p>Here&apos;s every variable you need for this section and where to get it:</p>
<!--kg-card-begin: html-->
<table>
<tbody><tr><th>Variable</th><th>Where to get it</th></tr>
<tr><td><code>DATABASE_URL</code></td><td>Auto-populated by the Neon integration</td></tr>
<tr><td><code>DATABASE_URL_UNPOOLED</code></td><td>Auto-populated by the Neon integration</td></tr>
<tr><td><code>WHOP_CLIENT_ID</code></td><td>Whop app &gt; OAuth tab &gt; Client ID</td></tr>
<tr><td><code>WHOP_CLIENT_SECRET</code></td><td>Whop app &gt; OAuth tab &gt; Client Secret</td></tr>
<tr><td><code>SESSION_SECRET</code></td><td>Generate with <code>openssl rand -base64 32</code></td></tr>
<tr><td><code>NEXT_PUBLIC_APP_URL</code></td><td>Your Vercel URL (e.g. <code>https://shelfie-xyz.vercel.app</code>)</td></tr>
</tbody></table>
<!--kg-card-end: html-->
<p>Add <code>WHOP_CLIENT_ID</code>, <code>WHOP_CLIENT_SECRET</code>, <code>SESSION_SECRET</code>, and <code>NEXT_PUBLIC_APP_URL</code> to Vercel (the Neon variables are already there from the integration). Then pull everything locally:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env pull .env.local</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then add these two to your <code>.env.local</code> only (not on Vercel - they&apos;re for local development):</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env.local</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">WHOP_SANDBOX=true
NEXT_PUBLIC_APP_URL=http://localhost:3000</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The local <code>NEXT_PUBLIC_APP_URL</code> override points to <code>localhost:3000</code> so OAuth redirects work during development. On Vercel, it stays as your production URL.</p><h3 id="global-css">Global CSS</h3><p>We&apos;re going to set up a dark crimson color scheme in this project. You can customize the look by changing the color values and other adjustments like border radiuses.</p><p>Go to <code>src/app</code> and create a file called <code>globals.css</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">globals.css</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-css">@import &quot;tailwindcss&quot;;

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

@theme {
  --color-background: #1A0A10;
  --color-surface: #221218;
  --color-surface-elevated: #2D1A22;
  --color-border: #3D2830;
  --color-text-primary: #F5F0E1;
  --color-text-secondary: #A89890;
  --color-accent: #B8293D;
  --color-accent-hover: #D4324A;
  --color-success: #4ADE80;
  --color-warning: #FBBF24;
  --color-error: #F87171;

  --font-sans: &quot;Inter&quot;, system-ui, sans-serif;
  --font-mono: &quot;JetBrains Mono&quot;, monospace;

  --radius-xs: 0px;
  --radius-sm: 0px;
  --radius-md: 0px;
  --radius-lg: 0px;
  --radius-xl: 0px;
  --radius-2xl: 0px;
  --radius-3xl: 0px;
}

.dark {
  --color-background: #1A0A10;
  --color-surface: #221218;
  --color-surface-elevated: #2D1A22;
  --color-border: #3D2830;
  --color-text-primary: #F5F0E1;
  --color-text-secondary: #A89890;
  --color-accent: #B8293D;
  --color-accent-hover: #D4324A;
  --color-success: #4ADE80;
  --color-warning: #FBBF24;
  --color-error: #F87171;
}

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="root-layout">Root layout</h3><p>Now, we&apos;ll build the root layout that sets up theming, navigation, and a skip-to-content link for accessibility. Go to <code>src/app</code> and create a file called <code>layout.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">layout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import type { Metadata } from &quot;next&quot;;
import { Inter, JetBrains_Mono } from &quot;next/font/google&quot;;
import { ThemeProvider } from &quot;next-themes&quot;;
import { Navbar } from &quot;@/components/navbar&quot;;
import &quot;./globals.css&quot;;

const inter = Inter({
  variable: &quot;--font-sans&quot;,
  subsets: [&quot;latin&quot;],
});

const jetbrainsMono = JetBrains_Mono({
  variable: &quot;--font-mono&quot;,
  subsets: [&quot;latin&quot;],
});

export const metadata: Metadata = {
  title: &quot;Shelfie - Sell What You Create&quot;,
  description:
    &quot;The marketplace for digital products - templates, ebooks, design assets, and more.&quot;,
};

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body
        className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased bg-background text-text-primary`}
      &gt;
        &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;light&quot; enableSystem&gt;
          &lt;a
            href=&quot;#main-content&quot;
            className=&quot;sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-accent focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-white&quot;
          &gt;
            Skip to main content
          &lt;/a&gt;
          &lt;Navbar /&gt;
          &lt;main id=&quot;main-content&quot; className=&quot;min-h-[calc(100vh-4rem)]&quot;&gt;{children}&lt;/main&gt;
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="environment-validation">Environment validation</h3><p>When an environment variable is missing or formatted wrong, the error can be buried deep down and cause problems. To fix this, we&apos;re going to use Zod to validate them. Go to <code>src/lib</code> and create a file called <code>env.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 envSchema = z.object({
  DATABASE_URL: z.string().min(1),
  DATABASE_URL_UNPOOLED: z.string().min(1),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_API_KEY: z.string().min(1),
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_COMPANY_ID: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().min(1),
  UPLOADTHING_TOKEN: z.string().min(1),
  SESSION_SECRET: z.string().min(32),
  NEXT_PUBLIC_APP_URL: z.string().url(),
  PLATFORM_FEE_PERCENT: z.coerce.number().min(0).max(100).default(5),
  WHOP_SANDBOX: z.string().optional(),
});

type Env = z.infer&lt;typeof envSchema&gt;;

export const env = new Proxy({} as Env, {
  get(_, key: string) {
    const value = process.env[key];
    const field = envSchema.shape[key as keyof typeof envSchema.shape];
    if (field) return field.parse(value);
    return value;
  },
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="utility-helpers">Utility helpers</h3><p>We&apos;re going to need some functions that helps us with class merging, price formatting, slug generation (for URLs), and username generation. To build it, go to <code>src/lib</code> and create a file called <code>utils.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">utils.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { clsx, type ClassValue } from &quot;clsx&quot;;
import { twMerge } from &quot;tailwind-merge&quot;;

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export function formatPrice(cents: number): string {
  if (cents === 0) return &quot;Free&quot;;
  return `$${(cents / 100).toFixed(2)}`;
}

export function generateSlug(title: string): string {
  const base = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, &quot;-&quot;)
    .replace(/^-|-$/g, &quot;&quot;);
  const suffix = Math.random().toString(36).substring(2, 8);
  return `${base}-${suffix}`;
}

export function formatFileSize(bytes: number): string {
  if (bytes &lt; 1024) return `${bytes} B`;
  if (bytes &lt; 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function generateUsername(name: string | null | undefined): string {
  const base = (name || &quot;seller&quot;)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, &quot;-&quot;)
    .replace(/^-|-$/g, &quot;&quot;);
  const suffix = Math.random().toString(36).substring(2, 6);
  return `${base}-${suffix}`;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="prisma-setup">Prisma setup</h3><p>We need two files for Prisma: a config file and a client singleton that our app uses at runtime. In the project root, create a file called <code>prisma.config.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.config.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { config } from &quot;dotenv&quot;;
config({ path: &quot;.env.local&quot; });

import { defineConfig, env } from &quot;prisma/config&quot;;

export default defineConfig({
  schema: &quot;prisma/schema.prisma&quot;,
  migrations: { path: &quot;prisma/migrations&quot; },
  datasource: { url: env(&quot;DATABASE_URL_UNPOOLED&quot;) },
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, the Prisma client singleton. We build this so that every file reuses the same database connection. Go to <code>src/lib</code> and create a file called <code>prisma.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">prisma.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { PrismaClient } from &quot;@/generated/prisma/client&quot;;
import { PrismaPg } from &quot;@prisma/adapter-pg&quot;;
import { Pool } from &quot;pg&quot;;
import { env } from &quot;@/lib/env&quot;;

const pool = new Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(pool);

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

export const prisma =
  globalForPrisma.prisma || new PrismaClient({ adapter });

if (process.env.NODE_ENV !== &quot;production&quot;) globalForPrisma.prisma = prisma;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then generate the client and push the schema using the commands:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma generate
npx prisma db push</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="session-configuration">Session configuration</h3><p>To track who&apos;s logged in, we&apos;ll store the session data in an encrypted browser cookie with <code>iron-session</code>. Go to <code>src/lib</code> and create a file called <code>session.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getIronSession, type SessionOptions } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;
import { env } from &quot;@/lib/env&quot;;

export interface SessionData {
  userId?: string;
  whopUserId?: string;
  accessToken?: string;
}

const sessionOptions: SessionOptions = {
  password: env.SESSION_SECRET,
  cookieName: &quot;shelfie_session&quot;,
  cookieOptions: {
    secure: process.env.NODE_ENV === &quot;production&quot;,
    httpOnly: true,
    sameSite: &quot;lax&quot; as const,
  },
};

export async function getSession() {
  return getIronSession&lt;SessionData&gt;(await cookies(), sessionOptions);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="whop-sdk-and-oauth-configuration">Whop SDK and OAuth configuration</h3><p>We need a Whop SDK client that&apos;s sandbox-aware. When <code>WHOP_SANDBOX</code> environment variable is set to <code>true</code>, API calls go to <code>sandbox-api.whop.com</code> instead of production.</p><p>Go to <code>src/lib</code> and create a file called <code>whop.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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;;

const isSandbox = process.env.WHOP_SANDBOX === &quot;true&quot;;

let _whop: Whop | null = null;

export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      apiKey: process.env.WHOP_API_KEY!,
      webhookKey: process.env.WHOP_WEBHOOK_SECRET
        ? Buffer.from(process.env.WHOP_WEBHOOK_SECRET).toString(&quot;base64&quot;)
        : undefined,
      ...(isSandbox &amp;&amp; { baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot; }),
    });
  }
  return _whop;
}

let _companyWhop: Whop | null = null;

export function getCompanyWhop(): Whop {
  if (!_companyWhop) {
    _companyWhop = new Whop({
      apiKey: process.env.WHOP_COMPANY_API_KEY!,
      ...(isSandbox &amp;&amp; { baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot; }),
    });
  }
  return _companyWhop;
}

export const WHOP_OAUTH_BASE = isSandbox
  ? &quot;https://sandbox-api.whop.com&quot;
  : &quot;https://api.whop.com&quot;;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="auth-helpers">Auth helpers</h3><p>The project needs three levels of authentication: optional, required, and seller-only. Go to <code>src/lib</code> and create a file called <code>auth.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { getSession } from &quot;./session&quot;;
import { prisma } from &quot;./prisma&quot;;

export async function getAuthUser() {
  const session = await getSession();
  if (!session.userId) return null;

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    include: { sellerProfile: true },
  });

  return user;
}

export async function requireAuth() {
  const user = await getAuthUser();
  if (!user) redirect(&quot;/sign-in&quot;);
  return user;
}

export async function requireSeller() {
  const user = await requireAuth();
  if (!user.sellerProfile) redirect(&quot;/sell&quot;);
  if (!user.sellerProfile.kycComplete) redirect(&quot;/sell?kyc=incomplete&quot;);
  return { user, sellerProfile: user.sellerProfile };
}

export async function completeKycIfNeeded(userId: string): Promise&lt;boolean&gt; {
  const profile = await prisma.sellerProfile.findUnique({
    where: { userId },
  });
  if (!profile || profile.kycComplete) return !!profile?.kycComplete;

  await prisma.sellerProfile.update({
    where: { id: profile.id },
    data: { kycComplete: true },
  });
  return true;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-login-route">The login route</h3><p>When a user clicks on the &quot;Sign in with Whop&quot; button, we need to create a PKCE challenge, store the verifier in a cookie, and redirect the user to Whop&apos;s authentication page. To build this system, go to <code>src/app/api/auth/login</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { WHOP_OAUTH_BASE } from &quot;@/lib/whop&quot;;
import { env } from &quot;@/lib/env&quot;;

export async function GET() {
  const clientId = env.WHOP_CLIENT_ID;
  const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;

  const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest(&quot;SHA-256&quot;, data);
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, &quot;-&quot;)
    .replace(/\//g, &quot;_&quot;)
    .replace(/=+$/, &quot;&quot;);

  const state = crypto.randomUUID();
  const nonce = crypto.randomUUID();

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: &quot;code&quot;,
    scope: &quot;openid profile email&quot;,
    state,
    code_challenge: codeChallenge,
    code_challenge_method: &quot;S256&quot;,
    nonce,
  });

  const authUrl = `${WHOP_OAUTH_BASE}/oauth/authorize?${params}`;

  const response = NextResponse.redirect(authUrl);

  response.cookies.set(&quot;oauth_code_verifier&quot;, codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === &quot;production&quot;,
    sameSite: &quot;lax&quot;,
    maxAge: 600,
    path: &quot;/&quot;,
  });
  response.cookies.set(&quot;oauth_state&quot;, state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === &quot;production&quot;,
    sameSite: &quot;lax&quot;,
    maxAge: 600,
    path: &quot;/&quot;,
  });

  return response;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="callback-route">Callback route</h3><p>We added the callback URI in the OAuth tab of the Whop app previously. Now, let&apos;s build the route that handles when Whop redirects the user back to our app. Go to <code>src/app/api/auth/callback</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;
import { WHOP_OAUTH_BASE } from &quot;@/lib/whop&quot;;
import { env } from &quot;@/lib/env&quot;;

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get(&quot;code&quot;);
  const state = searchParams.get(&quot;state&quot;);

  const storedState = request.cookies.get(&quot;oauth_state&quot;)?.value;
  const codeVerifier = request.cookies.get(&quot;oauth_code_verifier&quot;)?.value;

  if (!code || !state || state !== storedState || !codeVerifier) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=invalid_state`
    );
  }

  const tokenRes = await fetch(`${WHOP_OAUTH_BASE}/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: `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=token_exchange`
    );
  }

  const tokens = await tokenRes.json();

  const userInfoRes = await fetch(`${WHOP_OAUTH_BASE}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userInfoRes.ok) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/sign-in?error=userinfo`
    );
  }

  const userInfo = await userInfoRes.json();

  const user = await prisma.user.upsert({
    where: { whopUserId: userInfo.sub },
    update: {
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username,
      avatar: userInfo.picture,
    },
    create: {
      whopUserId: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username,
      avatar: userInfo.picture,
    },
  });

  const session = await getSession();
  session.userId = user.id;
  session.whopUserId = user.whopUserId;
  session.accessToken = tokens.access_token;
  await session.save();

  const response = NextResponse.redirect(
    `${env.NEXT_PUBLIC_APP_URL}/dashboard`
  );

  response.cookies.delete(&quot;oauth_code_verifier&quot;);
  response.cookies.delete(&quot;oauth_state&quot;);

  return response;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="logout-route">Logout route</h3><p>The logout route should remove the session cookie. Go to <code>src/app/api/auth/logout</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;
import { env } from &quot;@/lib/env&quot;;

export async function POST() {
  const session = await getSession();
  session.destroy();

  return NextResponse.redirect(`${env.NEXT_PUBLIC_APP_URL}/`);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="sign-in-page">Sign-in page</h3><p>Now, let&apos;s build a simple sign-in page with a single &quot;Sign in with Whop&quot; button. Go to <code>src/app/sign-in</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Store } from &quot;lucide-react&quot;;

const ERROR_MESSAGES: Record&lt;string, string&gt; = {
  invalid_state: &quot;Sign-in session expired. Please try again.&quot;,
  token_exchange: &quot;Could not complete sign-in. Please try again.&quot;,
  userinfo: &quot;Could not retrieve your profile. Please try again.&quot;,
};

export default async function SignInPage({
  searchParams,
}: {
  searchParams: Promise&lt;{ error?: string }&gt;;
}) {
  const { error } = await searchParams;
  const errorMessage = error ? ERROR_MESSAGES[error] || &quot;Something went wrong. Please try again.&quot; : null;

  return (
    &lt;div className=&quot;flex min-h-[80vh] items-center justify-center px-4&quot;&gt;
      &lt;div className=&quot;w-full max-w-sm text-center&quot;&gt;
        &lt;Store className=&quot;mx-auto h-12 w-12 text-accent&quot; aria-hidden=&quot;true&quot; /&gt;
        &lt;h1 className=&quot;mt-4 text-2xl font-bold text-text-primary&quot;&gt;
          Welcome to Shelfie
        &lt;/h1&gt;
        &lt;p className=&quot;mt-2 text-sm text-text-secondary&quot;&gt;
          Sign in with your Whop account to buy and sell digital products.
        &lt;/p&gt;

        {errorMessage &amp;&amp; (
          &lt;div
            role=&quot;alert&quot;
            className=&quot;mt-4 rounded-lg bg-error/10 p-3 text-sm text-error&quot;
          &gt;
            {errorMessage}
          &lt;/div&gt;
        )}

        &lt;a
          href=&quot;/api/auth/login&quot;
          className=&quot;mt-8 block w-full rounded-lg bg-accent px-6 py-3 text-center text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;
        &gt;
          Sign in with Whop
        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="navigation-bar">Navigation bar</h3><p>We need a navigation bar so that our users can easily navigate the app. The navigation bar will display a &quot;Sign In&quot; button for guests and user details, navigation links, and a logout button for logged-in users.</p><p>Go to <code>src/components</code> and create a file called <code>navbar.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">navbar.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { getAuthUser } from &quot;@/lib/auth&quot;;
import { Store, ShoppingBag, LogOut, LogIn } from &quot;lucide-react&quot;;

export async function Navbar() {
  const user = await getAuthUser();

  return (
    &lt;header className=&quot;sticky top-0 z-50 border-b border-border bg-surface/80 backdrop-blur-md&quot;&gt;
      &lt;nav aria-label=&quot;Main navigation&quot; className=&quot;mx-auto flex h-16 max-w-7xl items-center justify-between px-4&quot;&gt;
        &lt;Link href=&quot;/&quot; className=&quot;flex items-center gap-2 text-xl font-bold text-text-primary&quot;&gt;
          &lt;Store className=&quot;h-6 w-6 text-accent&quot; aria-hidden=&quot;true&quot; /&gt;
          &lt;span className=&quot;hidden sm:inline&quot;&gt;Shelfie&lt;/span&gt;
        &lt;/Link&gt;

        &lt;div className=&quot;flex items-center gap-3 sm:gap-6&quot;&gt;
          &lt;Link
            href=&quot;/products&quot;
            className=&quot;text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;
          &gt;
            Browse
          &lt;/Link&gt;

          {user ? (
            &lt;&gt;
              &lt;Link
                href=&quot;/sell/dashboard&quot;
                className=&quot;text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;
              &gt;
                Sell
              &lt;/Link&gt;
              &lt;Link
                href=&quot;/dashboard&quot;
                aria-label=&quot;My purchases&quot;
                className=&quot;p-2 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;
              &gt;
                &lt;ShoppingBag className=&quot;h-4 w-4&quot; aria-hidden=&quot;true&quot; /&gt;
              &lt;/Link&gt;
              &lt;div className=&quot;flex items-center gap-2 sm:gap-3&quot;&gt;
                {user.avatar &amp;&amp; (
                  &lt;img
                    src={user.avatar}
                    alt={user.name || &quot;User avatar&quot;}
                    className=&quot;h-8 w-8 rounded-full&quot;
                  /&gt;
                )}
                &lt;form action=&quot;/api/auth/logout&quot; method=&quot;POST&quot;&gt;
                  &lt;button
                    type=&quot;submit&quot;
                    aria-label=&quot;Sign out&quot;
                    className=&quot;p-2 text-text-secondary hover:text-text-primary transition-colors&quot;
                  &gt;
                    &lt;LogOut className=&quot;h-4 w-4&quot; aria-hidden=&quot;true&quot; /&gt;
                  &lt;/button&gt;
                &lt;/form&gt;
              &lt;/div&gt;
            &lt;/&gt;
          ) : (
            &lt;Link
              href=&quot;/sign-in&quot;
              className=&quot;inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;
            &gt;
              &lt;LogIn className=&quot;h-4 w-4&quot; aria-hidden=&quot;true&quot; /&gt;
              &lt;span className=&quot;hidden sm:inline&quot;&gt;Sign In&lt;/span&gt;
            &lt;/Link&gt;
          )}
        &lt;/div&gt;
      &lt;/nav&gt;
    &lt;/header&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="category-constants">Category constants</h3><p>We need a list of product categories that we&apos;ll reuse across the app. Go to <code>src/constants</code> and create a file called <code>categories.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">categories.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import {
  FileText,
  BookOpen,
  Code,
  Palette,
  Music,
  Video,
  Camera,
  GraduationCap,
  Package,
  type LucideIcon,
} from &quot;lucide-react&quot;;

export const CATEGORIES = [
  { value: &quot;TEMPLATES&quot;, label: &quot;Templates&quot;, icon: FileText },
  { value: &quot;EBOOKS&quot;, label: &quot;Ebooks&quot;, icon: BookOpen },
  { value: &quot;SOFTWARE&quot;, label: &quot;Software&quot;, icon: Code },
  { value: &quot;DESIGN&quot;, label: &quot;Design&quot;, icon: Palette },
  { value: &quot;AUDIO&quot;, label: &quot;Audio&quot;, icon: Music },
  { value: &quot;VIDEO&quot;, label: &quot;Video&quot;, icon: Video },
  { value: &quot;PHOTOGRAPHY&quot;, label: &quot;Photography&quot;, icon: Camera },
  { value: &quot;EDUCATION&quot;, label: &quot;Education&quot;, icon: GraduationCap },
  { value: &quot;OTHER&quot;, label: &quot;Other&quot;, icon: Package },
] as const satisfies readonly { value: string; label: string; icon: LucideIcon }[];

export const CATEGORY_MAP = Object.fromEntries(
  CATEGORIES.map((c) =&gt; [c.value, c])
) as Record&lt;string, (typeof CATEGORIES)[number]&gt;;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="nextjs-configuration">Next.js configuration</h3><p>We need to allow remote images from UploadThing and Whop&apos;s CDNs. In the project root, create a file called <code>next.config.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<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(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { hostname: &quot;utfs.io&quot; },
      { hostname: &quot;assets.whop.com&quot; },
      { hostname: &quot;cdn.whop.com&quot; },
    ],
  },
};

export default nextConfig;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="part-2-seller-onboarding">Part 2: Seller onboarding</h2><p>In this section, we&apos;re going build the seller onboarding where users can sign up as sellers. When they click on the &quot;Get Started&quot; button in our project, we&apos;ll create a connected account for them on Whop, verify their identity through a Whop-hosted KYC, and save their details in the database.</p><h3 id="add-environment-variables">Add environment variables</h3><p>The seller onboarding flow needs three new variables:</p>
<!--kg-card-begin: html-->
<table>
<tbody><tr><th>Variable</th><th>Where to get it</th></tr>
<tr><td><code>WHOP_API_KEY</code></td><td>App API key (Developer &gt; API Keys)</td></tr>
<tr><td><code>WHOP_COMPANY_ID</code></td><td>Your platform&apos;s company ID (from dashboard URL, starts with <code>biz_</code>)</td></tr>
<tr><td><code>WHOP_COMPANY_API_KEY</code></td><td>Company API key (Business Settings &gt; API Keys)</td></tr>
</tbody></table>
<!--kg-card-end: html-->
<p><code>WHOP_API_KEY</code> needs these permission scopes: <code>company:create</code>, <code>company:basic:read</code>, <code>account_link:create</code>. In sandbox, the default key usually has all scopes enabled.<br>After adding these new environment variables to Vercel (via project settings &gt; Environment Variables), use the command below to pull them:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">vercel env pull .env.local</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-sell-page">The sell page</h3><p>The sell page will pitch becoming a seller to all users. When a user clicks the &quot;Get Started&quot; button, we redirect them to a Whop-hosted KYC or skips it. We&apos;ll expand on the skipping reason in the next section.</p><p>To build the sell page, go to <code>src/app/sell</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState, Suspense } from &quot;react&quot;;
import { ArrowRight, DollarSign, Shield, Zap, CheckCircle, AlertCircle } from &quot;lucide-react&quot;;
import { useRouter, useSearchParams } from &quot;next/navigation&quot;;

export default function SellPage() {
  return (
    &lt;Suspense&gt;
      &lt;SellPageContent /&gt;
    &lt;/Suspense&gt;
  );
}

function SellPageContent() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const kycIncomplete = searchParams.get(&quot;kyc&quot;) === &quot;incomplete&quot;;
  const [loading, setLoading] = useState(false);
  const [sandboxMessage, setSandboxMessage] = useState(false);

  async function handleOnboard() {
    setLoading(true);
    try {
      const res = await fetch(&quot;/api/sell/onboard&quot;, { method: &quot;POST&quot; });
      const data = await res.json();

      if (data.sandbox) {
        setSandboxMessage(true);
        setTimeout(() =&gt; router.push(&quot;/sell/dashboard&quot;), 2000);
        return;
      }

      if (data.redirect) {
        if (data.redirect.startsWith(&quot;http&quot;)) {
          window.location.href = data.redirect;
        } else {
          router.push(data.redirect);
        }
      }
    } catch (error) {
      console.error(&quot;Onboarding failed:&quot;, error);
    } finally {
      setLoading(false);
    }
  }

  return (
    &lt;div className=&quot;mx-auto max-w-4xl px-4 py-16 text-center&quot;&gt;
      &lt;h1 className=&quot;text-4xl font-extrabold text-text-primary sm:text-5xl&quot;&gt;
        Share your work with the world
      &lt;/h1&gt;
      &lt;p className=&quot;mx-auto mt-4 max-w-lg text-lg text-text-secondary&quot;&gt;
        Sell digital products on Shelfie. We handle payments, payouts, and
        compliance - you focus on creating.
      &lt;/p&gt;

      {kycIncomplete &amp;&amp; (
        &lt;div className=&quot;mt-8 inline-flex items-center gap-2 bg-warning/10 px-6 py-3 text-sm font-medium text-warning&quot;&gt;
          &lt;AlertCircle className=&quot;h-5 w-5&quot; aria-hidden=&quot;true&quot; /&gt;
          Complete identity verification to start selling. Click below to continue.
        &lt;/div&gt;
      )}

      &lt;div className=&quot;mt-12 grid gap-6 sm:grid-cols-3&quot;&gt;
        &lt;div className=&quot;border border-border bg-surface p-6 text-center&quot;&gt;
          &lt;DollarSign className=&quot;mx-auto h-10 w-10 text-accent&quot; /&gt;
          &lt;h3 className=&quot;mt-4 text-base font-semibold text-text-primary&quot;&gt;
            Set your own price
          &lt;/h3&gt;
          &lt;p className=&quot;mt-2 text-sm text-text-secondary&quot;&gt;
            Free or paid. You decide how much your work is worth.
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className=&quot;border border-border bg-surface p-6 text-center&quot;&gt;
          &lt;Shield className=&quot;mx-auto h-10 w-10 text-accent&quot; /&gt;
          &lt;h3 className=&quot;mt-4 text-base font-semibold text-text-primary&quot;&gt;
            We handle payments
          &lt;/h3&gt;
          &lt;p className=&quot;mt-2 text-sm text-text-secondary&quot;&gt;
            Whop processes payments, handles compliance, and manages disputes.
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className=&quot;border border-border bg-surface p-6 text-center&quot;&gt;
          &lt;Zap className=&quot;mx-auto h-10 w-10 text-accent&quot; /&gt;
          &lt;h3 className=&quot;mt-4 text-base font-semibold text-text-primary&quot;&gt;
            Keep 95% of every sale
          &lt;/h3&gt;
          &lt;p className=&quot;mt-2 text-sm text-text-secondary&quot;&gt;
            Just a 5% platform fee. Withdraw to your bank anytime.
          &lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      {sandboxMessage &amp;&amp; (
        &lt;div className=&quot;mt-8 inline-flex items-center gap-2 bg-success/10 px-6 py-3 text-sm font-medium text-success&quot;&gt;
          &lt;CheckCircle className=&quot;h-5 w-5&quot; aria-hidden=&quot;true&quot; /&gt;
          This demo uses Whop Sandbox - KYC is not required. Redirecting to
          dashboard...
        &lt;/div&gt;
      )}

      {!sandboxMessage &amp;&amp; (
        &lt;&gt;
          &lt;button
            onClick={handleOnboard}
            disabled={loading}
            className=&quot;mt-12 inline-flex items-center gap-2 bg-accent px-8 py-3.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50&quot;
          &gt;
            {loading ? &quot;Setting up...&quot; : kycIncomplete ? &quot;Complete Verification&quot; : &quot;Get Started&quot;}
            &lt;ArrowRight className=&quot;h-4 w-4&quot; /&gt;
          &lt;/button&gt;

          &lt;p className=&quot;mt-4 text-xs text-text-secondary&quot;&gt;
            {kycIncomplete
              ? &quot;You\u2019ll be redirected to Whop to complete identity verification.&quot;
              : &quot;You\u2019ll need to verify your identity to receive payouts. This is handled securely by Whop.&quot;}
          &lt;/p&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-onboarding-api-route">The onboarding API route</h3><p>This route handles the entire onboarding flow from creating a connected account on Whop to generating a KYC link.</p><p>You&apos;ll notice <code>isSandbox</code> checks throughout the code. In the sandbox environment on Whop, you don&apos;t need to complete the KYC flow since you&apos;re not moving real money, so we skip it during development.</p><p>The route sets <code>kycComplete: true</code> immediately and returns a sandbox flag instead of a KYC URL. <strong>In production (when <code>WHOP_SANDBOX</code> is removed), these branches are never reached</strong>. Every seller goes through Whop&apos;s real identity verification before they can list products.</p><p>Go to <code>src/app/api/sell/onboard</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { prisma } from &quot;@/lib/prisma&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { generateUsername } from &quot;@/lib/utils&quot;;
import { env } from &quot;@/lib/env&quot;;

const isSandbox = process.env.WHOP_SANDBOX === &quot;true&quot;;

export async function POST() {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    include: { sellerProfile: true },
  });

  if (!user) {
    return NextResponse.json({ error: &quot;User not found&quot; }, { status: 404 });
  }

  if (user.sellerProfile?.kycComplete) {
    return NextResponse.json({ redirect: &quot;/sell/dashboard&quot; });
  }

  if (user.sellerProfile) {
    if (isSandbox) {
      await prisma.sellerProfile.update({
        where: { id: user.sellerProfile.id },
        data: { kycComplete: true },
      });
      return NextResponse.json({ sandbox: true });
    }

    const accountLink = await getWhop().accountLinks.create({
      company_id: user.sellerProfile.whopCompanyId,
      use_case: &quot;account_onboarding&quot;,
      return_url: `${env.NEXT_PUBLIC_APP_URL}/sell/kyc-return`,
      refresh_url: `${env.NEXT_PUBLIC_APP_URL}/sell?refresh=true`,
    });

    return NextResponse.json({ redirect: accountLink.url });
  }

  const company = await getWhop().companies.create({
    email: user.email,
    title: `${user.name || &quot;Seller&quot;}&apos;s Store`,
    parent_company_id: env.WHOP_COMPANY_ID,
  });

  const username = generateUsername(user.name);

  if (isSandbox) {
    await prisma.sellerProfile.create({
      data: {
        userId: user.id,
        username,
        whopCompanyId: company.id,
        kycComplete: true,
      },
    });
    return NextResponse.json({ sandbox: true });
  }

  await prisma.sellerProfile.create({
    data: {
      userId: user.id,
      username,
      whopCompanyId: company.id,
      kycComplete: false,
    },
  });

  const accountLink = await getWhop().accountLinks.create({
    company_id: company.id,
    use_case: &quot;account_onboarding&quot;,
    return_url: `${env.NEXT_PUBLIC_APP_URL}/sell/kyc-return`,
    refresh_url: `${env.NEXT_PUBLIC_APP_URL}/sell?refresh=true`,
  });

  return NextResponse.json({ redirect: accountLink.url });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="username-generation">Username generation</h3><p>The <code>generateUsername()</code> function in <code>utils.ts</code> creates a URL-friendly username from the user&apos;s name:</p><pre><code>&quot;Alex Rivera&quot;    &#x2192; &quot;alex-rivera-k7m2&quot;
&quot;Sarah Chen&quot;     &#x2192; &quot;sarah-chen-p9x4&quot;
null             &#x2192; &quot;seller-w3f1&quot;
</code></pre><p>The random suffix guarantees uniqueness without a database check.</p><h3 id="checkpoint">Checkpoint</h3><ol><li>Sign in via Whop OAuth (set up earlier)</li><li>Navigate to <code>/sell</code></li><li>Click &quot;Get Started&quot;</li><li>In sandbox, you&apos;ll see a success message and auto-redirect to the dashboard</li><li>In production: you&apos;d be redirected to Whop&apos;s KYC page &gt; complete verification &gt; land on <code>/sell/kyc-return</code> &gt; auto-redirect to dashboard</li><li>Check your database. You should see a <code>SellerProfile</code> row with <code>whopCompanyId</code> and <code>kycComplete = true</code></li></ol><h2 id="part-3-product-listings-and-file-uploads">Part 3: Product listings and file uploads</h2><p>In this part, we&apos;re going to build the product creation form, file uploads, the draft flow, and the publishing flow.</p><h3 id="file-uploads-with-uploadthing">File uploads with UploadThing</h3><p>In this project, we&apos;re going to use UploadThing for file uploads. It will handle storage, CDN delivery, size validation, while we just define what file types to accept and who can upload.</p><p>First, let&apos;s install UploadThing by using the command below:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install uploadthing @uploadthing/react</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then, get an UploadThing token and add it to Vercel:</p><ol><li>Go to UploadThings.com and create a project</li><li>Copy the <code>UPLOADTHING_TOKEN</code> from the UploadThing dashboard</li><li>Add it to Vercel via the Environment Variables page of the project settings</li><li>Pull the new environment variables to local using the <code>vercel env pull .env.local</code> command</li></ol><h3 id="file-router">File router</h3><p>Let&apos;s define what type of files users can upload, their size limits, and authentication checks. Go to <code>src/app/api/uploadthing</code> and create a file called <code>core.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">core.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { createUploadthing, type FileRouter } from &quot;uploadthing/next&quot;;
import { UploadThingError } from &quot;uploadthing/server&quot;;
import { getSession } from &quot;@/lib/session&quot;;

const f = createUploadthing();

export const ourFileRouter = {
  productFile: f({
    pdf: { maxFileSize: &quot;16MB&quot;, maxFileCount: 10 },
    image: { maxFileSize: &quot;16MB&quot;, maxFileCount: 10 },
    video: { maxFileSize: &quot;16MB&quot;, maxFileCount: 10 },
  })
    .middleware(async () =&gt; {
      const session = await getSession();
      if (!session.userId) throw new UploadThingError(&quot;Unauthorized&quot;);
      return { userId: session.userId };
    })
    .onUploadComplete(async ({ metadata, file }) =&gt; {
      return {
        uploadedBy: metadata.userId,
        name: file.name,
        size: file.size,
        key: file.key,
        url: file.ufsUrl,
      };
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="route-handler">Route handler</h3><p>Go to <code>src/app/api/uploadthing</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { createRouteHandler } from &quot;uploadthing/next&quot;;
import { ourFileRouter } from &quot;./core&quot;;

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
});</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="client-helper">Client helper</h3><p>We need a React hook that lets us trigger file uploads from the browser. Go to <code>src/lib</code> and create a file called <code>uploadthing.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">uploadthing.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { generateReactHelpers } from &quot;@uploadthing/react&quot;;
import type { OurFileRouter } from &quot;@/app/api/uploadthing/core&quot;;

export const { useUploadThing } = generateReactHelpers&lt;OurFileRouter&gt;();</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="the-product-api-route">The product API route</h3><p>Now, we need to validate the input, generate a unique slug, and save the product as a draft once a seller fills out the product form.<br>Go to <code>src/app/api/sell/products</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;
import { generateSlug } from &quot;@/lib/utils&quot;;

const createProductSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().min(1).max(5000),
  price: z.number().int().min(0),
  category: z.enum([
    &quot;TEMPLATES&quot;, &quot;EBOOKS&quot;, &quot;SOFTWARE&quot;, &quot;DESIGN&quot;,
    &quot;AUDIO&quot;, &quot;VIDEO&quot;, &quot;PHOTOGRAPHY&quot;, &quot;EDUCATION&quot;, &quot;OTHER&quot;,
  ]),
  content: z.string().max(50000).optional(),
  externalUrl: z.string().url().optional().or(z.literal(&quot;&quot;)),
  files: z.array(z.object({
    fileName: z.string(),
    fileKey: z.string(),
    fileUrl: z.string().url(),
    fileSize: z.number().int(),
    mimeType: z.string(),
  })).optional(),
});

export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile || !sellerProfile.kycComplete) {
    return NextResponse.json(
      { error: &quot;Complete seller onboarding first&quot; },
      { status: 403 }
    );
  }

  const body = await request.json();
  const parsed = createProductSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: &quot;Validation failed&quot;, details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { title, description, price, category, content, externalUrl, files } =
    parsed.data;

  const slug = generateSlug(title);
  const thumbnailFile = files?.find((f) =&gt; f.mimeType.startsWith(&quot;image/&quot;));

  const product = await prisma.product.create({
    data: {
      sellerProfileId: sellerProfile.id,
      title,
      slug,
      description,
      price,
      category,
      content: content || null,
      externalUrl: externalUrl || null,
      thumbnailUrl: thumbnailFile?.fileUrl || null,
      files: files
        ? {
            create: files.map((f, i) =&gt; ({
              fileName: f.fileName,
              fileKey: f.fileKey,
              fileUrl: f.fileUrl,
              fileSize: f.fileSize,
              mimeType: f.mimeType,
              displayOrder: i,
            })),
          }
        : undefined,
    },
    include: { files: true },
  });

  return NextResponse.json(product, { status: 201 });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="updating-and-deleting-products">Updating and deleting products</h3><p>To make some quality-of-life improvements to the project, we need to let sellers edit their drafts or remove existing ones.</p><p>To build it, go to <code>src/app/api/sell/products/[productId]</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;

const updateProductSchema = z.object({
  title: z.string().min(1).max(100).optional(),
  description: z.string().min(1).max(5000).optional(),
  price: z.number().int().min(0).optional(),
  category: z.enum([
    &quot;TEMPLATES&quot;, &quot;EBOOKS&quot;, &quot;SOFTWARE&quot;, &quot;DESIGN&quot;,
    &quot;AUDIO&quot;, &quot;VIDEO&quot;, &quot;PHOTOGRAPHY&quot;, &quot;EDUCATION&quot;, &quot;OTHER&quot;,
  ]).optional(),
  content: z.string().max(50000).optional().nullable(),
  externalUrl: z.string().url().optional().nullable().or(z.literal(&quot;&quot;)),
  thumbnailUrl: z.string().url().optional().nullable(),
  files: z.array(z.object({
    fileName: z.string(),
    fileKey: z.string(),
    fileUrl: z.string().url(),
    fileSize: z.number().int(),
    mimeType: z.string(),
  })).optional(),
  removeFileIds: z.array(z.string()).optional(),
});

export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: &quot;Not a seller&quot; }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: true },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  }

  if (product.status === &quot;PUBLISHED&quot;) {
    return NextResponse.json(
      { error: &quot;Cannot edit a published product. Unpublish first.&quot; },
      { status: 400 }
    );
  }

  const body = await request.json();
  const parsed = updateProductSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: &quot;Validation failed&quot;, details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { files, removeFileIds, ...fields } = parsed.data;

  if (removeFileIds &amp;&amp; removeFileIds.length &gt; 0) {
    await prisma.productFile.deleteMany({
      where: { id: { in: removeFileIds }, productId },
    });
  }

  if (files &amp;&amp; files.length &gt; 0) {
    const existingCount = product.files.length - (removeFileIds?.length || 0);
    await prisma.productFile.createMany({
      data: files.map((f, i) =&gt; ({
        productId,
        fileName: f.fileName,
        fileKey: f.fileKey,
        fileUrl: f.fileUrl,
        fileSize: f.fileSize,
        mimeType: f.mimeType,
        displayOrder: existingCount + i,
      })),
    });
  }

  const newThumbnail = files?.find((f) =&gt; f.mimeType.startsWith(&quot;image/&quot;));

  const updated = await prisma.product.update({
    where: { id: productId },
    data: {
      ...fields,
      externalUrl: fields.externalUrl || null,
      ...(newThumbnail &amp;&amp; !product.thumbnailUrl
        ? { thumbnailUrl: newThumbnail.fileUrl }
        : {}),
    },
    include: { files: { orderBy: { displayOrder: &quot;asc&quot; } } },
  });

  return NextResponse.json(updated);
}

export async function DELETE(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: &quot;Not a seller&quot; }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  }

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

  return NextResponse.json({ success: true });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="publishing-products">Publishing products</h3><p>Now that we have all the foundation, let&apos;s build the product publishing flow. When a seller clicks Publish, we create a Whop checkout configuration on their connected account. We do this on publish (not on draft creation) because draft products shouldn&apos;t have checkout links.</p><p>Go to <code>src/app/api/sell/products/[productId]/publish</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;
import { getCompanyWhop } from &quot;@/lib/whop&quot;;
import { env } from &quot;@/lib/env&quot;;

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile || !sellerProfile.kycComplete) {
    return NextResponse.json(
      { error: &quot;Complete seller onboarding first&quot; },
      { status: 403 }
    );
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: true },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  }

  if (product.status === &quot;PUBLISHED&quot;) {
    return NextResponse.json(
      { error: &quot;Product is already published&quot; },
      { status: 400 }
    );
  }

  const hasFiles = product.files.length &gt; 0;
  const hasContent = !!product.content;
  const hasLink = !!product.externalUrl;

  if (!hasFiles &amp;&amp; !hasContent &amp;&amp; !hasLink) {
    return NextResponse.json(
      {
        error:
          &quot;Product must have at least one file, text content, or external link&quot;,
      },
      { status: 400 }
    );
  }

  try {
    const whopProduct = await getCompanyWhop().products.create({
      company_id: sellerProfile.whopCompanyId,
      title: product.title,
      description: product.description,
    });

    const feePercent = env.PLATFORM_FEE_PERCENT;

    if (product.price === 0) {
      const updated = await prisma.product.update({
        where: { id: productId },
        data: {
          status: &quot;PUBLISHED&quot;,
          whopProductId: whopProduct.id,
        },
      });

      return NextResponse.json(updated);
    }

    const feeAmount = Math.round(product.price * (feePercent / 100));

    const checkoutConfig = await (getCompanyWhop().checkoutConfigurations.create as any)({
      plan: {
        company_id: sellerProfile.whopCompanyId,
        currency: &quot;usd&quot;,
        initial_price: product.price / 100,
        plan_type: &quot;one_time&quot;,
        application_fee_amount: feeAmount / 100,
      },
    });

    const updated = await prisma.product.update({
      where: { id: productId },
      data: {
        status: &quot;PUBLISHED&quot;,
        whopProductId: whopProduct.id,
        whopPlanId: checkoutConfig.plan?.id ?? null,
        whopCheckoutUrl: checkoutConfig.purchase_url,
      },
    });

    return NextResponse.json(updated);
  } catch (err) {
    console.error(&quot;Publish error:&quot;, err);
    const message = err instanceof Error ? err.message : &quot;Whop API error&quot;;
    return NextResponse.json({ error: message }, { status: 500 });
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-product-page">Create product page</h3><p>Now, let&apos;s build the product creation form where sellers enter product details and upload their files. Initially, all products are saved as drafts.</p><p>Go to <code>src/app/sell/products/new</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState, useRef } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { X, Upload, Check, Loader2 } from &quot;lucide-react&quot;;
import { CATEGORIES } from &quot;@/constants/categories&quot;;
import { formatFileSize } from &quot;@/lib/utils&quot;;
import { useUploadThing } from &quot;@/lib/uploadthing&quot;;

const ALLOWED_TYPES = [
  &quot;application/pdf&quot;,
  &quot;image/png&quot;,
  &quot;image/jpeg&quot;,
  &quot;image/gif&quot;,
  &quot;image/webp&quot;,
  &quot;video/mp4&quot;,
];
const MAX_FILE_SIZE = 16 * 1024 * 1024; // 16 MB

interface UploadedFile {
  fileName: string;
  fileKey: string;
  fileUrl: string;
  fileSize: number;
  mimeType: string;
}

export default function NewProductPage() {
  const router = useRouter();
  const fileInputRef = useRef&lt;HTMLInputElement&gt;(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const [files, setFiles] = useState&lt;UploadedFile[]&gt;([]);

  const { startUpload, isUploading } = useUploadThing(&quot;productFile&quot;, {
    onClientUploadComplete: (res) =&gt; {
      const uploaded = res.map((file) =&gt; ({
        fileName: file.name,
        fileKey: file.key,
        fileUrl: file.url,
        fileSize: file.size,
        mimeType: &quot;&quot;,
      }));
      setFiles((prev) =&gt; [...prev, ...uploaded]);
    },
    onUploadError: (err) =&gt; {
      setError(err.message || &quot;Upload failed&quot;);
    },
  });

  function removeFile(fileKey: string) {
    setFiles((prev) =&gt; prev.filter((f) =&gt; f.fileKey !== fileKey));
  }

  async function handleFiles(fileList: FileList) {
    setError(null);

    const validFiles: File[] = [];
    for (const file of Array.from(fileList)) {
      if (!ALLOWED_TYPES.includes(file.type)) {
        setError(`${file.name}: file type not allowed`);
        return;
      }
      if (file.size &gt; MAX_FILE_SIZE) {
        setError(`${file.name}: exceeds 16 MB limit`);
        return;
      }
      validFiles.push(file);
    }

    if (validFiles.length === 0) return;

    const res = await startUpload(validFiles);
    if (res) {
      setFiles((prev) =&gt;
        prev.map((f) =&gt; {
          if (f.mimeType) return f;
          const original = validFiles.find((v) =&gt; v.name === f.fileName);
          return original ? { ...f, mimeType: original.type } : f;
        })
      );
    }
  }

  async function handleSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget);

    const priceStr = formData.get(&quot;price&quot;) as string;
    const priceInCents = Math.round(parseFloat(priceStr || &quot;0&quot;) * 100);

    const body = {
      title: formData.get(&quot;title&quot;) as string,
      description: formData.get(&quot;description&quot;) as string,
      price: priceInCents,
      category: formData.get(&quot;category&quot;) as string,
      content: (formData.get(&quot;content&quot;) as string) || undefined,
      externalUrl: (formData.get(&quot;externalUrl&quot;) as string) || undefined,
      files: files.length &gt; 0 ? files : undefined,
    };

    try {
      const res = await fetch(&quot;/api/sell/products&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify(body),
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.error || &quot;Failed to create product&quot;);
        return;
      }

      const product = await res.json();
      router.push(`/sell/products/${product.id}/edit`);
    } catch {
      setError(&quot;Something went wrong&quot;);
    } finally {
      setLoading(false);
    }
  }

  return (
    &lt;div className=&quot;mx-auto max-w-2xl px-4 py-8&quot;&gt;
      &lt;h1 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;
        Create a New Product
      &lt;/h1&gt;
      &lt;p className=&quot;mt-1 text-sm text-text-secondary&quot;&gt;
        Fill in the details, upload your files, and publish when ready.
      &lt;/p&gt;

      {error &amp;&amp; (
        &lt;div
          role=&quot;alert&quot;
          className=&quot;mt-4 rounded-lg bg-error/10 p-3 text-sm text-error&quot;
        &gt;
          {error}
        &lt;/div&gt;
      )}

      &lt;form onSubmit={handleSubmit} className=&quot;mt-8 space-y-6&quot;&gt;
        &lt;div&gt;
          &lt;label
            htmlFor=&quot;title&quot;
            className=&quot;block text-sm font-medium text-text-primary&quot;
          &gt;
            Title
          &lt;/label&gt;
          &lt;input
            type=&quot;text&quot; id=&quot;title&quot; name=&quot;title&quot; required maxLength={100}
            className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
            placeholder=&quot;e.g. Premium Icon Pack&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label
            htmlFor=&quot;description&quot;
            className=&quot;block text-sm font-medium text-text-primary&quot;
          &gt;
            Description
          &lt;/label&gt;
          &lt;textarea
            id=&quot;description&quot; name=&quot;description&quot; required rows={4} maxLength={5000}
            className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
            placeholder=&quot;Describe what buyers will get...&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
          &lt;div&gt;
            &lt;label htmlFor=&quot;price&quot; className=&quot;block text-sm font-medium text-text-primary&quot;&gt;
              Price (USD)
            &lt;/label&gt;
            &lt;input
              type=&quot;number&quot; id=&quot;price&quot; name=&quot;price&quot; min=&quot;0&quot; step=&quot;0.01&quot; defaultValue=&quot;0&quot;
              className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
              placeholder=&quot;0.00 for free&quot;
            /&gt;
            &lt;p className=&quot;mt-1 text-xs text-text-secondary&quot;&gt;
              Set to 0 for a free product
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;label htmlFor=&quot;category&quot; className=&quot;block text-sm font-medium text-text-primary&quot;&gt;
              Category
            &lt;/label&gt;
            &lt;select
              id=&quot;category&quot; name=&quot;category&quot; required
              className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
            &gt;
              {CATEGORIES.map((cat) =&gt; (
                &lt;option key={cat.value} value={cat.value}&gt;{cat.label}&lt;/option&gt;
              ))}
            &lt;/select&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label className=&quot;block text-sm font-medium text-text-primary&quot;&gt;Files&lt;/label&gt;
          &lt;p className=&quot;mt-0.5 text-xs text-text-secondary&quot;&gt;
            PDF, images (PNG, JPG, GIF, WebP), video (MP4). Max 16 MB each.
          &lt;/p&gt;

          {files.length &gt; 0 &amp;&amp; (
            &lt;div className=&quot;mt-3 space-y-2&quot;&gt;
              {files.map((file) =&gt; (
                &lt;div key={file.fileKey} className=&quot;flex items-center gap-3 rounded-lg border border-border bg-surface p-3&quot;&gt;
                  &lt;Check className=&quot;h-4 w-4 shrink-0 text-success&quot; aria-hidden=&quot;true&quot; /&gt;
                  &lt;span className=&quot;flex-1 truncate text-sm text-text-primary&quot;&gt;{file.fileName}&lt;/span&gt;
                  &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;{formatFileSize(file.fileSize)}&lt;/span&gt;
                  &lt;button type=&quot;button&quot; onClick={() =&gt; removeFile(file.fileKey)} aria-label={`Remove ${file.fileName}`} className=&quot;p-2 text-text-secondary hover:text-error transition-colors&quot;&gt;
                    &lt;X className=&quot;h-4 w-4&quot; aria-hidden=&quot;true&quot; /&gt;
                  &lt;/button&gt;
                &lt;/div&gt;
              ))}
            &lt;/div&gt;
          )}

          {isUploading &amp;&amp; (
            &lt;div className=&quot;mt-3 flex items-center gap-3 rounded-lg border border-border bg-surface p-3&quot;&gt;
              &lt;Loader2 className=&quot;h-4 w-4 shrink-0 animate-spin text-accent&quot; aria-hidden=&quot;true&quot; /&gt;
              &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Uploading...&lt;/span&gt;
            &lt;/div&gt;
          )}

          &lt;div
            onDragOver={(e) =&gt; { e.preventDefault(); e.currentTarget.dataset.dragging = &quot;true&quot;; }}
            onDragLeave={(e) =&gt; { delete e.currentTarget.dataset.dragging; }}
            onDrop={(e) =&gt; {
              e.preventDefault();
              delete e.currentTarget.dataset.dragging;
              if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
            }}
            onClick={() =&gt; fileInputRef.current?.click()}
            className=&quot;mt-3 flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed border-border p-8 transition-colors hover:border-accent/50 data-[dragging]:border-accent data-[dragging]:bg-accent/5&quot;
          &gt;
            &lt;Upload className=&quot;h-8 w-8 text-text-secondary&quot; aria-hidden=&quot;true&quot; /&gt;
            &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Click or drag files to upload&lt;/span&gt;
            &lt;input
              ref={fileInputRef} type=&quot;file&quot; multiple
              accept=&quot;.pdf,.png,.jpg,.jpeg,.gif,.webp,.mp4&quot;
              onChange={(e) =&gt; e.target.files &amp;&amp; handleFiles(e.target.files)}
              className=&quot;hidden&quot;
            /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label htmlFor=&quot;content&quot; className=&quot;block text-sm font-medium text-text-primary&quot;&gt;
            Text Content &lt;span className=&quot;text-text-secondary&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;textarea
            id=&quot;content&quot; name=&quot;content&quot; rows={6}
            className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 font-mono text-sm&quot;
            placeholder=&quot;Add text or markdown content that buyers will see after purchase...&quot;
          /&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label htmlFor=&quot;externalUrl&quot; className=&quot;block text-sm font-medium text-text-primary&quot;&gt;
            External Link &lt;span className=&quot;text-text-secondary&quot;&gt;(optional)&lt;/span&gt;
          &lt;/label&gt;
          &lt;input
            type=&quot;url&quot; id=&quot;externalUrl&quot; name=&quot;externalUrl&quot;
            className=&quot;mt-1.5 w-full rounded-lg border border-border bg-surface px-4 py-2.5 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
            placeholder=&quot;https://...&quot;
          /&gt;
        &lt;/div&gt;

        &lt;button
          type=&quot;submit&quot; disabled={loading || isUploading}
          className=&quot;w-full rounded-lg bg-accent px-6 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50&quot;
        &gt;
          {loading ? &quot;Creating...&quot; : &quot;Create Product (Draft)&quot;}
        &lt;/button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="edit-and-publish-page">Edit and publish page</h3><p>Now that the creation form is done, let&apos;s create the page where sellers can edit and publish their products. Go to <code>src/app/sell/products/[productId]/edit</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { ArrowLeft } from &quot;lucide-react&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireSeller } from &quot;@/lib/auth&quot;;
import { formatPrice, formatFileSize } from &quot;@/lib/utils&quot;;
import { PublishButton } from &quot;@/components/publish-button&quot;;
import { UnpublishButton } from &quot;@/components/unpublish-button&quot;;
import { DeleteButton } from &quot;@/components/delete-button&quot;;
import { EditForm } from &quot;@/components/edit-form&quot;;

export default async function EditProductPage({
  params,
}: {
  params: Promise&lt;{ productId: string }&gt;;
}) {
  const { productId } = await params;
  const { sellerProfile } = await requireSeller();

  const product = await prisma.product.findUnique({
    where: { id: productId },
    include: { files: { orderBy: { displayOrder: &quot;asc&quot; } } },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) notFound();

  return (
    &lt;div className=&quot;mx-auto max-w-2xl px-4 py-8&quot;&gt;
      &lt;Link href=&quot;/sell/dashboard&quot;
        className=&quot;inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
        &lt;ArrowLeft className=&quot;h-4 w-4&quot; /&gt; Back to dashboard
      &lt;/Link&gt;

      &lt;div className=&quot;mt-6 flex items-center justify-between&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;
            {product.status === &quot;DRAFT&quot; ? &quot;Edit Product&quot; : product.title}
          &lt;/h1&gt;
          &lt;p className=&quot;text-sm text-text-secondary&quot;&gt;
            {formatPrice(product.price)} &#xB7;{&quot; &quot;}
            &lt;span className={product.status === &quot;PUBLISHED&quot; ? &quot;text-success&quot; : &quot;text-warning&quot;}&gt;
              {product.status}
            &lt;/span&gt;
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className=&quot;flex items-center gap-2&quot;&gt;
          {product.status === &quot;DRAFT&quot; &amp;&amp; (
            &lt;&gt;
              &lt;DeleteButton productId={product.id} /&gt;
              &lt;PublishButton productId={product.id} /&gt;
            &lt;/&gt;
          )}

          {product.status === &quot;PUBLISHED&quot; &amp;&amp; (
            &lt;&gt;
              &lt;UnpublishButton productId={product.id} /&gt;
              &lt;Link href={`/products/${product.slug}`}
                className=&quot;border border-border px-4 py-2.5 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
                View Live
              &lt;/Link&gt;
            &lt;/&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;

      {product.status === &quot;DRAFT&quot; &amp;&amp; (
        &lt;EditForm
          product={{
            id: product.id,
            title: product.title,
            description: product.description,
            price: product.price,
            category: product.category,
            content: product.content,
            externalUrl: product.externalUrl,
            thumbnailUrl: product.thumbnailUrl,
            files: product.files,
          }}
        /&gt;
      )}

      {product.status === &quot;PUBLISHED&quot; &amp;&amp; (
        &lt;div className=&quot;mt-8 space-y-6&quot;&gt;
          {product.thumbnailUrl &amp;&amp; (
            &lt;div className=&quot;overflow-hidden&quot;&gt;
              &lt;img src={product.thumbnailUrl} alt={product.title}
                className=&quot;w-full object-cover max-h-64&quot; /&gt;
            &lt;/div&gt;
          )}

          &lt;div className=&quot;border border-border bg-surface p-5&quot;&gt;
            &lt;h2 className=&quot;text-sm font-semibold text-text-primary&quot;&gt;Description&lt;/h2&gt;
            &lt;p className=&quot;mt-2 text-sm text-text-secondary whitespace-pre-wrap&quot;&gt;{product.description}&lt;/p&gt;
          &lt;/div&gt;

          &lt;div className=&quot;border border-border bg-surface p-5&quot;&gt;
            &lt;h2 className=&quot;text-sm font-semibold text-text-primary&quot;&gt;Files ({product.files.length})&lt;/h2&gt;
            {product.files.length === 0 ? (
              &lt;p className=&quot;mt-2 text-sm text-text-secondary&quot;&gt;No files uploaded yet.&lt;/p&gt;
            ) : (
              &lt;div className=&quot;mt-3 space-y-2&quot;&gt;
                {product.files.map((file) =&gt; (
                  &lt;div key={file.id} className=&quot;flex items-center gap-3 bg-surface-elevated p-3&quot;&gt;
                    &lt;span className=&quot;flex-1 truncate text-sm text-text-primary&quot;&gt;{file.fileName}&lt;/span&gt;
                    &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;{formatFileSize(file.fileSize)}&lt;/span&gt;
                  &lt;/div&gt;
                ))}
              &lt;/div&gt;
            )}
          &lt;/div&gt;

          {product.content &amp;&amp; (
            &lt;div className=&quot;border border-border bg-surface p-5&quot;&gt;
              &lt;h2 className=&quot;text-sm font-semibold text-text-primary&quot;&gt;Text Content&lt;/h2&gt;
              &lt;p className=&quot;mt-2 text-sm text-text-secondary whitespace-pre-wrap&quot;&gt;{product.content}&lt;/p&gt;
            &lt;/div&gt;
          )}

          {product.externalUrl &amp;&amp; (
            &lt;div className=&quot;border border-border bg-surface p-5&quot;&gt;
              &lt;h2 className=&quot;text-sm font-semibold text-text-primary&quot;&gt;External Link&lt;/h2&gt;
              &lt;p className=&quot;mt-2 text-sm text-accent&quot;&gt;{product.externalUrl}&lt;/p&gt;
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>We need to make the publish button a separate client component so that it can show error tooltips. Go to <code>src/components</code> and create a file called <code>publish-button.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">publish-button.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState, useEffect } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { Rocket } from &quot;lucide-react&quot;;

export function PublishButton({ productId }: { productId: string }) {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);

  useEffect(() =&gt; {
    if (!error) return;
    const timer = setTimeout(() =&gt; setError(null), 4000);
    return () =&gt; clearTimeout(timer);
  }, [error]);

  async function handlePublish() {
    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`/api/sell/products/${productId}/publish`, {
        method: &quot;POST&quot;,
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.error || &quot;Failed to publish&quot;);
        return;
      }

      router.refresh();
    } catch {
      setError(&quot;Something went wrong&quot;);
    } finally {
      setLoading(false);
    }
  }

  return (
    &lt;div className=&quot;relative&quot;&gt;
      &lt;button onClick={handlePublish} disabled={loading}
        className=&quot;inline-flex items-center gap-2 rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors disabled:opacity-50&quot;&gt;
        &lt;Rocket className=&quot;h-4 w-4&quot; /&gt;
        {loading ? &quot;Publishing...&quot; : &quot;Publish&quot;}
      &lt;/button&gt;
      {error &amp;&amp; (
        &lt;div role=&quot;alert&quot;
          className=&quot;absolute right-0 top-full z-10 mt-2 w-64 rounded-lg border border-error/20 bg-error/10 px-3 py-2 text-xs text-error shadow-lg backdrop-blur-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="unpublishing-and-deleting-products">Unpublishing and deleting products</h3><p>Lastly, we need to let sellers unpublish or completely delete products if they wish. Go to <code>src/app/api/sell/products/[productId]/unpublish</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!sellerProfile) {
    return NextResponse.json({ error: &quot;Not a seller&quot; }, { status: 403 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.sellerProfileId !== sellerProfile.id) {
    return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  }

  if (product.status !== &quot;PUBLISHED&quot;) {
    return NextResponse.json({ error: &quot;Product is not published&quot; }, { status: 400 });
  }

  const updated = await prisma.product.update({
    where: { id: productId },
    data: {
      status: &quot;DRAFT&quot;,
      whopCheckoutUrl: null,
    },
  });

  return NextResponse.json(updated);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="checkpoint-1">Checkpoint</h3><p>You can now:</p><ol><li>Navigate to <code>/sell/products/new</code></li><li>Fill in title, description, price, category</li><li>Create the product (saved as DRAFT)</li><li>Land on the edit page</li><li>Upload files, edit any field, and click &quot;Save Changes&quot;</li><li>Click &quot;Publish.&quot; This will create a Whop checkout configuration on the seller&apos;s connected account</li><li>The product is now PUBLISHED and visible on the marketplace</li></ol><h2 id="part-4-marketplace-and-discovery">Part 4: Marketplace and discovery</h2><p>In this part, we&apos;re going to build the buyer-facing side of the project. We&apos;ll work on searchable product catalog, product detail pages, seller profiles, a like system, and cookie ratings.</p><h3 id="schema-update-for-ratings">Schema update for ratings</h3><p>We want our buyers to be able to rate the products they purchase, so let&apos;s build a rating system. But first, we need to update our schema. Go to <code>prisma</code> and update <code>schema.prisma</code> by adding the following model:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-prisma">model Rating {
  id        String   @id @default(cuid())
  userId    String
  productId String
  cookies   Float // 0.5-5 in 0.5 increments
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

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

  @@unique([userId, productId])
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Then add the relation field to both existing models:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">schema.prisma</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-prisma">model User {
  // ... add the line below to the models
  ratings Rating[]
}

model Product {
  // ... add the line below to the models
  ratings Rating[]
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Run the migration:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx prisma generate &amp;&amp; npx prisma db push</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="product-cards">Product cards</h3><p>We&apos;ll use the same product cards in homepage, catalog, and seller profiles. Go to <code>src/components</code> and create a file called <code>product-card.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">product-card.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { Heart, FileText, Image, Video, ExternalLink } from &quot;lucide-react&quot;;
import { CookieDisplay } from &quot;@/components/cookie-rating&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;
import { CATEGORY_MAP } from &quot;@/constants/categories&quot;;

interface ProductCardProps {
  product: {
    slug: string;
    title: string;
    price: number;
    category: string;
    thumbnailUrl: string | null;
    _count: {
      likes: number;
      files: number;
      ratings?: number;
    };
    avgRating?: number;
    sellerProfile: {
      username: string;
      user: {
        name: string | null;
        avatar: string | null;
      };
    };
  };
}

export function ProductCard({ product }: ProductCardProps) {
  const categoryInfo = CATEGORY_MAP[product.category];

  return (
    &lt;Link
      href={`/products/${product.slug}`}
      className=&quot;group block overflow-hidden rounded-xl border border-border bg-surface transition-all hover:-translate-y-0.5 hover:shadow-lg&quot;
    &gt;
      &lt;div className=&quot;relative aspect-[4/3] overflow-hidden bg-surface-elevated&quot;&gt;
        {product.thumbnailUrl ? (
          &lt;img
            src={product.thumbnailUrl}
            alt={product.title}
            className=&quot;h-full w-full object-cover transition-transform group-hover:scale-105&quot;
          /&gt;
        ) : (
          &lt;div className=&quot;flex h-full items-center justify-center&quot;&gt;
            &lt;FileText className=&quot;h-12 w-12 text-text-secondary/30&quot; aria-hidden=&quot;true&quot; /&gt;
          &lt;/div&gt;
        )}

        &lt;div className=&quot;absolute right-3 top-3&quot;&gt;
          &lt;span
            className={`rounded-lg px-3 py-1.5 text-sm font-bold ${
              product.price === 0
                ? &quot;bg-success/90 text-white&quot;
                : &quot;bg-black/70 text-white backdrop-blur-sm&quot;
            }`}
          &gt;
            {formatPrice(product.price)}
          &lt;/span&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;p-4&quot;&gt;
        &lt;h3 className=&quot;line-clamp-2 text-base font-semibold text-text-primary&quot;&gt;
          {product.title}
        &lt;/h3&gt;

        &lt;p className=&quot;mt-1 text-sm text-text-secondary&quot;&gt;
          by @{product.sellerProfile.username}
        &lt;/p&gt;

        &lt;div className=&quot;mt-3 flex items-center gap-3 text-xs text-text-secondary&quot;&gt;
          &lt;span className=&quot;inline-flex items-center gap-1&quot;&gt;
            &lt;Heart className=&quot;h-3.5 w-3.5&quot; aria-hidden=&quot;true&quot; /&gt;
            {product._count.likes}
          &lt;/span&gt;
          {product.avgRating &amp;&amp; product.avgRating &gt; 0 &amp;&amp; (
            &lt;CookieDisplay average={product.avgRating} count={product._count.ratings ?? 0} /&gt;
          )}
          &lt;span&gt;
            {product._count.files} {product._count.files === 1 ? &quot;file&quot; : &quot;files&quot;}
          &lt;/span&gt;
          {categoryInfo &amp;&amp; (
            &lt;span className=&quot;bg-surface-elevated px-2 py-0.5&quot;&gt;
              {categoryInfo.label}
            &lt;/span&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/Link&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="marketplace">Marketplace</h3><p>Our marketplace will have a search bar, category filter pills, and a paginated product grid. Go to <code>src/app/products</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { prisma } from &quot;@/lib/prisma&quot;;
import { ProductCard } from &quot;@/components/product-card&quot;;
import { CATEGORIES } from &quot;@/constants/categories&quot;;
import { Search } from &quot;lucide-react&quot;;
import Link from &quot;next/link&quot;;
import type { Category, Prisma } from &quot;@/generated/prisma/client&quot;;

const PRODUCTS_PER_PAGE = 12;

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise&lt;{ category?: string; q?: string; page?: string }&gt;;
}) {
  const { category, q, page } = await searchParams;
  const currentPage = Math.max(1, parseInt(page || &quot;1&quot;));

  const where: Prisma.ProductWhereInput = {
    status: &quot;PUBLISHED&quot;,
    ...(category &amp;&amp; { category: category as Category }),
    ...(q &amp;&amp; {
      OR: [
        { title: { contains: q, mode: &quot;insensitive&quot; as const } },
        { description: { contains: q, mode: &quot;insensitive&quot; as const } },
      ],
    }),
  };

  const [products, total] = await Promise.all([
    prisma.product.findMany({
      where,
      orderBy: { createdAt: &quot;desc&quot; },
      skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
      take: PRODUCTS_PER_PAGE,
      include: {
        sellerProfile: { include: { user: true } },
        ratings: { select: { cookies: true } },
        _count: { select: { likes: true, files: true, ratings: true } },
      },
    }),
    prisma.product.count({ where }),
  ]);

  const totalPages = Math.ceil(total / PRODUCTS_PER_PAGE);

  return (
    &lt;div className=&quot;mx-auto max-w-7xl px-4 py-8&quot;&gt;
      &lt;div className=&quot;mb-8&quot;&gt;
        &lt;form role=&quot;search&quot; aria-label=&quot;Search products&quot; className=&quot;relative&quot;&gt;
          &lt;label htmlFor=&quot;product-search&quot; className=&quot;sr-only&quot;&gt;Search products&lt;/label&gt;
          &lt;Search className=&quot;absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-text-secondary&quot; aria-hidden=&quot;true&quot; /&gt;
          &lt;input
            type=&quot;search&quot;
            id=&quot;product-search&quot;
            name=&quot;q&quot;
            defaultValue={q}
            placeholder=&quot;Search digital products...&quot;
            className=&quot;w-full rounded-xl border border-border bg-surface py-3.5 pl-12 pr-4 text-text-primary placeholder:text-text-secondary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20&quot;
          /&gt;
          {category &amp;&amp; &lt;input type=&quot;hidden&quot; name=&quot;category&quot; value={category} /&gt;}
        &lt;/form&gt;

        &lt;div className=&quot;mt-4 flex flex-wrap gap-2&quot;&gt;
          &lt;Link
            href=&quot;/products&quot;
            className={`px-4 py-2.5 text-sm font-medium transition-colors ${
              !category
                ? &quot;bg-accent text-white&quot;
                : &quot;bg-surface-elevated text-text-secondary hover:text-text-primary&quot;
            }`}
          &gt;
            All
          &lt;/Link&gt;
          {CATEGORIES.map((cat) =&gt; (
            &lt;Link
              key={cat.value}
              href={`/products?category=${cat.value}${q ? `&amp;q=${q}` : &quot;&quot;}`}
              className={`px-4 py-2.5 text-sm font-medium transition-colors ${
                category === cat.value
                  ? &quot;bg-accent text-white&quot;
                  : &quot;bg-surface-elevated text-text-secondary hover:text-text-primary&quot;
              }`}
            &gt;
              {cat.label}
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      &lt;/div&gt;

      {/* Product grid */}
      {products.length === 0 ? (
        &lt;div className=&quot;py-24 text-center&quot;&gt;
          &lt;p className=&quot;text-lg text-text-secondary&quot;&gt;No products found.&lt;/p&gt;
          &lt;Link href=&quot;/products&quot; className=&quot;mt-2 text-sm text-accent hover:underline&quot;&gt;
            Clear filters
          &lt;/Link&gt;
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4&quot;&gt;
          {products.map((product) =&gt; (
            &lt;ProductCard
              key={product.id}
              product={{
                ...product,
                avgRating:
                  product._count.ratings &gt; 0
                    ? product.ratings.reduce((s, r) =&gt; s + r.cookies, 0) / product._count.ratings
                    : 0,
              }}
            /&gt;
          ))}
        &lt;/div&gt;
      )}

      {/* Pagination */}
      {totalPages &gt; 1 &amp;&amp; (
        &lt;div className=&quot;mt-12 flex justify-center gap-2&quot;&gt;
          {Array.from({ length: totalPages }, (_, i) =&gt; i + 1).map((p) =&gt; (
            &lt;Link
              key={p}
              href={`/products?page=${p}${category ? `&amp;category=${category}` : &quot;&quot;}${q ? `&amp;q=${q}` : &quot;&quot;}`}
              aria-label={`Page ${p}`}
              aria-current={p === currentPage ? &quot;page&quot; : undefined}
              className={`rounded-lg px-4 py-2.5 text-sm font-medium transition-colors ${
                p === currentPage
                  ? &quot;bg-accent text-white&quot;
                  : &quot;bg-surface-elevated text-text-secondary hover:text-text-primary&quot;
              }`}
            &gt;
              {p}
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="product-detail-page">Product detail page</h3><p>The product detail pages should provide enough information to the buyers so that they can easily decide whether to purchase or not. We need to display the product description, the files list, seller info, ratings, and a purchase card.</p><p>To build it, go to <code>src/app/products/[slug]</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound, redirect } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { FileText, Image as ImageIcon, Video, ExternalLink, Download, Lock } from &quot;lucide-react&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getAuthUser } from &quot;@/lib/auth&quot;;
import { formatPrice, formatFileSize } from &quot;@/lib/utils&quot;;
import { LikeButton } from &quot;@/components/like-button&quot;;
import { CookieRating } from &quot;@/components/cookie-rating&quot;;
import { CATEGORY_MAP } from &quot;@/constants/categories&quot;;

const FILE_ICONS: Record&lt;string, typeof FileText&gt; = {
  &quot;application/pdf&quot;: FileText,
  &quot;image/&quot;: ImageIcon,
  &quot;video/&quot;: Video,
};

function getFileIcon(mimeType: string) {
  for (const [prefix, icon] of Object.entries(FILE_ICONS)) {
    if (mimeType.startsWith(prefix)) return icon;
  }
  return FileText;
}

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

  const product = await prisma.product.findUnique({
    where: { slug },
    include: {
      sellerProfile: { include: { user: true } },
      files: { orderBy: { displayOrder: &quot;asc&quot; } },
      ratings: { select: { cookies: true } },
      _count: { select: { likes: true, purchases: true, ratings: true } },
    },
  });

  if (!product || product.status !== &quot;PUBLISHED&quot;) notFound();

  const user = await getAuthUser();

  const purchase = user
    ? await prisma.purchase.findUnique({
        where: {
          userId_productId: { userId: user.id, productId: product.id },
        },
      })
    : null;

  const liked = user
    ? !!(await prisma.like.findUnique({
        where: {
          userId_productId: { userId: user.id, productId: product.id },
        },
      }))
    : false;

  const userRating = user
    ? await prisma.rating.findUnique({
        where: { userId_productId: { userId: user.id, productId: product.id } },
      })
    : null;

  const avgRating =
    product._count.ratings &gt; 0
      ? product.ratings.reduce((sum, r) =&gt; sum + r.cookies, 0) / product._count.ratings
      : 0;

  const categoryInfo = CATEGORY_MAP[product.category];
  const seller = product.sellerProfile;

  return (
    &lt;div className=&quot;mx-auto max-w-7xl px-4 py-8 pb-24 lg:pb-8&quot;&gt;
      &lt;div className=&quot;grid gap-8 lg:grid-cols-[1fr_380px]&quot;&gt;
        &lt;div&gt;
          {product.thumbnailUrl &amp;&amp; (
            &lt;div className=&quot;overflow-hidden rounded-xl&quot;&gt;
              &lt;img
                src={product.thumbnailUrl}
                alt={product.title}
                className=&quot;w-full object-cover&quot;
              /&gt;
            &lt;/div&gt;
          )}

          &lt;h1 className=&quot;mt-6 text-3xl font-bold text-text-primary&quot;&gt;
            {product.title}
          &lt;/h1&gt;

          &lt;Link
            href={`/sellers/${seller.username}`}
            className=&quot;mt-3 inline-flex items-center gap-3 text-sm text-text-secondary hover:text-text-primary transition-colors&quot;
          &gt;
            {seller.user.avatar &amp;&amp; (
              &lt;img
                src={seller.user.avatar}
                alt={seller.user.name || &quot;Seller avatar&quot;}
                className=&quot;h-8 w-8 rounded-full&quot;
              /&gt;
            )}
            &lt;div&gt;
              &lt;span className=&quot;font-medium&quot;&gt;@{seller.username}&lt;/span&gt;
              {seller.headline &amp;&amp; (
                &lt;span className=&quot;ml-2 text-text-secondary&quot;&gt;
                  &#xB7; {seller.headline}
                &lt;/span&gt;
              )}
            &lt;/div&gt;
          &lt;/Link&gt;

          &lt;div className=&quot;mt-4 flex items-center gap-3&quot;&gt;
            {user ? (
              &lt;LikeButton
                productId={product.id}
                initialLiked={liked}
                initialCount={product._count.likes}
              /&gt;
            ) : (
              &lt;span className=&quot;inline-flex items-center gap-1.5 text-sm text-text-secondary&quot;&gt;
                &#x2665; {product._count.likes}
              &lt;/span&gt;
            )}
            {categoryInfo &amp;&amp; (
              &lt;span className=&quot;bg-surface-elevated px-3 py-1 text-xs font-medium text-text-secondary&quot;&gt;
                {categoryInfo.label}
              &lt;/span&gt;
            )}
            &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;
              {product._count.purchases} sales
            &lt;/span&gt;
          &lt;/div&gt;

          &lt;div className=&quot;mt-3&quot;&gt;
            &lt;CookieRating
              productId={product.id}
              initialRating={userRating?.cookies ?? null}
              averageRating={avgRating}
              ratingCount={product._count.ratings}
              canRate={!!purchase}
            /&gt;
          &lt;/div&gt;

          &lt;div className=&quot;mt-8&quot;&gt;
            &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;
              Description
            &lt;/h2&gt;
            &lt;p className=&quot;mt-2 whitespace-pre-wrap text-text-secondary leading-relaxed&quot;&gt;
              {product.description}
            &lt;/p&gt;
          &lt;/div&gt;

          &lt;div className=&quot;mt-8&quot;&gt;
            &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;
              What&amp;apos;s included
            &lt;/h2&gt;
            &lt;div className=&quot;mt-3 space-y-2&quot;&gt;
              {product.files.map((file) =&gt; {
                const Icon = getFileIcon(file.mimeType);
                return (
                  &lt;div
                    key={file.id}
                    className=&quot;flex items-center gap-3 rounded-lg border border-border bg-surface p-3&quot;
                  &gt;
                    &lt;Icon className=&quot;h-5 w-5 text-text-secondary&quot; /&gt;
                    &lt;span className=&quot;flex-1 text-sm font-medium text-text-primary&quot;&gt;
                      {file.fileName}
                    &lt;/span&gt;
                    &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;
                      {formatFileSize(file.fileSize)}
                    &lt;/span&gt;
                    &lt;Lock className=&quot;h-4 w-4 text-text-secondary/50&quot; aria-hidden=&quot;true&quot; /&gt;
                  &lt;/div&gt;
                );
              })}

              {product.content &amp;&amp; (
                &lt;div className=&quot;flex items-center gap-3 rounded-lg border border-border bg-surface p-3&quot;&gt;
                  &lt;FileText className=&quot;h-5 w-5 text-text-secondary&quot; /&gt;
                  &lt;span className=&quot;flex-1 text-sm font-medium text-text-primary&quot;&gt;
                    Text content included
                  &lt;/span&gt;
                  &lt;Lock className=&quot;h-4 w-4 text-text-secondary/50&quot; /&gt;
                &lt;/div&gt;
              )}

              {product.externalUrl &amp;&amp; (
                &lt;div className=&quot;flex items-center gap-3 rounded-lg border border-border bg-surface p-3&quot;&gt;
                  &lt;ExternalLink className=&quot;h-5 w-5 text-text-secondary&quot; /&gt;
                  &lt;span className=&quot;flex-1 text-sm font-medium text-text-primary&quot;&gt;
                    External resource link
                  &lt;/span&gt;
                  &lt;Lock className=&quot;h-4 w-4 text-text-secondary/50&quot; /&gt;
                &lt;/div&gt;
              )}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div className=&quot;hidden lg:block lg:sticky lg:top-24 lg:self-start&quot;&gt;
          &lt;div className=&quot;rounded-xl border border-border bg-surface p-6&quot;&gt;
            &lt;p className=&quot;text-center text-3xl font-bold text-text-primary&quot;&gt;
              {formatPrice(product.price)}
            &lt;/p&gt;

            {purchase ? (
              &lt;Link
                href={`/products/${product.slug}/download`}
                className=&quot;mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-success px-6 py-3 text-sm font-semibold text-white hover:bg-success/90 transition-colors&quot;
              &gt;
                &lt;Download className=&quot;h-4 w-4&quot; /&gt;
                Download
              &lt;/Link&gt;
            ) : product.price === 0 ? (
              &lt;form action={`/api/products/${product.id}/purchase`} method=&quot;POST&quot;&gt;
                &lt;button
                  type=&quot;submit&quot;
                  className=&quot;mt-6 w-full rounded-lg bg-success px-6 py-3 text-sm font-semibold text-white hover:bg-success/90 transition-colors&quot;
                &gt;
                  Get for Free
                &lt;/button&gt;
              &lt;/form&gt;
            ) : product.whopCheckoutUrl ? (
              &lt;a
                href={product.whopCheckoutUrl}
                className=&quot;mt-6 flex w-full items-center justify-center rounded-lg bg-accent px-6 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;
              &gt;
                Buy Now
              &lt;/a&gt;
            ) : null}

            &lt;div className=&quot;mt-4 text-center text-xs text-text-secondary&quot;&gt;
              {product.files.length} {product.files.length === 1 ? &quot;file&quot; : &quot;files&quot;} &#xB7; Instant download
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface p-4 lg:hidden&quot;&gt;
        &lt;div className=&quot;mx-auto flex max-w-7xl items-center justify-between gap-4&quot;&gt;
          &lt;p className=&quot;text-lg font-bold text-text-primary&quot;&gt;
            {formatPrice(product.price)}
          &lt;/p&gt;
          {purchase ? (
            &lt;Link
              href={`/products/${product.slug}/download`}
              className=&quot;inline-flex items-center gap-2 rounded-lg bg-success px-6 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors&quot;
            &gt;
              &lt;Download className=&quot;h-4 w-4&quot; /&gt;
              Download
            &lt;/Link&gt;
          ) : product.price === 0 ? (
            &lt;form action={`/api/products/${product.id}/purchase`} method=&quot;POST&quot;&gt;
              &lt;button
                type=&quot;submit&quot;
                className=&quot;rounded-lg bg-success px-6 py-2.5 text-sm font-semibold text-white hover:bg-success/90 transition-colors&quot;
              &gt;
                Get for Free
              &lt;/button&gt;
            &lt;/form&gt;
          ) : product.whopCheckoutUrl ? (
            &lt;a
              href={product.whopCheckoutUrl}
              className=&quot;rounded-lg bg-accent px-6 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;
            &gt;
              Buy Now
            &lt;/a&gt;
          ) : null}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="like-button">Like button</h3><p>On top of the rating system, let&apos;s add a like system so that buyers can quickly show appreciation for products. Go to <code>src/components</code> and create a file called <code>like-button.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">like-button.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState, useTransition } from &quot;react&quot;;
import { Heart } from &quot;lucide-react&quot;;
import { cn } from &quot;@/lib/utils&quot;;

interface LikeButtonProps {
  productId: string;
  initialLiked: boolean;
  initialCount: number;
}

export function LikeButton({ productId, initialLiked, initialCount }: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    setLiked(!liked);
    setCount(liked ? count - 1 : count + 1);

    startTransition(async () =&gt; {
      const res = await fetch(`/api/products/${productId}/like`, { method: &quot;POST&quot; });
      if (!res.ok) {
        setLiked(liked);
        setCount(count);
      }
    });
  }

  return (
    &lt;button onClick={handleClick} disabled={isPending}
      aria-label={liked ? &quot;Unlike&quot; : &quot;Like&quot;}
      aria-pressed={liked}
      className={cn(
        &quot;inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-all&quot;,
        liked
          ? &quot;border-accent/30 bg-accent/10 text-accent&quot;
          : &quot;border-border bg-surface text-text-secondary hover:border-accent/30 hover:text-accent&quot;
      )}&gt;
      &lt;Heart className={cn(&quot;h-4 w-4 transition-transform&quot;, liked &amp;&amp; &quot;fill-current scale-110&quot;)} aria-hidden=&quot;true&quot; /&gt;
      {count}
    &lt;/button&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>We also need an API route that toggles the like on and off. Go to <code>src/app/api/products/[productId]/like</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const existingLike = await prisma.like.findUnique({
    where: { userId_productId: { userId: session.userId, productId } },
  });

  if (existingLike) {
    await prisma.like.delete({ where: { id: existingLike.id } });
    return NextResponse.json({ liked: false });
  }

  await prisma.like.create({ data: { userId: session.userId, productId } });
  return NextResponse.json({ liked: true });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="rating-api-route">Rating API route</h3><p>Now, let&apos;s build the API route that will validate the rating value (within 0.5 and 5), check for a purchase, and upsert the rating. Go to <code>src/app/api/products/[productId]/rate</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;

const VALID_RATINGS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];

const rateSchema = z.object({
  cookies: z.number().refine((v) =&gt; VALID_RATINGS.includes(v), {
    message: &quot;Rating must be 0.5-5 in 0.5 increments&quot;,
  }),
});

export async function POST(
  request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const purchase = await prisma.purchase.findUnique({
    where: { userId_productId: { userId: session.userId, productId } },
  });
  if (!purchase) {
    return NextResponse.json({ error: &quot;Purchase required&quot; }, { status: 403 });
  }

  const body = await request.json();
  const parsed = rateSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: &quot;Invalid rating&quot; }, { status: 400 });
  }

  const rating = await prisma.rating.upsert({
    where: { userId_productId: { userId: session.userId, productId } },
    create: { userId: session.userId, productId, cookies: parsed.data.cookies },
    update: { cookies: parsed.data.cookies },
  });

  return NextResponse.json(rating);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="rating-component">Rating component</h3><p>In this project, we&apos;ll use cookies instead of classic stars for ratings. So, we need a component that renders custom SVG cookies with three states: full, half-bitten, and empty.</p><p>Go to <code>src/components</code> and create a file called <code>cookie-rating.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">cookie-rating.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { cn } from &quot;@/lib/utils&quot;;

function CookieFull({ className }: { className?: string }) {
  return (
    &lt;svg viewBox=&quot;0 0 24 24&quot; className={className} fill=&quot;none&quot;&gt;
      &lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;10&quot; fill=&quot;currentColor&quot; /&gt;
      &lt;circle cx=&quot;8&quot; cy=&quot;8&quot; r=&quot;1.5&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;14&quot; cy=&quot;7&quot; r=&quot;1.2&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;10&quot; cy=&quot;13&quot; r=&quot;1.3&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;15&quot; cy=&quot;14&quot; r=&quot;1.5&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;7&quot; cy=&quot;15&quot; r=&quot;1&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;16&quot; cy=&quot;10&quot; r=&quot;1&quot; fill=&quot;var(--color-surface)&quot; /&gt;
    &lt;/svg&gt;
  );
}

function CookieHalf({ className }: { className?: string }) {
  return (
    &lt;svg viewBox=&quot;0 0 24 24&quot; className={className} fill=&quot;none&quot;&gt;
      &lt;path
        d=&quot;M12 2 A10 10 0 1 0 19 18 A7 7 0 0 1 19 6 A10 10 0 0 0 12 2z&quot;
        fill=&quot;currentColor&quot;
      /&gt;
      &lt;circle cx=&quot;6.5&quot; cy=&quot;9&quot; r=&quot;1.5&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;10&quot; cy=&quot;15&quot; r=&quot;1.4&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;5&quot; cy=&quot;14.5&quot; r=&quot;1&quot; fill=&quot;var(--color-surface)&quot; /&gt;
      &lt;circle cx=&quot;21&quot; cy=&quot;9&quot; r=&quot;0.8&quot; fill=&quot;currentColor&quot; /&gt;
      &lt;circle cx=&quot;22&quot; cy=&quot;13&quot; r=&quot;0.6&quot; fill=&quot;currentColor&quot; /&gt;
      &lt;circle cx=&quot;20&quot; cy=&quot;16&quot; r=&quot;0.5&quot; fill=&quot;currentColor&quot; /&gt;
    &lt;/svg&gt;
  );
}

function CookieEmpty({ className }: { className?: string }) {
  return (
    &lt;svg viewBox=&quot;0 0 24 24&quot; className={className} fill=&quot;none&quot;&gt;
      &lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;10&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;1.5&quot; /&gt;
      &lt;circle cx=&quot;8&quot; cy=&quot;8&quot; r=&quot;1.5&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;0.8&quot; /&gt;
      &lt;circle cx=&quot;14&quot; cy=&quot;7&quot; r=&quot;1.2&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;0.8&quot; /&gt;
      &lt;circle cx=&quot;10&quot; cy=&quot;13&quot; r=&quot;1.3&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;0.8&quot; /&gt;
      &lt;circle cx=&quot;15&quot; cy=&quot;14&quot; r=&quot;1.5&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;0.8&quot; /&gt;
      &lt;circle cx=&quot;7&quot; cy=&quot;15&quot; r=&quot;1&quot; stroke=&quot;currentColor&quot; strokeWidth=&quot;0.8&quot; /&gt;
    &lt;/svg&gt;
  );
}

interface CookieRatingProps {
  productId: string;
  initialRating: number | null;
  averageRating: number;
  ratingCount: number;
  canRate: boolean;
}

export function CookieRating({
  productId,
  initialRating,
  averageRating,
  ratingCount,
  canRate,
}: CookieRatingProps) {
  const [rating, setRating] = useState(initialRating);
  const [hover, setHover] = useState&lt;number | null&gt;(null);
  const [saving, setSaving] = useState(false);

  async function handleRate(cookies: number) {
    if (!canRate || saving) return;
    setSaving(true);
    setRating(cookies);

    try {
      const res = await fetch(`/api/products/${productId}/rate`, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ cookies }),
      });
      if (!res.ok) setRating(initialRating);
    } catch {
      setRating(initialRating);
    } finally {
      setSaving(false);
    }
  }

  function renderCookie(position: number, value: number) {
    const full = position;
    const half = position - 0.5;

    if (value &gt;= full) {
      return &lt;CookieFull className=&quot;h-full w-full&quot; /&gt;;
    } else if (value &gt;= half) {
      return &lt;CookieHalf className=&quot;h-full w-full&quot; /&gt;;
    }
    return &lt;CookieEmpty className=&quot;h-full w-full&quot; /&gt;;
  }

  if (!canRate) {
    return (
      &lt;div className=&quot;flex items-center gap-1.5&quot;&gt;
        &lt;div className=&quot;flex gap-0.5&quot;&gt;
          {[1, 2, 3, 4, 5].map((pos) =&gt; (
            &lt;div
              key={pos}
              className={cn(
                &quot;h-6 w-6&quot;,
                Math.round(averageRating * 2) / 2 &gt;= pos - 0.5
                  ? &quot;text-warning&quot;
                  : &quot;text-border&quot;
              )}
            &gt;
              {renderCookie(pos, Math.round(averageRating * 2) / 2)}
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
        &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;
          {averageRating &gt; 0 ? averageRating.toFixed(1) : &quot;No ratings&quot;}{&quot; &quot;}
          {ratingCount &gt; 0 &amp;&amp; `(${ratingCount})`}
        &lt;/span&gt;
      &lt;/div&gt;
    );
  }

  const display = hover ?? rating ?? 0;

  return (
    &lt;div className=&quot;flex items-center gap-2&quot;&gt;
      &lt;div className=&quot;flex&quot;&gt;
        {[1, 2, 3, 4, 5].map((pos) =&gt; (
          &lt;div key={pos} className=&quot;relative h-7 w-7&quot;&gt;
            &lt;button
              type=&quot;button&quot;
              disabled={saving}
              onClick={() =&gt; handleRate(pos - 0.5)}
              onMouseEnter={() =&gt; setHover(pos - 0.5)}
              onMouseLeave={() =&gt; setHover(null)}
              aria-label={`Rate ${pos - 0.5} cookies`}
              className=&quot;absolute inset-y-0 left-0 w-1/2 z-10 cursor-pointer&quot;
            /&gt;
            &lt;button
              type=&quot;button&quot;
              disabled={saving}
              onClick={() =&gt; handleRate(pos)}
              onMouseEnter={() =&gt; setHover(pos)}
              onMouseLeave={() =&gt; setHover(null)}
              aria-label={`Rate ${pos} cookies`}
              className=&quot;absolute inset-y-0 right-0 w-1/2 z-10 cursor-pointer&quot;
            /&gt;
            &lt;div
              className={cn(
                &quot;h-full w-full pointer-events-none transition-colors&quot;,
                display &gt;= pos - 0.5 ? &quot;text-warning&quot; : &quot;text-border&quot;
              )}
            &gt;
              {renderCookie(pos, display)}
            &lt;/div&gt;
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
      &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;
        {rating
          ? `${rating} cookie${rating !== 1 ? &quot;s&quot; : &quot;&quot;}`
          : &quot;Rate this product&quot;}
      &lt;/span&gt;
    &lt;/div&gt;
  );
}

export function CookieDisplay({
  average,
  count,
}: {
  average: number;
  count: number;
}) {
  if (count === 0) return null;
  return (
    &lt;div className=&quot;flex items-center gap-1&quot;&gt;
      &lt;CookieFull className=&quot;h-3.5 w-3.5 text-warning&quot; /&gt;
      &lt;span className=&quot;text-xs text-text-secondary&quot;&gt;
        {average.toFixed(1)}
      &lt;/span&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="seller-profiles">Seller profiles</h3><p>Lastly, let&apos;s build the seller profiles where users can see seller info and their products. Go to <code>src/app/sellers/[username]</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound } from &quot;next/navigation&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { ProductCard } from &quot;@/components/product-card&quot;;

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

  const sellerProfile = await prisma.sellerProfile.findUnique({
    where: { username },
    include: {
      user: true,
      products: {
        where: { status: &quot;PUBLISHED&quot; },
        orderBy: { createdAt: &quot;desc&quot; },
        include: {
          sellerProfile: { include: { user: true } },
          _count: { select: { likes: true, files: true, purchases: true } },
        },
      },
    },
  });

  if (!sellerProfile) notFound();

  const totalSales = sellerProfile.products.reduce(
    (sum: number, p) =&gt; sum + p._count.purchases, 0
  );

  return (
    &lt;div className=&quot;mx-auto max-w-7xl px-4 py-8&quot;&gt;
      &lt;div className=&quot;rounded-xl bg-gradient-to-br from-accent/20 to-accent/5 p-8&quot;&gt;
        &lt;div className=&quot;flex items-center gap-6&quot;&gt;
          {sellerProfile.user.avatar &amp;&amp; (
            &lt;img src={sellerProfile.user.avatar} alt={sellerProfile.user.name || &quot;Seller avatar&quot;}
              className=&quot;h-20 w-20 rounded-full border-4 border-surface&quot; /&gt;
          )}
          &lt;div&gt;
            &lt;h1 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;
              {sellerProfile.user.name || `@${sellerProfile.username}`}
            &lt;/h1&gt;
            &lt;p className=&quot;text-sm text-text-secondary&quot;&gt;@{sellerProfile.username}&lt;/p&gt;
            {sellerProfile.headline &amp;&amp; (
              &lt;p className=&quot;mt-1 text-sm text-text-secondary&quot;&gt;{sellerProfile.headline}&lt;/p&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
        {sellerProfile.bio &amp;&amp; (
          &lt;p className=&quot;mt-4 max-w-2xl text-sm text-text-secondary leading-relaxed&quot;&gt;
            {sellerProfile.bio}
          &lt;/p&gt;
        )}
        &lt;div className=&quot;mt-4 flex gap-6 text-sm text-text-secondary&quot;&gt;
          &lt;span&gt;&lt;strong className=&quot;text-text-primary&quot;&gt;{sellerProfile.products.length}&lt;/strong&gt; products&lt;/span&gt;
          &lt;span&gt;&lt;strong className=&quot;text-text-primary&quot;&gt;{totalSales}&lt;/strong&gt; sales&lt;/span&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className=&quot;mt-8&quot;&gt;
        &lt;h2 className=&quot;text-xl font-bold text-text-primary&quot;&gt;Products&lt;/h2&gt;
        {sellerProfile.products.length === 0 ? (
          &lt;p className=&quot;mt-4 text-text-secondary&quot;&gt;No products published yet.&lt;/p&gt;
        ) : (
          &lt;div className=&quot;mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4&quot;&gt;
            {sellerProfile.products.map((product) =&gt; (
              &lt;ProductCard key={product.id} product={product} /&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="checkpoint-2">Checkpoint</h3><p>Now, let&apos;s the marketplace end-to-end:</p><ol><li>Navigate to <code>/products</code>. You should see your published products. Try searching by title and filtering by category</li><li>Click a product to open <code>/products/[slug]</code>. Verify the description, file list, seller info, and purchase card are all showing</li><li>Click the heart icon to like a product, the count should update instantly</li><li>Visit <code>/sellers/[username]</code>. The seller&apos;s profile should show their published products and stats</li><li>If you have a purchased product, try clicking the cookies on the product detail page to leave a rating</li></ol><h2 id="part-5-checkout-payments-and-file-delivery">Part 5: Checkout, payments, and file delivery</h2><p>In this section, we&apos;re going to build the purchasing flow. Paid products will redirect users to a Whop-hosted checkout, and free products just create a purchase record.</p><h3 id="how-the-payment-flow-works">How the payment flow works</h3><p>When a buyer navigates to a product page and clicks &quot;Buy Now&quot;:</p><ol><li>The browser navigates to Whop-hosted checkout and completes payment</li><li>Whop splits the payment: 5% to your platform account, 95% to the seller&apos;s connected account</li><li>Whop fires a successful payment webhook to your <code>/api/webhooks/whop</code> endpoint</li><li>Your webhook handler creates a <code>Purchase</code> record</li></ol><h3 id="free-product-purchases">Free product purchases</h3><p>Free products created on our project doesn&apos;t need a checkout flow, so we can just create a purchase record directly and redirect the user to the download page. Go to <code>src/app/api/products/[productId]/purchase</code> and create a file called <code>route.ts</code> with the content:</p><pre><code class="language-ts">import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { env } from &quot;@/lib/env&quot;;

export async function POST(
  _request: NextRequest,
  { params }: { params: Promise&lt;{ productId: string }&gt; }
) {
  const { productId } = await params;
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const product = await prisma.product.findUnique({
    where: { id: productId },
  });

  if (!product || product.status !== &quot;PUBLISHED&quot;) {
    return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
  }

  if (product.price !== 0) {
    return NextResponse.json(
      { error: &quot;This product requires payment. Use the checkout link.&quot; },
      { status: 400 }
    );
  }

  const existingPurchase = await prisma.purchase.findUnique({
    where: {
      userId_productId: {
        userId: session.userId,
        productId,
      },
    },
  });

  if (existingPurchase) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/products/${product.slug}/download`
    );
  }

  await prisma.purchase.create({
    data: {
      userId: session.userId,
      productId,
      pricePaid: 0,
    },
  });

  return NextResponse.redirect(
    `${env.NEXT_PUBLIC_APP_URL}/products/${product.slug}/download`
  );
}
</code></pre><h3 id="webhook-setup">Webhook setup</h3><p>Before we build the webhook handler, let&apos;s create a webhook in Whop and get its secret:</p><ol><li>Go to your Whop sandbox dashboard and open the Developer page</li><li>There, find the Webhooks section and click the <strong>Create webhook</strong> button</li><li>Set the endpoint URL to <code>https://your-vercel-url.vercel.app/api/webhooks/whop</code> (replace with your production URL)</li><li>Enable the <code>payment_succeeded</code> event and click <strong>Save</strong></li><li>Copy the webhook secret that starts with <code>ws_</code> and add it to Vercel under the <code>WHOP_WEBHOOK_SECRET</code> environment variable</li><li>Pull the environment variables to local using the <code>vercel env pull .env.local</code> command</li></ol><h3 id="webhook-handler">Webhook handler</h3><p>Our webhook handler will verify the request signature, check for idempotency (for when webhooks delivered more than once), and creates a purchase record. Go to <code>src/app/api/webhooks/whop</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getWhop } from &quot;@/lib/whop&quot;;

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

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

  const whop = getWhop();

  let webhookData: WhopEvent;
  try {
    webhookData = whop.webhooks.unwrap(bodyText, {
      headers: headerObj,
    }) as unknown as WhopEvent;
  } catch (err) {
    console.error(&quot;Webhook verification failed:&quot;, err);
    try {
      webhookData = JSON.parse(bodyText) as WhopEvent;
    } catch {
      return NextResponse.json({ error: &quot;Invalid webhook&quot; }, { status: 400 });
    }
  }

  const eventId = webhookData.id;
  if (!eventId) {
    return NextResponse.json({ error: &quot;Missing event ID&quot; }, { status: 400 });
  }

  const existing = await prisma.webhookEvent.findUnique({
    where: { id: eventId },
  });

  if (existing) {
    return NextResponse.json({ status: &quot;already_processed&quot; });
  }

  if (webhookData.type === &quot;payment.succeeded&quot;) {
    const payment = webhookData.data;
    const plan = payment?.plan as Record&lt;string, unknown&gt; | undefined;
    const user = payment?.user as Record&lt;string, unknown&gt; | undefined;
    const planId = plan?.id as string | undefined;
    const whopUserId = user?.id as string | undefined;

    if (!planId || !whopUserId) {
      console.error(&quot;Missing plan or user on payment webhook:&quot;, JSON.stringify(webhookData.data, null, 2));
      return NextResponse.json({ error: &quot;Missing data&quot; }, { status: 400 });
    }

    const product = await prisma.product.findFirst({
      where: { whopPlanId: planId },
    });

    if (!product) {
      console.error(&quot;No product found for plan:&quot;, planId);
      return NextResponse.json({ error: &quot;Product not found&quot; }, { status: 404 });
    }

    const dbUser = await prisma.user.findUnique({
      where: { whopUserId },
    });

    if (!dbUser) {
      console.error(&quot;No user found for Whop user:&quot;, whopUserId);
      return NextResponse.json({ error: &quot;User not found&quot; }, { status: 404 });
    }

    await prisma.purchase.upsert({
      where: {
        userId_productId: {
          userId: dbUser.id,
          productId: product.id,
        },
      },
      update: {},
      create: {
        userId: dbUser.id,
        productId: product.id,
        whopPaymentId: payment.id as string,
        pricePaid: Math.round(((payment.subtotal as number) ?? 0) * 100),
      },
    });

    await prisma.webhookEvent.create({ data: { id: eventId } });
  }

  return NextResponse.json({ status: &quot;ok&quot; });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="configurable-platform-fee">Configurable platform fee</h3><p>In this project, we set our platform fee via the <code>PLATFORM_FEE_PERCENT</code> environment variable. While the default is 5% to match Gumroad&apos;s pricing, you can always change it by editing the environment variable:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">.env</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">PLATFORM_FEE_PERCENT=10  # 10% platform fee</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="file-delivery">File delivery</h3><p>After building the purchase flow, let&apos;s work on the file delivery system. The download page after purchase checks whether the user has purchased the product or not. To build it, go to <code>src/app/products/[slug]/download</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { notFound, redirect } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { Download, FileText, Image as ImageIcon, Video, ExternalLink, ArrowLeft } from &quot;lucide-react&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { formatFileSize } from &quot;@/lib/utils&quot;;

function getFileIcon(mimeType: string) {
  if (mimeType.startsWith(&quot;image/&quot;)) return ImageIcon;
  if (mimeType.startsWith(&quot;video/&quot;)) return Video;
  return FileText;
}

export default async function DownloadPage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const user = await requireAuth();

  const product = await prisma.product.findUnique({
    where: { slug },
    include: {
      files: { orderBy: { displayOrder: &quot;asc&quot; } },
      sellerProfile: { include: { user: true } },
    },
  });

  if (!product) notFound();

  const purchase = await prisma.purchase.findUnique({
    where: { userId_productId: { userId: user.id, productId: product.id } },
  });

  if (!purchase) {
    redirect(`/products/${slug}`);
  }

  return (
    &lt;div className=&quot;mx-auto max-w-3xl px-4 py-8&quot;&gt;
      &lt;Link href={`/products/${slug}`}
        className=&quot;inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
        &lt;ArrowLeft className=&quot;h-4 w-4&quot; /&gt; Back to product
      &lt;/Link&gt;

      &lt;div className=&quot;mt-6&quot;&gt;
        &lt;p className=&quot;text-sm font-medium text-success&quot;&gt;Purchase confirmed&lt;/p&gt;
        &lt;h1 className=&quot;mt-1 text-2xl font-bold text-text-primary&quot;&gt;{product.title}&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-sm text-text-secondary&quot;&gt;
          by @{product.sellerProfile.username}
        &lt;/p&gt;
      &lt;/div&gt;

      {product.files.length &gt; 0 &amp;&amp; (
        &lt;div className=&quot;mt-8&quot;&gt;
          &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;Files&lt;/h2&gt;
          &lt;div className=&quot;mt-3 space-y-2&quot;&gt;
            {product.files.map((file) =&gt; {
              const Icon = getFileIcon(file.mimeType);
              return (
                &lt;div key={file.id}
                  className=&quot;flex items-center gap-3 rounded-lg border border-border bg-surface p-4&quot;&gt;
                  &lt;Icon className=&quot;h-5 w-5 text-text-secondary&quot; /&gt;
                  &lt;div className=&quot;flex-1&quot;&gt;
                    &lt;p className=&quot;text-sm font-medium text-text-primary&quot;&gt;{file.fileName}&lt;/p&gt;
                    &lt;p className=&quot;text-xs text-text-secondary&quot;&gt;
                      {formatFileSize(file.fileSize)} &#xB7; {file.mimeType}
                    &lt;/p&gt;
                  &lt;/div&gt;
                  &lt;a href={file.fileUrl} download={file.fileName}
                    target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;
                    aria-label={`Download ${file.fileName}`}
                    className=&quot;inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors&quot;&gt;
                    &lt;Download className=&quot;h-4 w-4&quot; aria-hidden=&quot;true&quot; /&gt; Download
                  &lt;/a&gt;
                &lt;/div&gt;
              );
            })}
          &lt;/div&gt;
        &lt;/div&gt;
      )}

      {product.content &amp;&amp; (
        &lt;div className=&quot;mt-8&quot;&gt;
          &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;Content&lt;/h2&gt;
          &lt;div className=&quot;mt-3 rounded-lg border border-border bg-surface p-6&quot;&gt;
            &lt;div className=&quot;prose prose-sm max-w-none text-text-secondary whitespace-pre-wrap&quot;&gt;
              {product.content}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      )}

      {product.externalUrl &amp;&amp; (
        &lt;div className=&quot;mt-8&quot;&gt;
          &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;External Resource&lt;/h2&gt;
          &lt;a href={product.externalUrl} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;
            className=&quot;mt-3 flex items-center gap-3 rounded-lg border border-border bg-surface p-4 text-accent hover:bg-surface-elevated transition-colors&quot;&gt;
            &lt;ExternalLink className=&quot;h-5 w-5&quot; /&gt;
            &lt;span className=&quot;text-sm font-medium&quot;&gt;{product.externalUrl}&lt;/span&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="checkpoint-3">Checkpoint</h3><p>Test the full purchase and download flow:</p><ol><li>Create a free product, publish it, then click &quot;Get for Free.&quot; You should be redirected to the download page immediately</li><li>Create a paid product, publish it, sign in as a different user (or use incognito), and click &quot;Buy Now&quot;</li><li>Complete payment on Whop&apos;s checkout (test card: <code>4242 4242 4242 4242</code>, any future date, any CVC)</li><li>Navigate back to the product page, the button should now say &quot;Download&quot;</li><li>Click &quot;Download&quot; and verify you see the files, text content, and external links</li><li>Open an incognito window and try the download URL without signing in. You should be redirected to sign-in</li><li>Sign in as a user who hasn&apos;t purchase. You should be redirected to the product page</li></ol><h2 id="part-6-seller-dashboard-buyer-dashboard-and-payouts">Part 6: Seller dashboard, buyer dashboard, and payouts</h2><p>In this final section, we&apos;re going to build the seller/buyer dashboards and connect the payout system.</p><h3 id="seller-dashboard">Seller dashboard</h3><p>We want the seller dashboard to show earnings, sales statistics, and a list of all products. Go to <code>src/app/sell/dashboard</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { Plus, DollarSign, ShoppingBag, Package, Heart } from &quot;lucide-react&quot;;
import { requireSeller } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;
import { env } from &quot;@/lib/env&quot;;
import { ProfileEditor } from &quot;./profile-editor&quot;;

export default async function SellerDashboardPage() {
  const { sellerProfile } = await requireSeller();

  const products = await prisma.product.findMany({
    where: { sellerProfileId: sellerProfile.id },
    orderBy: { createdAt: &quot;desc&quot; },
    include: {
      _count: { select: { purchases: true, likes: true } },
      purchases: { select: { pricePaid: true } },
    },
  });

  const totalSales = products.reduce(
    (sum: number, p) =&gt; sum + p._count.purchases, 0
  );
  const totalLikes = products.reduce(
    (sum: number, p) =&gt; sum + p._count.likes, 0
  );
  const totalEarnings = products.reduce(
    (sum: number, p) =&gt;
      sum + p.purchases.reduce((s: number, pur) =&gt; s + pur.pricePaid, 0),
    0
  );
  const feePercent = env.PLATFORM_FEE_PERCENT;
  const netEarnings = Math.round(totalEarnings * ((100 - feePercent) / 100));

  return (
    &lt;div className=&quot;mx-auto max-w-7xl px-4 py-8&quot;&gt;
      &lt;div className=&quot;flex items-center justify-between&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;Seller Dashboard&lt;/h1&gt;
          &lt;p className=&quot;mt-1 text-sm text-text-secondary&quot;&gt;
            @{sellerProfile.username}
            {sellerProfile.headline &amp;&amp; (
              &lt;span className=&quot;ml-2&quot;&gt;&#xB7; {sellerProfile.headline}&lt;/span&gt;
            )}
          &lt;/p&gt;
          &lt;ProfileEditor
            headline={sellerProfile.headline}
            bio={sellerProfile.bio}
          /&gt;
        &lt;/div&gt;
        &lt;Link href=&quot;/sell/products/new&quot;
          className=&quot;inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;&gt;
          &lt;Plus className=&quot;h-4 w-4&quot; /&gt; New Product
        &lt;/Link&gt;
      &lt;/div&gt;

      {/* Stats */}
      &lt;div className=&quot;mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4&quot;&gt;
        &lt;div className=&quot;rounded-xl border border-border bg-surface p-5&quot;&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            &lt;DollarSign className=&quot;h-5 w-5 text-success&quot; /&gt;
            &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Net Earnings&lt;/span&gt;
          &lt;/div&gt;
          &lt;p className=&quot;mt-2 text-2xl font-bold text-text-primary&quot;&gt;
            {formatPrice(netEarnings)}
          &lt;/p&gt;
        &lt;/div&gt;
        &lt;div className=&quot;rounded-xl border border-border bg-surface p-5&quot;&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            &lt;ShoppingBag className=&quot;h-5 w-5 text-accent&quot; /&gt;
            &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Total Sales&lt;/span&gt;
          &lt;/div&gt;
          &lt;p className=&quot;mt-2 text-2xl font-bold text-text-primary&quot;&gt;{totalSales}&lt;/p&gt;
        &lt;/div&gt;
        &lt;div className=&quot;rounded-xl border border-border bg-surface p-5&quot;&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            &lt;Package className=&quot;h-5 w-5 text-warning&quot; /&gt;
            &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Products&lt;/span&gt;
          &lt;/div&gt;
          &lt;p className=&quot;mt-2 text-2xl font-bold text-text-primary&quot;&gt;{products.length}&lt;/p&gt;
        &lt;/div&gt;
        &lt;div className=&quot;rounded-xl border border-border bg-surface p-5&quot;&gt;
          &lt;div className=&quot;flex items-center gap-3&quot;&gt;
            &lt;Heart className=&quot;h-5 w-5 text-accent&quot; /&gt;
            &lt;span className=&quot;text-sm text-text-secondary&quot;&gt;Total Likes&lt;/span&gt;
          &lt;/div&gt;
          &lt;p className=&quot;mt-2 text-2xl font-bold text-text-primary&quot;&gt;{totalLikes}&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      {/* Product list */}
      &lt;div className=&quot;mt-8&quot;&gt;
        &lt;h2 className=&quot;text-lg font-semibold text-text-primary&quot;&gt;Your Products&lt;/h2&gt;

        {products.length === 0 ? (
          &lt;div className=&quot;mt-6 rounded-xl border border-dashed border-border p-12 text-center&quot;&gt;
            &lt;Package className=&quot;mx-auto h-12 w-12 text-text-secondary/30&quot; /&gt;
            &lt;p className=&quot;mt-4 text-text-secondary&quot;&gt;
              No products yet. Create your first product to start selling.
            &lt;/p&gt;
            &lt;Link href=&quot;/sell/products/new&quot;
              className=&quot;mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;&gt;
              &lt;Plus className=&quot;h-4 w-4&quot; /&gt; Create Product
            &lt;/Link&gt;
          &lt;/div&gt;
        ) : (
          &lt;div className=&quot;mt-4 space-y-3&quot;&gt;
            {products.map((product) =&gt; {
              const revenue = product.purchases.reduce(
                (s: number, p) =&gt; s + p.pricePaid, 0
              );
              return (
                &lt;div key={product.id}
                  className=&quot;flex items-center gap-4 rounded-xl border border-border bg-surface p-4&quot;&gt;
                  &lt;div className=&quot;h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-elevated&quot;&gt;
                    {product.thumbnailUrl ? (
                      &lt;img src={product.thumbnailUrl} alt=&quot;&quot; className=&quot;h-full w-full object-cover&quot; /&gt;
                    ) : (
                      &lt;div className=&quot;flex h-full items-center justify-center&quot;&gt;
                        &lt;Package className=&quot;h-6 w-6 text-text-secondary/30&quot; /&gt;
                      &lt;/div&gt;
                    )}
                  &lt;/div&gt;

                  &lt;div className=&quot;flex-1 min-w-0&quot;&gt;
                    &lt;p className=&quot;truncate text-sm font-semibold text-text-primary&quot;&gt;{product.title}&lt;/p&gt;
                    &lt;p className=&quot;text-xs text-text-secondary&quot;&gt;
                      {formatPrice(product.price)} &#xB7; {product._count.purchases} sales &#xB7; {formatPrice(revenue)} revenue
                    &lt;/p&gt;
                  &lt;/div&gt;

                  &lt;span className={`px-3 py-1 text-xs font-medium ${
                    product.status === &quot;PUBLISHED&quot;
                      ? &quot;bg-success/10 text-success&quot;
                      : &quot;bg-warning/10 text-warning&quot;
                  }`}&gt;
                    {product.status}
                  &lt;/span&gt;

                  &lt;div className=&quot;flex gap-2&quot;&gt;
                    &lt;Link href={`/sell/products/${product.id}/edit`}
                      className=&quot;rounded-lg border border-border px-3 py-2.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
                      Edit
                    &lt;/Link&gt;
                    {product.status === &quot;PUBLISHED&quot; &amp;&amp; (
                      &lt;Link href={`/products/${product.slug}`}
                        className=&quot;rounded-lg border border-border px-3 py-2.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
                        View
                      &lt;/Link&gt;
                    )}
                  &lt;/div&gt;
                &lt;/div&gt;
              );
            })}
          &lt;/div&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="payouts">Payouts</h3><p>In this project, we&apos;ll redirect sellers to Whop&apos;s payout portal to withdraw earnings. To send a seller to the payout portal, we&apos;ll generate a link using Whop&apos;s account links API. You could add a &quot;Manage Payouts&quot; button to the seller dashboard that runs this code and redirects:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Payouts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const accountLink = await getWhop().accountLinks.create({
  company_id: sellerProfile.whopCompanyId,
  use_case: &quot;payouts_portal&quot;,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/sell/dashboard`,
  refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/sell/dashboard?refresh=true`,
});

// Redirect seller to accountLink.url</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="seller-profile-editing">Seller profile editing</h3><p>Also in the dashboard, we&apos;ll let sellers edit their headline and bio, so, let&apos;s build the API route for it. Go to <code>src/app/api/sell/profile</code> and create a file called <code>route.ts</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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 { getSession } from &quot;@/lib/session&quot;;

const updateProfileSchema = z.object({
  headline: z.string().max(100).optional().nullable(),
  bio: z.string().max(2000).optional().nullable(),
});

export async function PATCH(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const profile = await prisma.sellerProfile.findUnique({
    where: { userId: session.userId },
  });

  if (!profile) {
    return NextResponse.json({ error: &quot;Not a seller&quot; }, { status: 403 });
  }

  const body = await request.json();
  const parsed = updateProfileSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: &quot;Validation failed&quot; }, { status: 400 });
  }

  const updated = await prisma.sellerProfile.update({
    where: { id: profile.id },
    data: {
      headline: parsed.data.headline ?? null,
      bio: parsed.data.bio ?? null,
    },
  });

  return NextResponse.json(updated);
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="buyer-dashboard">Buyer dashboard</h3><p>Now, let&apos;s move on to the buyer dashboard. We want it to show all purchased products with download links. Go to <code>src/app/dashboard</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { Download, Package, ShoppingBag } from &quot;lucide-react&quot;;
import { requireAuth } from &quot;@/lib/auth&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { formatPrice } from &quot;@/lib/utils&quot;;

export default async function BuyerDashboardPage() {
  const user = await requireAuth();

  const purchases = await prisma.purchase.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: &quot;desc&quot; },
    include: {
      product: {
        include: {
          sellerProfile: { include: { user: true } },
          _count: { select: { files: true } },
        },
      },
    },
  });

  return (
    &lt;div className=&quot;mx-auto max-w-7xl px-4 py-8&quot;&gt;
      &lt;div className=&quot;flex items-center gap-3&quot;&gt;
        &lt;ShoppingBag className=&quot;h-6 w-6 text-accent&quot; /&gt;
        &lt;h1 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;My Purchases&lt;/h1&gt;
      &lt;/div&gt;

      {purchases.length === 0 ? (
        &lt;div className=&quot;mt-12 text-center&quot;&gt;
          &lt;Package className=&quot;mx-auto h-16 w-16 text-text-secondary/20&quot; /&gt;
          &lt;p className=&quot;mt-4 text-lg text-text-secondary&quot;&gt;No purchases yet.&lt;/p&gt;
          &lt;Link href=&quot;/products&quot;
            className=&quot;mt-4 inline-block rounded-lg bg-accent px-6 py-2.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;&gt;
            Browse Products
          &lt;/Link&gt;
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3&quot;&gt;
          {purchases.map((purchase) =&gt; (
            &lt;div key={purchase.id}
              className=&quot;rounded-xl border border-border bg-surface overflow-hidden&quot;&gt;
              &lt;div className=&quot;aspect-[4/3] bg-surface-elevated&quot;&gt;
                {purchase.product.thumbnailUrl ? (
                  &lt;img src={purchase.product.thumbnailUrl} alt=&quot;&quot;
                    className=&quot;h-full w-full object-cover&quot; /&gt;
                ) : (
                  &lt;div className=&quot;flex h-full items-center justify-center&quot;&gt;
                    &lt;Package className=&quot;h-12 w-12 text-text-secondary/20&quot; /&gt;
                  &lt;/div&gt;
                )}
              &lt;/div&gt;
              &lt;div className=&quot;p-4&quot;&gt;
                &lt;h3 className=&quot;font-semibold text-text-primary&quot;&gt;
                  {purchase.product.title}
                &lt;/h3&gt;
                &lt;p className=&quot;mt-1 text-xs text-text-secondary&quot;&gt;
                  by @{purchase.product.sellerProfile.username} &#xB7;{&quot; &quot;}
                  {formatPrice(purchase.pricePaid)} &#xB7; {purchase.product._count.files} files
                &lt;/p&gt;
                &lt;p className=&quot;mt-0.5 text-xs text-text-secondary&quot;&gt;
                  Purchased{&quot; &quot;}
                  {purchase.createdAt.toLocaleDateString(&quot;en-US&quot;, {
                    month: &quot;short&quot;, day: &quot;numeric&quot;, year: &quot;numeric&quot;,
                  })}
                &lt;/p&gt;
                &lt;Link href={`/products/${purchase.product.slug}/download`}
                  className=&quot;mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors&quot;&gt;
                  &lt;Download className=&quot;h-4 w-4&quot; /&gt; Download
                &lt;/Link&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="landing-page">Landing page</h3><p>Our landing page needs a hero with search, trending products, categories, and a seller CTA. Go to <code>src/app</code> and create a file called <code>page.tsx</code> with the content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import Link from &quot;next/link&quot;;
import { ArrowRight, Store, CreditCard, TrendingUp, Package, Search } from &quot;lucide-react&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;
import { ProductCard } from &quot;@/components/product-card&quot;;
import { CATEGORIES } from &quot;@/constants/categories&quot;;

export default async function HomePage() {
  const trendingProducts = await prisma.product.findMany({
    where: { status: &quot;PUBLISHED&quot; },
    orderBy: { likes: { _count: &quot;desc&quot; } },
    take: 8,
    include: {
      sellerProfile: { include: { user: true } },
      ratings: { select: { cookies: true } },
      _count: { select: { likes: true, files: true, ratings: true } },
    },
  });

  return (
    &lt;div&gt;
      &lt;section className=&quot;relative overflow-hidden bg-gradient-to-br from-accent/10 via-background to-background&quot;&gt;
        &lt;div className=&quot;mx-auto max-w-7xl px-4 py-24 text-center&quot;&gt;
          &lt;h1 className=&quot;text-5xl font-extrabold tracking-tight text-text-primary sm:text-6xl lg:text-7xl&quot;&gt;
            Sell what you create
          &lt;/h1&gt;
          &lt;p className=&quot;mx-auto mt-6 max-w-2xl text-lg text-text-secondary&quot;&gt;
            The marketplace for digital products - templates, ebooks, design
            assets, and more. Upload your files, set a price, and start earning.
          &lt;/p&gt;
          &lt;form
            action=&quot;/products&quot;
            method=&quot;GET&quot;
            className=&quot;mx-auto mt-10 flex max-w-lg items-center border border-border bg-surface&quot;
          &gt;
            &lt;Search className=&quot;ml-4 h-4 w-4 text-text-secondary&quot; aria-hidden=&quot;true&quot; /&gt;
            &lt;input
              type=&quot;search&quot;
              name=&quot;q&quot;
              placeholder=&quot;Search products...&quot;
              className=&quot;flex-1 bg-transparent px-3 py-3 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none&quot;
            /&gt;
            &lt;button
              type=&quot;submit&quot;
              className=&quot;bg-accent px-5 py-3 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;
            &gt;
              Search
            &lt;/button&gt;
          &lt;/form&gt;

          &lt;div className=&quot;mt-6 flex items-center justify-center gap-4&quot;&gt;
            &lt;Link href=&quot;/products&quot;
              className=&quot;text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
              Browse All
            &lt;/Link&gt;
            &lt;Link href=&quot;/sell&quot;
              className=&quot;inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors&quot;&gt;
              Start Selling &lt;ArrowRight className=&quot;h-4 w-4&quot; /&gt;
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;mx-auto max-w-7xl px-4 py-16&quot;&gt;
        &lt;h2 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;Trending right now&lt;/h2&gt;
        {trendingProducts.length &gt; 0 ? (
          &lt;div className=&quot;mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4&quot;&gt;
            {trendingProducts.map((product) =&gt; (
              &lt;ProductCard
                key={product.id}
                product={{
                  ...product,
                  avgRating:
                    product._count.ratings &gt; 0
                      ? product.ratings.reduce((s, r) =&gt; s + r.cookies, 0) / product._count.ratings
                      : 0,
                }}
              /&gt;
            ))}
          &lt;/div&gt;
        ) : (
          &lt;div className=&quot;mt-8 rounded-xl border border-dashed border-border p-12 text-center&quot;&gt;
            &lt;Package className=&quot;mx-auto h-12 w-12 text-text-secondary/20&quot; /&gt;
            &lt;p className=&quot;mt-4 text-text-secondary&quot;&gt;
              No products yet. Be the first to{&quot; &quot;}
              &lt;Link href=&quot;/sell&quot; className=&quot;text-accent hover:underline&quot;&gt;
                list something
              &lt;/Link&gt;
              .
            &lt;/p&gt;
          &lt;/div&gt;
        )}
      &lt;/section&gt;

      &lt;section className=&quot;mx-auto max-w-7xl px-4 py-16&quot;&gt;
        &lt;h2 className=&quot;text-2xl font-bold text-text-primary&quot;&gt;Browse by category&lt;/h2&gt;
        &lt;div className=&quot;mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3&quot;&gt;
          {CATEGORIES.map((cat) =&gt; (
            &lt;Link key={cat.value} href={`/products?category=${cat.value}`}
              className=&quot;flex items-center gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:-translate-y-0.5 hover:shadow-md&quot;&gt;
              &lt;cat.icon className=&quot;h-8 w-8 text-accent&quot; /&gt;
              &lt;span className=&quot;text-base font-semibold text-text-primary&quot;&gt;{cat.label}&lt;/span&gt;
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      &lt;/section&gt;

      &lt;section className=&quot;mx-auto max-w-7xl px-4 py-16&quot;&gt;
        &lt;div className=&quot;rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 p-12 text-center&quot;&gt;
          &lt;h2 className=&quot;text-3xl font-bold text-text-primary&quot;&gt;Turn your skills into income&lt;/h2&gt;
          &lt;p className=&quot;mx-auto mt-4 max-w-lg text-text-secondary&quot;&gt;
            Join creators selling digital products on Shelfie. We handle payments, payouts, and compliance - you keep 95% of every sale.
          &lt;/p&gt;
          &lt;div className=&quot;mt-8 flex items-center justify-center gap-8 text-sm text-text-secondary&quot;&gt;
            &lt;div className=&quot;flex items-center gap-2&quot;&gt;
              &lt;Store className=&quot;h-5 w-5 text-accent&quot; /&gt; Free to start
            &lt;/div&gt;
            &lt;div className=&quot;flex items-center gap-2&quot;&gt;
              &lt;CreditCard className=&quot;h-5 w-5 text-accent&quot; /&gt; 5% platform fee
            &lt;/div&gt;
            &lt;div className=&quot;flex items-center gap-2&quot;&gt;
              &lt;TrendingUp className=&quot;h-5 w-5 text-accent&quot; /&gt; Instant payouts
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;Link href=&quot;/sell&quot;
            className=&quot;mt-8 inline-flex items-center gap-2 rounded-lg bg-accent px-8 py-3.5 text-sm font-semibold text-white hover:bg-accent-hover transition-colors&quot;&gt;
            Start Selling &lt;ArrowRight className=&quot;h-4 w-4&quot; /&gt;
          &lt;/Link&gt;
        &lt;/div&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="whats-next">What&apos;s next?</h3><p>Our project is now complete. We build a user authentication system using Whop OAuth, seller onboarding and KYC, product creation and file uploads via UploadThing, a product publishing flow with Whop checkout configurations, a marketplace with searches and categories, a whole payment system foundation via Whop Payments Network, ratings, dashboards, and more.</p><p>Here are a few ideas you can implement to further improve your project:</p><ul><li><strong>Subscription products -</strong> recurring membership options for sellers</li><li><strong>Promo codes -</strong> discount codes sellers can create</li><li><strong>Rich text editor -</strong> upgrading plain text descriptions with markdown text</li><li><strong>Analytics dashboard -</strong> product views, conversion rates, trends, and other analytical data for sellers</li><li><strong>Limited downloads -</strong> customizable download limits for buyers</li></ul><h2 id="build-your-own-platform-with-whop">Build your own platform with Whop</h2><p>Just like this Gumroad clone project, there are many other platforms you can create with Whop. Whether you&apos;re looking to create a <a href="https://whop.com/blog/build-substack-clone/" rel="noreferrer">Substack clone</a> or a <a href="https://whop.com/blog/build-ai-chatbot-saas/" rel="noreferrer">chatbot SaaS</a>, Whop&apos;s infrastructure can help you easily build your dream projects.</p><p>Grab the entire source code of this <a href="https://github.com/whopio/whop-tutorials/tree/main/gumroad-clone">Gumroad clone project on GitHub</a> and check out our developer documentation to learn more about what you can do with the Whop infrastructure.</p><div class="kg-card kg-button-card kg-align-left"><a href="https://docs.whop.com/developer/api/getting-started" class="kg-btn kg-btn-accent">Go to Whop developer documentation</a></div>]]></content:encoded></item>

These legal disclaimers are here because this hub is run by Google as a service. If you don't want to agree to these terms you can use a different hub or even run your own. The PubSubHubbub protocol is decentralized and free.

©2022 Google - Terms of Service - Privacy Policy