From 5c1651381813af224e5d1e7aa0fe123bfc20183c Mon Sep 17 00:00:00 2001 From: Suryansh Chourasia Date: Mon, 9 Sep 2024 19:39:45 +0530 Subject: [PATCH 1/4] feat/add razorpay --- .env.example | 6 +- package.json | 1 + .../migration.sql | 31 ++++++ prisma/schema.prisma | 32 +++++-- src/actions/job.action.ts | 29 +++++- src/app/api/order/route.ts | 39 ++++++++ src/app/api/verify/route.ts | 52 ++++++++++ src/components/job-form.tsx | 45 ++++----- src/hooks/useRazorPay.ts | 95 +++++++++++++++++++ 9 files changed, 291 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20240909140232_add_razorpay_schema/migration.sql create mode 100644 src/app/api/order/route.ts create mode 100644 src/app/api/verify/route.ts create mode 100644 src/hooks/useRazorPay.ts diff --git a/.env.example b/.env.example index 146c48e8..f606d153 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ DATABASE_URL="postgres://postgres:postgres@db:5432/job-board-db" NEXTAUTH_SECRET="koXrQGB5TFDhiX47swxHW+4KALDX4kAvnQ5RHHvAOIzB" -NEXTAUTH_URL="http://localhost:3000" \ No newline at end of file +NEXTAUTH_URL="http://localhost:3000" + +RAZORPAY_WEBHOOK_SECRET= +RAZORPAY_ID= +RAZORPAY_SECRET= \ No newline at end of file diff --git a/package.json b/package.json index d1a451a5..6e189553 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "next-auth": "^4.24.7", "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.12", + "razorpay": "^2.9.4", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.2", diff --git a/prisma/migrations/20240909140232_add_razorpay_schema/migration.sql b/prisma/migrations/20240909140232_add_razorpay_schema/migration.sql new file mode 100644 index 00000000..65a00077 --- /dev/null +++ b/prisma/migrations/20240909140232_add_razorpay_schema/migration.sql @@ -0,0 +1,31 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "transactionId" TEXT; + +-- CreateTable +CREATE TABLE "RazorpayTransactions" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "razorpayPaymentId" TEXT NOT NULL, + "razorpayOrderId" TEXT NOT NULL, + "razorpaySignature" TEXT NOT NULL, + "status" TEXT NOT NULL, + "jobId" TEXT, + + CONSTRAINT "RazorpayTransactions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpayPaymentId_key" ON "RazorpayTransactions"("razorpayPaymentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpayOrderId_key" ON "RazorpayTransactions"("razorpayOrderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpaySignature_key" ON "RazorpayTransactions"("razorpaySignature"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_jobId_key" ON "RazorpayTransactions"("jobId"); + +-- AddForeignKey +ALTER TABLE "RazorpayTransactions" ADD CONSTRAINT "RazorpayTransactions_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 29409066..850468ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,21 +19,35 @@ model User { } model Job { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String title String description String? - companyName String @map("company_name") - workMode WorkMode @map("work_mode") - currency Currency @default(INR) + companyName String @map("company_name") + workMode WorkMode @map("work_mode") + currency Currency @default(INR) location JobLocations - hasSalaryRange Boolean @default(false) @map("has_salary_range") + hasSalaryRange Boolean @default(false) @map("has_salary_range") minSalary Int? maxSalary Int? - isVerifiedJob Boolean @default(false) @map("is_verified_job") - postedAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + isVerifiedJob Boolean @default(false) @map("is_verified_job") + postedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + transaction RazorpayTransactions? + transactionId String? +} + +model RazorpayTransactions { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + razorpayPaymentId String @unique + razorpayOrderId String @unique + razorpaySignature String @unique + status String + job Job? @relation(fields: [jobId], references: [id]) + jobId String? @unique } enum Currency { diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index ccea7d26..c466b1d6 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -16,7 +16,9 @@ import { getAllJobsAdditonalType, getJobType } from '@/types/jobs.types'; type additional = { isVerifiedJob: boolean; + jobId: string; }; + export const createJob = withServerActionAsyncCatcher< JobPostSchemaType, ServerActionReturnType @@ -32,7 +34,8 @@ export const createJob = withServerActionAsyncCatcher< maxSalary, minSalary, } = result; - await prisma.job.create({ + + const createdJob = await prisma.job.create({ data: { userId: '1', // Default to 1 since there's no session to check for user id title, @@ -47,8 +50,8 @@ export const createJob = withServerActionAsyncCatcher< }, }); const message = 'Job created successfully, waiting for admin approval'; - const additonal = { isVerifiedJob: false }; - return new SuccessResponse(message, 201, additonal).serialize(); + const additional = { isVerifiedJob: false, jobId: createdJob.id }; + return new SuccessResponse(message, 201, additional).serialize(); }); export const getAllJobs = withServerActionAsyncCatcher< @@ -126,3 +129,23 @@ export const getJobById = withServerActionAsyncCatcher< job, }).serialize(); }); + +export const createTransactions = async (response: any) => { + try { + await prisma.razorpayTransactions.create({ + data: { + razorpayOrderId: response.razorpayOrderId, + razorpayPaymentId: response.razorpayPaymentId, + razorpaySignature: response.razorpaySignature, + status: response.status, + job: { connect: { id: response.jobId } }, + }, + }); + return new SuccessResponse( + 'Transaction successfully, waiting for admin approval', + 201 + ).serialize(); + } catch (error) { + console.error('Verification error createTransactions:', error); + } +}; diff --git a/src/app/api/order/route.ts b/src/app/api/order/route.ts new file mode 100644 index 00000000..7d5262bc --- /dev/null +++ b/src/app/api/order/route.ts @@ -0,0 +1,39 @@ +import Razorpay from 'razorpay'; +import { NextRequest, NextResponse } from 'next/server'; + +import { z } from 'zod'; + +const razorpayInstance = new Razorpay({ + key_id: process.env.RAZORPAY_ID || '', + key_secret: process.env.RAZORPAY_SECRET || '', +}); + +const payloadSchema = z.object({ + amount: z.string(), + currency: z.string(), +}); + +export async function POST(request: NextRequest) { + try { + const payload = await request.json(); + + const { amount, currency } = payloadSchema.parse(payload); + const options = { + amount, + currency, + receipt: 'rcp1', + }; + const order = await razorpayInstance.orders.create(options); + + return NextResponse.json( + { + id: order.id, + currency: order.currency, + amount: order.amount, + }, + { status: 200 } + ); + } catch (error) { + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts new file mode 100644 index 00000000..2a8ae929 --- /dev/null +++ b/src/app/api/verify/route.ts @@ -0,0 +1,52 @@ +import { createJob, createTransactions } from '@/actions/job.action'; +import { NextRequest, NextResponse } from 'next/server'; +import { validateWebhookSignature } from 'razorpay/dist/utils/razorpay-utils'; + +export async function POST(req: NextRequest) { + const jsonBody = await req.json(); + const razorpay_signature: string | null = req.headers.get( + 'X-Razorpay-Signature' + ); + + if (!razorpay_signature) + return NextResponse.json({ error: 'Signature not found' }, { status: 404 }); + + const isPaymentValid: boolean = validateWebhookSignature( + JSON.stringify(jsonBody), + razorpay_signature, + process.env.RAZORPAY_WEBHOOK_SECRET! + ); + + if (!isPaymentValid) { + return NextResponse.json( + { error: 'Payment not verified. Payment signature invalid' }, + { status: 404 } + ); + } + + const jobData = jsonBody.payload.payment.entity.notes; + + try { + const response = await createJob(jobData); + + if (!response.status) { + return NextResponse.json({ error: response.message }, { status: 404 }); + } + + const transactionData = { + razorpayOrderId: jsonBody.payload.payment.entity.order_id, + razorpayPaymentId: jsonBody.payload.payment.entity.id, + razorpaySignature: razorpay_signature, + jobId: response?.additional?.jobId, + status: jsonBody.payload.payment.entity.status, + }; + await createTransactions(transactionData); + + return NextResponse.json( + { message: 'Purchase Successful' }, + { status: 200 } + ); + } catch (error) { + return NextResponse.json({ error }, { status: 409 }); + } +} diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index 6219e3e5..e550c21e 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -16,7 +16,7 @@ import { SelectValue, } from '@/components/ui/select'; import { zodResolver } from '@hookform/resolvers/zod'; -import React from 'react'; +import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { JobPostSchema, @@ -29,15 +29,21 @@ import { Label } from './ui/label'; import { Switch } from './ui/switch'; import { useToast } from './ui/use-toast'; import { WorkMode } from '@prisma/client'; +import { Loader2 } from 'lucide-react'; +import { useRazorpay } from '@/hooks/useRazorPay'; +import { useRouter } from 'next/navigation'; + const PostJobForm = () => { - const { toast } = useToast(); + const router = useRouter(); + const processPayment = useRazorpay(); + const [isLoading, setIsLoading] = useState(false); const form = useForm({ resolver: zodResolver(JobPostSchema), defaultValues: { title: '', description: '', - companyName: '', + companyName: '', location: undefined, hasSalaryRange: false, minSalary: 0, @@ -45,28 +51,15 @@ const PostJobForm = () => { }, }); const handleFormSubmit = async (data: JobPostSchemaType) => { - try { - const response = await createJob(data); - - if (!response.status) { - return toast({ - title: response.name || 'Something went wrong', - description: response.message || 'Internal server error', - variant: 'destructive', - }); - } - toast({ - title: response.message, - variant: 'success', - }); - form.reset(form.formState.defaultValues); - } catch (_error) { - toast({ - title: 'Something went wrong will creating job', - description: 'Internal server error', - variant: 'destructive', - }); - } + setIsLoading(true); + await processPayment({ + amount: 1000, + data, + successCallback: () => { + router.push('/'); //Note: Check where to redirect after payment + }, + }); + setIsLoading(false); }; const watchHasSalaryRange = form.watch('hasSalaryRange'); @@ -265,7 +258,7 @@ const PostJobForm = () => { )}
-
diff --git a/src/hooks/useRazorPay.ts b/src/hooks/useRazorPay.ts new file mode 100644 index 00000000..4a8eeb42 --- /dev/null +++ b/src/hooks/useRazorPay.ts @@ -0,0 +1,95 @@ +import { toast } from '@/components/ui/use-toast'; +import { JobPostSchemaType } from '@/lib/validators/jobs.validator'; + +const intializeRazorpay = () => { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + reject(false); + }; + document.body.appendChild(script); + }); +}; + +export const useRazorpay = () => { + const processPayment = async ({ + data, + amount, + successCallback, + }: { + amount: number; + data: JobPostSchemaType; + successCallback: () => void; + }) => { + const razorpay = await intializeRazorpay(); + if (!razorpay) { + toast({ + title: 'Failed to initialize Razorpay', + variant: 'destructive', + }); + return; + } + + const order = await fetch('/api/order', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: (amount * 100).toString(), + currency: 'INR', + }), + }); + const orderDetails = await order.json(); + + if (!orderDetails.id) { + toast({ + title: 'Failed to create order', + variant: 'destructive', + }); + return; + } + const options = { + key: process.env.RAZORPAY_ID, + amount: orderDetails.amount, + currency: orderDetails.currency, + name: data.title, + description: data.description, + order_id: orderDetails.id, + // prefill: { + // email: data.email, // Note: Add email field after commit + // }, + notes: data, + modal: { + ondismiss: () => { + toast({ + title: 'Payment Cancelled', + variant: 'destructive', + }); + }, + }, + handler: () => { + toast({ + title: 'Payment Successful Thank you!', + variant: 'success', + }); + successCallback && successCallback(); + }, + }; + + const paymentObject = new (window as any).Razorpay(options); + paymentObject.on('payment.failed', (response: any) => { + toast({ + title: response.error.description, + variant: 'destructive', + }); + }); + paymentObject.open(); + }; + return processPayment; +}; From c861ae7a3f521a07e38a627c0f6f7ba392e1bdc7 Mon Sep 17 00:00:00 2001 From: Suryansh Chourasia Date: Thu, 12 Sep 2024 22:59:05 +0530 Subject: [PATCH 2/4] merged main into feat/razorpay --- .env.example | 7 +- next.config.js | 9 + package.json | 4 + .../migrations/20240902211016_/migration.sql | 15 - .../migration.sql | 10 +- prisma/schema.prisma | 8 +- prisma/seed.ts | 343 +++------- src/actions/job.action.ts | 16 + src/app/[...404]/page.tsx | 57 +- src/app/api/s3-upload/route.ts | 43 ++ src/app/create/page.tsx | 8 +- src/app/globals.css | 48 ++ src/components/DescriptionEditor.tsx | 63 ++ src/components/job-form.tsx | 590 +++++++++++++----- src/components/job-landing.tsx | 17 +- src/hooks/useRazorPay.ts | 6 +- src/lib/validators/jobs.validator.ts | 6 + src/types/jobs.types.ts | 1 + 18 files changed, 789 insertions(+), 462 deletions(-) delete mode 100644 prisma/migrations/20240902211016_/migration.sql rename prisma/migrations/{20240901095201_ => 20240909111324_new_fields}/migration.sql (77%) create mode 100644 src/app/api/s3-upload/route.ts create mode 100644 src/components/DescriptionEditor.tsx diff --git a/.env.example b/.env.example index f606d153..9d0f15be 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ DATABASE_URL="postgres://postgres:postgres@db:5432/job-board-db" NEXTAUTH_SECRET="koXrQGB5TFDhiX47swxHW+4KALDX4kAvnQ5RHHvAOIzB" NEXTAUTH_URL="http://localhost:3000" - RAZORPAY_WEBHOOK_SECRET= RAZORPAY_ID= -RAZORPAY_SECRET= \ No newline at end of file +RAZORPAY_SECRET= +AWS_S3_REGION=your-aws-region +AWS_S3_ACCESS_KEY_ID=your-access-ID +AWS_S3_SECRET_ACCESS_KEY=your-access-key +AWS_S3_BUCKET_NAME=your-bucket diff --git a/next.config.js b/next.config.js index 5b4cce95..8e2fe13f 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,15 @@ const nextConfig = { fullUrl: true, }, }, + images: { + remotePatterns: [ + { + protocol: 'https', + //Add aws s3 bucket hostname + hostname: '', //example - youraws.s3.ap-south-2.amazonaws.com + }, + ], + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index 6e189553..0be342e2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" }, "dependencies": { + "@aws-sdk/client-s3": "^3.645.0", + "@aws-sdk/s3-request-presigner": "^3.645.0", "@hookform/resolvers": "^3.9.0", "@prisma/client": "5.18.0", "@radix-ui/react-accordion": "^1.2.0", @@ -39,6 +41,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "framer-motion": "^11.5.4", "lodash": "^4.17.21", "lucide-react": "^0.426.0", "next": "14.2.5", @@ -50,6 +53,7 @@ "react-dom": "^18", "react-hook-form": "^7.52.2", "react-icons": "^5.2.1", + "react-quill": "^2.0.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1", diff --git a/prisma/migrations/20240902211016_/migration.sql b/prisma/migrations/20240902211016_/migration.sql deleted file mode 100644 index 3029dfde..00000000 --- a/prisma/migrations/20240902211016_/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - Changed the type of `location` on the `Job` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - -*/ --- CreateEnum -CREATE TYPE "JobLocations" AS ENUM ('BANGLORE', 'DELHI', 'MUMBAI', 'PUNE', 'CHENNAI', 'HYDERABAD', 'KOLKATA', 'AHMEDABAD', 'JAIPUR', 'SURAT'); - --- AlterTable -ALTER TABLE "Job" DROP COLUMN "location", -ADD COLUMN "location" "JobLocations" NOT NULL; - --- DropEnum -DROP TYPE "Location"; diff --git a/prisma/migrations/20240901095201_/migration.sql b/prisma/migrations/20240909111324_new_fields/migration.sql similarity index 77% rename from prisma/migrations/20240901095201_/migration.sql rename to prisma/migrations/20240909111324_new_fields/migration.sql index 3ac83c34..b49156f4 100644 --- a/prisma/migrations/20240901095201_/migration.sql +++ b/prisma/migrations/20240909111324_new_fields/migration.sql @@ -8,7 +8,7 @@ CREATE TYPE "WorkMode" AS ENUM ('remote', 'hybrid', 'office'); CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); -- CreateEnum -CREATE TYPE "Location" AS ENUM ('BANGLORE', 'DELHI', 'MUMBAI', 'PUNE', 'CHENNAI', 'HYDERABAD', 'KOLKATA', 'AHMEDABAD', 'JAIPUR', 'SURAT'); +CREATE TYPE "JobLocations" AS ENUM ('BANGLORE', 'DELHI', 'MUMBAI', 'CHENNAI', 'PUNE', 'HYDERABAD', 'KOLKATA', 'AHMEDABAD', 'JAIPUR', 'SURAT'); -- CreateTable CREATE TABLE "User" ( @@ -30,9 +30,15 @@ CREATE TABLE "Job" ( "title" TEXT NOT NULL, "description" TEXT, "company_name" TEXT NOT NULL, + "company_bio" TEXT NOT NULL, + "company_email" TEXT NOT NULL, + "category" TEXT NOT NULL, + "type" TEXT NOT NULL, "work_mode" "WorkMode" NOT NULL, "currency" "Currency" NOT NULL DEFAULT 'INR', - "location" "Location" NOT NULL, + "location" "JobLocations" NOT NULL, + "application" TEXT NOT NULL, + "companyLogo" TEXT NOT NULL, "has_salary_range" BOOLEAN NOT NULL DEFAULT false, "minSalary" INTEGER, "maxSalary" INTEGER, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 850468ce..e2f16026 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,9 +24,15 @@ model Job { title String description String? companyName String @map("company_name") + companyBio String @map("company_bio") + companyEmail String @map("company_email") + category String + type String workMode WorkMode @map("work_mode") currency Currency @default(INR) location JobLocations + application String + companyLogo String hasSalaryRange Boolean @default(false) @map("has_salary_range") minSalary Int? maxSalary Int? @@ -70,8 +76,8 @@ enum JobLocations { BANGLORE DELHI MUMBAI - PUNE CHENNAI + PUNE HYDERABAD KOLKATA AHMEDABAD diff --git a/prisma/seed.ts b/prisma/seed.ts index 81001261..347cfcfc 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -18,8 +18,15 @@ let jobs = [ title: 'Frontend Developer', description: 'Develop and maintain web applications.', companyName: 'Tech Corp', + companyBio: + 'Leading tech solutions provider specializing in innovative web development.', + companyEmail: 'contact@techcorp.com', + category: 'development', + type: 'full-time', workMode: WorkMode.remote, currency: Currency.USD, + application: 'apply@techcorp.com', + companyLogo: '', hasSalaryRange: true, minSalary: 60000, maxSalary: 80000, @@ -31,8 +38,15 @@ let jobs = [ title: 'Backend Developer', description: 'Build and maintain server-side logic.', companyName: 'Innovatech', + companyBio: + 'Innovatech specializes in backend systems and cloud-based solutions.', + companyEmail: 'careers@innovatech.com', + category: 'development', + type: 'full-time', workMode: WorkMode.office, - currency: Currency.INR, + currency: Currency.USD, + application: 'jobs@innovatech.com', + companyLogo: '', hasSalaryRange: false, minSalary: null, maxSalary: null, @@ -44,8 +58,15 @@ let jobs = [ title: 'Full Stack Developer', description: 'Develop both client-side and server-side software.', companyName: 'Global Solutions', + companyBio: + 'Global Solutions offers comprehensive IT services for businesses worldwide.', + companyEmail: 'recruitment@globalsolutions.com', + category: 'development', + type: 'full-time', workMode: WorkMode.hybrid, currency: Currency.USD, + application: 'careers@globalsolutions.com', + companyLogo: '', hasSalaryRange: true, minSalary: 90000, maxSalary: 120000, @@ -58,8 +79,15 @@ let jobs = [ description: 'Automate and streamline the company’s operations and processes.', companyName: 'DevOps Ltd.', + companyBio: + 'DevOps Ltd. specializes in automation and cloud infrastructure management.', + companyEmail: 'jobs@devopsltd.com', + category: 'development', + type: 'full-time', workMode: WorkMode.remote, - currency: Currency.INR, + currency: Currency.USD, + application: 'apply@devopsltd.com', + companyLogo: '', hasSalaryRange: true, minSalary: 50000, maxSalary: 70000, @@ -72,8 +100,15 @@ let jobs = [ description: 'Oversee product development and ensure the success of the product.', companyName: 'Productive Minds', + companyBio: + 'Productive Minds helps businesses achieve their goals through strategic product management.', + companyEmail: 'hr@productiveminds.com', + category: 'management', + type: 'full-time', workMode: WorkMode.hybrid, currency: Currency.USD, + application: 'careers@productiveminds.com', + companyLogo: '', hasSalaryRange: true, minSalary: 110000, maxSalary: 150000, @@ -86,8 +121,15 @@ let jobs = [ description: 'Analyze and interpret complex data to help the company make informed decisions.', companyName: 'Data Insights', + companyBio: + 'Data Insights provides data-driven solutions to empower businesses.', + companyEmail: 'recruitment@datainsights.com', + category: 'development', + type: 'full-time', workMode: WorkMode.office, - currency: Currency.INR, + currency: Currency.USD, + application: 'apply@datainsights.com', + companyLogo: '', hasSalaryRange: true, minSalary: 80000, maxSalary: 100000, @@ -100,8 +142,15 @@ let jobs = [ description: 'Design user-friendly interfaces for web and mobile applications.', companyName: 'Creative Designs', + companyBio: + 'Creative Designs excels in crafting intuitive and visually appealing user interfaces.', + companyEmail: 'careers@creativedesigns.com', + category: 'design', + type: 'full-time', workMode: WorkMode.remote, currency: Currency.USD, + application: 'jobs@creativedesigns.com', + companyLogo: '', hasSalaryRange: true, minSalary: 70000, maxSalary: 90000, @@ -113,8 +162,15 @@ let jobs = [ title: 'Mobile App Developer', description: 'Develop and maintain mobile applications.', companyName: 'App Innovators', + companyBio: + 'App Innovators is a leader in mobile application development and innovation.', + companyEmail: 'careers@appinnovators.com', + category: 'development', + type: 'full-time', workMode: WorkMode.hybrid, - currency: Currency.INR, + currency: Currency.USD, + application: 'apply@appinnovators.com', + companyLogo: '', hasSalaryRange: false, minSalary: null, maxSalary: null, @@ -126,8 +182,14 @@ let jobs = [ title: 'Cloud Engineer', description: 'Design and manage cloud-based systems and services.', companyName: 'Cloud Works', + companyBio: 'Cloud Works provides cutting-edge cloud computing solutions.', + companyEmail: 'hr@cloudworks.com', + category: 'development', + type: 'full-time', workMode: WorkMode.office, currency: Currency.USD, + application: 'careers@cloudworks.com', + companyLogo: '', hasSalaryRange: true, minSalary: 100000, maxSalary: 130000, @@ -139,8 +201,15 @@ let jobs = [ title: 'Security Analyst', description: 'Ensure the security and integrity of company systems.', companyName: 'SecureTech', + companyBio: + 'SecureTech specializes in cybersecurity solutions for modern businesses.', + companyEmail: 'security@securetech.com', + category: 'support', + type: 'full-time', workMode: WorkMode.remote, - currency: Currency.INR, + currency: Currency.USD, + application: 'jobs@securetech.com', + companyLogo: '', hasSalaryRange: true, minSalary: 75000, maxSalary: 95000, @@ -152,8 +221,15 @@ let jobs = [ title: 'QA Engineer', description: 'Ensure the quality of software products.', companyName: 'QA Solutions', + companyBio: + 'QA Solutions ensures top-notch quality assurance services for software.', + companyEmail: 'contact@qasolutions.com', + category: 'support', + type: 'full-time', workMode: WorkMode.remote, currency: Currency.USD, + application: 'apply@qasolutions.com', + companyLogo: '', hasSalaryRange: true, minSalary: 45000, maxSalary: 50000, @@ -163,249 +239,22 @@ let jobs = [ id: '12', userId: '2', title: 'Technical Writer', - description: 'Write technical documentation for software and hardware.', - companyName: 'Tech Docs', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 30000, - maxSalary: 35000, - isVerifiedJob: false, - }, - { - id: '13', - userId: '1', - title: 'IT Support Specialist', - description: 'Provide technical support for IT systems.', - companyName: 'Support Corp', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 20000, - maxSalary: 25000, - isVerifiedJob: true, - }, - { - id: '14', - userId: '2', - title: 'Network Administrator', - description: 'Manage and maintain network infrastructure.', - companyName: 'Net Admins', - workMode: WorkMode.remote, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 35000, - maxSalary: 40000, - isVerifiedJob: true, - }, - { - id: '15', - userId: '1', - title: 'System Analyst', - description: 'Analyze and improve IT systems.', - companyName: 'Sys Solutions', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 27000, - maxSalary: 32000, - isVerifiedJob: false, - }, - { - id: '16', - userId: '2', - title: 'Sales Engineer', - description: 'Support sales teams with technical expertise.', - companyName: 'Sales Tech', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 30000, - maxSalary: 35000, - isVerifiedJob: true, - }, - { - id: '17', - userId: '1', - title: 'Marketing Specialist', - description: 'Develop and execute marketing strategies.', - companyName: 'Market Pro', - workMode: WorkMode.remote, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 20000, - maxSalary: 25000, - isVerifiedJob: true, - }, - { - id: '18', - userId: '2', - title: 'Content Manager', - description: 'Manage and curate content for the company.', - companyName: 'Content Creators', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 25000, - maxSalary: 30000, - isVerifiedJob: false, - }, - { - id: '19', - userId: '1', - title: 'Graphic Designer', - description: 'Design visual content for digital and print media.', - companyName: 'Design Pros', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 22000, - maxSalary: 27000, - isVerifiedJob: true, - }, - { - id: '20', - userId: '2', - title: 'Business Analyst', - description: 'Analyze business processes and recommend improvements.', - companyName: 'Business Solutions', + description: 'Create and manage technical documentation.', + companyName: 'WriteTech', + companyBio: + 'WriteTech specializes in high-quality technical writing services.', + companyEmail: 'hr@writetech.com', + category: 'writing', + type: 'contract', workMode: WorkMode.remote, currency: Currency.USD, - hasSalaryRange: true, - minSalary: 38000, - maxSalary: 43000, - isVerifiedJob: true, - }, - { - id: '21', - userId: '1', - title: 'SEO Specialist', - description: 'Optimize websites for search engines.', - companyName: 'SEO Experts', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 15000, - maxSalary: 20000, - isVerifiedJob: false, - }, - { - id: '22', - userId: '2', - title: 'Data Analyst', - description: 'Analyze data to provide business insights.', - companyName: 'DataPro', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 23000, - maxSalary: 28000, - isVerifiedJob: true, - }, - { - id: '23', - userId: '1', - title: 'Operations Manager', - description: 'Oversee daily operations of the company.', - companyName: 'OpsCorp', - workMode: WorkMode.remote, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 29000, - maxSalary: 34000, - isVerifiedJob: true, - }, - { - id: '24', - userId: '2', - title: 'Customer Service Manager', - description: 'Manage customer service teams and operations.', - companyName: 'Customer Care Inc.', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 26000, - maxSalary: 31000, - isVerifiedJob: true, - }, - { - id: '25', - userId: '1', - title: 'Product Designer', - description: 'Design and develop new products.', - companyName: 'Product Innovators', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 32000, - maxSalary: 37000, - isVerifiedJob: true, - }, - { - id: '26', - userId: '2', - title: 'Social Media Manager', - description: 'Manage the company’s social media presence.', - companyName: 'Social Media Pros', - workMode: WorkMode.remote, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 18000, - maxSalary: 23000, - isVerifiedJob: true, - }, - { - id: '27', - userId: '1', - title: 'HR Specialist', - description: 'Manage human resources activities.', - companyName: 'HR Hub', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 24000, - maxSalary: 29000, - isVerifiedJob: true, - }, - { - id: '28', - userId: '2', - title: 'Supply Chain Manager', - description: 'Oversee supply chain operations.', - companyName: 'Supply Chain Solutions', - workMode: WorkMode.office, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 30000, - maxSalary: 35000, - isVerifiedJob: true, - }, - { - id: '29', - userId: '1', - title: 'E-commerce Manager', - description: 'Manage online sales and operations.', - companyName: 'E-commerce Pros', - workMode: WorkMode.remote, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 27000, - maxSalary: 32000, + application: 'careers@writetech.com', + companyLogo: '', + hasSalaryRange: false, + minSalary: null, + maxSalary: null, isVerifiedJob: true, }, - { - id: '30', - userId: '2', - title: 'Project Coordinator', - description: 'Assist in project management and coordination.', - companyName: 'Project Managers Inc.', - workMode: WorkMode.hybrid, - currency: Currency.USD, - hasSalaryRange: true, - minSalary: 12000, - maxSalary: 17000, - isVerifiedJob: false, - }, ]; async function seedUsers() { @@ -455,10 +304,16 @@ async function seedJobs() { title: j.title, description: j.description, companyName: j.companyName, + companyBio: j.companyBio, + companyEmail: j.companyEmail, + category: j.category, + type: j.type, workMode: j.workMode, currency: j.currency, + application: j.application, //@ts-ignore location: j.location, + companyLogo: j.companyLogo, hasSalaryRange: j.hasSalaryRange, minSalary: j.minSalary, maxSalary: j.maxSalary, diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index c466b1d6..85d0a57e 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -26,7 +26,13 @@ export const createJob = withServerActionAsyncCatcher< const result = JobPostSchema.parse(data); const { companyName, + companyBio, + companyEmail, + type, + category, + application, location, + companyLogo, title, workMode, description, @@ -41,10 +47,16 @@ export const createJob = withServerActionAsyncCatcher< title, description, companyName, + companyBio, + companyEmail, + type, + category, + application, hasSalaryRange, minSalary, maxSalary, location, + companyLogo, workMode, isVerifiedJob: false, // Default to false since there's no session to check for admin role }, @@ -86,6 +98,7 @@ export const getAllJobs = withServerActionAsyncCatcher< minSalary: true, maxSalary: true, postedAt: true, + companyLogo: true, }, }); const totalJobsPromise = prisma.job.count({ @@ -118,6 +131,9 @@ export const getJobById = withServerActionAsyncCatcher< title: true, description: true, companyName: true, + companyBio: true, + companyEmail: true, + companyLogo: true, location: true, workMode: true, minSalary: true, diff --git a/src/app/[...404]/page.tsx b/src/app/[...404]/page.tsx index dc33722b..42f7b062 100644 --- a/src/app/[...404]/page.tsx +++ b/src/app/[...404]/page.tsx @@ -1,8 +1,12 @@ +'use client'; import React from 'react'; import Link from 'next/link'; import { Poppins } from 'next/font/google'; +import { Home, AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { motion } from 'framer-motion'; -//applying font from google +// Applying font from Google const fontOptions = Poppins({ subsets: ['latin'], variable: '--font-poppins', @@ -11,29 +15,44 @@ const fontOptions = Poppins({ const Custom404Page = () => { return ( - <> -
+ -

- Oops! + + + +

+ 404 - Page Not Found

-

- We couldn’t find the page you’re looking for.
- 404 - PAGE NOT FOUND +

+ We are sorry, but we could not find the page you are looking for.

-

- The page you are looking for was moved, removed, renamed, or might - never have existed. +

+ The page may have been moved, removed, renamed, or might never have + existed.

- - Visit the homepage - -
- + + + + Return to Homepage + + + + + ); }; diff --git a/src/app/api/s3-upload/route.ts b/src/app/api/s3-upload/route.ts new file mode 100644 index 00000000..b3102bf3 --- /dev/null +++ b/src/app/api/s3-upload/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +const s3Client = new S3Client({ + region: process.env.AWS_S3_REGION!, + credentials: { + accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_S3_SECRET_ACCESS_KEY!, + }, +}); + +export async function GET(req: Request): Promise { + const url = new URL(req.url); + const file = url.searchParams.get('file'); + const fileType = url.searchParams.get('fileType'); + const uniqueKey = url.searchParams.get('uniqueKey'); + + if (!file || !fileType || !uniqueKey) { + return NextResponse.json( + { error: 'File name and file type are required' }, + { status: 400 } + ); + } + + try { + const command = new PutObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET_NAME!, + Key: uniqueKey, + ContentType: fileType, + }); + + const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 60 }); + + return NextResponse.json({ url: signedUrl }); + } catch (error) { + console.error('Error generating presigned URL:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 7b32f80e..92548bd3 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -3,7 +3,13 @@ import React from 'react'; const page = () => { return ( -
+
+
+

Post a job

+

+ 100xJobs is trusted by leading companies +

+
); diff --git a/src/app/globals.css b/src/app/globals.css index 6228a08a..b3c421ed 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -297,3 +297,51 @@ transform: rotate(360deg); } } + +/* Custom styles for Quill editor */ +.ql-toolbar.ql-snow { + border: none !important; + background-color: #1f2937 !important; + border-bottom: 1px solid #374151 !important; +} + +.ql-container.ql-snow { + border: none !important; + max-height: 56rem; /* Set a fixed height */ + overflow-y: hidden; /* Hide vertical overflow */ + overflow-x: auto; /* Allow horizontal scrolling */ +} + +.ql-editor { + min-height: 10px; + overflow-y: hidden; /* Prevent height increase */ + white-space: nowrap; /* Ensure long lines don't wrap */ +} + +.ql-editor.ql-blank::before { + color: #6b7280 !important; +} + +.ql-snow .ql-stroke { + stroke: #9ca3af !important; +} + +.ql-snow .ql-fill { + fill: #9ca3af !important; +} + +.ql-snow .ql-picker { + color: #9ca3af !important; +} + +/* Ensure the editor takes up full width of its container */ +.job-description-editor { + width: 100% !important; +} + +/* Ensure long content does not wrap within the editor */ +.job-description-editor .ql-editor { + white-space: nowrap !important; + overflow-x: hidden !important; /* Allow horizontal scrolling */ + overflow-y: hidden !important; /* Hide vertical overflow */ +} diff --git a/src/components/DescriptionEditor.tsx b/src/components/DescriptionEditor.tsx new file mode 100644 index 00000000..0bc0878f --- /dev/null +++ b/src/components/DescriptionEditor.tsx @@ -0,0 +1,63 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import dynamic from 'next/dynamic'; +const ReactQuill = dynamic(() => import('react-quill'), { ssr: false }); +import 'react-quill/dist/quill.snow.css'; + +interface DescriptionEditorProps { + fieldName: string; + initialValue?: string; + onDescriptionChange: (fieldName: string, content: string) => void; + placeholder?: string; +} + +const DescriptionEditor: React.FC = ({ + fieldName, + initialValue = '', + onDescriptionChange, + placeholder = '', +}) => { + const [description, setDescription] = useState(initialValue || ''); + + useEffect(() => { + setDescription(initialValue || ''); + }, [initialValue]); + + const handleChange = (content: string) => { + setDescription(content); + onDescriptionChange(fieldName, content); // Pass the content back to the parent form + }; + + const modules = { + toolbar: [ + ['bold', 'italic', 'underline'], + [{ header: '1' }, { header: '2' }], + [{ list: 'ordered' }, { list: 'bullet' }], + ['link'], + ], + }; + + const formats = [ + 'bold', + 'italic', + 'underline', + 'header', + 'list', + 'bullet', + 'link', + ]; + + return ( + + ); +}; + +export default DescriptionEditor; diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index e550c21e..7896a035 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -1,5 +1,5 @@ 'use client'; -import { createJob } from '@/actions/job.action'; + import { Form, FormControl, @@ -16,7 +16,7 @@ import { SelectValue, } from '@/components/ui/select'; import { zodResolver } from '@hookform/resolvers/zod'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { JobPostSchema, @@ -24,34 +24,110 @@ import { } from '../lib/validators/jobs.validator'; import { Button } from './ui/button'; import { Input } from './ui/input'; -import { Textarea } from './ui/textarea'; -import { Label } from './ui/label'; -import { Switch } from './ui/switch'; import { useToast } from './ui/use-toast'; -import { WorkMode } from '@prisma/client'; import { Loader2 } from 'lucide-react'; import { useRazorpay } from '@/hooks/useRazorPay'; import { useRouter } from 'next/navigation'; - +import { Calendar, LucideRocket, MailOpenIcon } from 'lucide-react'; +import DescriptionEditor from './DescriptionEditor'; +import Image from 'next/image'; +import { FaFileUpload } from 'react-icons/fa'; +import { Switch } from './ui/switch'; +import { Label } from './ui/label'; const PostJobForm = () => { + const { toast } = useToast(); const router = useRouter(); const processPayment = useRazorpay(); + const [isLoading, setIsLoading] = useState(false); + + const companyLogoImg = useRef(null); const form = useForm({ resolver: zodResolver(JobPostSchema), defaultValues: { title: '', description: '', - companyName: '', + companyName: '', + companyBio: '', + companyEmail: '', location: undefined, - hasSalaryRange: false, + companyLogo: '', + workMode: 'remote', + type: 'full-time', + category: 'design', + hasSalaryRange: true, minSalary: 0, maxSalary: 0, + application: '', }, }); + + const handleClick = () => { + //@ts-ignore + document.getElementById('fileInput').click(); + }; + + const [file, setFile] = useState(null); + const [previewImg, setPreviewImg] = useState(null); + + const handleDescriptionChange = (fieldName: any, value: String) => { + form.setValue(fieldName, value); + }; + + const submitImage = async (file: File | null) => { + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const uniqueFileName = `${file.name}-${Date.now()}`; + const fileType = file.type; + + const res = await fetch( + `/api/s3-upload?file=${encodeURIComponent(file.name)}&fileType=${encodeURIComponent(fileType)}&uniqueKey=${encodeURIComponent(uniqueFileName)}` + ); + if (!res.ok) { + throw new Error('Failed to fetch presigned URL'); + } + + const { url: presignedUrl } = await res.json(); + const upload = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': fileType }, + }); + + if (!upload.ok) { + throw new Error('Upload failed'); + } + + const pubUrl = presignedUrl.split('?')[0]; + return pubUrl; + } catch (error) { + console.error('Image upload failed:', error); + } + }; + + const handleFileChange = async (e: any) => { + const selectedFile = e.target.files[0]; + const reader = new FileReader(); + reader.onload = () => { + if (companyLogoImg.current) { + companyLogoImg.current.src = reader.result as string; + } + setPreviewImg(reader.result as string); + }; + reader.readAsDataURL(selectedFile); + if (selectedFile) { + setFile(selectedFile); + } + }; + const handleFormSubmit = async (data: JobPostSchemaType) => { setIsLoading(true); + data.companyLogo = (await submitImage(file)) ?? ''; await processPayment({ amount: 1000, data, @@ -59,6 +135,8 @@ const PostJobForm = () => { router.push('/'); //Note: Check where to redirect after payment }, }); + setPreviewImg(null); + form.reset(form.formState.defaultValues); setIsLoading(false); }; const watchHasSalaryRange = form.watch('hasSalaryRange'); @@ -69,201 +147,377 @@ const PostJobForm = () => { form.setValue('minSalary', 0); form.setValue('maxSalary', 0); } + form.setValue('companyLogo', 'https://wwww.example.com'); }, [watchHasSalaryRange, form]); return ( -
- -
- ( - - - Job Title - - - - - - - )} - /> +
+
+
+ +

Posted for

+

30 days

-
- ( - - - Description - - -