How to build a Product Hunt Clone - Part 2 of 2

Ashutosh K Singh

Introduction

This jam is the final part of a two-part series on creating a Product Hunt Clone in Next.js. In the first part, we configured the initial Next.js app with Auth0, Cloudinary, and Airtable. Then, we fetched and displayed the products on the homepage. We also discussed how to add and delete products in our app.

In this part, we will build the User Profile page where users can see their products and add the functionality to update the products. We will also discuss how to upvote a product.

If you want to jump right into the code, check out the GitHub Repo here.

You can refer to the first part here: How to build a Product Hunt Clone with Next.js - Part 1 of 2

CodeSandbox

Since the preview in the CodeSandbox is embedded inside another page, you will see the products but you will not be able to log in via the preview. Instead, to explore and play navigate to deployed version, https://d00re.sse.codesandbox.io/.

How to create User Profile page

In this section, you will create the My Profile page, showing the current user's information and their products. You have already created the link to the My Profile page in the Navbar component, i.e., /user/profile.

You can split the My Profile page into two parts. First, you need to show the user information, and second, you need to show the products created by that user. You can easily extract user information from the user object of the useUser() hook. For the second part, you will need to create a function in the lib/api.js file to get the products where the Sub of product is equal to the current user's sub.

Create and export a function named getProductsByUserSub() by adding the following code to lib/api.js file.

1export const getProductsByUserSub = async (sub) => {
2 const data = await table
3 .select({
4 filterByFormula: `Sub = "${sub}"`,
5 })
6 .firstPage();
7 return data.map((record) => {
8 return { id: record.id, ...record.fields };
9 });
10};

In the above code, you pass the sub of the user as an argument to the function and, with the help of the filterByFormula method, filter the records where the Sub of product is equal to the current user's sub.

The next step is to create the profile.js file under the pages/user directory. Run the following command in the terminal.

1mkdir pages/user
2touch pages/user/profile.js

Add the following code to the user/profile.js file.

1// pages/user/profile.js
2import Head from "next/head";
3import { useUser, withPageAuthRequired, getSession } from "@auth0/nextjs-auth0";
4import Product from "../../components/Product";
5import Navbar from "../../components/Navbar";
6import {getProductsByUserSub} from "../../lib/api"
7
8export default function Profile({products}) {
9 const { user, error, isLoading } = useUser();
10
11 if (isLoading) {
12 return (
13 <div className="mx-auto my-64 text-gray-800 text-center text-3xl">
14 Loading
15 </div>
16 );
17 }
18 if (error) return <div>{error.message}</div>;
19
20 return (
21 <div className="contianer px-2">
22 <Head>
23 <title> User: {user.name}</title>
24 <link rel="icon" href="/favicon.ico" />
25 </Head>
26 <Navbar />
27 <div className="flex space-x-4 px-4 my-10 justify-around">
28 <div className="h-36 w-96 rounded-3xl shadow-lg relative flex flex-col
29 items-center justify-between md:items-start p-5 transition-all duration-150">
30 <img
31 className="rounded-full w-20 h-20 shadow-sm absolute -top-8
32 transform md:scale-110 duration-700"
33 src={user.picture}
34 alt={user.name}
35 />
36
37 <div className=" align-middle text-2xl font-semibold text-gray-800
38 text-center m-auto md:m-0 md:mt-8">
39 {user.name}
40 </div>
41 <ul className="text-lg text-gray-600 font-light md:block">
42 <li>Products: {products ? products.length : 0}</li>
43 </ul>
44 </div>
45 </div>
46
47 {products.length>0 ? (
48 products.map((product) => (
49 <Product
50 key={product.id}
51 id={product.id}
52 name={product.Name}
53 link={product.Link}
54 description={product.Description}
55 publicId={product.PublicId}
56 check={true}
57 />
58 ))
59 ) : (
60 <div className="mx-auto my-12 text-gray-800 text-center text-3xl">
61 You are yet to create your first Product.
62 </div>
63 )}
64 </div>
65 );
66}
67
68export const getServerSideProps = withPageAuthRequired({
69 returnTo: "/api/auth/login",
70 async getServerSideProps(ctx) {
71 const sub = await getSession(ctx.req).user.sub;
72 const data = await getProductsByUserSub(sub);
73 return {
74 props: {
75 products: data,
76 },
77 };
78 },
79});

In the above code, you are using Next.js getServerSideProps function to fetch the products created by the current user with the help of the getProductsByUserSub() function.

You will notice that you have wrapped the getServerSideProps() function with withPageAuthRequired() method from the @auth0/nextjs-auth0 SDK. This withPageAuthRequired protects the My Profile page from anonymous logins and redirects them to the login page. You can read more about this method here.

The getProductsByUserSub() function requires the user's sub to fetch the products, accessed using the getSession() method. The getSession() method gets the user's session from the request. You can read more about this method here.

The records from Airtable are passed as props to the Profile component. In this component, you use the user object to show the current user's name and profile picture.

You also show the total number of products created by the user by calculating the length of the products array. You then map over the products array, and similar to the index.js file, show the products on the page using the Product component.

Since the products fetched from Airtable are created by the current user, you pass true in the check prop of the Product component.

There is also a default message if the current user has not created any products.

Here is how the My Profile page will look if the user has not created any product.

Here is the My Profile page after the user has created a product.

How to update a Product

In this section, you will create the update/[id] dynamic route to edit/update a product. In this jam, you will update only the textual data of the product and not its image.

To update the product, you will create a form with Name, Link, and Description fields. Then, you will fetch the selected product and prefill the form with its data so that the user can see and update the product accordingly.

You might remember that in the Product component, you were sending the id of the product in the query parameter.

1<button
2 className="mx-1 h-6 w-6"
3 onClick={() =>
4 router.push({
5 pathname: "/update/[id]",
6 query: {
7 id: id,
8 },
9 })
10 }
11>

You will use this id in the query to fetch the corresponding product from Airtable.

Create and export functions named getProductById() and updateProduct() in lib/api.js file.

1export const getProductById = async (id) => {
2 const data = await table.find(id);
3 return { id: data.id, ...data.fields };
4};
5
6export const updateProduct = async ({ id, name, link, description }) => {
7 const data = await table.update(
8 id,
9 {
10 Name: name,
11 Description: description,
12 Link: link,
13 },
14
15 function (err, records) {
16 if (err) {
17 console.error(err);
18 }
19 }
20 );
21};

In the getProductById function, you use Airtable's find() method to retrieve the product based on its id.

In the updateProduct function, you use Airtable's update method to update the product. You pass the product's id to be updated as the first argument, followed by an object containing the fields to be updated, i.e., Name, Description, and Link. The remaining fields like Sub and PublicId remain unchanged.

Run the following command to create the [id].js under the pages/update directory.

1mkdir pages/update
2touch pages/update/[id].js

Add the following code to [id].js file.

1import React, { useState } from "react";
2import Head from "next/head";
3import { useUser, withPageAuthRequired } from "@auth0/nextjs-auth0";
4import { getProductById } from "../../lib/api";
5import { useRouter } from "next/router";
6import Navbar from "../../components/Navbar";
7
8const UpdateProduct = ({ product }) => {
9 const router = useRouter();
10
11 const [name, setName] = useState(product.Name);
12 const [link, setLink] = useState(product.Link);
13 const [description, setDescription] = useState(product.Description);
14 const { user, error, isLoading } = useUser();
15
16 if (isLoading) return <div>Loading</div>;
17 if (error) return <div>{error.message}</div>;
18
19 const handleSubmit = async (e) => {
20 await e.preventDefault();
21 const data = await fetch("/api/updateProduct", {
22 method: "POST",
23 body: JSON.stringify({ id: router.query.id, name, description, link }),
24 headers: {
25 "Content-Type": "application/json",
26 }
27 });
28 await router.replace("/");
29 };
30
31 if (user) {
32 return (
33 <div>
34 <Head>
35 <title> Update Product : {product.Name} </title>
36 <link rel="icon" href="/favicon.ico" />
37 </Head>
38 <Navbar />
39 <div className="md:grid justify-items-center md:gap-6">
40 <div className="mt-0 md:mt-0 md:col-span-2">
41 <h3 className="text-4xl text-center font-normal leading-normal mt-0 text-indigo-800">
42 Update Product: {product.Name}
43 </h3>
44 <form>
45 <div className="shadow-md sm:rounded-md sm:overflow-hidden">
46 <div className="px-4 py-5 bg-white space-y-6 sm:p-6">
47 <div className="grid grid-cols-3 gap-6">
48 <div className="col-span-3 sm:col-span-2">
49 <label
50 htmlFor="product_name"
51 className="block text-sm font-medium text-gray-700"
52 >
53 Name of the Product
54 </label>
55 <div className="mt-1 flex rounded-md shadow-sm">
56 <input
57 type="text"
58 name="product_name"
59 id="product_name"
60 value={name}
61 onChange={(e) => setName(e.target.value)}
62 autoComplete="given-name"
63 className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
64 />
65 </div>
66 </div>
67 </div>
68
69 <div>
70 <label
71 htmlFor="product_description"
72 className="block text-sm font-medium text-gray-700"
73 >
74 Description
75 </label>
76 <div className="mt-1">
77 <textarea
78 id="product_description"
79 name="product_description"
80 rows={3}
81 value={description}
82 onChange={(e) => setDescription(e.target.value)}
83 className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
84 placeholder="Brief description for your Product."
85 />
86 </div>
87 </div>
88 <div className="grid grid-cols-3 gap-6">
89 <div className="col-span-3 sm:col-span-2">
90 <label
91 htmlFor="product_link"
92 className="block text-sm font-medium text-gray-700"
93 >
94 Link
95 </label>
96 <div className="mt-1 flex rounded-md shadow-sm">
97 <input
98 type="text"
99 name="product_link"
100 id="product_link"
101 value={link}
102 onChange={(e) => setLink(e.target.value)}
103 className="focus:ring-indigo-500 focus:border-indigo-500 flex-1 block w-full rounded-none rounded-md sm:text-sm border-gray-300"
104 placeholder="www.example.com"
105 />
106 </div>
107 </div>
108 </div>
109 </div>
110 <div className="px-4 py-2 bg-gray-50 text-right sm:px-6">
111 <button
112 type="submit"
113 onClick={handleSubmit}
114 className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
115 >
116 Update
117 </button>
118 </div>
119 </div>
120 </form>
121 </div>
122 </div>
123 </div>
124 );
125 }
126};
127
128export default UpdateProduct;
129
130export const getServerSideProps = withPageAuthRequired({
131 returnTo: "/api/auth/login",
132 async getServerSideProps(ctx) {
133 const data = await getProductById(ctx.params.id);
134
135 return {
136 props: {
137 product: data,
138 },
139 };
140 },
141});

You use the same code for the form as in the product/insert.js file.

Similar to /user/profile.js file, you use the getServerSideProps() function wrapped with the withPageAuthRequired method to redirect any anonymous user to the login page.

You access the product's id using the params object and pass it to the getProductById() function. Finally, the product fetched is sent to the UpdateProduct function as a prop.

You define three states for name, description, and link of the product and set their default value to their corresponding value in the product object.

Then, in the UpdateProduct function's return statement, you create a two-way data bind between the user input and the states.

Once the user has updated the product and clicks the Update button, the handleSubmit() function is triggered, sending a POST request to /api/updateProduct API route with the id, name, description, and link of the product in the request body.

Run the following commands to create the /api/updateProduct API route.

1touch pages/api/updateProduct.js

Add the following code to updateProduct.js file.

1import { withApiAuthRequired } from "@auth0/nextjs-auth0";
2import {updateProduct} from "../../lib/api"
3
4export default withApiAuthRequired(async function handler(req, res) {
5 if (req.method !== "POST") {
6 return res.status(405);
7 }
8 const { name, description, link, id } = req.body;
9
10 await updateProduct({ id, name, description, link });
11 return res.status(200).json({ msg: "Product Updated" });
12
13});

You protect this API route with the withApiAuthRequired() method from @auth0/nextjs-auth0 SDK. Then, you import the updateProduct function from the lib/api.js file and pass the arguments sent in the request body to it. Finally, after the product has been updated successfully, Product Updated is sent as a response.

Here is a GIF showing the Update Product feature in action.

How to upvote a Product

In this section, you will create the upvote feature in Products. Like the actual Product Hunt, a user should be able to upvote a product only once and should be able to remove their upvote from the product. For tutorial purposes, we will call the process of removing the upvote - downvote.

Note: You are not actually downvoting a product; you are removing your upvote from it.

For every upvote, you need to keep track of the id of the product and the unique identifier of a user, i.e., the user's sub. You can store the id and sub together to represent an upvote.

By checking if the user's sub is already associated with a product, i.e., a pair of the current user's sub and the product id, you can make sure each user votes for a product only once. Then, when the user removes their upvote, you can delete this product id and sub pair.

If you were using a NoSQL database like MongoDB, you could have created a new field in the schema and store the sub of the current user in an array when the upvote button is clicked. This array would contain the sub of all the users who upvoted a particular product. And to downvote a product, you could remove that user's sub from the array.

The resultant product object would look similar to this.

1{
2 "id": "rec176WGNlRJGN0mg",
3 "Name": "Dev Desks",
4 "Description": "The place where Devs share Desks! ",
5 "PublicId": "product-hunt-clone/wji2dipmt3cssvvwbr06",
6 "Link": "https://devdesks.io/",
7 "Sub": "google-oauth2|10535765999545372338041",
8 "Votes": [
9 "google-oauth2|10535765999545372338041",
10 "google-oauth2|1053576593456372338041",
11 "google-oauth2|105357659995452323041"
12 ]
13}

But, you cannot create an array object in Airtable, so its not possible to store the votes in the same manner as in a NoSQL database.

Here are a few workarounds for this limitation:

  • You can create a Long text field in the schema and store the sub as strings separated by commas and then use the split(',') and join() methods to convert them into an array. Here you will have to make sure that when the user upvotes a product, you add to the existing string to retain the previous votes.
  • You can create another table in the same base named votes, and for each vote, you can store both sub and the id of the product in it. You can then filter out the votes of a product using its id.

You will use the second method in this jam and create a new table named votes in the Product Hunt Clone base.

Head over to your Airtable account and open the Product Hunt Clone base. Create a new table named votes with the following schema.

  • ProductId - Id of the product upvoted.
  • Sub - Sub of the user who upvoted the product.

You can refer to the votes table of this project here.

Each record in the votes table represents a vote. So, for example, if there is one product and three users in your Product Hunt Clone app. And each user upvotes the product; there will be three records in the votes table and one record in the products table.

The next step is to create the getVotesByProductId , upvoteProduct and downvoteProduct functions in lib/api.js. Add the following code to lib/api.js file.

1export const getVotesByProductId = async (productId) => {
2 const data = await base("votes")
3 .select({
4 view: "Grid view",
5 filterByFormula: `ProductId = "${productId}"`,
6 })
7 .firstPage();
8
9 return data.map((vote) => {
10 return { id: vote.id, ...vote.fields };
11 });
12};
13export const upvoteProduct = async ({ productId, sub }) => {
14 const data = await base("votes").create({
15 ProductId: productId,
16 Sub: sub,
17 });
18};
19
20export const downvoteProduct = async ({ id }) => {
21 const data = await base("votes").destroy(id);
22};

The getVotesByProductId() functions filter out the records based on the product id. This means that the response returned from this function is the votes of a single product.

The upvoteProduct function creates a new record in the votes table with the current user's sub and the product's id upvoted. The productId passed to the upvoteProduct function is the id of the product.

The downvoteProduct function deletes the vote or record from the votes table. The id passed to the downvoteProduct function is the id of the vote. You might ask how to know which vote or record to delete from the votes table. Or which vote id should be passed in the downvoteProduct function.

Since each user can only vote once, if the user has upvoted a product, then the response returned from the getVotesByProductId function will have an object containing the sub of that user. You can filter out that object from all the objects in the array. That object will contain the id of the vote to be deleted.

The next step is to create the API routes for these functions. Create the getVotesByProductId/[id] , upvote and downvote API routes in the api directory.

1touch pages/api/upvote.js
2touch pages/api/downvote.js
3mkdir pages/api/getVotesByProductId
4touch pages/api/getVotesByProductId/[id].js

Add the following code to getVotesByProductId/[id].js file.

1import { getVotesByProductId } from "../../../lib/api";
2
3export default async function handler(req, res) {
4 if (req.method !== "GET") {
5 return res.status(405);
6 }
7
8 const { id } = req.query;
9
10 try {
11 const votes = await getVotesByProductId(id );
12 return res.status(200).send(votes);
13 } catch (err) {
14 console.error(err);
15 res.status(500).json({ msg: "Something went wrong." });
16 }
17}

You use the id of the product extracted from the request query and pass it to the getVotesByProductId function. The data returned from the getVotesByProductId function is sent as the response.

Add the following code to upvote.js file.

1import { upvoteProduct } from "../../lib/api";
2import { withApiAuthRequired, getSession } from "@auth0/nextjs-auth0";
3
4export default withApiAuthRequired(async function handler(req, res) {
5 const session = getSession(req, res);
6 const sub = await session.user.sub;
7
8 if (req.method !== "PUT") {
9 return res.status(405);
10 }
11 const { id } = req.body;
12
13 try {
14 const upvotedProduct = await upvoteProduct({ productId:id, sub });
15 return res.status(200).json({ msg: "Upvoted" });
16 } catch (err) {
17 console.error(err);
18 res.status(500).json({ msg: "Something went wrong." });
19 }
20});

In the above code, the product's id is extracted from the request body while the sub is accessed using the getSession() method from @auth0/nextjs-auth0 SDK. Finally, both the id and sub are passed to the upvoteProduct function.

Add the following code to downvote.js file.

1import { downvoteProduct } from "../../lib/api";
2import { withApiAuthRequired } from "@auth0/nextjs-auth0";
3
4export default withApiAuthRequired(async function handler(req, res) {
5
6 if (req.method !== "PUT") {
7 return res.status(405);
8 }
9 const { id } = req.body;
10
11 try {
12 const downvotedProduct = await downvoteProduct({ id });
13 return res.status(200).json({ msg: "Downvoted" });
14 } catch (err) {
15 console.error(err);
16 res.status(500).json({ msg: "Something went wrong." });
17 }
18});

Every vote in the votes table is a record. You delete a vote is deleted in the same way a product was deleted in the first part of the series, i.e., you use the destroy method and pass the id of the vote to it.

You will fetch the votes using the getVotesByProductId/[id] API route in the Product component. But, you cannot use the getServerSideProps() method in the Product component.

You will need to install the swr package to fetch the votes. You can read more about swr package here.

Run the following command in the terminal.

1npm install swr

Update the components/Product.js file like this.

1import React from "react";
2import { Image, Transformation } from "cloudinary-react";
3import { useUser } from "@auth0/nextjs-auth0";
4import { useRouter } from "next/router";
5import useSWR from "swr";
6import Vote from "./Vote";
7
8export default function Product({
9 name,
10 id,
11 publicId,
12 description,
13 link,
14 check,
15}) {
16 const router = useRouter();
17 const { user, error, isLoading } = useUser();
18 const { data, mutate } = useSWR(`/api/getVotesByProductId/${id}`);
19
20 const deleteThisProduct = async (e) => {
21 await fetch("/api/deleteProduct", {
22 method: "DELETE",
23 body: JSON.stringify({ id, publicId }),
24 headers: {
25 "Content-Type": "application/json",
26 },
27 });
28 await router.reload();
29 };
30
31 return (
32 <div className="max-w-md mx-auto my-4 bg-white rounded-xl shadow-xl overflow-hidden md:max-w-2xl">
33 <div className="md:flex">
34 <div className="md:flex-shrink-0">
35 <Image
36 className="h-48 w-full object-cover md:w-48"
37 publicId={publicId}
38 cloudName={process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}
39 alt={name}
40 format="webp"
41 secure
42 >
43 <Transformation width="800" gravity="auto" crop="fill" />
44 </Image>
45 </div>
46 <div className="p-8 w-full">
47 <div className="flex justify-between items-start">
48 <a
49 href={link}
50 className="block mt-1 text-xl font-semibold leading-tight font-medium text-indigo-700 hover:underline"
51 >
52 {name}
53 </a>
54 <Vote votes={data} refreshVotes={mutate} productId={id} />
55 </div>
56 <p className="mt-2 text-gray-600 w-10/12">{description} </p>
57
58 {check && (
59 <div className=" flex justify-end">
60 <button
61 className="mx-1 h-6 w-6"
62 onClick={() =>
63 router.push({
64 pathname: "/update/[id]",
65 query: {
66 id: id,
67 },
68 })
69 }
70 >
71 <svg
72 xmlns="http://www.w3.org/2000/svg"
73 className="h-6 w-6"
74 fill="none"
75 viewBox="0 0 24 24"
76 stroke="gray"
77 >
78 <path
79 strokeLinecap="round"
80 strokeLinejoin="round"
81 strokeWidth={2}
82 d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
83 />
84 </svg>
85 </button>
86 <button onClick={deleteThisProduct} className="px-1">
87 <svg
88 xmlns="http://www.w3.org/2000/svg"
89 className="h-6 w-6"
90 fill="none"
91 viewBox="0 0 24 24"
92 stroke="gray"
93 >
94 <path
95 strokeLinecap="round"
96 strokeLinejoin="round"
97 strokeWidth={2}
98 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
99 />
100 </svg>
101 </button>
102 </div>
103 )}
104 </div>
105 </div>
106 </div>
107 );
108}

You use the useSWR hook to fetch the data from the getVotesByProductId/[id] API route.

1const { data, mutate } = useSWR(`/api/getVotesByProductId/${id}`);

The data fetched is stored in the data variable. This data array is passed to the Votes component along with id of the product and mutate function.

1<Vote votes={data} refreshVotes={mutate} productId={id} />

The mutate in the above code is a function that is pre-bound to the SWR's key or /api/getVotesByProductId/${id}. This function updates the data array without any need to refresh the page. This means that if you upvote a product and call this function, the data array will be updated to include the latest addition without any reload of the whole page. You can read more about mutation here.

The next step is to create the Vote component in the components directory. Run the following command to create the Vote component.

1touch components/Vote.js

Add the following code to the Vote.js file.

1import React from "react";
2import { useUser } from "@auth0/nextjs-auth0";
3import { useRouter } from "next/router";
4
5export default function Vote({ votes, refreshVotes, productId }) {
6 const router = useRouter();
7 const { user, error, isLoading } = useUser();
8
9 const voteOfThisUser = user && votes ? votes.filter(function(vote){
10 return vote.Sub == user.sub;
11 }) : null;
12
13 const upvoteThisProduct = async (e) => {
14 await e.preventDefault();
15 if (!user) {
16 router.push("/api/auth/login");
17 } else {
18 await fetch("/api/upvote", {
19 method: "PUT",
20 body: JSON.stringify({ id: productId }),
21 headers: {
22 "Content-Type": "application/json",
23 },
24 });
25 refreshVotes();
26 }
27 };
28
29 const downvoteThisProduct = async (e) => {
30 await e.preventDefault();
31 await fetch("/api/downvote", {
32 method: "PUT",
33 body: JSON.stringify({ id: voteOfThisUser[0].id }),
34 headers: {
35 "Content-Type": "application/json",
36 },
37 });
38 refreshVotes();
39
40 };
41 if (!votes || isLoading || error) return <div>...</div>
42
43 return (
44 <div className="flex">
45 {user && votes.some((elem) => elem.Sub === user.sub) ? (
46 <button
47 className="flex border shadow-sm border-purple-800 rounded-md p-1"
48 onClick={downvoteThisProduct}
49 >
50 <svg
51 xmlns="http://www.w3.org/2000/svg"
52 className="h-6 w-6"
53 viewBox="0 0 20 20"
54 fill="purple"
55 >
56 <path
57 fillRule="evenodd"
58 d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z"
59 clipRule="evenodd"
60 />
61 </svg>
62 <p className="text-purple-700">
63 { votes.length}
64 </p>
65 </button>
66 ) : (
67 <button
68 className="flex border border-gray p-1 shadow-sm rounded-md"
69 onClick={upvoteThisProduct}
70 >
71 <svg
72 xmlns="http://www.w3.org/2000/svg"
73 className="h-6 w-6"
74 fill="none"
75 viewBox="0 0 24 24"
76 stroke="gray"
77 >
78 <path
79 strokeLinecap="round"
80 strokeLinejoin="round"
81 strokeWidth={2}
82 d="M9 11l3-3m0 0l3 3m-3-3v8m0-13a9 9 0 110 18 9 9 0 010-18z"
83 />
84 </svg>
85 <p className="text-gray-600">
86 { votes.length}
87 </p>
88 </button>
89 )}
90 </div>
91 );
92}

In the above function, you show the number of votes using the length of the votes array. By default, a user is shown the upvote button, which triggers the upvoteThisProduct function. This function checks if the user is logged in or not. If an anonymous user tries to upvote a product, they are redirected to the login page.

Otherwise, if the user is logged in, then a POST request is sent to the /api/upvote API route with the product's id in the request body. After which, the refreshVotes or mutate function is triggered to update the count of the votes.

If the user has already upvoted the product, they are shown the downvote button, the colored version of the upvote button. This button triggers the downvoteThisProduct function, sending a POST request to the api/downvote API route with the id of the vote in the request body. After the POST request, the refreshVotes function is triggered.

Here is how the record to be deleted is filtered from the votes array.

1const voteOfThisUser =
2 user && votes
3 ? votes.filter(function (vote) {
4 return vote.Sub == user.sub;
5 })
6 : null;

Here is a GIF showing the upvote and downvote in action.

How to delete votes along with product

When a user deletes the product, the votes associated with that product should also get deleted. In this section, you will update the deleteThisProduct function in the Product component and the deleteProduct API route.

Since there can be any number of votes associated with a product, you will first need to create an array of the id of all such votes.

Modify deleteThisProduct function in Product component like this.

1const deleteThisProduct = async (e) => {
2 const votesId =
3 data &&
4 data.map((vote) => {
5 return vote.id;
6 });
7
8 await fetch("/api/deleteProduct", {
9 method: "DELETE",
10 body: JSON.stringify({ id, publicId, votesId }),
11 headers: {
12 "Content-Type": "application/json",
13 },
14 });
15 await router.reload();
16 };

In the above code, you map over the votes array to create a new array containing only the id of votes. Then, you pass this voteId array in the request body of the /api/deleteProduct DELETE request.

Update api/deleteProduct.js file this.

1import { withApiAuthRequired } from "@auth0/nextjs-auth0";
2const cloudinary = require("cloudinary").v2;
3var Airtable = require("airtable");
4
5cloudinary.config({
6 cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
7 api_key: process.env.CLOUDINARY_API_KEY,
8 api_secret: process.env.CLOUDINARY_API_SECRET,
9});
10
11const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
12 process.env.AIRTABLE_BASE_ID
13);
14
15export default withApiAuthRequired(async function handler(req, res) {
16 if (req.method !== "DELETE") {
17 return res.status(405);
18 }
19 const { id, publicId, votesId } = req.body;
20
21 try {
22 const deletedProduct = await base("products").destroy(id);
23 const deletedVotes = await base("votes").destroy(votesId)
24
25 const deleteImageFromCloudinary = cloudinary.uploader.destroy(
26 publicId,
27 function (error, result) {
28 if (error) {
29 console.log(error);
30 }
31 }
32 );
33 return res.status(200).json({ msg: "Product Deleted" });
34 } catch (err) {
35 console.error(err);
36 res.status(500).json({ msg: "Something went wrong." });
37 }
38});

You delete the votes using the destroy method and pass the votesId array to it. So now, when the user deletes a product, all the votes are deleted along with it.

Conclusion

In this media jam, we created a Profile page where the user can see the products created by them. Then, we built the dynamic route to update the product. Finally, we discussed how to upvote a product and how to delete votes along with the product.

You can follow this jam and create your own unique version of this project. There are many features and functionality that you can add to this project.

Here are a few ideas to get you started:

  • In this project, the user cannot update the product's image. You can add the feature to update the product's image. You can either upload a new image to Cloudinary and update Airtable with the public_id of this new image. Or you can replace the existing image while retaining the original public_id.
  • You can follow the same steps to create a YouTube or Instagram Clone.
  • Style the app using UI libraries like Chakra UI, Material UI, etc.

Here are a few resources that you might find helpful.

Happy coding!

Ashutosh K Singh

JavaScript Developer

I'm a JavaScript Developer & Technical Writer. I develop awesome stuff with JavaScript and love to write about them.