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

  1. Head to the Neon Dashboard and create a new project
  2. Enter the name of your project, and select the region closest to you. We are using Postgres Version 15.
  3. 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.

  1. Initialize a new project
pnpm create next-app@latest
  1. Install the dependancies
pnpm add @whop-apps/sdk @whop/frosted-ui prisma
  1. 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:

.env.local
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.

tailwind.config.ts
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.

layout.client.tsx
"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.

layout.tsx
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:

globals.css
@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.

lib/prisma.ts
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:

pnpm prisma init

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:

model videos {
    id         Int       @id @default(autoincrement())
    video_id   String    @db.VarChar(255)
    video_url  String    @db.VarChar(255)
    company_id String    @db.VarChar(255)
    video_type String    @db.VarChar(255)
    product_id String    @unique @db.VarChar(255)
    created_at DateTime? @default(now()) @db.Timestamp(6)
}

Now, we need to tell Neon that we want to create these tables. To do this, run the following command in your terminal:

pnpm prisma db push

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:

pnpm prisma generate

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:

pnpm whop-proxy

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>
  );
}

Creating the Video Form

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
components/Form.tsx

"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
lib/action.ts
"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