DEV Community

Cover image for 🚀 2 Tricks to build an Ebay clone in 30 Minutes 🚀
Max Campbell for Byteflow

Posted on • Originally published at blog.byteflow.app

🚀 2 Tricks to build an Ebay clone in 30 Minutes 🚀

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 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:

npm install -g blitz
Enter fullscreen mode Exit fullscreen mode

Yarn:

yarn global add blitz
Enter fullscreen mode Exit fullscreen mode

Then create a new project by running the following:

blitz new bidding-site
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

npm run dev
Enter fullscreen mode Exit fullscreen mode

or for Yarn:

yarn dev
Enter fullscreen mode Exit fullscreen mode

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:

bids     Bid[]
Enter fullscreen mode Exit fullscreen mode

Then create these new models:

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
}
Enter fullscreen mode Exit fullscreen mode

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:

blitz g all Product
Enter fullscreen mode Exit fullscreen mode

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:

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(),
});
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

blitz g crud Bid
Enter fullscreen mode Exit fullscreen mode

This will scaffold out CRUD mutations and queries for bids.

Then update src/bids/schemas.ts to:

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(),
});
Enter fullscreen mode Exit fullscreen mode

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:

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
  }
)
Enter fullscreen mode Exit fullscreen mode

Then create a new file called bids/components/CreateBidForm.tsx and add the following:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
})
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

endDate   DateTime
Enter fullscreen mode Exit fullscreen mode

So it looks like this:

model Product {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  name      String
  bids      Bid[]
  endDate   DateTime
}
Enter fullscreen mode Exit fullscreen mode

Then run the following to migrate the database:

blitz prisma migrate reset && blitz prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

Then install react-date-picker with:

npm i react-date-picker
Enter fullscreen mode Exit fullscreen mode

Or, if you are using Yarn:

yarn add react-date-picker
Enter fullscreen mode Exit fullscreen mode

Then update products/components/ProductForm.tsx to:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then update the CreateProductSchema in products/schemas.ts to:

export const CreateProductSchema = z.object({
  name: z.string(),
  date: z.date(),
})
Enter fullscreen mode Exit fullscreen mode

Then update products/mutations/createProduct.ts to:

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
  }
)
Enter fullscreen mode Exit fullscreen mode

Here we have updated the resolver to add the date to our Product model in the DB.

Then install date-fns by running:

npm i date-fns
Enter fullscreen mode Exit fullscreen mode

Or, if you are using Yarn:

yarn add date-fns
Enter fullscreen mode Exit fullscreen mode

Now let's show the date the auction ends on the product page. Open pages/products/[productId].tsx and add below the product title:

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

So it looks like:

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
Enter fullscreen mode Exit fullscreen mode

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

Product Page Image

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:

npm i quirrel concurrently
Enter fullscreen mode Exit fullscreen mode

Or for Yarn:

yarn add quirrel concurrently
Enter fullscreen mode Exit fullscreen mode

Now update the package.json dev command to:

"dev": "concurrently --raw \"blitz dev\" 'quirrel'",
Enter fullscreen mode Exit fullscreen mode

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:

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
})
Enter fullscreen mode Exit fullscreen mode

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:

await auctionEnd.enqueue(product.id, {
      runAt: input.date, // Run when the auction ends
      id: nanoid(), // The ID representing the job for future reference
})
Enter fullscreen mode Exit fullscreen mode

So it looks like this:

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

export const Signup = z.object({
  email,
  password,
  phoneNumber: z.string()
})
Enter fullscreen mode Exit fullscreen mode

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

phoneNumber String
Enter fullscreen mode Exit fullscreen mode

Then run:

blitz prisma migrate reset && blitz prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

(Note: this will remove everything currently in the DB)

Then update the resolver in src/auth/mutations/signup.ts to:

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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:

npm i @byteflow-inc/sdk
Enter fullscreen mode Exit fullscreen mode

Or for Yarn:

yarn add @byteflow-inc/sdk
Enter fullscreen mode Exit fullscreen mode

(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:

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;
Enter fullscreen mode Exit fullscreen mode

You can get a Byteflow API key by creating a free account here

Now let's update src/pages/api/queues/auctionEnd.ts to:

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,
  })
})
Enter fullscreen mode Exit fullscreen mode

Now let's add a notification for when you get outbid. Update the resolver in src/bids/mutations/createBid.ts to:

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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) or by using something more cookie cutter like MUI. I hope you found this tutorial useful!

Top comments (0)