This tutorial demonstrates how to build an app that allows a seller to insert a link to a YouTube / Imgur video, and then display the video in the users hub.
You could use it to:
- Display a welcome video for people getting started with your company
- Display a video guide to your product
This app uses:
Prerequisites
Before we can start building, we need to create an app on Whop. If you haven’t already, head to the Developer Settings page and create a new app.
This app will use the following view paths:
- Seller Path:
/seller-view/$companyId
- Customer Path:
/customer-view/$companyId
Project Setup
Before we start building, we are going to set up our database. This example uses Neon, but you can use any database you like.
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!
Next.js Setup
Now that we have created our database, we can move onto the app itself. This tutorial will use Next.js with the App Directory.
- Initialize a new project
- Install the dependancies
- Set up Environment Variables
To ensure that we don’t leave any sensitive information in our code, we are going to use environment variables to store our database connection string, and app information.
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.
Building the app
This section will walk you through building the app.
Structuring the project
Before we start building, we need to lay out 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 pages
Create a new path inside of the app
folder:
seller-view/[companyId]/page.tsx
and
customer/[productId]/page.tsx
Creating the miscellaneous folders
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";
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.
Creating our schema
So far, we have created the database in Neon. However, we haven’t told Prisma what our database looks like (because we haven’t created a table yet). We first need to initialize Prisma, and then create our schema.
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 this model to the bottom of the file:
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 and the types in our IDE, 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 the Seller page
Now that we have set up our database, we can start building our seller page. This is where the sellers will be able to select the product they would like to add a video to, and then insert the video link.
In this file we will:
- Verify that the user is allowed to access the page
- Fetch all the companies products
- Render a grid of products that will redirect to the video form when clicked
seller-view/[companyId]/page.tsx
import {
hasAccess,
authorizedUserOn,
validateToken,
WhopAPI,
} from "@whop-apps/sdk";
import { headers } from "next/headers";
import Link from "next/link";
interface Product {
name: string;
id: string;
description?: string;
}
export default async function SellerView({
params,
}: {
params: { companyId: string };
}) {
const { userId } = await validateToken({ headers });
const access = await hasAccess({
to: authorizedUserOn(params.companyId),
headers,
});
if (!access) {
return <p>no access</p>;
}
const companyProducts = await WhopAPI.app().GET("/app/products", {
params: {
query: {
company_id: params.companyId,
},
},
});
if (!companyProducts.data) {
return <p>No product founds</p>;
}
return (
<div className="container mx-auto px-4">
<h1 className="text-2xl font-bold my-6">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{companyProducts.data.data.map((product: Product) => (
<Link
key={product.id}
href={`/seller-view/${params.companyId}/${product.id}`}
>
<div className="transform transition duration-500 hover:scale-105 bg-white rounded-lg border border-gray-200 shadow-md hover:shadow-xl overflow-hidden">
<div className="p-5">
<h2 className="text-lg font-bold mb-2">{product.name}</h2>
<p className="text-gray-700">{product.description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
When the seller clicks on a product, they will be redirected to a form where they can insert the video link. They will also be able to select the type of video they are inserting (YouTube or Imgur).
In this file we will:
- Fetch the current video details for the product (if there are any)
- Render a form that allows the seller to insert a video link
seller-view/[companyId]/[productId]/page.tsx
import SellerForm from "@/components/Form";
import { db } from "@/lib/prisma";
export default async function ProductView({
params,
}: {
params: { productId: string; companyId: string };
}) {
const currentVideoURL = await db.videos.findUnique({
where: {
product_id: params.productId,
},
select: {
video_url: true,
video_type: true,
},
});
return (
<SellerForm
companyId={params.companyId}
productId={params.productId}
currentVideoURL={currentVideoURL?.video_url}
currentVideoType={currentVideoURL?.video_type}
/>
);
}
Now, we need to create the actual form component. Create a new file called Form.tsx
in the components
folder.
In this file we will:
- Create a form where the user can input the video link, which will then trigger a server action
- Display a success/error message when the server action is complete
"use client";
import {
Input,
Button,
toast,
Toaster,
RadioCardGroup,
} from "@whop/frosted-ui";
import { useFormState, useFormStatus } from "react-dom";
import { useEffect, useState } from "react";
import AddVideoLink from "@/lib/actions";
const initialState = {
message: null,
errorMessage: null,
};
export default function SellerForm({
companyId,
productId,
currentVideoURL,
currentVideoType,
}: {
companyId: string;
productId: string;
currentVideoURL?: string;
currentVideoType?: string;
}) {
const [state, formAction] = useFormState(AddVideoLink, initialState);
const [videoType, setVideoType] = useState(currentVideoType || "youtube");
const { pending } = useFormStatus();
useEffect(() => {
if (state?.message === "success") {
toast.success("Successfully added video!");
}
}, [state?.message]);
return (
<div className="flex justify-center items-center h-screen bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200">
<div className="bg-white w-[600px] p-8 rounded-lg shadow-lg">
<h1 className="text-lg text-center font-semibold mb-4">YouTube App</h1>
<form action={formAction}>
<input type="hidden" name="companyId" value={companyId} />
<input type="hidden" name="productId" value={productId} />
<div>
<RadioCardGroup
className="flex space-x-6"
colorScheme="brand"
defaultValue={videoType}
onValueChange={(value) => setVideoType(value)}
value={videoType}
name="videoType"
items={[
{
description: "A YouTube Video",
value: "youtube",
label: "YouTube",
},
{
description: "An Imgur Video",
value: "imgur",
label: "Imgur",
},
]}
/>
</div>
<div className="my-4">
<Input
label={{
children: "Video URL",
tooltip: {
description:
"The URL of the YouTube video you want your users to watch.",
},
}}
messageIcon
size="md"
className="w-full"
name="url"
defaultValue={currentVideoURL}
errorMessage={state?.errorMessage}
/>
</div>
<Button isLoading={pending} type="submit" className="w-full">
Submit
</Button>
</form>
</div>
<Toaster />
</div>
);
}
Creating the server action
Now that we have created our form, we need to create the server action that will be triggered when the form is submitted. Create a new file called action.ts
in the lib
folder.
**In this file we will: **
- Create an asynchronous function that receives the form data
- Parse the form data
- Extract the YouTube video ID from the URL (if it is a YouTube video)
- Upsert the video details in the database
"use server";
import { db } from "./prisma";
export default async function AddVideoLink(prevState: any, formData: FormData) {
const url = formData.get("url") as string;
const companyId = formData.get("companyId") as string;
const productId = formData.get("productId") as string;
const videoType = formData.get("videoType") as string;
let youtubeId = "";
if (videoType == "youtube") {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
youtubeId = params.get("v") || "";
}
await db.videos.upsert({
where: {
product_id: productId,
},
update: {
company_id: companyId,
product_id: productId,
video_id: youtubeId,
video_url: url,
video_type: videoType,
},
create: {
company_id: companyId,
product_id: productId,
video_id: youtubeId,
video_url: url,
video_type: videoType,
},
});
return { message: "success" };
}
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 displayed inside of the Whop hub and will allow the customer to watch the video.
We will have to check what type of video the seller has added, as that dictates how we display the video.
In this file we will:
- Fetch the video details from the database
- Display the video
customer/[productId]/page.tsx
import { db } from "@/lib/prisma";
export default async function CustomerView({
params,
}: {
params: { productId: string };
}) {
const video = await db.videos.findUnique({
where: {
product_id: params.productId,
},
select: {
video_type: true,
video_url: true,
video_id: true,
},
});
if (video?.video_type === "youtube") {
return (
<iframe
className="absolute top-0 left-0 w-full h-full"
src={`https://www.youtube.com/embed/${video.video_id}`}
/>
);
}
return (
<video controls className="absolute top-0 left-0 w-full h-full">
<source src={video?.video_url} type="video/mp4" />
</video>
);
}
Next steps
If you have made it this far, congratulations! You have successfully built an app.
If you want to view the source code for this app, you can find it on GitHub