From 895bcd32d9107ba56807e4a34cfd463f2fecdd64 Mon Sep 17 00:00:00 2001 From: Dhruvil Mehta <68022411+dhruvilmehta@users.noreply.github.com> Date: Tue, 2 Apr 2024 18:47:18 -0700 Subject: [PATCH] added course purchases and auth system --- package.json | 1 + .../20240402232925_purchase/migration.sql | 54 ++ prisma/schema.prisma | 464 ++++++++++-------- src/app/api/auth/register/route.ts | 36 ++ src/app/api/razorpay/route.ts | 81 +++ src/app/api/razorpay/verify/route.ts | 80 +++ src/app/new-courses/[courseId]/page.tsx | 52 ++ src/app/new-courses/page.tsx | 57 +++ src/app/signup/page.tsx | 15 + src/components/Appbar.tsx | 96 ++-- src/components/CourseCard.tsx | 12 +- src/components/Sidebar.tsx | 12 +- src/components/Signup.tsx | 194 ++++++++ src/components/VideoPlayer.tsx | 30 +- src/components/VideoPlayer2.tsx | 180 +++---- src/components/bookmark/BookmarkView.tsx | 12 +- .../us-section/why-us-card/why-us-content.ts | 2 +- src/components/razorpay/Razorpay.tsx | 102 ++++ src/db/course.ts | 18 +- src/lib/auth.ts | 12 +- src/lib/utils.ts | 22 +- 21 files changed, 1120 insertions(+), 412 deletions(-) create mode 100644 prisma/migrations/20240402232925_purchase/migration.sql create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/razorpay/route.ts create mode 100644 src/app/api/razorpay/verify/route.ts create mode 100644 src/app/new-courses/[courseId]/page.tsx create mode 100644 src/app/new-courses/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/components/Signup.tsx create mode 100644 src/components/razorpay/Razorpay.tsx diff --git a/package.json b/package.json index 118646510..785ffa96c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,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..d410addeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,217 +1,247 @@ -generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model Course { - id Int @id @default(autoincrement()) - appxCourseId Int - discordRoleId String - title String - imageUrl String - description String - openToEveryone Boolean @default(false) - slug String - content CourseContent[] - purchasedBy UserPurchases[] - bookmarks Bookmark[] -} - -model UserPurchases { - user User @relation(fields: [userId], references: [id]) - userId String - course Course @relation(fields: [courseId], references: [id]) - courseId Int - assignedAt DateTime @default(now()) - - @@id([userId, courseId]) -} - -model Content { - id Int @id @default(autoincrement()) - type String @default("folder") - title String - hidden Boolean @default(false) - description String? - thumbnail String? - parentId Int? - parent Content? @relation("ContentToContent", fields: [parentId], references: [id]) - videoProgress VideoProgress[] - children Content[] @relation("ContentToContent") - courses CourseContent[] - createdAt DateTime @default(now()) - VideoMetadata VideoMetadata? - NotionMetadata NotionMetadata? - notionMetadataId Int? - comments Comment[] - commentsCount Int @default(0) - bookmark Bookmark? -} - -model CourseContent { - course Course @relation(fields: [courseId], references: [id]) - courseId Int - content Content @relation(fields: [contentId], references: [id]) - contentId Int - - @@id([courseId, contentId]) -} - -model NotionMetadata { - id Int @id @default(autoincrement()) - contentId Int - content Content @relation(fields: [contentId], references: [id]) - notionId String - - @@unique([contentId]) -} - -model VideoMetadata { - id Int @id @default(autoincrement()) - contentId Int - video_1080p_mp4_1 String? // Link to 1080p mp4 quality video variant 1 - video_1080p_mp4_2 String? // Link to 1080p mp4 quality video variant 2 - video_1080p_mp4_3 String? // Link to 1080p mp4 quality video variant 3 - video_1080p_mp4_4 String? // Link to 1080p mp4 quality video variant 4 - video_1080p_1 String? // Link to 1080p quality video variant 1 - video_1080p_2 String? // Link to 1080p quality video variant 2 - video_1080p_3 String? // Link to 1080p quality video variant 3 - video_1080p_4 String? // Link to 1080p quality video variant 4 - video_720p_mp4_1 String? // Link to 720p mp4 quality video variant 1 - video_720p_mp4_2 String? // Link to 720p mp4 quality video variant 2 - video_720p_mp4_3 String? // Link to 720p mp4 quality video variant 3 - video_720p_mp4_4 String? // Link to 720p mp4 quality video variant 4 - video_720p_1 String? // Link to 720p quality video variant 1 - video_720p_2 String? // Link to 720p quality video variant 2 - video_720p_3 String? // Link to 720p quality video variant 3 - video_720p_4 String? // Link to 720p quality video variant 4 - video_360p_mp4_1 String? // Link to 360p mp4 quality video variant 1 - video_360p_mp4_2 String? // Link to 360p mp4 quality video variant 2 - video_360p_mp4_3 String? // Link to 360p mp4 quality video variant 3 - video_360p_mp4_4 String? // Link to 360p mp4 quality video variant 4 - video_360p_1 String? // Link to 360p quality video variant 1 - video_360p_2 String? // Link to 360p quality video variant 2 - video_360p_3 String? // Link to 360p quality video variant 3 - video_360p_4 String? // Link to 360p quality video variant 4 - subtitles String? // Link to subtitles file - segments Json? - content Content @relation(fields: [contentId], references: [id]) - slides String? // link to slides - thumbnail_mosiac_url String? - duration Int? - - @@unique([contentId]) -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model User { - id String @id @default(cuid()) - name String? - email String? @unique - token String? - sessions Session[] - purchases UserPurchases[] - videoProgress VideoProgress[] - comments Comment[] - votes Vote[] - discordConnect DiscordConnect? - disableDrm Boolean @default(false) - bookmarks Bookmark[] - password String? - appxUserId String? - appxUsername String? -} - -model DiscordConnect { - id String @id @default(cuid()) - username String - discordId String @unique - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model DiscordConnectBulk { - id String @id @default(cuid()) - username String - discordId String - userId String -} - -model VideoProgress { - id Int @id @default(autoincrement()) - userId String - contentId Int - currentTimestamp Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) - markAsCompleted Boolean @default(false) - updatedAt DateTime @default(now()) @updatedAt - - @@unique([contentId, userId]) -} - -model Bookmark { - id Int @id @default(autoincrement()) - userId String - contentId Int @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - courseId Int - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) -} - -model Comment { - id Int @id @default(autoincrement()) - content String - commentType CommentType @default(DEFAULT) - approved Boolean @default(false) - contentId Int - commentedOn Content @relation(fields: [contentId], references: [id]) - parentId Int? - parent Comment? @relation("ParentComment", fields: [parentId], references: [id]) - children Comment[] @relation("ParentComment") - userId String - user User @relation(fields: [userId], references: [id]) - upvotes Int @default(0) - downvotes Int @default(0) - repliesCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - votes Vote[] - isPinned Boolean @default(false) -} - -model Vote { - id Int @id @default(autoincrement()) - commentId Int - comment Comment @relation(fields: [commentId], references: [id]) - userId String - user User @relation(fields: [userId], references: [id]) - voteType VoteType // enum - createdAt DateTime @default(now()) - - @@unique([commentId, userId]) -} - -enum VoteType { - UPVOTE - DOWNVOTE -} - -enum CommentType { - INTRO - DEFAULT -} +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Course { + id Int @id @default(autoincrement()) + price Int @default(1000) + appxCourseId Int + discordRoleId String + title String + imageUrl String + description String + openToEveryone Boolean @default(false) + slug String + content CourseContent[] + purchasedBy UserPurchases[] + bookmarks Bookmark[] + purchases Purchase[] + receipts Receipt[] +} + +model UserPurchases { + user User @relation(fields: [userId], references: [id]) + userId String + course Course @relation(fields: [courseId], references: [id]) + courseId Int + assignedAt DateTime @default(now()) + + @@id([userId, courseId]) +} + +model Content { + id Int @id @default(autoincrement()) + type String @default("folder") + title String + hidden Boolean @default(false) + description String? + thumbnail String? + parentId Int? + parent Content? @relation("ContentToContent", fields: [parentId], references: [id]) + videoProgress VideoProgress[] + children Content[] @relation("ContentToContent") + courses CourseContent[] + createdAt DateTime @default(now()) + VideoMetadata VideoMetadata? + NotionMetadata NotionMetadata? + notionMetadataId Int? + comments Comment[] + commentsCount Int @default(0) + bookmark Bookmark? +} + +model CourseContent { + course Course @relation(fields: [courseId], references: [id]) + courseId Int + content Content @relation(fields: [contentId], references: [id]) + contentId Int + + @@id([courseId, contentId]) +} + +model NotionMetadata { + id Int @id @default(autoincrement()) + contentId Int + content Content @relation(fields: [contentId], references: [id]) + notionId String + + @@unique([contentId]) +} + +model VideoMetadata { + id Int @id @default(autoincrement()) + contentId Int + video_1080p_mp4_1 String? // Link to 1080p mp4 quality video variant 1 + video_1080p_mp4_2 String? // Link to 1080p mp4 quality video variant 2 + video_1080p_mp4_3 String? // Link to 1080p mp4 quality video variant 3 + video_1080p_mp4_4 String? // Link to 1080p mp4 quality video variant 4 + video_1080p_1 String? // Link to 1080p quality video variant 1 + video_1080p_2 String? // Link to 1080p quality video variant 2 + video_1080p_3 String? // Link to 1080p quality video variant 3 + video_1080p_4 String? // Link to 1080p quality video variant 4 + video_720p_mp4_1 String? // Link to 720p mp4 quality video variant 1 + video_720p_mp4_2 String? // Link to 720p mp4 quality video variant 2 + video_720p_mp4_3 String? // Link to 720p mp4 quality video variant 3 + video_720p_mp4_4 String? // Link to 720p mp4 quality video variant 4 + video_720p_1 String? // Link to 720p quality video variant 1 + video_720p_2 String? // Link to 720p quality video variant 2 + video_720p_3 String? // Link to 720p quality video variant 3 + video_720p_4 String? // Link to 720p quality video variant 4 + video_360p_mp4_1 String? // Link to 360p mp4 quality video variant 1 + video_360p_mp4_2 String? // Link to 360p mp4 quality video variant 2 + video_360p_mp4_3 String? // Link to 360p mp4 quality video variant 3 + video_360p_mp4_4 String? // Link to 360p mp4 quality video variant 4 + video_360p_1 String? // Link to 360p quality video variant 1 + video_360p_2 String? // Link to 360p quality video variant 2 + video_360p_3 String? // Link to 360p quality video variant 3 + video_360p_4 String? // Link to 360p quality video variant 4 + subtitles String? // Link to subtitles file + segments Json? + content Content @relation(fields: [contentId], references: [id]) + slides String? // link to slides + thumbnail_mosiac_url String? + duration Int? + + @@unique([contentId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + token String? + sessions Session[] + purchases UserPurchases[] + videoProgress VideoProgress[] + comments Comment[] + votes Vote[] + discordConnect DiscordConnect? + disableDrm Boolean @default(false) + bookmarks Bookmark[] + 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 { + id String @id @default(cuid()) + username String + discordId String @unique + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model DiscordConnectBulk { + id String @id @default(cuid()) + username String + discordId String + userId String +} + +model VideoProgress { + id Int @id @default(autoincrement()) + userId String + contentId Int + currentTimestamp Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) + markAsCompleted Boolean @default(false) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([contentId, userId]) +} + +model Bookmark { + id Int @id @default(autoincrement()) + userId String + contentId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + courseId Int + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) +} + +model Comment { + id Int @id @default(autoincrement()) + content String + commentType CommentType @default(DEFAULT) + approved Boolean @default(false) + contentId Int + commentedOn Content @relation(fields: [contentId], references: [id]) + parentId Int? + parent Comment? @relation("ParentComment", fields: [parentId], references: [id]) + children Comment[] @relation("ParentComment") + userId String + user User @relation(fields: [userId], references: [id]) + upvotes Int @default(0) + downvotes Int @default(0) + repliesCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + votes Vote[] + isPinned Boolean @default(false) +} + +model Vote { + id Int @id @default(autoincrement()) + commentId Int + comment Comment @relation(fields: [commentId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + voteType VoteType // enum + createdAt DateTime @default(now()) + + @@unique([commentId, userId]) +} + +enum VoteType { + UPVOTE + DOWNVOTE +} + +enum CommentType { + INTRO + DEFAULT +} 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..dddd2906e --- /dev/null +++ b/src/app/api/razorpay/route.ts @@ -0,0 +1,81 @@ +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: { + + // } + }; + + 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..7c08f972e --- /dev/null +++ b/src/app/new-courses/[courseId]/page.tsx @@ -0,0 +1,52 @@ +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 null; + + return ( + + + + + + {course.title} + {course.description} + + + + + + + ); +} diff --git a/src/app/new-courses/page.tsx b/src/app/new-courses/page.tsx new file mode 100644 index 000000000..9346368e5 --- /dev/null +++ b/src/app/new-courses/page.tsx @@ -0,0 +1,57 @@ +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 ( + <> +
+ {/* TODO: Improve this UI */} + {courses.map((course) => ( + + + + + + {course.title} + {course.description} + + + + + + + + + ))} +
+ + ); +} 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..ea18dcde3 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,58 @@ export const Appbar = () => {
-
- {/* Search Bar for smaller devices */} - +
+ {/* Search Bar for smaller devices */} +
- {currentPath.includes('courses') && bookmarkPageUrl && ( - - )} - +
+
+
+ {currentPath.includes('courses') && bookmarkPageUrl && ( + + )} - + + +
- - +
-
@@ -107,14 +110,15 @@ export const Appbar = () => {
- {' '} - +
diff --git a/src/components/CourseCard.tsx b/src/components/CourseCard.tsx index d0ca115d5..9a16e3258 100644 --- a/src/components/CourseCard.tsx +++ b/src/components/CourseCard.tsx @@ -21,12 +21,12 @@ export const CourseCard = ({
{course.totalVideos !== undefined && course.totalVideosWatched !== undefined && ( - - )} + + )}
{course.title}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6bdac5d62..89f7469b1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -157,16 +157,16 @@ export function ToggleButton({ ); 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(); + } + }} + /> + +
+ {requiredError.passReq && ( + Password is required + )} +
+
+ +
+
+ +
+ ); +}; + +export default Signup; diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index ef1cbb434..92ef05e61 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -22,23 +22,23 @@ export const VideoPlayer = ({ } const handleKeyPress = (event: any) => { switch (event.code) { - case 'Space': // Space bar for play/pause - if (player.paused()) { - player.play(); + case 'Space': // Space bar for play/pause + if (player.paused()) { + player.play(); + event.stopPropagation(); + } else { + player.pause(); + event.stopPropagation(); + } + break; + case 'ArrowRight': // Right arrow for seeking forward 5 seconds + player.currentTime(player.currentTime() + 5); event.stopPropagation(); - } else { - player.pause(); + break; + case 'ArrowLeft': // Left arrow for seeking backward 5 seconds + player.currentTime(player.currentTime() - 5); event.stopPropagation(); - } - break; - case 'ArrowRight': // Right arrow for seeking forward 5 seconds - player.currentTime(player.currentTime() + 5); - event.stopPropagation(); - break; - case 'ArrowLeft': // Left arrow for seeking backward 5 seconds - player.currentTime(player.currentTime() - 5); - event.stopPropagation(); - break; + break; } }; diff --git a/src/components/VideoPlayer2.tsx b/src/components/VideoPlayer2.tsx index b14d2bfa1..87342c837 100644 --- a/src/components/VideoPlayer2.tsx +++ b/src/components/VideoPlayer2.tsx @@ -77,40 +77,40 @@ export const VideoPlayer: FunctionComponent = ({ const newIndexDown = currentIndexDown !== 0 ? currentIndexDown - 1 : currentIndexDown; switch (event.code) { - case 'Period': // Increase playback speed - player.playbackRate(PLAYBACK_RATES[newIndexPeriod]); - event.stopPropagation(); - break; - case 'Comma': // Decrease playback speed - player.playbackRate(PLAYBACK_RATES[newIndexComma]); - event.stopPropagation(); - break; - case 'ArrowUp': // Increase volume - videoRef.current?.children[0].children[6].children[3].classList.add( - 'vjs-hover', - ); - if (volumeSetTimeout !== null) clearTimeout(volumeSetTimeout); - volumeSetTimeout = setTimeout(() => { - videoRef.current?.children[0].children[6].children[3].classList.remove( + case 'Period': // Increase playback speed + player.playbackRate(PLAYBACK_RATES[newIndexPeriod]); + event.stopPropagation(); + break; + case 'Comma': // Decrease playback speed + player.playbackRate(PLAYBACK_RATES[newIndexComma]); + event.stopPropagation(); + break; + case 'ArrowUp': // Increase volume + videoRef.current?.children[0].children[6].children[3].classList.add( 'vjs-hover', ); - }, 1000); - player.volume(VOLUME_LEVELS[newIndexUp]); - event.stopPropagation(); - break; - case 'ArrowDown': // Decrease volume - videoRef.current?.children[0].children[6].children[3].classList.add( - 'vjs-hover', - ); - if (volumeSetTimeout !== null) clearTimeout(volumeSetTimeout); - volumeSetTimeout = setTimeout(() => { - videoRef.current?.children[0].children[6].children[3].classList.remove( + if (volumeSetTimeout !== null) clearTimeout(volumeSetTimeout); + volumeSetTimeout = setTimeout(() => { + videoRef.current?.children[0].children[6].children[3].classList.remove( + 'vjs-hover', + ); + }, 1000); + player.volume(VOLUME_LEVELS[newIndexUp]); + event.stopPropagation(); + break; + case 'ArrowDown': // Decrease volume + videoRef.current?.children[0].children[6].children[3].classList.add( 'vjs-hover', ); - }, 1000); - player.volume(VOLUME_LEVELS[newIndexDown]); - event.stopPropagation(); - break; + if (volumeSetTimeout !== null) clearTimeout(volumeSetTimeout); + volumeSetTimeout = setTimeout(() => { + videoRef.current?.children[0].children[6].children[3].classList.remove( + 'vjs-hover', + ); + }, 1000); + player.volume(VOLUME_LEVELS[newIndexDown]); + event.stopPropagation(); + break; } } else if (event.code === 'KeyT') { player.playbackRate(2); @@ -126,70 +126,70 @@ export const VideoPlayer: FunctionComponent = ({ return; // Do nothing if the active element is an input or textarea } switch (event.code) { - case 'Space': // Space bar for play/pause - if (player.paused()) { - player.play(); + case 'Space': // Space bar for play/pause + if (player.paused()) { + player.play(); + event.stopPropagation(); + } else { + player.pause(); + event.stopPropagation(); + } + event.preventDefault(); + break; + case 'ArrowRight': // Right arrow for seeking forward 5 seconds + player.currentTime(player.currentTime() + 5); event.stopPropagation(); - } else { - player.pause(); + break; + case 'ArrowLeft': // Left arrow for seeking backward 5 seconds + player.currentTime(player.currentTime() - 5); event.stopPropagation(); - } - event.preventDefault(); - break; - case 'ArrowRight': // Right arrow for seeking forward 5 seconds - player.currentTime(player.currentTime() + 5); - event.stopPropagation(); - break; - case 'ArrowLeft': // Left arrow for seeking backward 5 seconds - player.currentTime(player.currentTime() - 5); - event.stopPropagation(); - break; - case 'KeyF': // F key for fullscreen - if (player.isFullscreen_) document.exitFullscreen(); - else player.requestFullscreen(); - event.stopPropagation(); - break; - case 'KeyR': // 'R' key to restart playback from the beginning - player.currentTime(0); - event.stopPropagation(); - break; - case 'KeyM': // 'M' key to toggle mute/unmute - if (player.volume() === 0) { - player.volume(1); - } else { - player.volume(0); - } - event.stopPropagation(); - break; - case 'KeyK': // 'K' key for play/pause toggle - if (player.paused()) { - player.play(); - } else { - player.pause(); - } - event.stopPropagation(); - break; - case 'KeyJ': // 'J' key for seeking backward 10 seconds multiplied by the playback rate - player.currentTime( - player.currentTime() - 10 * player.playbackRate(), - ); - event.stopPropagation(); - break; - case 'KeyL': // 'L' key for seeking forward 10 seconds multiplied by the playback rate - player.currentTime( - player.currentTime() + 10 * player.playbackRate(), - ); - event.stopPropagation(); - break; - case 'KeyC': - if (subtitles && player.textTracks().length) { - if (player.textTracks()[0].mode === 'showing') { - player.textTracks()[0].mode = 'hidden'; + break; + case 'KeyF': // F key for fullscreen + if (player.isFullscreen_) document.exitFullscreen(); + else player.requestFullscreen(); + event.stopPropagation(); + break; + case 'KeyR': // 'R' key to restart playback from the beginning + player.currentTime(0); + event.stopPropagation(); + break; + case 'KeyM': // 'M' key to toggle mute/unmute + if (player.volume() === 0) { + player.volume(1); } else { - player.textTracks()[0].mode = 'showing'; + player.volume(0); } - } - break; + event.stopPropagation(); + break; + case 'KeyK': // 'K' key for play/pause toggle + if (player.paused()) { + player.play(); + } else { + player.pause(); + } + event.stopPropagation(); + break; + case 'KeyJ': // 'J' key for seeking backward 10 seconds multiplied by the playback rate + player.currentTime( + player.currentTime() - 10 * player.playbackRate(), + ); + event.stopPropagation(); + break; + case 'KeyL': // 'L' key for seeking forward 10 seconds multiplied by the playback rate + player.currentTime( + player.currentTime() + 10 * player.playbackRate(), + ); + event.stopPropagation(); + break; + case 'KeyC': + if (subtitles && player.textTracks().length) { + if (player.textTracks()[0].mode === 'showing') { + player.textTracks()[0].mode = 'hidden'; + } else { + player.textTracks()[0].mode = 'showing'; + } + } + break; } } }; diff --git a/src/components/bookmark/BookmarkView.tsx b/src/components/bookmark/BookmarkView.tsx index bed0c839d..649ca34cc 100644 --- a/src/components/bookmark/BookmarkView.tsx +++ b/src/components/bookmark/BookmarkView.tsx @@ -19,12 +19,12 @@ const BookmarkView = ({ {bookmarkData === null || 'error' in bookmarkData || !bookmarkData.length ? ( -
-
No bookmark added yet!
-
- ) : ( - - )} +
+
No bookmark added yet!
+
+ ) : ( + + )}
); diff --git a/src/components/landing/us-section/why-us-card/why-us-content.ts b/src/components/landing/us-section/why-us-card/why-us-content.ts index 96f986b7f..302b9e830 100644 --- a/src/components/landing/us-section/why-us-card/why-us-content.ts +++ b/src/components/landing/us-section/why-us-card/why-us-content.ts @@ -51,6 +51,6 @@ export const whyUs: TwhyUs = [ tagline: 'Assignments', headline: 'Learn by doing', description: - 'Harkirat personally creates assignments after every lecture, so it\'s extremely hands on.', + "Harkirat personally creates assignments after every lecture, so it's extremely hands on.", }, ]; 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 ( +
+ +
+ ); +}; diff --git a/src/db/course.ts b/src/db/course.ts index b9a69ace6..348ba0bf9 100644 --- a/src/db/course.ts +++ b/src/db/course.ts @@ -54,7 +54,7 @@ export async function getAllCoursesAndContentHierarchy(): Promise< contentId: number; }[]; }[] - > { +> { const value = await Cache.getInstance().get( 'getAllCoursesAndContentHierarchy', [], @@ -100,7 +100,7 @@ export async function getAllVideos(): Promise< createdAt: Date; notionMetadataId: number | null; }[] - > { +> { const value = await Cache.getInstance().get('getAllVideos', []); if (value) { return value; @@ -252,13 +252,13 @@ export const getFullCourseContent = async (courseId: number) => { videoProgress: content.type === 'video' ? { - duration: videoProgress.find((x) => x.contentId === content.id) - ?.currentTimestamp, - markAsCompleted: videoProgress.find( - (x) => x.contentId === content.id, - )?.markAsCompleted, - videoFullDuration: content.VideoMetadata?.duration, - } + duration: videoProgress.find((x) => x.contentId === content.id) + ?.currentTimestamp, + markAsCompleted: videoProgress.find( + (x) => x.contentId === content.id, + )?.markAsCompleted, + videoFullDuration: content.VideoMetadata?.duration, + } : null, }, ]), 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, }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b63b64c8a..46120fa7d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -265,17 +265,17 @@ export const constructCommentPrismaQuery = ( let orderBy: Prisma.Enumerable = {}; switch (commentfilter) { - case CommentFilter.mu: - orderBy = { upvotes: 'desc' }; - break; - case CommentFilter.md: - orderBy = { downvotes: 'desc' }; - break; - case CommentFilter.mr: - orderBy = { createdAt: 'desc' }; - break; - default: - orderBy = { upvotes: 'desc' }; + case CommentFilter.mu: + orderBy = { upvotes: 'desc' }; + break; + case CommentFilter.md: + orderBy = { downvotes: 'desc' }; + break; + case CommentFilter.mr: + orderBy = { createdAt: 'desc' }; + break; + default: + orderBy = { upvotes: 'desc' }; } const where: Prisma.CommentWhereInput = {};