🚀 2 Tricks to build an Ebay clone in 30 Minutes 🚀
Tags: webdev, javascript, react, tutorial
Published: 2023-08-21 18:12:18

In this tutorial, you'll learn how to:

- Build a Bidding system with SMS notifications.
- Handle time dependent jobs in a safe manner
- **[TRICK 1]:** Building a web application super quickly with Blitz.js
- **[TRICK 2]:** Bid status notifications using Byteflow

So let's get started!


##Byteflow:Integrate SMS Messaging in minutes 💬
Just a quick background about us. [Byteflow](https://byteflow.app) is a Twilio alternative that **does not require a 15+ business day verification period** to start sending SMS messages.

## The tools

Before we get into this tutorial, let's look at the tools we will be using:

- Blitz.js: Blitz.js is a react meta-framework. It builds on top of Next.js, providing a "Rails-like" experience where the backend and frontend are tightly integrated.

- Prisma: Prisma is an ORM that offers tight integration with Typescript. The Prisma schema is defined in a separate Prisma file and is converted into TS types auto-magically, making it super easy to use.

- Quirrel: Quirrel is a simple open-source system for job scheduling that we will use to schedule jobs to run when the auction is complete.

## Setting up the project

First, we need to install Blitz.js.

npm:
```bash
npm install -g blitz
```

Yarn:
```bash
yarn global add blitz
```

Then create a new project by running the following:
```bash
blitz new bidding-site
```
Then select the following options:
```
Pick a new project's language › Typescript
Pick your new app template › Full
Install dependencies? › (Pick what you prefer for me, that is Yarn)
Pick a form library › React Hook Form
```
Once you do this, you will get a simple application with a full DB, basic authentication, and everything we need.

Before we continue let's start a new dev server by running:
```bash
npm run dev
```
or for Yarn:
```bash
yarn dev
```
This will start the dev server at `http://localhost:3000`

## Scaffolding the DB
We will be using Prisma as our ORM to interface with our database. Start by opening the `db/schema.prisma` file and add the following value to the `User` model:
```prisma
bids     Bid[]
```

Then create these new models:
```prisma
model Product {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  name      String
  bids      Bid[]
}

model Bid {
  id          Int     @id @default(autoincrement())
  bidValueUSD Int
  Product     Product @relation(fields: [productId], references: [id], onDelete: Cascade)
  productId   Int
  User        User    @relation(fields: [userId], references: [id])
  userId      Int
}
```
Here we have:
- A Bid Model that represents an individual bid on a product
- A Product Model representing a product that users can place bids on.

## Products

Then run:
```bash
blitz g all Product
```
This will scaffold mutations and queries for products and generate a page for creating, deleting, viewing, and updating products.

Blitz.js does a pretty good job of scaffolding, but it's not perfect, so we will need to make a few changes.

First update `src/products/schema.ts` to:
```typescript
import { z } from "zod";

export const CreateProductSchema = z.object({
  name: z.string()
});
export const UpdateProductSchema = z.object({
  id: z.number(),
});

export const DeleteProductSchema = z.object({
  id: z.number(),
});
```
Here we have updated it so that when creating a product, it takes in the name of the product.

Then update `src/products/components/ProductForm.tsx` to:
```tsx
import React from "react";
import { Form, FormProps } from "src/core/components/Form";
import { LabeledTextField } from "src/core/components/LabeledTextField";
import { z } from "zod";
export { FORM_ERROR } from "src/core/components/Form";
export function ProductForm<S extends z.ZodType<any, any>>(
  props: FormProps<S>
) {
  return (
    <Form<S> {...props}>
      <LabeledTextField name="name" label="Name" placeholder="Diamond watch" />
    </Form>
  );
}
```
Here we have added an input field for the name of the product.
After you create an account, you can then create a product by going to `http://localhost:3000/products/new`

## Bids

Now that you can create products let's create the bidding system.

Start by running:
```bash
blitz g crud Bid
```
This will scaffold out CRUD mutations and queries for bids.

Then update `src/bids/schemas.ts` to:
```typescript
import { z } from "zod";

export const CreateBidSchema = z.object({
  bidValueUSD: z.number(),
  productId: z.number()
});
export const UpdateBidSchema = z.object({
  id: z.number(),
});

export const DeleteBidSchema = z.object({
  id: z.number(),
});
```
When creating a new bid, we have updated the schema to include the `bidValueUSD` and the `productId`.


Then update `src/bids/mutations/createBid.ts` to:
```typescript
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateBidSchema } from "../schemas"

export default resolver.pipe(
  resolver.zod(CreateBidSchema),
  resolver.authorize(),
  async (input, ctx) => {
    const bid = await db.bid.create({
      data: {
        userId: ctx.session.userId,
        productId: input.productId,
        bidValueUSD: input.bidValueUSD,
      },
    })

    return bid
  }
)
```

Then create a new file called `bids/components/CreateBidForm.tsx` and add the following:
```tsx
import { AuthenticationError, PromiseReturnType } from "blitz"
import Link from "next/link"
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "src/core/components/Form"
import login from "src/auth/mutations/login"
import { Login } from "src/auth/schemas"
import { useMutation } from "@blitzjs/rpc"
import { Routes } from "@blitzjs/next"
import createBid from "../mutations/createBid"
import { z } from "zod"

type CreateBidFormProps = {
  onSuccess?: () => void
  productId: number
  currentMaxBid?: number
}

export const CreateBidForm = (props: CreateBidFormProps) => {
  const [createBidMutation] = useMutation(createBid)
  return (
    <div>
      <h2>Create Bid</h2>

      <Form
        submitText= "Create Bid"
        schema={z.object({
          value: z.string(),
        })}
        onSubmit={async (values) => {
          try {
            if (props.currentMaxBid != undefined) {
              if (parseInt(values.value) <= props.currentMaxBid) {
                return { [FORM_ERROR]: "Your bid must be greater than the current highest bid"}
              }
            }
            await createBidMutation({
              bidValueUSD: parseInt(values.value),
              productId: props.productId,
            })
            props.onSuccess?.()
          } catch (error: any) {
            return {
              [FORM_ERROR]:
                "Sorry, we had an unexpected error. Please try again. - " + error.toString(),
            }
          }
        }}
      >
        <LabeledTextField name="value" label="Bid Value" placeholder="100" />
      </Form>
    </div>
  )
}

export default CreateBidForm
```
This is a pretty simple form component that takes in the value for the bid and then triggers the `createBidMutation` when submitted.


Now let's update the resolver inside of `products/queries/getProduct.ts` to:
```typescript
export default resolver.pipe(resolver.zod(GetProduct), resolver.authorize(), async ({ id }) => {
  const product = await db.product.findFirst({ where: { id }, include: { bids: true } })

  if (!product) throw new NotFoundError()

  return product
})
```
Here we have just told to prisma to include bids when getting the product so we can show them on the product page.

Then updates `pages/products/[productId.tsx]` to:
```tsx
import { Suspense, useEffect, useState } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"

import Layout from "src/core/layouts/Layout"
import getProduct from "src/products/queries/getProduct"
import deleteProduct from "src/products/mutations/deleteProduct"
import createBid from "../../bids/mutations/createBid"
import { Bid } from ".prisma/client"
import Form from "../../core/components/Form"
import CreateBidForm from "../../bids/components/CreateBidForm"

export const Product = () => {
  const router = useRouter()
  const productId = useParam("productId", "number")
  const [deleteProductMutation] = useMutation(deleteProduct)
  const [product, { refetch }] = useQuery(getProduct, { id: productId })
  const [sortedBids, setSortedBids] = useState<Bid[]>([])

  useEffect(() => {
    setSortedBids(product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD))
  }, [product.bids])

  return (
    <>
      <Head>
        <title>Product - {product.name}</title>
      </Head>

      <div>
        <h1>{product.name}</h1>

        {product.bids.length == 0 ? <p>No bids</p> : <p>Top Bid: {sortedBids[0]?.bidValueUSD}</p>}

        <hr />
        <CreateBidForm
          currentMaxBid={product.bids[0]?.bidValueUSD}
          onSuccess={() => refetch()}
          productId={product.id}
        />
        <hr />

        <Link href={Routes.EditProductPage({ productId: product.id })}>Edit</Link>

        <button
          type= "button"
          onClick={async () => {
            if (window.confirm("This will be deleted")) {
              await deleteProductMutation({ id: product.id })
              await router.push(Routes.ProductsPage())
            }
          }}
          style={{ marginLeft: "0.5rem" }}
        >
          Delete
        </button>
      </div>
    </>
  )
}

const ShowProductPage = () => {
  return (
    <div>
      <p>
        <Link href={Routes.ProductsPage()}>Products</Link>
      </p>

      <Suspense fallback={<div>Loading...</div>}>
        <Product />
      </Suspense>
    </div>
  )
}

ShowProductPage.authenticate = true
ShowProductPage.getLayout = (page) => <Layout>{page}</Layout>

export default ShowProductPage
```
The changes here are pretty simple. We sort the bids returned from the `getProduct` query so the biggest bid is first. Then we show the largest bid. We also add the `CreateBidForm` so users can create new bids on the product.

You should now be able to create a product and place bids on it.

## Ending Auctions
Auctions should end at some point, so let's start by adding the following to the `Product` model in the Prisma schema:
```prisma
endDate   DateTime
```
So it looks like this:
```prisma
model Product {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  name      String
  bids      Bid[]
  endDate   DateTime
}
```
Then run the following to migrate the database:
```bash
blitz prisma migrate reset && blitz prisma migrate dev
```

Then install `react-date-picker` with:
```bash
npm i react-date-picker
```
Or, if you are using Yarn:
```bash
yarn add react-date-picker
```

Then update `products/components/ProductForm.tsx` to:
```tsx
import React, { useState } from "react"
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { z } from "zod"
import DatePicker from "react-date-picker"
import "react-date-picker/dist/DatePicker.css"
import "react-calendar/dist/Calendar.css"
import { Form, FORM_ERROR } from "src/core/components/Form"
interface FormResult {
  name: string
  date: Date
}

export function ProductForm<S extends z.ZodType<any, any>>({
  onSubmit,
}: {
  onSubmit: (data: FormResult) => void
}) {
  const [value, onChange] = useState<Date | null>(null)
  return (
    <Form
      schema={z.object({ name: z.string() })}
      submitText= "Submit"
      onSubmit={async (x) => {
        console.log("HELLO!")
        if (value == null)
          return {
            [FORM_ERROR]: "Please select an end date",
          }
        onSubmit({
          name: x.name,
          date: value,
        })
      }}
    >
      <LabeledTextField name="name" label="Name" placeholder="Diamond watch" />
      <label>Date:</label>
      <DatePicker
        minDate={new Date()} //Don't allow the user to select any dates before now
        onChange={(x) => onChange(x)}
        value={value}
      />
    </Form>
  )
}
```

Then update the `CreateProductSchema` in `products/schemas.ts` to:
```typescript
export const CreateProductSchema = z.object({
  name: z.string(),
  date: z.date(),
})
```

Then update `products/mutations/createProduct.ts` to:
```typescript
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateProductSchema } from "../schemas"

export default resolver.pipe(
  resolver.zod(CreateProductSchema),
  resolver.authorize(),
  async (input) => {
    const product = await db.product.create({
      data: {
        endDate: input.date,
        name: input.name,
      },
    })

    return product
  }
)
```
Here we have updated the resolver to add the date to our `Product` model in the DB.

Then install `date-fns` by running:
```bash
npm i date-fns
```
Or, if you are using Yarn:
```bash
yarn add date-fns
```

Now let's show the date the auction ends on the product page. Open `pages/products/[productId].tsx` and add below the product title:
```tsx
<p>Auction ending in: {formatDistanceToNow(product.endDate, { addSuffix: true })}</p>
```
So it looks like:
```tsx
import { Suspense, useEffect, useState } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"
import Layout from "src/core/layouts/Layout"
import getProduct from "src/products/queries/getProduct"
import deleteProduct from "src/products/mutations/deleteProduct"
import { Bid } from ".prisma/client"
import { formatDistanceToNow } from "date-fns"
import CreateBidForm from "../../bids/components/CreateBidForm"

export const Product = () => {
  const router = useRouter()
  const productId = useParam("productId", "number")
  const [deleteProductMutation] = useMutation(deleteProduct)
  const [product, { refetch }] = useQuery(getProduct, { id: productId })
  const [sortedBids, setSortedBids] = useState<Bid[]>([])

  useEffect(() => {
    console.log(product)
    setSortedBids(product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD))
  }, [product.bids])

  return (
    <>
      <Head>
        <title>Product - {product.name}</title>
      </Head>

      <div>
        <h1>{product.name}</h1>

        <p>Auction ending in: {formatDistanceToNow(product.endDate, { addSuffix: true })}</p>

        {product.bids.length == 0 ? <p>No bids</p> : <p>Top Bid: {sortedBids[0]?.bidValueUSD}</p>}

        <hr />
        <CreateBidForm
          currentMaxBid={product.bids[0]?.bidValueUSD}
          onSuccess={() => refetch()}
          productId={product.id}
        />
        <hr />

        <Link href={Routes.EditProductPage({ productId: product.id })}>Edit</Link>

        <button
          type= "button"
          onClick={async () => {
            if (window.confirm("This will be deleted")) {
              await deleteProductMutation({ id: product.id })
              await router.push(Routes.ProductsPage())
            }
          }}
          style={{ marginLeft: "0.5rem" }}
        >
          Delete
        </button>
      </div>
    </>
  )
}

const ShowProductPage = () => {
  return (
    <div>
      <p>
        <Link href={Routes.ProductsPage()}>Products</Link>
      </p>

      <Suspense fallback={<div>Loading...</div>}>
        <Product />
      </Suspense>
    </div>
  )
}

ShowProductPage.authenticate = true
ShowProductPage.getLayout = (page) => <Layout>{page}</Layout>

export default ShowProductPage
```

Now if you go to the product page, you will be able to see the auction end date.


![Product Page Image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ad1kjth80f918mffuug2.png)

Now that we have the date in the DB and a decent UI, we need to remove the product from the DB and award it to the highest bidder when the auction ends. 

To do that, we will use `Quirrel`, a simple open-source system for job scheduling. You can add it to your Blitz app by running:
```bash
npm i quirrel concurrently
```
Or for Yarn:
```bash
yarn add quirrel concurrently
```
Now update the `package.json` dev command to:
```json
"dev": "concurrently --raw \"blitz dev\" 'quirrel'",
```
This makes it so that when you run `yarn dev` (or `npm run dev`), it will run a local `quirrel` server along with blitz.js. At this point, you should stop your current dev server and restart it with `npm run dev` or `yarn dev`.

Then create the following file `src/pages/api/queues/auctionEnd.ts` and add the following:
```typescript
import { Queue } from "quirrel/next-pages"
import db from "../../../../db"

export default Queue("api/queues/auctionEnd", async (productId: number) => {
  const product = await db.product.delete({
    where: {
      id: productId,
    },
    include: {
      bids: true,
    },
  })
  // Sort bids so the highest value ones are first
  let sortedBids = product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD)
  // TODO: Message the winner of the bid
})
```
This is a simple stub job that we will extend later. Right now, it just deletes the product when the auction ends.

Now let's make it run when the auction ends. To do that, add the following to the `createProduct` mutation resolver:
```typescript
await auctionEnd.enqueue(product.id, {
      runAt: input.date, // Run when the auction ends
      id: nanoid(), // The ID representing the job for future reference
})
```
So it looks like this:
```typescript
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateProductSchema } from "../schemas"
import { nanoid } from "nanoid"
import auctionEnd from "../../pages/api/queues/auctionEnd"

export default resolver.pipe(
  resolver.zod(CreateProductSchema),
  resolver.authorize(),
  async (input) => {
    const product = await db.product.create({
      data: {
        endDate: input.date,
        name: input.name,
      },
    })
    await auctionEnd.enqueue(product.id, {
      runAt: input.date, // Run when the auction ends
      id: nanoid(), // The ID representing the job for future reference
    })

    return product
  }
)
```

## Notifications
First, let's make the user input their phone number at signup so we can message them todo that update `src/auth/components/SignupForm.tsx` to:
```tsx
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "src/core/components/Form"
import signup from "src/auth/mutations/signup"
import { Signup } from "src/auth/schemas"
import { useMutation } from "@blitzjs/rpc"

type SignupFormProps = {
  onSuccess?: () => void
}

export const SignupForm = (props: SignupFormProps) => {
  const [signupMutation] = useMutation(signup)
  return (
    <div>
      <h1>Create an Account</h1>

      <Form
        submitText= "Create Account"
        schema={Signup}
        initialValues={{ email:"  ", password:"  "}}
        onSubmit={async (values) => {
          try {
            await signupMutation(values)
            props.onSuccess?.()
          } catch (error: any) {
            if (error.code === "P2002" && error.meta?.target?.includes("email")) {
              // This error comes from Prisma
            } else {
              return { [FORM_ERROR]: error.toString() }
            }
          }
        }}
      >
        <LabeledTextField name="email" label="Email" placeholder="Email" />
        <LabeledTextField name="phoneNumber" label="Phone Number" placeholder="+11234567890" />
        <LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
      </Form>
    </div>
  )
}

export default SignupForm
```
Here we have added a new `LabeledTextField` to take in the phone number. Then update the `Signup` zod schema in `src/auth/schemas.ts` to:
```typescript
export const Signup = z.object({
  email,
  password,
  phoneNumber: z.string()
})
```

Then add the following to the User model in `db/schema.prisma`:
```prisma
phoneNumber String
```

Then run:
```
blitz prisma migrate reset && blitz prisma migrate dev
```
(Note: this will remove everything currently in the DB)

Then update the resolver in `src/auth/mutations/signup.ts` to:
```typescript
export default resolver.pipe(
  resolver.zod(Signup),
  async ({ email, password, phoneNumber }, ctx) => {
    const hashedPassword = await SecurePassword.hash(password.trim())
    const user = await db.user.create({
      data: {
        email: email.toLowerCase().trim(),
        hashedPassword,
        role: "USER",
        phoneNumber: phoneNumber,
      },
      select: { id: true, name: true, email: true, role: true },
    })

    await ctx.session.$create({ userId: user.id, role: user.role as Role })
    return user
  }
)
```
Here we have added the `phoneNumber` to the `db.user.create` call.

Now let's expand the `auctionEnd` job to message the bid winner. To do that, let's first install the Byteflow SDK:
```bash
npm i @byteflow-inc/sdk
```
Or for Yarn:
```bash
yarn add @byteflow-inc/sdk
```
(Side note: You can use Twilio here if you prefer but will have to wait 15+ business days (1+ month real-time) to start sending messages)

Now create the file `integrations/byteflow.ts` and add:
```typescript
import { ByteFlow } from "@byteflow-inc/sdk"

// NOTE: In a real deployment, make sure to store this API Key // in an environment variable instead
const sdk = new ByteFlow("<YOUR_API_KEY_HERE>")

export default sdk;
```
You can get a Byteflow API key by creating a free account [here](https://dashboard.byteflow.app)

Now let's update `src/pages/api/queues/auctionEnd.ts` to:
```typescript
import { Queue } from "quirrel/next-pages"
import db from "../../../../db"

export default Queue("api/queues/auctionEnd", async (productId: number) => {
  const product = await db.product.delete({
    where: {
      id: productId,
    },
    include: {
      bids: {
        select: {
          bidValueUSD: true,
          User: {
            select: {
              phoneNumber: true,
            },
          },
        },
      },
    },
  })
  // Sort bids so the highest value ones are first
  let sortedBids = product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD)
  // Message the winner of bid
  if (sortedBids[0]?.User.phoneNumber == undefined)
    throw new Error("Could not get phoneNumber for user!")
  await sdk.sendMessage({
    message_content: `You have won the auction for: ${product.name} 🎉`,
    destination_number: sortedBids[0]?.User.phoneNumber,
  })
})
```

Now let's add a notification for when you get outbid. Update the resolver in `src/bids/mutations/createBid.ts` to:
```typescript
export default resolver.pipe(
  resolver.zod(CreateBidSchema),
  resolver.authorize(),
  async (input, ctx) => {
    const bidsForProduct = await db.bid.findMany({
      where: {
        productId: input.productId,
      },
      select: {
        bidValueUSD: true,
        User: {
          select: {
            phoneNumber: true,
          },
        },
      },
    })

    let sortedBids = bidsForProduct.sort((a, b) => b.bidValueUSD - a.bidValueUSD)

    let currentHighestBidPhoneNumber = sortedBids[0]?.User.phoneNumber
    if (currentHighestBidPhoneNumber != undefined) {
      await sdk.sendMessage({
        message_content: `You have been outbid. The new highest bid is $${input.bidValueUSD}`,
        destination_number: currentHighestBidPhoneNumber,
      })
    }

    const bid = await db.bid.create({
      data: {
        userId: ctx.session.userId,
        productId: input.productId,
        bidValueUSD: input.bidValueUSD,
      },
    })

    return bid
  }
)
```
Here we have updated the resolver to get the current highest bid and, if it exists, send them a message to tell them they have been outbid.


## Conclusion
In this article, we built an Ebay clone that lets users create products, bid on products, and get notifications. There is plenty of stuff you can do to expand on this, such as adding styling maybe by making your own component library ([tutorial on that](https://dev.to/byteflowinc/the-ultimate-guide-to-building-a-ui-library-mbp)) or by using something more cookie cutter like MUI. I hope you found this tutorial useful!