Codes App
A tutorial on how to build an app that allows sellers to sell codes and customers to purchase them through in-app purchases
The app uses:
Seller Product View
Prerequisites
Before we can start building, we need to make sure we created an app on Whop. If you haven’t done this yet, head to the Developer Settings and create a new app.
This app will use the following URL paths:
- Seller Product Path:
seller/$companyId/$productId
- Customer Path:
customer/$companyId
For more information on creating an app, check out the Create an app guide.
Project Setup
Before we start building we’re going to set up our database. This example uses Neon, but you can use any database you want.
Database Setup
- Head to the Neon Dashboard and create a new project
- Enter the name of your project, and select the region closest to you. We are using Postgres Version 15.
- Copy the connection string and save it somewhere safe. We will need it later.
We have now set up our database! We will create our tables later on
Next.js Setup
This tutorial uses the Next.js App Directory.
- Initialize a new project
- Install the dependencies
- Create the environment variables
Create a .env.local
file in the root of your project and add the following:
WHOP_API_KEY=
NEXT_PUBLIC_WHOP_APP_ID=
DATABASE_URL=
WHOP_API_KEY
is your Whop API key. You can find this in your app’s settings.NEXT_PUBLIC_WHOP_APP_ID
is your Whop App ID. You can find this in your app’s settings.DATABASE_URL
is the connection string we copied earlier.
You can find out more about the App ID and API key here.
- Enable Server Actions
As server actions are still an experimental feature of Next.js, we have to manually enable them. To do this, head to next.config.js
and add the following:
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
Building the App
This section will walk you through building the app.
Structuring the project
Before we start building, we need to structure our project. We will:
- Create the path for our seller and customer page
- Create a
lib
folder for our database client and server actions - Create a
components
folder for our components - Setup Frosted UI
Creating the paths
Create a new path inside of the app
folder:
app/seller/[companyId]/[productId]/page.tsx
and
app/customer/[productId]/page.tsx
Creating the misc folders
In the root of your directory, create 2 new folders called lib
and components
.
Setting up Frosted UI
To use Frosted UI, we need to edit our layout.tsx
and tailwind.config.ts
files.
import type { Config } from "tailwindcss";
import preset from "@whop/frosted-ui/dist/preset";
const config = preset({
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
});
export default config;
Now, create a folder in the app directory called layout.client.tsx
. This is where we will define our client layout.
"use client"; // this line is important
import { FC, PropsWithChildren } from "react";
import { TooltipProvider, Toaster } from "@whop/frosted-ui";
export const ClientLayout: FC<PropsWithChildren> = ({ children }) => {
return (
<TooltipProvider>
{children}
<Toaster />
</TooltipProvider>
);
};
Finally, we need to edit the layout.tsx
file to use our new layout.
import { ClientLayout } from "./layout.client";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ClientLayout>{children}</ClientLayout>
</body>
</html>
);
}
Finally, head to globals.css
and remove all the content except the 3 @tailwind
imports, like so:
@tailwind base;
@tailwind components;
@tailwind utilities;
Creating the Database Client
We will be using Prisma as our ORM. Prisma is a great tool that allows us to easily interact with our database with TypeSafety. To get started, we are going to create a new file called prisma.ts
in our lib
folder.
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
You may get an error saying that PrismaClient
is not exported. This is
expected and will be resolved in the next step.
Defining our schema
To make use of our ORM, we are going to define our schema. Instead of doing this manually, we will get Prisma to generate it for us. To do this, run the following command in your terminal
This will set up Prisma and the files needed for it to work, now we need to create our table and schema. In your schema.prisma
file, add these two models to the bottom of the file:
model Codes {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
companyID String
lineItemID String
price Float
name String
description String
code String
purchases purchases[]
@@unique([name, companyID])
}
model purchases {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
lineItemID String
userID String
codeId Int
codes Codes @relation(fields: [codeId], references: [id])
@@index([codeId])
}
Now, we need to tell Neon that we want to create these tables. To do this, run the following command in your terminal:
To make use of our schema, we need to generate a client to enable us to interact with it. This will also resolve the import error we faced earlier. To do this, run the following command in your terminal:
Developing locally
To develop locally and see our changes on the Whop site, we need to use the whop-proxy
command. To do this, run the following command in your terminal:
To find out more about the whop-proxy
command, check out the
documentation.
Creating our Seller page
Now that we have set up our database, we can start building our seller page. This is where sellers will be able to add videos to their products.
Head to the page.tsx
we made in the seller/[companyId]/[productId]
folder.
In this file we will:
- Verify that the user is allowed to access the page
- Display a form to create a new code
import NewCodeForm from "@/components/CodeForm";
import { hasAccess, validateToken, authorizedUserOn } from "@whop-apps/sdk";
import { headers } from "next/headers";
export default async function SellerPage({
params,
}: {
params: { companyId: string; productId: string };
}) {
const { userId } = await validateToken({ headers });
const access = await hasAccess({
to: authorizedUserOn(params.companyId),
headers,
});
if (!access) {
return <p>You do not have access to this page.</p>;
}
return (
<div className="bg-gray-100 min-h-screen py-6 flex flex-col justify-center sm:py-12">
<div className="relative py-3 sm:max-w-xl sm:mx-auto">
<h1 className="text-4xl font-bold mb-4">Create a code</h1>
<div className="bg-white p-4 sm:p-6 rounded-3xl shadow-lg">
<NewCodeForm companyId={params.companyId} />
</div>
</div>
</div>
);
}
Creating the Seller Form
Since our form will be a client component, we are going to create it in a separate file. In the components
folder we made earlier, create a new file called SellerForm.tsx
.
This page will take in the following props:
companyId
- The ID of the company
"use client";
import { Input, NumberInput, Button } from "@whop/frosted-ui";
import { createCode } from "@/lib/actions";
export function NewCodeForm({ companyId }: { companyId: string }) {
return (
<div>
<form action={createCode}>
<div className="flex flex-col space-y-4">
<input type="hidden" name="companyId" value={companyId} />
<Input label="Code Name" name="name" />
<Input label="Description" name="description" />
<Input label="Code" name="code" placeholder="Enter the code here" />
<NumberInput
label="Price"
variant="price"
name="price"
placeholder="50"
/>
<Button className="w-full" type="submit">
Create
</Button>
</div>
</form>
</div>
);
}
Creating the Server Action
Now that we have created our form, we need to create the server action that will handle the form submission. Create a new file called actions.ts
in the lib
folder.
In this file, we are:
- Creating a server action that will:
- Create a [line item] for the code, this allows us to use in-app purchases
- Create a new entry in our database for the code
"use server";
import { db } from "@/app/prisma";
import { WhopAPI } from "@whop-apps/sdk";
export default async function createCode(formData: FormData) {
const companyId = formData.get("companyId") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const code = formData.get("code") as string;
let priceString = formData.get("price") as string;
const price = parseInt(priceString);
const lineItemResponse = await WhopAPI.app().POST("/app/line_items", {
body: {
allow_multiple_quantity: false,
amount: price,
base_currency: "usd",
company_id: companyId,
name,
description,
},
});
if (lineItemResponse.isErr) {
console.log(lineItemResponse);
throw new Error("Failed to create line item");
}
const lineItemId = lineItemResponse.data.id;
await db.codes.create({
data: {
companyID: companyId,
name,
description,
price,
lineItemID: lineItemId,
code,
},
});
}
We have now created our seller page! If you head to the seller page, you should be able to add a video URL and see it in your database.
Creating the Customer page
This page will be what the users see when they click on the app. It will display a list of all available codes with a button to purchase. If a code has been purchased, the option to purchase it will be disabled and the code will be displayed.
This will be done in the customer/[productId]/page.tsx
file.
In this file we will:
- Fetch all the codes from the database attached to the company
- Render a grid of cards with the code information
import { db } from "@/app/prisma";
import { validateToken } from "@whop-apps/sdk";
import { headers } from "next/headers";
import CodeCard from "@/components/CodeCard";
export default async function CustomerPage({
params,
}: {
params: { companyId: string };
}) {
const { userId } = await validateToken({ headers });
const codes = await db.codes.findMany({
where: {
companyID: params.companyId,
},
include: {
purchases: {
where: {
userID: userId,
},
},
},
});
if (codes.length === 0) {
return <p>This company does not have any codes yet.</p>;
}
return (
<div className="grid grid-cols-1 p-5 md:grid-cols-2 gap-4">
{codes.map((code) => {
// Check if there's a purchase for this riddle by the current user
const purchasedByUser = code.purchases.some(
(purchase) => purchase.userID === userId
);
return (
<CodeCard key={code.id} code={code} purchased={purchasedByUser} />
);
})}
</div>
);
}
Creating the Code Card
This component will be used to display the code information, such as price and description. There will also be a button that will allow the user to purchase the code, and open the in-app purchase iFrame.
"use client";
import { Button, Tag } from "@whop/frosted-ui";
import { WhopApp } from "@/lib/iframe";
import { useCallback, useTransition } from "react";
import { createPurchase } from "@/lib/actions";
import "@/lib/iframe";
interface CodeCardProps {
name: string;
description: string;
price: number;
lineItemID: string;
code: string;
id: number;
}
export default function CodeCard({
code,
purchased,
userID,
}: {
code: CodeCardProps;
purchased: boolean;
userID: string;
}) {
let [isPending, startTransition] = useTransition();
const handlePurchaseButton = useCallback(async () => {
try {
const result = await WhopApp.inAppPurchase({
line_item_id: code.lineItemID,
});
if (result.status === "ok") {
startTransition(() => {
createPurchase({
codeId: code.id,
lineItemID: code.lineItemID,
userID,
});
});
}
} catch (e) {
console.error(e);
}
}, [code.lineItemID, code.id, userID]);
return (
<div className="border rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow duration-300">
<h2 className="text-2xl font-semibold mb-2">{code.name}</h2>
<p className="text-gray-600 mb-4">{code.description}</p>
{purchased ? (
<div>
<p className="text-gray-600 mb-4">Code: {code.code}</p>
<div className="flex justify-between items-center">
<span className="text-xl font-bold line-through">
${code.price.toFixed(2)} <Tag text="Purchased" className="ml-3" />
</span>
<Button disabled={true}>Purchase</Button>
</div>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-xl font-bold">${code.price.toFixed(2)} </span>
<Button onClick={handlePurchaseButton}>Purchase</Button>
</div>
)}
</div>
);
}
Server action
You will notice that we imported another server action from our actions.ts
file. This will create a new entry in our database for the purchase.
Add this to the actions.ts
file:
export async function createPurchase({
lineItemID,
userID,
codeId,
}: {
lineItemID: string;
userID: string;
codeId: number;
}) {
await db.purchases.create({
data: {
lineItemID,
userID,
codeId: codeId,
},
});
}
Next steps
Now that we have created the app, we need to host it so anyone can use it. We recommend using Vercel, but you can use any hosting provider you want.
Make sure to keep your environment variables secret and don’t commit them to your repository. Your company must also have Whop payments enabled to support in-app purchases.
Was this page helpful?