diff --git a/package.json b/package.json
index f6f4ce667..bbf1b17e4 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"next-themes": "^0.2.1",
"node-fetch": "^3.3.2",
"notion-client": "^6.16.0",
+ "razorpay": "^2.9.2",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.50.1",
diff --git a/prisma/migrations/20240402232925_purchase/migration.sql b/prisma/migrations/20240402232925_purchase/migration.sql
new file mode 100644
index 000000000..77efa2af6
--- /dev/null
+++ b/prisma/migrations/20240402232925_purchase/migration.sql
@@ -0,0 +1,54 @@
+-- AlterTable
+ALTER TABLE "Course" ADD COLUMN "price" INTEGER NOT NULL DEFAULT 1000;
+
+-- CreateTable
+CREATE TABLE "Receipt" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "courseId" INTEGER NOT NULL DEFAULT 0,
+ "razorpayOrderId" TEXT,
+
+ CONSTRAINT "Receipt_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Purchase" (
+ "id" TEXT NOT NULL,
+ "receiptId" TEXT NOT NULL,
+ "razorpay_payment_id" TEXT NOT NULL,
+ "razorpay_order_id" TEXT NOT NULL,
+ "razorpay_signature" TEXT NOT NULL,
+ "purchasedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "paymentVerified" BOOLEAN NOT NULL DEFAULT false,
+ "purchasedCourseId" INTEGER NOT NULL,
+ "purchasedById" TEXT NOT NULL,
+
+ CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Receipt_razorpayOrderId_key" ON "Receipt"("razorpayOrderId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Purchase_receiptId_key" ON "Purchase"("receiptId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Purchase_razorpay_payment_id_key" ON "Purchase"("razorpay_payment_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Purchase_razorpay_order_id_key" ON "Purchase"("razorpay_order_id");
+
+-- AddForeignKey
+ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_receiptId_fkey" FOREIGN KEY ("receiptId") REFERENCES "Receipt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedCourseId_fkey" FOREIGN KEY ("purchasedCourseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedById_fkey" FOREIGN KEY ("purchasedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a4bc79d74..2a507b801 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -10,6 +10,7 @@ datasource db {
model Course {
id Int @id @default(autoincrement())
+ price Int @default(1000)
appxCourseId Int
discordRoleId String
title String
@@ -20,6 +21,8 @@ model Course {
content CourseContent[]
purchasedBy UserPurchases[]
bookmarks Bookmark[]
+ purchases Purchase[]
+ receipts Receipt[]
}
model UserPurchases {
@@ -132,6 +135,33 @@ model User {
password String?
appxUserId String?
appxUsername String?
+ transactions Purchase[]
+ receipts Receipt[]
+}
+
+model Receipt{
+ id String @id @default(cuid())
+ user User @relation(fields: [userId], references: [id])
+ userId String
+ course Course @relation(fields: [courseId], references: [id])
+ courseId Int @default(0)
+ razorpayOrderId String? @unique
+ purchase Purchase?
+}
+
+model Purchase {
+ id String @id @default(cuid())
+ receipt Receipt @relation(fields: [receiptId], references: [id])
+ receiptId String @unique
+ razorpay_payment_id String @unique
+ razorpay_order_id String @unique
+ razorpay_signature String
+ purchasedAt DateTime @default(now())
+ paymentVerified Boolean @default(false)
+ purchasedCourse Course @relation(fields: [purchasedCourseId], references: [id])
+ purchasedCourseId Int
+ purchasedBy User @relation(fields: [purchasedById], references: [id])
+ purchasedById String
}
model DiscordConnect {
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
new file mode 100644
index 000000000..8efbf1b82
--- /dev/null
+++ b/src/app/api/auth/register/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from 'next/server';
+import db from '@/db';
+import bcrypt from 'bcrypt';
+import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
+
+export async function POST(req: NextRequest) {
+ try {
+ const { name, email, password } = await req.json();
+
+ const hashedPassword = await bcrypt.hash(password, 10);
+ await db.user.create({
+ data: {
+ email,
+ name,
+ password: hashedPassword,
+ },
+ });
+
+ return NextResponse.json(
+ { message: 'Account created sucessfully' },
+ { status: 201 },
+ );
+ } catch (e) {
+ if (e instanceof PrismaClientKnownRequestError) {
+ return NextResponse.json(
+ { error: 'Email already taken.' },
+ { status: 400 },
+ );
+ }
+ console.log(e);
+ return NextResponse.json(
+ { error: 'Failed to parse JSON input' },
+ { status: 400 },
+ );
+ }
+}
diff --git a/src/app/api/razorpay/route.ts b/src/app/api/razorpay/route.ts
new file mode 100644
index 000000000..e06dd515a
--- /dev/null
+++ b/src/app/api/razorpay/route.ts
@@ -0,0 +1,83 @@
+import { NextRequest, NextResponse } from 'next/server';
+import Razorpay from 'razorpay';
+import db from '@/db';
+import { z } from 'zod';
+
+const schema = z.object({
+ courseId: z.number(),
+ userId: z.string(),
+});
+
+const razorpay = new Razorpay({
+ key_id: process.env.RAZORPAY_KEY_ID!,
+ key_secret: process.env.RAZORPAY_SECRET!,
+});
+
+export async function POST(req: NextRequest) {
+ const reqBody = await req.json();
+ const body = schema.safeParse(reqBody);
+
+ if (!body.success) {
+ return NextResponse.json(
+ {
+ error: 'Error parsing the body of the request',
+ },
+ {
+ status: 422,
+ },
+ );
+ }
+
+ const course = await db.course.findFirst({
+ where: {
+ id: body.data.courseId,
+ },
+ });
+
+ if (!course)
+ return NextResponse.json({ error: 'Course Not Found' }, { status: 404 });
+
+ const receipt = await db.receipt.create({
+ data: {
+ userId: body.data.userId,
+ courseId: body.data.courseId,
+ },
+ });
+
+ const payment_capture = 1;
+ const amount = course.price;
+ const options = {
+ amount: (amount * 100).toString(),
+ currency: 'INR',
+ receipt: receipt.id,
+ payment_capture,
+ notes: {
+ userId: body.data.userId,
+ courseId: body.data.courseId,
+ receipt: receipt.id,
+ },
+ };
+
+ try {
+ const response = await razorpay.orders.create(options);
+
+ await db.receipt.update({
+ where: {
+ id: receipt.id,
+ },
+ data: {
+ razorpayOrderId: response.id,
+ },
+ });
+
+ return NextResponse.json({
+ id: response.id,
+ currency: response.currency,
+ amount: response.amount,
+ razorPayKey: process.env.RAZORPAY_KEY_ID,
+ });
+ } catch (err) {
+ console.log(err);
+ return NextResponse.json(err);
+ }
+}
diff --git a/src/app/api/razorpay/verify/route.ts b/src/app/api/razorpay/verify/route.ts
new file mode 100644
index 000000000..aad654f4b
--- /dev/null
+++ b/src/app/api/razorpay/verify/route.ts
@@ -0,0 +1,80 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { validatePaymentVerification } from 'razorpay/dist/utils/razorpay-utils';
+import { z } from 'zod';
+import db from '@/db';
+
+const razorPayZodSchema = z.object({
+ userId: z.string(),
+ courseId: z.number(),
+ razorpay_order_id: z.string(),
+ razorpay_payment_id: z.string(),
+ razorpay_signature: z.string(),
+});
+
+export async function POST(req: NextRequest) {
+ const jsonBody = await req.json();
+
+ const body = razorPayZodSchema.safeParse(jsonBody);
+
+ if (!body.success) {
+ return NextResponse.json(
+ {
+ error: 'Invalid Body',
+ },
+ { status: 422 },
+ );
+ }
+
+ const {
+ razorpay_order_id,
+ razorpay_payment_id,
+ razorpay_signature,
+ courseId,
+ userId,
+ } = body.data;
+
+ const isPaymentValid = validatePaymentVerification(
+ {
+ order_id: razorpay_order_id,
+ payment_id: razorpay_payment_id,
+ },
+ razorpay_signature,
+ process.env.RAZORPAY_SECRET!,
+ );
+
+ if (!isPaymentValid)
+ return NextResponse.json(
+ { error: 'Payment not verified' },
+ { status: 404 },
+ );
+
+ const receipt = await db.receipt.findFirst({
+ where: {
+ razorpayOrderId: razorpay_order_id,
+ },
+ });
+
+ if (!receipt)
+ return NextResponse.json({ error: 'Receipt not found' }, { status: 404 });
+
+ await db.purchase.create({
+ data: {
+ receiptId: receipt.id,
+ razorpay_order_id,
+ razorpay_payment_id,
+ razorpay_signature,
+ paymentVerified: isPaymentValid,
+ purchasedById: userId,
+ purchasedCourseId: courseId,
+ },
+ });
+
+ await db.userPurchases.create({
+ data: {
+ userId,
+ courseId,
+ },
+ });
+
+ return NextResponse.json({ message: 'Purchase Successful' }, { status: 200 });
+}
diff --git a/src/app/new-courses/[courseId]/page.tsx b/src/app/new-courses/[courseId]/page.tsx
new file mode 100644
index 000000000..daf0e9bc1
--- /dev/null
+++ b/src/app/new-courses/[courseId]/page.tsx
@@ -0,0 +1,66 @@
+import { RazorPayComponent } from '@/components/razorpay/Razorpay';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import Image from 'next/image';
+import db from '@/db';
+import { authOptions } from '@/lib/auth';
+import { getServerSession } from 'next-auth';
+import { redirect } from 'next/navigation';
+
+export default async function CourseDetails({
+ params,
+}: {
+ params: { courseId: string[] };
+}) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/signin');
+ }
+ const course = await db.course.findFirst({
+ where: {
+ id: Number(params.courseId),
+ },
+ });
+ if (!course) return
Course does not exist
;
+
+ const ifPurchasedByUser =
+ (await db.userPurchases.count({
+ where: {
+ userId: session.user.id,
+ courseId: course.id,
+ },
+ })) > 0
+ ? true
+ : false;
+
+ return (
+
+
+
+
+
+ {course.title}
+ {course.description}
+
+
+
+ {ifPurchasedByUser ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/new-courses/page.tsx b/src/app/new-courses/page.tsx
new file mode 100644
index 000000000..297cfb690
--- /dev/null
+++ b/src/app/new-courses/page.tsx
@@ -0,0 +1,60 @@
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import db from '@/db';
+import { authOptions } from '@/lib/auth';
+import { getServerSession } from 'next-auth';
+import Image from 'next/image';
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+
+export default async function PurchasePage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/signin');
+ }
+
+ const courses = await db.course.findMany({
+ where: {
+ purchasedBy: {
+ none: {
+ userId: session.user.id,
+ },
+ },
+ },
+ });
+
+ return (
+ <>
+
+ {courses.length === 0 ? (
+ No New Courses to Buy
+ ) : (
+ courses.map((course) => (
+
+
+
+
+
+ {course.title}
+ {course.description}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+ >
+ );
+}
diff --git a/src/app/receipts/page.tsx b/src/app/receipts/page.tsx
new file mode 100644
index 000000000..1c2133f92
--- /dev/null
+++ b/src/app/receipts/page.tsx
@@ -0,0 +1,57 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import db from '@/db';
+import { authOptions } from '@/lib/auth';
+import { getServerSession } from 'next-auth';
+import Image from 'next/image';
+import { redirect } from 'next/navigation';
+
+export default async function Receipts() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/signin');
+ }
+
+ const courses = await db.purchase.findMany({
+ where: {
+ purchasedById: session.user.id,
+ },
+ select: {
+ receiptId: true,
+ purchasedCourse: true,
+ },
+ });
+
+ return (
+ <>
+
+ {courses.length === 0 ? (
+ No Receipts found
+ ) : (
+ courses.map((course) => (
+
+
+
+
+
+ {course.purchasedCourse.title}
+
+ {course.purchasedCourse.description}
+
+
+
+ Receipt Id: {course.receiptId}
+
+
+ ))
+ )}
+
+ >
+ );
+}
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
new file mode 100644
index 000000000..e39d09db6
--- /dev/null
+++ b/src/app/signup/page.tsx
@@ -0,0 +1,15 @@
+import { authOptions } from '@/lib/auth';
+import { getServerSession } from 'next-auth';
+import { redirect } from 'next/navigation';
+import React from 'react';
+import Signup from '@/components/Signup';
+
+const SignupPage = async () => {
+ const session = await getServerSession(authOptions);
+ if (session?.user) {
+ redirect('/');
+ }
+ return ;
+};
+
+export default SignupPage;
diff --git a/src/components/Appbar.tsx b/src/components/Appbar.tsx
index 723121f94..ed7fd00ad 100644
--- a/src/components/Appbar.tsx
+++ b/src/components/Appbar.tsx
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
+import { useRouter } from 'next/navigation';
import { JoinDiscord } from './JoinDiscord';
import { AppbarAuth } from './AppbarAuth';
import { useSession } from 'next-auth/react';
@@ -17,6 +18,7 @@ import SearchBar from './search/SearchBar';
import MobileScreenSearch from './search/MobileScreenSearch';
export const Appbar = () => {
+ const router = useRouter();
const session = useSession();
const [sidebarOpen, setSidebarOpen] = useRecoilState(sidebarOpenAtom);
const currentPath = usePathname();
@@ -45,57 +47,60 @@ export const Appbar = () => {
-
- {/* Search Bar for smaller devices */}
-
+
+ {/* Search Bar for smaller devices */}
+
- {currentPath.includes('courses') && bookmarkPageUrl && (
-
- )}
-
+
+
+
+ {currentPath.includes('courses') && bookmarkPageUrl && (
+
+ )}
-
+
+
+
+
+
+
-
-
-
@@ -107,14 +112,15 @@ export const Appbar = () => {
-
diff --git a/src/components/Signup.tsx b/src/components/Signup.tsx
new file mode 100644
index 000000000..b81f75575
--- /dev/null
+++ b/src/components/Signup.tsx
@@ -0,0 +1,194 @@
+'use client';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Toaster } from '@/components/ui/sonner';
+import { signIn } from 'next-auth/react';
+import { useRouter } from 'next/navigation';
+import React, { useRef, useState } from 'react';
+
+import { toast } from 'sonner';
+const Signup = () => {
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+ const [requiredError, setRequiredError] = useState({
+ emailReq: false,
+ passReq: false,
+ });
+
+ function togglePasswordVisibility() {
+ setIsPasswordVisible((prevState: any) => !prevState);
+ }
+ const router = useRouter();
+ const email = useRef('');
+ const password = useRef('');
+ const name = useRef('');
+
+ const handleSubmit = async (e?: React.FormEvent
) => {
+ if (e) {
+ e.preventDefault();
+ }
+
+ if (!email.current || !password.current) {
+ setRequiredError({
+ emailReq: email.current ? false : true,
+ passReq: password.current ? false : true,
+ });
+ return;
+ }
+
+ const register = await fetch('/api/auth/register', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: name.current,
+ email: email.current,
+ password: password.current,
+ }),
+ });
+ const data = await register.json();
+
+ if (register.status !== 201) {
+ toast(data.error, {
+ action: {
+ label: 'Close',
+ onClick: () => console.log('Closed Toast'),
+ },
+ });
+ return;
+ }
+
+ const res = await signIn('credentials', {
+ username: email.current,
+ password: password.current,
+ redirect: false,
+ });
+
+ if (!res?.error) {
+ router.push('/');
+ } else {
+ toast('Error Signing in', {
+ action: {
+ label: 'Close',
+ onClick: () => console.log('Closed Toast'),
+ },
+ });
+ }
+ };
+ return (
+
+
+
+ Signup
+
+
+
+
+
+ {
+ name.current = e.target.value;
+ }}
+ />
+
+
+
+ {
+ setRequiredError((prevState) => ({
+ ...prevState,
+ emailReq: false,
+ }));
+ email.current = e.target.value;
+ }}
+ />
+ {requiredError.emailReq && (
+ Email is required
+ )}
+
+
+
+
+
{
+ setRequiredError((prevState) => ({
+ ...prevState,
+ passReq: false,
+ }));
+ password.current = e.target.value;
+ }}
+ onKeyDown={async (e) => {
+ if (e.key === 'Enter') {
+ setIsPasswordVisible(false);
+ handleSubmit();
+ }
+ }}
+ />
+
+ {isPasswordVisible ? (
+
+ ) : (
+
+ )}
+
+
+ {requiredError.passReq && (
+
Password is required
+ )}
+
+
+
+ Signup
+
+
+
+
+
+ );
+};
+
+export default Signup;
diff --git a/src/components/razorpay/Razorpay.tsx b/src/components/razorpay/Razorpay.tsx
new file mode 100644
index 000000000..95a98ad34
--- /dev/null
+++ b/src/components/razorpay/Razorpay.tsx
@@ -0,0 +1,102 @@
+'use client';
+import { Button } from '../ui/button';
+import { toast } from 'sonner';
+import { useRouter } from 'next/navigation';
+
+const initializeRazorpay = () => {
+ return new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = 'https://checkout.razorpay.com/v1/checkout.js';
+
+ script.onload = () => {
+ resolve(true);
+ };
+ script.onerror = () => {
+ resolve(false);
+ };
+
+ document.body.appendChild(script);
+ });
+};
+
+type RazorPayInputType = {
+ courseId: number;
+ userId: string;
+};
+
+export const RazorPayComponent = ({ courseId, userId }: RazorPayInputType) => {
+ const router = useRouter();
+
+ const makePayment = async () => {
+ const res = await initializeRazorpay();
+
+ if (!res) {
+ alert('Razorpay SDK Failed to load');
+ return;
+ }
+
+ const data = await fetch('/api/razorpay', {
+ method: 'POST',
+ body: JSON.stringify({
+ courseId,
+ userId,
+ }),
+ }).then((res) => res.json());
+
+ const options = {
+ key_id: data.razorPayKey,
+ name: '100xdevs',
+ currency: data.currency,
+ amount: data.amount,
+ order_id: data.id,
+ description: 'Thank you for purchasing course',
+ image: '/harkirat.png',
+ handler: async (response: any) => {
+ const verify = await fetch('/api/razorpay/verify', {
+ method: 'POST',
+ body: JSON.stringify({ ...response, userId, courseId }),
+ });
+ const data = await verify.json();
+ if (verify.status !== 200) {
+ toast(data.error, {
+ action: {
+ label: 'Close',
+ onClick: () => console.log('Closed Toast'),
+ },
+ });
+ } else {
+ toast(data.message, {
+ action: {
+ label: 'Close',
+ onClick: () => console.log('Closed Toast'),
+ },
+ });
+ router.push('/');
+ }
+ },
+ };
+
+ const paymentObject = new (window as any).Razorpay(options);
+ paymentObject.on('payment.failed', () => {
+ toast('Payment did not execute', {
+ action: {
+ label: 'Close',
+ onClick: () => console.log('Closed Toast'),
+ },
+ });
+ });
+ paymentObject.open();
+ };
+
+ return (
+
+
+ Pay Now
+
+
+ );
+};
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 12d152129..050f837ee 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -113,11 +113,13 @@ export const authOptions = {
name: true,
},
});
- if (
- userDb &&
- userDb.password &&
- (await bcrypt.compare(credentials.password, userDb.password))
- ) {
+ if (userDb && userDb.password) {
+ const valid = await bcrypt.compare(
+ credentials.password,
+ userDb.password,
+ );
+ if (!valid) return null;
+
const jwt = await generateJWT({
id: userDb.id,
});