Skip to content

Build a Udemy Clone with Strapi and Next.js (Part 3)

Strapi, Next.js11 min read

Introduction

In previous part we implemented user authentication, manage courses page & user progress.

In this final part of the series, we will learn how to implement rating system, cart feature & payment with Stripe.

Building cart feature

Go to the Content-Type builder and then click on "Create new collection type"

cart collection

Then add these fields user: Relation - Cart has one user (Userfrom user permission plugin) courses: Relation - Cart has many Courses

Then let's create a context to hold the cart data.

Now we need to configure the permissions for the Authenticated user in Users & permissions plugin => Roles.

cart permission

To be able to easily access & modify the cart we need to create hooks & utility function

frontend/hooks/useCart.js

import { useEffect, useState } from "react";
import axios from "axios";
export const useCart = () => {
const [userCart, setUserCart] = useState([]);
const [loading, setLoading] = useState(true);
const fetchUserCart = async () => {
try {
const token = localStorage.getItem("token");
const currentUser = JSON.parse(localStorage.getItem("user"));
const config = {
headers: { Authorization: `Bearer ${token}` },
};
const response = await axios.get(
`http://localhost:1337/api/carts?filters[user][id][$eq]=${currentUser.id}&populate=courses`,
config,
);
const cartData = response.data.data;
setUserCart(cartData[0]);
} catch (error) {
console.error("Error fetching user cart:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUserCart();
}, []);
const removeFromCart = async (courseId, cartId) => {
const token = localStorage.getItem("token");
const config = {
headers: { Authorization: `Bearer ${token}` },
};
try {
await axios.put(
`http://localhost:1337/api/carts/${cartId}`,
{
data: {
courses: {
disconnect: [courseId],
},
},
},
config,
);
fetchUserCart();
} catch (error) {
console.error("Error removing from cart:", error);
}
};
return { userCart, loading, removeFromCart, fetchUserCart };
};
export const addToCart = async (cart, course, toast) => {
const token = localStorage.getItem("token");
const currentUser = JSON.parse(localStorage.getItem("user"));
const config = {
headers: { Authorization: `Bearer ${token}` },
};
if (cart?.length === 0 || !cart) {
try {
await axios.post(
`http://localhost:1337/api/carts`,
{
data: {
user: {
connect: [currentUser.documentId],
},
courses: {
connect: [course.documentId],
},
},
},
config,
);
toast({
title: "Success",
description: "Course added to cart",
duration: 3000,
variant: "success",
});
} catch (error) {
console.error("Error adding to cart:", error);
}
} else {
try {
const response = await axios.put(
`http://localhost:1337/api/carts/${cart.documentId}`,
{
data: {
courses: {
connect: [course.documentId],
},
},
},
config,
);
toast({
title: "Success",
description: "Course added to cart",
duration: 3000,
variant: "success",
});
} catch (error) {
console.error("Error adding to cart:", error);
}
}
};

In the code above we defines a custom React hook, useCart, which manages a user's shopping cart by fetching, adding, and removing courses.

Additionally, the addToCart function is defined to handle adding courses to the cart, providing user feedback through a toast notification upon success.

We need to create a component for adding course to cart from the course overview page in frontend/app/course/[slug]/page.js

Let's name it Sidebar

frontend/components/Sidebar.jsx

"use client";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useCart, addToCart } from "@/hooks/useCart";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
const Sidebar = ({ course }) => {
const { toast } = useToast();
const { userCart, fetchUserCart } = useCart();
const handleAddToCart = async () => {
await addToCart(userCart, course, toast);
await fetchUserCart();
};
return (
<Card className="w-96 border p-6">
<img src={course.thumbnail} alt="course thumbnail" />
<CardHeader>
<CardTitle>
<span className="text-2xl font-bold">{`$${course.price}`}</span>
</CardTitle>
</CardHeader>
<CardContent>
{currentUser && userCart?.courses?.filter((item) => item.id === course.id).length >
0 ? (
<Link href="/cart">
<Button className="w-full bg-blue-500 text-white">
Go to cart
</Button>
</Link>
) :
currentUser && (
<Button
className="w-full bg-blue-500 text-white"
onClick={handleAddToCart}
>
Add to cart
</Button>
)}
</CardContent>
</Card>
);
};
export default Sidebar;

In this component we call the useCart hooks and then display add to cart button if course is not already in the cart, if it's already in the cart we display a link to the cart page.

After that modify frontend/app/course/[slug]/page.js to display <Sidebar course={course} />

Now Course overview page will look like this.

sidebar component

Lastly, we need to create cart page.

frontend/app/cart/page.jsx

"use client";
import Navbar from "@/components/Navbar";
import {
Card,
CardContent,
CardHeader,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check, Delete } from "lucide-react";
import Loader from "@/components/Loader";
import { useCart } from "@/hooks/useCart";
const CartPage = () => {
const { userCart, loading, removeFromCart } = useCart();
const handleRemoveFromCart = (courseId, cartId) => {
removeFromCart(courseId, cartId);
};
if (loading) return <Loader />;
return (
<div className="min-h-screen bg-gray-100 pt-4 px-24">
<Navbar />
<main className="container mx-auto px-4 py-8">
<div className="flex gap-12">
<div className="flex-1">
<h1 className="text-3xl font-semibold mb-6">Shopping Cart</h1>
{userCart?.courses?.length === 0 || !userCart?.courses ? (
<p className="text-center text-gray-500">Your cart is empty</p>
) : (
<div className="space-y-4">
{userCart.courses.map((item) => {
return (
<Card
key={item.id}
className="bg-white shadow-md rounded-md max-w-2xl"
>
<CardContent className="flex gap-4 pt-6">
<img className='w-24' src={item.thumbnail} alt="course thumbnail" />
<div>
<h2 className="text-lg font-semibold">{item.title}</h2>
<p className="font-bold text-xl">${item.price}</p>
</div>
{/* Add any additional item details here */}
</CardContent>
<CardFooter className="flex justify-end">
<Button
variant="outline"
color="red"
onClick={() =>
handleRemoveFromCart(
item.documentId,
userCart.documentId,
)
}
className="flex items-center space-x-2"
>
<Delete className="w-5 h-5" />
<span>Remove</span>
</Button>
</CardFooter>
</Card>
);
})}
</div>
)}
</div>
</div>
</main>
</div>
);
};
export default CartPage;

Now we have the full flow for cart feature.

cart feature

Implementing checkout & payment with Stripe

You will need to sign up for a stripe account to implement this feature.

If you already have an account, then configure this ENV variables in frontend

.env.local

NEXT_PUBLIC_STRIPE_PUBLIC_KEY=YOUR_PUBLIC_KEY
STRIPE_SECRET_KEY=YOUR_SECRET_KEY
NEXT_PUBLIC_URL=http://localhost:3000

Get publishable key from this page

https://dashboard.stripe.com/test/apikeys

Install Stripe SDK

npm install @stripe/stripe-js stripe

And then create API endpoint to pass cart data to Stripe.

frontend/app/api/checkout/route.js

import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
export async function POST(request) {
const { items, token,cartId } = await request.json();
const courseIds = items.map((item) => item.courseId);
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: items.map((item) => ({
price_data: {
currency: "usd",
product_data: {
name: item.name,
},
unit_amount: item.price * 100,
},
quantity: item.quantity,
})),
payment_intent_data: {
metadata: {
courseIds: JSON.stringify(courseIds),
token: token,
cartId: cartId
},
},
expand: ["line_items"],
mode: "payment",
success_url: `${process.env.NEXT_PUBLIC_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/cancel`,
});
return NextResponse.json({ id: session.id });
}

In the code above we are passing back the courseId's, user token & cartId to our webhook if the payment is succesful.

Next, we need to create this webhook to detect if payment is succesful.

frontend/app/api/stripe_webhooks/route.js

import { NextResponse } from "next/server";
import axios from "axios";
export async function POST(request) {
try {
const body = await request.json();
if (body.type === "payment_intent.succeeded") {
const metadata = body.data.object.metadata;
const courseIds = JSON.parse(metadata.courseIds);
const token = metadata.token;
const cartId = metadata.cartId;
const config = {
headers: { Authorization: `Bearer ${token}` },
};
try {
const res = await axios.put(
`http://127.0.0.1:1337/api/carts/${cartId}`,
{
data: {
courses: {
disconnect: [...courseIds],
},
},
},
config,
);
console.log(res.status, res.statusText, "res");
} catch (error) {
console.error("Error removing from cart:", error);
}
try {
const updateCoursePromises = courseIds.map((courseId) =>
axios.put(
`http://127.0.0.1:1337/api/courses/${courseId}`,
{
data: {
isPurchased: true,
},
},
config,
),
);
const responses = await Promise.all(updateCoursePromises);
} catch (error) {
console.error("Error updating courses:", error);
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error parsing request body:", error);
return NextResponse.json(
{ success: false, error: "Invalid request body" },
{ status: 400 },
);
}
}

In the code above, when the payment is succesful we are doing 2 things:

  1. Removing the course from the user cart
  2. Updating isPurchased field to true

Additionally, we will create a page for user to get redirected to if they have succesful payment and when they cancelled the payment.

frontend/app/success/page.jsx

import Navbar from '@/components/Navbar';
const SuccessPage = () => (
<div className="min-h-screen bg-gray-100 p-4 text-center px-24">
<Navbar/>
<h1 className="text-3xl font-semibold mb-6 mt-12">Payment Successful!</h1>
<p>Thank you for your purchase.</p>
</div>
);
export default SuccessPage;
success payment

For the cancellation create this page frontend/app/cancel/page.jsx

import Navbar from '@/components/Navbar';
const CancelPage = () => (
<div className="min-h-screen bg-gray-100 p-4 text-center px-24">
<Navbar />
<h1 className="text-3xl font-semibold mb-6 mt-12">Payment Cancelled</h1>
<p>Your payment was cancelled. Please try again.</p>
</div>
);
export default CancelPage;;
cancel payment

Now for the checkout button

frontend/components/CheckoutCart.jsx

"use client";
import React from "react";
import { loadStripe } from "@stripe/stripe-js";
import { useCart } from "@/hooks/useCart";
import { Button } from "@/components/ui/button";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
const CheckoutCart = () => {
const { userCart } = useCart();
const handleCheckout = async () => {
const response = await fetch("/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
items: userCart.courses.map((item) => ({
name: item.title,
quantity: 1,
price: item.price,
courseId: item.documentId,
})),
token: localStorage.getItem('token'),
cartId: userCart.documentId
}),
});
const { id } = await response.json();
const stripe = await stripePromise;
stripe.redirectToCheckout({ sessionId: id });
};
return (
<div className="min-h-screen bg-gray-100 p-4">
<h1 className="text-3xl font-semibold mb-6">Checkout</h1>
<Button
onClick={handleCheckout}
className="bg-blue-500 text-white px-4 py-2 rounded-md"
>
Checkout with Stripe
</Button>
</div>
);
};
export default CheckoutCart;

In the code above we are iterating through cart array which contains courses and pass it as line items to Stripe SDK.

And then in frontend/app/cart/page.jsx conditionally render the checkout button.

{userCart?.courses?.length > 0 && (
<CheckoutCart />
)}
checkout button

Lastly, let's modify the course frontend/app/my-learning/[courseSlug]/page.jsx to only allow user that already purchased to access the content.

import { useRouter } from "next/navigation";
const router = useRouter();
// ... existing code
// if (isLoading) return <Loader />;
if (!course.isPurchased) {
router.push(`/`);
}
Testing the payment

Now to test it properly we will need Stripe CLI

In mac you can use brew install stripe/stripe-cli/stripe, you can find the equivalent command for windows here https://docs.stripe.com/stripe-cli

In the frontend run the following command

stripe listen --forward-to localhost:3000/api/stripe_webhooks
checkout

After succesful payment you will get redirected to this page

success

Implementing course rating system

Create Rating collection

Go to the Content-Type builder, and then create new collection type and name it "Rating".

rating collection

Then configure the following fields.

  • Relation, Course has many ratings
  • Relation, User has many ratings
  • rating, number with type Integer

After that configure permissions for ratings

Go to Users & permission plugin => Roles => Authenticated

rating permission

Now let's create the UI, In frontend run

npx shadcn@latest add dialog

Then create this component frontend/components/CourseRatingDialog.jsx

import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Star } from "lucide-react";
import axios from "axios";
const CourseRatingDialog = ({
selectedCourse,
setSelectedCourse,
setCourseProgresses,
isAlreadyRated,
fetchCourseProgresses,
}) => {
const [rating, setRating] = useState(0);
const [tempRating, setTempRating] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const getRatingText = (rating) => {
switch (rating) {
case 1:
return "Very Bad";
case 2:
return "Bad";
case 3:
return "Good";
case 4:
return "Great";
case 5:
return "Excellent";
default:
return "Rate this course";
}
};
const handleStarClick = (star) => {
setRating(star);
setTempRating(star);
};
const handleOpen = () => {
setIsOpen(true);
setSelectedCourse(selectedCourse);
};
const handleClose = () => {
setIsOpen(false);
setSelectedCourse(null);
setRating(0);
setTempRating(0);
};
const handleRating = async () => {
if (!selectedCourse || !rating) return;
try {
const currentUser = localStorage.getItem("user");
const token = localStorage.getItem("token");
const currentUserId = JSON.parse(currentUser).documentId;
const config = {
headers: { Authorization: `Bearer ${token}` },
};
await axios.post(
"http://localhost:1337/api/ratings",
{
data: {
rating: rating,
course: {
connect: [selectedCourse.documentId],
},
user: {
connect: [currentUserId],
},
},
},
config,
);
setCourseProgresses((prevProgresses) =>
prevProgresses.map((progress) =>
progress.course.id === selectedCourse.id
? { ...progress, userRating: rating }
: progress,
),
);
fetchCourseProgresses();
setSelectedCourse(null);
setRating(0);
setTempRating(0);
handleClose();
} catch (error) {
console.error("Error saving rating:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<div onClick={handleOpen} className="cursor-pointer">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-4 w-4 cursor-pointer ${
star <= (isAlreadyRated?.rating || 0)
? "text-yellow-400 fill-yellow-400"
: "text-gray-300"
}`}
/>
))}
</div>
{isAlreadyRated ? (
<p className="text-sm mt-1">Your rating</p>
) : (
<p className="text-sm mt-1">Leave a rating</p>
)}
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rate this course</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center">
<h2 className="text-xl mb-4">{selectedCourse?.title}</h2>
<div className="flex flex-col items-center mb-4">
<div className="flex mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-8 w-8 cursor-pointer ${
star <= (tempRating || rating || 0)
? "text-yellow-400 fill-yellow-400"
: "text-gray-300"
}`}
onClick={() => handleStarClick(star)}
onMouseEnter={() => setTempRating(star)}
onMouseLeave={() => setTempRating(rating)}
/>
))}
</div>
<span className="text-sm">{getRatingText(tempRating)}</span>
</div>
<Button className="px-4 py-2" onClick={handleRating}>
Submit Rating
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default CourseRatingDialog;

The CourseRatingDialog component will allow users to rate a course through a star-based interface within a dialog.

And then modify frontend/app/my-learning/page.jsx

import CourseRatingDialog from "@/components/CourseRatingDialog";
const [selectedCourse, setSelectedCourse] = useState(null);
const currentUser = JSON.parse(localStorage.getItem("user"));
const fetchCourseProgresses = async () => {
try {
const token = localStorage.getItem("token");
const config = {
headers: { Authorization: `Bearer ${token}` },
};
const response = await axios.get(
`http://127.0.0.1:1337/api/course-progresses?filters[user][id][$eq]=${currentUser.id}&[populate][course][populate][ratings][populate]=user`,
config,
);
setCourseProgresses(response.data.data);
setIsLoading(false);
} catch (error) {
console.error("Error fetching course progresses:", error);
setIsLoading(false);
}
};
useEffect(() => {
fetchCourseProgresses();
}, []);
// ...rest of code
<div className="flex justify-between items-center mt-4">
<Link
href={`/my-learning/${progress.course.slug}`}
className="inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Continue Learning
</Link>
<CourseRatingDialog
isAlreadyRated={progress.course.ratings.find(
(rating) => rating.user.id === currentUser.id,
)}
selectedCourse={progress.course}
setSelectedCourse={setSelectedCourse}
setCourseProgresses={setCourseProgresses}
fetchCourseProgresses={fetchCourseProgresses}
/>
</div>
// ...rest of code

This is the result

rating

Conclusion

In the this final part of the article series, we've learned how to implement rating system, cart feature & payment with Stripe.

You can find the full code in https://github.com/ZehaIrawan/strapi-udemy-clone