Skip to content

Commit fb040bf

Browse files
committed
Chore: Rebase
1 parent 43d56a1 commit fb040bf

File tree

11 files changed

+306
-20
lines changed

11 files changed

+306
-20
lines changed

.env.sample

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ GITHUB_CLIENT_SECRET='<YOUR_KEY>'
66
GITHUB_CLIENT_ID='<YOUR_KEY>'
77
DISCORD_CLIENT_ID='<YOUR_KEY>'
88
DISCORD_CLIENT_SECRET='<YOUR_KEY>'
9+
RAZORPAY_ID=<your_razorpay_id>
10+
RAZORPAY_SECRET=<your_razorpay_secret>
11+
RAZORPAY_WEBHOOK_SECRET=<your_razorpay_webhook_secret>
912

1013
APP_NAME='http://localhost:3000'
1114

1215
JWT_SECRET='<YOUR_KEY>'
1316

1417
GMAIL_PASSWORD='<YOUR_KEY>'
15-
GMAIL_USER='<YOUR_KEY>'
18+
GMAIL_USER='<YOUR_KEY>'

.prettierrc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
"singleQuote": true,
55
"trailingComma": "es5",
66
"semi": false,
7-
"jsxSingleQuote": true
7+
"jsxSingleQuote": true,
8+
"plugins": ["prettier-plugin-tailwindcss"]
9+
810
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@hookform/resolvers": "^3.9.0",
1919
"@lucia-auth/adapter-prisma": "^4.0.1",
20+
"@node-rs/argon2": "^1.8.3",
2021
"@prisma/client": "^5.18.0",
2122
"@radix-ui/react-alert-dialog": "^1.1.1",
2223
"@radix-ui/react-aspect-ratio": "^1.1.0",
@@ -54,6 +55,7 @@
5455
"oslo": "^1.2.1",
5556
"prettier": "^3.3.3",
5657
"prisma": "^5.18.0",
58+
"razorpay": "^2.9.4",
5759
"react": "^18",
5860
"react-dom": "^18",
5961
"react-hook-form": "^7.52.2",
@@ -79,6 +81,7 @@
7981
"eslint-config-next": "14.2.5",
8082
"husky": "^9.1.4",
8183
"postcss": "^8",
84+
"prettier-plugin-tailwindcss": "^0.6.6",
8285
"tailwindcss": "^3.4.1",
8386
"typescript": "^5"
8487
}

prisma/schema.prisma

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ model User {
2525
passwordReset PasswordResetToken?
2626
examSubmissions ExamSubmission[]
2727
ExamProgress ExamProgress[]
28+
payments Payment[]
2829
2930
@@map("users")
3031
}
@@ -100,6 +101,7 @@ model Exam {
100101
createdAt DateTime @default(now())
101102
updatedAt DateTime @updatedAt
102103
ExamProgress ExamProgress[]
104+
payments Payment[]
103105
104106
@@map("exams")
105107
}
@@ -151,3 +153,19 @@ model ExamProgress {
151153
152154
@@unique([examId, userId])
153155
}
156+
157+
model Payment {
158+
id String @id @default(cuid())
159+
userId String
160+
examId String
161+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
162+
exam Exam @relation(fields: [examId], references: [id], onDelete: Cascade)
163+
amount Int
164+
status String
165+
orderId String
166+
createdAt DateTime @default(now())
167+
updatedAt DateTime @updatedAt
168+
169+
@@index([userId, examId])
170+
@@map("payments")
171+
}
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import AvailableExams from '@/components/exams/avaiable'
22
import React, { useEffect } from 'react'
3+
import { validateRequest } from '@/auth'
4+
35

46
const Page = async () => {
5-
return <AvailableExams />
7+
const session = await validateRequest()
8+
const user = session?.user
9+
10+
if (!user) return null
11+
12+
return <AvailableExams user={user} />
613
}
714

815
export default Page

src/app/api/order/create/route.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Razorpay from 'razorpay'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
4+
import { validateRequest } from '@/auth'
5+
import { z } from 'zod'
6+
7+
const razorpayInstance = new Razorpay({
8+
key_id: process.env.RAZORPAY_ID || '',
9+
key_secret: process.env.RAZORPAY_SECRET || '',
10+
})
11+
12+
const payloadSchema = z.object({
13+
amount: z.string(),
14+
currency: z.string(),
15+
})
16+
17+
export async function POST(request: NextRequest) {
18+
const session = await validateRequest()
19+
const user = session?.user
20+
if (!user) {
21+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
22+
}
23+
24+
const payload = await request.json()
25+
const { amount, currency } = payloadSchema.parse(payload)
26+
27+
const options = {
28+
amount,
29+
currency,
30+
receipt: 'rcp1',
31+
}
32+
33+
try {
34+
const order = await razorpayInstance.orders.create(options)
35+
return NextResponse.json(
36+
{
37+
id: order.id,
38+
currency: order.currency,
39+
amount: order.amount,
40+
},
41+
{ status: 200 }
42+
)
43+
} catch (error) {
44+
return NextResponse.json({ error }, { status: 500 })
45+
}
46+
}

src/app/api/order/verify/route.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { validateWebhookSignature } from 'razorpay/dist/utils/razorpay-utils'
3+
import { z } from 'zod'
4+
5+
import db from '@/lib/db'
6+
7+
const payloadSchema = z.object({
8+
payment: z.object({
9+
entity: z.object({
10+
amount: z.number(),
11+
id: z.string(),
12+
notes: z.object({
13+
userId: z.string(),
14+
examId: z.string(),
15+
}),
16+
order_id: z.string(),
17+
status: z.string(),
18+
}),
19+
}),
20+
})
21+
22+
export async function POST(req: NextRequest) {
23+
const jsonBody = await req.json()
24+
const razorpay_signature: string | null = req.headers.get(
25+
'X-Razorpay-Signature'
26+
)
27+
28+
if (!razorpay_signature)
29+
return NextResponse.json({ error: 'Signature not found' }, { status: 404 })
30+
31+
const isPaymentValid: boolean = validateWebhookSignature(
32+
JSON.stringify(jsonBody),
33+
razorpay_signature,
34+
process.env.RAZORPAY_WEBHOOK_SECRET!
35+
)
36+
37+
if (!isPaymentValid) {
38+
return NextResponse.json(
39+
{ error: 'Payment not verified. Payment signature invalid' },
40+
{ status: 404 }
41+
)
42+
}
43+
const {
44+
payment: {
45+
entity: {
46+
amount,
47+
id: paymentId,
48+
notes: { userId, examId },
49+
order_id,
50+
status,
51+
},
52+
},
53+
} = payloadSchema.parse(jsonBody.payload)
54+
55+
if (status !== 'authorized' && status !== 'captured') {
56+
return NextResponse.json(
57+
{ error: 'Payment not authorized' },
58+
{ status: 404 }
59+
)
60+
}
61+
62+
try {
63+
await db.payment.create({
64+
data: {
65+
amount,
66+
examId,
67+
id: paymentId,
68+
orderId: order_id,
69+
status,
70+
userId,
71+
},
72+
})
73+
74+
return NextResponse.json(
75+
{ message: 'Purchase Successful' },
76+
{ status: 200 }
77+
)
78+
} catch (error) {
79+
console.log(error)
80+
return NextResponse.json({ error }, { status: 409 })
81+
}
82+
}

src/app/globals.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ body {
107107
--bg-opacity: 0.5;
108108
}
109109

110-
111110
::view-transition-group(root) {
112111
animation-duration: 0.7s;
113112
animation-timing-function: linear(
@@ -202,3 +201,10 @@ body {
202201
opacity: 0;
203202
}
204203
}
204+
/* required as there was a white background by default */
205+
.razorpay-container {
206+
height: 70% !important;
207+
max-width: 380px;
208+
inset: 0;
209+
margin: auto;
210+
}

src/app/layout.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Metadata } from 'next'
22
import { Inter } from 'next/font/google'
3-
import './globals.css'
3+
import NextTopLoader from 'nextjs-toploader'
4+
import { Toaster } from 'sonner'
5+
46
import { ThemeProvider } from '@/components/providers/theme-provider'
57
import { ScrollToTopButton } from '@/components/global/scroll-to-top-button'
6-
import { Toaster } from 'sonner'
7-
import NextTopLoader from 'nextjs-toploader'
8+
9+
import './globals.css'
810

911
const inter = Inter({ subsets: ['latin'] })
1012

src/components/exams/avaiable.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
'use client'
44

55
import { useEffect, useState } from 'react'
6-
import Link from 'next/link'
6+
import { useRouter } from 'next/navigation'
77
import { motion, useAnimation } from 'framer-motion'
8+
import { User } from 'lucia'
9+
import { useRazorpay } from '@/hooks/use-razorpay'
810
import {
911
Clock,
1012
CreditCard,
13+
Loader2,
1114
FilePlus2,
1215
FileQuestion,
1316
Pencil,
@@ -39,8 +42,11 @@ type Exam = {
3942
price: number
4043
}
4144

42-
export default function AvailableExams() {
43-
const { exams, setExams } = useGlobalStore()
45+
export default function AvailableExams({ user }: { user: User }) {
46+
const { exams, setExams } = useGlobalStore((state) => ({
47+
exams: state.exams,
48+
setExams: state.setExams,
49+
}))
4450

4551
const [selectedExam, setSelectedExam] = useState<Exam | null>(null)
4652
const [isCreateModalOpen, setCreateModalOpen] = useState(false)
@@ -50,6 +56,10 @@ export default function AvailableExams() {
5056
const [examToDelete, setExamToDelete] = useState<string | null>(null)
5157

5258
const controls = useAnimation()
59+
const router = useRouter()
60+
const processPayment = useRazorpay()
61+
62+
const [isLoading, setIsLoading] = useState(false)
5363

5464
useEffect(() => {
5565
controls.start((i) => ({
@@ -108,6 +118,21 @@ export default function AvailableExams() {
108118
return colors[index % colors.length]
109119
}
110120

121+
const handlePaymentSuccess = (examId: string) => {
122+
router.push(`/take/${examId}`)
123+
}
124+
125+
const handleTakeTestClick = (examId: string, amount: number) => async () => {
126+
setIsLoading(true)
127+
await processPayment({
128+
amount,
129+
examId,
130+
successCallback: () => handlePaymentSuccess(examId),
131+
user,
132+
})
133+
setIsLoading(false)
134+
}
135+
111136
return (
112137
<div className='min-h-screen w-full'>
113138
<div className='mx-auto px-4 py-12'>
@@ -120,7 +145,7 @@ export default function AvailableExams() {
120145
<h1 className='text-4xl font-bold text-foreground'>
121146
Available Exams
122147
</h1>
123-
<p className='mt-2 text-lg text-muted-foreground max-w-2xl'>
148+
<p className='mt-2 max-w-2xl text-lg text-muted-foreground'>
124149
Choose from our selection of professional exams to test and
125150
certify your skills.
126151
</p>
@@ -143,13 +168,13 @@ export default function AvailableExams() {
143168
whileHover={{ y: -5, transition: { duration: 0.2 } }}
144169
>
145170
<Card
146-
className={`h-full flex flex-col bg-gradient-to-br ${getGradientColor(
171+
className={`flex h-full flex-col bg-gradient-to-br ${getGradientColor(
147172
index
148-
)} hover:shadow-lg transition-all duration-300 border border-secondary`}
173+
)} border border-secondary transition-all duration-300 hover:shadow-lg`}
149174
>
150175
<CardHeader>
151176
<div className='flex justify-between'>
152-
<CardTitle className='text-xl mb-2'>
177+
<CardTitle className='mb-2 text-xl'>
153178
{exam.title}
154179
</CardTitle>
155180
<div className='flex gap-2'>
@@ -171,29 +196,37 @@ export default function AvailableExams() {
171196
<CardDescription>{exam.description}</CardDescription>
172197
</CardHeader>
173198
<CardContent className='flex-grow'>
174-
<div className='flex items-center mb-4 text-muted-foreground'>
199+
<div className='mb-4 flex items-center text-muted-foreground'>
175200
<Clock className='mr-2 h-4 w-4' />
176201
<span>{exam.duration} minutes</span>
177202
</div>
178-
<div className='flex items-center mb-4 text-muted-foreground'>
203+
<div className='mb-4 flex items-center text-muted-foreground'>
179204
<FileQuestion className='mr-2 h-4 w-4' />
180205
<span>{exam.numQuestions} Questions</span>
181206
</div>
182-
<div className='flex items-center text-foreground font-semibold'>
207+
<div className='flex items-center font-semibold text-foreground'>
183208
<CreditCard className='mr-2 h-4 w-4' />
184209
<span>INR {exam.price}</span>
185210
</div>
186211
</CardContent>
187212
<CardFooter>
188-
<Button asChild className='w-full'>
189-
<Link href={`/take/${exam.id}`}>Take Test</Link>
213+
<Button
214+
className='w-full'
215+
disabled={isLoading}
216+
onClick={handleTakeTestClick(exam.id, exam.price)}
217+
>
218+
{isLoading ? (
219+
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
220+
) : (
221+
'Pay & Take Test'
222+
)}
190223
</Button>
191224
</CardFooter>
192225
</Card>
193226
</motion.div>
194227
))
195228
) : (
196-
<div className='flex flex-col h-96 justify-center items-center'>
229+
<div className='flex h-96 flex-col items-center justify-center'>
197230
<Loader />
198231
Loading Exams...
199232
</div>

0 commit comments

Comments
 (0)