diff --git a/.gitignore b/.gitignore index ba44e4d0..82fc4d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ bun.lockb package-lock.json yarn.lock +**/public/sw.js +**/public/workbox-*.js +**/public/worker-*.js +**/public/sw.js.map +**/public/workbox-*.js.map +**/public/worker-*.js.map \ No newline at end of file diff --git a/next.config.js b/next.config.js index a715d6bf..376019e0 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,4 @@ +// next.config.js import { fileURLToPath } from 'node:url'; import createJiti from 'jiti'; @@ -20,12 +21,11 @@ const nextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'job-board.b-cdn.net', // Change this to your CDN domain + hostname: 'job-board.b-cdn.net', }, { protocol: 'https', hostname: 'lh3.googleusercontent.com', - // Change this to your CDN domain }, { protocol: 'https', @@ -39,4 +39,4 @@ const nextConfig = { }, }; -export default nextConfig; // ES module export +export default nextConfig; diff --git a/package.json b/package.json index c8c006cd..2a7f56c0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@mui/material": "^6.1.3", "@prisma/client": "5.18.0", "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", @@ -79,6 +80,7 @@ "react": "^18", "react-day-picker": "^8.10.1", "react-dom": "^18", + "react-dropzone": "^14.2.10", "react-hook-form": "^7.52.2", "react-icons": "^5.2.1", "react-quill": "^2.0.0", diff --git a/prisma/migrations/20241024174828_profileupdate/migration.sql b/prisma/migrations/20241024174828_profileupdate/migration.sql new file mode 100644 index 00000000..1c77885b --- /dev/null +++ b/prisma/migrations/20241024174828_profileupdate/migration.sql @@ -0,0 +1,66 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `aboutMe` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `contactEmail` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "ProjectStack" AS ENUM ('GO', 'PYTHON', 'MERN', 'NEXTJS', 'AI_GPT_APIS', 'SPRINGBOOT', 'OTHERS'); + +-- CreateEnum +CREATE TYPE "DegreeType" AS ENUM ('BTech', 'MTech', 'BCA', 'MCA'); + +-- CreateEnum +CREATE TYPE "FieldOfStudyType" AS ENUM ('AI', 'Machine_Learning', 'CS', 'Mechanical'); + +-- DropForeignKey +ALTER TABLE "Experience" DROP CONSTRAINT "Experience_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_userId_fkey"; + +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "deletedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "isFeature" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "projectThumbnail" TEXT, +ADD COLUMN "stack" "ProjectStack" NOT NULL DEFAULT 'OTHERS'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "aboutMe" TEXT NOT NULL, +ADD COLUMN "contactEmail" TEXT NOT NULL, +ADD COLUMN "discordLink" TEXT, +ADD COLUMN "githubLink" TEXT, +ADD COLUMN "linkedinLink" TEXT, +ADD COLUMN "portfolioLink" TEXT, +ADD COLUMN "twitterLink" TEXT, +ADD COLUMN "username" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "Education" ( + "id" SERIAL NOT NULL, + "instituteName" TEXT NOT NULL, + "degree" "DegreeType" NOT NULL, + "fieldOfStudy" "FieldOfStudyType" NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "userId" TEXT NOT NULL, + + CONSTRAINT "Education_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- AddForeignKey +ALTER TABLE "Experience" ADD CONSTRAINT "Experience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Education" ADD CONSTRAINT "Education_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241025095014_user_updated/migration.sql b/prisma/migrations/20241025095014_user_updated/migration.sql new file mode 100644 index 00000000..1ab2f3cc --- /dev/null +++ b/prisma/migrations/20241025095014_user_updated/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "aboutMe" DROP NOT NULL, +ALTER COLUMN "contactEmail" DROP NOT NULL; diff --git a/prisma/migrations/20241025120951_resume_update_date/migration.sql b/prisma/migrations/20241025120951_resume_update_date/migration.sql new file mode 100644 index 00000000..6b96159f --- /dev/null +++ b/prisma/migrations/20241025120951_resume_update_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "resumeUpdateDate" TIMESTAMP(3); diff --git a/prisma/migrations/20241031043344_username_remove/migration.sql b/prisma/migrations/20241031043344_username_remove/migration.sql new file mode 100644 index 00000000..ee88353a --- /dev/null +++ b/prisma/migrations/20241031043344_username_remove/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "User_username_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "username"; diff --git a/prisma/migrations/20241031064849_company/migration.sql b/prisma/migrations/20241031064849_company/migration.sql new file mode 100644 index 00000000..13bf066d --- /dev/null +++ b/prisma/migrations/20241031064849_company/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - A unique constraint covering the columns `[companyId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "companyId" TEXT, +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "Company" ( + "id" TEXT NOT NULL, + "companyName" TEXT NOT NULL, + "companyLogo" TEXT, + "companyEmail" TEXT NOT NULL, + "companyBio" TEXT NOT NULL, + + CONSTRAINT "Company_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_companyId_key" ON "User"("companyId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 298bbd1c..52e6e348 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,14 +25,37 @@ model User { project Project[] resume String? - oauthProvider OauthProvider? // Tracks OAuth provider (e.g., 'google') - oauthId String? - + oauthProvider OauthProvider? // Tracks OAuth provider (e.g., 'google') + oauthId String? + createdAt DateTime @default(now()) blockedByAdmin DateTime? - onBoard Boolean @default(false) + onBoard Boolean @default(false) bookmark Bookmark[] - companyId String? @unique - company Company? @relation(fields: [companyId], references: [id]) + + githubLink String? + portfolioLink String? + linkedinLink String? + twitterLink String? + discordLink String? + + contactEmail String? + + aboutMe String? + + education Education[] + + resumeUpdateDate DateTime? + companyId String? @unique + company Company? @relation(fields: [companyId], references: [id]) +} + +model Company { + id String @id @default(cuid()) + companyName String + companyLogo String? + companyEmail String + companyBio String + user User? } enum OauthProvider { @@ -112,6 +135,17 @@ model Experience { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model Education { + id Int @id @default(autoincrement()) + instituteName String + degree DegreeType + fieldOfStudy FieldOfStudyType + startDate DateTime + endDate DateTime? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Project { id Int @id @default(autoincrement()) projectName String @@ -122,6 +156,7 @@ model Project { stack ProjectStack @default(OTHERS) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + isFeature Boolean @default(false) } model Company { @@ -165,3 +200,17 @@ enum EmployementType { Internship Contract } + +enum DegreeType { + BTech + MTech + BCA + MCA +} + +enum FieldOfStudyType { + AI + Machine_Learning + CS + Mechanical +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 5aa0b4a8..4d08dfc2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -14,9 +14,19 @@ const prisma = new PrismaClient(); const users = [ { id: '1', name: 'Jack', email: 'user@gmail.com' }, { id: '2', name: 'Admin', email: 'admin@gmail.com', role: Role.ADMIN, onBoard: true }, - { id: '3', name: 'Hr', email: 'hr@gmail.com', role: Role.HR }, + { id: '3', companyId: '1', name: 'Hr', email: 'hr@gmail.com', role: Role.HR, onBoard: true }, + { id: '4', companyId: '2', name: 'John', email: 'john@gmail.com', role: Role.HR, onBoard: true }, + { id: '5', companyId: '3', name: 'Jane', email: 'jane@gmail.com', role: Role.HR, onBoard: true }, ]; + +const companies = [ + { id: '1', compnayEmail: "careers@techcorps.com", companyName: 'Tech Corp', companyBio: 'Leading tech solutions provider specializing in innovative web development.', companyLogo: '/main.svg' }, + { id: '2', companyEmail: "careers@globalsolutions.com", companyName: 'Global Solutions', companyBio: 'Global Solutions offers comprehensive IT services for businesses worldwide.', companyLogo: '/main.svg' }, + { id: '3', companyEmail: 'careers@innovatech.com', companyName: 'Innovatech', companyBio: 'Innovatech specializes in backend systems and cloud-based solutions.', companyLogo: '/main.svg' }, +] + + let jobs = [ { id: '1', @@ -63,7 +73,6 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: false, - }, { id: '3', @@ -87,7 +96,7 @@ let jobs = [ minSalary: 90000, maxSalary: 120000, isVerifiedJob: true, - deleted: true + deleted: true, }, { id: '4', @@ -136,7 +145,7 @@ let jobs = [ minSalary: 110000, maxSalary: 150000, isVerifiedJob: true, - deleted: true + deleted: true, }, { id: '6', @@ -162,7 +171,6 @@ let jobs = [ minSalary: 80000, maxSalary: 100000, isVerifiedJob: false, - }, { id: '7', @@ -187,8 +195,7 @@ let jobs = [ minSalary: 70000, maxSalary: 90000, isVerifiedJob: false, - delted: true - + delted: true, }, { id: '8', @@ -213,8 +220,7 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: true, - deleted: true - + deleted: true, }, { id: '9', @@ -237,7 +243,6 @@ let jobs = [ minSalary: 100000, maxSalary: 130000, isVerifiedJob: true, - }, { id: '10', @@ -262,7 +267,6 @@ let jobs = [ minSalary: 75000, maxSalary: 95000, isVerifiedJob: false, - }, { id: '11', @@ -284,7 +288,6 @@ let jobs = [ minSalary: 25000, maxSalary: 50000, isVerifiedJob: true, - }, { id: '12', @@ -309,7 +312,7 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: true, - delted: false + delted: false, }, ]; @@ -328,6 +331,7 @@ async function seedUsers() { password: hashedPassword, role: u.role || Role.USER, emailVerified: new Date(), + companyId: u.companyId }, }); console.log(`User created or updated: ${u.email}`); @@ -340,6 +344,28 @@ async function seedUsers() { console.error('Error seeding users:', error); } } +async function seedCompanies() { + try { + await Promise.all( + companies.map(async (c) => + prisma.company.upsert({ + where: { id: c.id }, + create: { + id: c.id, + companyName: c.companyName, + companyEmail: c.companyEmail ?? "default@example.com", + companyBio: c.companyBio, + companyLogo: c.companyLogo, + }, + update: {}, + }) + ) + ); + console.log('✅ Company seed completed successfully'); + } catch (error) { + console.error('Error seeding companies:', error); + } +} async function seedJobs() { try { @@ -401,8 +427,9 @@ async function seedJobs() { } async function main() { + await seedCompanies(); await seedUsers(); await seedJobs(); } -main(); \ No newline at end of file +main(); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..a4fb0fd7 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /admin/* +Disallow: /manage/* + +Sitemap: https://job.vineet.tech/sitemap.xml \ No newline at end of file diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 659edb3e..b3b15ade 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -156,6 +156,7 @@ export const getAllJobs = withSession< skills: true, address: true, workMode: true, + expired: true, category: true, minSalary: true, maxSalary: true, @@ -219,6 +220,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< maxSalary: true, postedAt: true, skills: true, + expired: true, isVerifiedJob: true, companyLogo: true, }, @@ -252,6 +254,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< companyLogo: true, minExperience: true, maxExperience: true, + expired: true, isVerifiedJob: true, category: true, }, @@ -294,6 +297,7 @@ export const getJobById = withServerActionAsyncCatcher< minExperience: true, maxExperience: true, skills: true, + expired: true, address: true, workMode: true, hasSalaryRange: true, @@ -352,6 +356,7 @@ export const getRecentJobs = async () => { minExperience: true, maxExperience: true, skills: true, + expired: true, postedAt: true, companyLogo: true, type: true, @@ -601,6 +606,7 @@ export async function GetBookmarkByUserId() { minSalary: true, maxSalary: true, postedAt: true, + expired: true, companyLogo: true, }, }, diff --git a/src/actions/user.profile.actions.ts b/src/actions/user.profile.actions.ts index 51128cda..e4e1219b 100644 --- a/src/actions/user.profile.actions.ts +++ b/src/actions/user.profile.actions.ts @@ -1,8 +1,14 @@ 'use server'; import prisma from '@/config/prisma.config'; import { + aboutMeSchema, + AboutMeSchemaType, addSkillsSchemaType, expFormSchemaType, + profileEducationType, + ProfileProjectType, + profileSchema, + ProfileSchemaType, projectSchemaType, UserProfileSchemaType, } from '@/lib/validators/user.profile.validator'; @@ -13,6 +19,8 @@ import { ErrorHandler } from '@/lib/error'; import { withServerActionAsyncCatcher } from '@/lib/async-catch'; import { ServerActionReturnType } from '@/types/api.types'; import { SuccessResponse } from '@/lib/success'; +import { withSession } from '@/lib/session'; +import { revalidatePath } from 'next/cache'; export const updateUser = async ( email: string, @@ -39,36 +47,39 @@ export const updateUser = async ( } }; -export const changePassword = async ( - email: string, - data: { - currentPassword: string; - newPassword: string; - confirmNewPassword: string; - } -) => { +export const changePassword = async (data: { + currentPassword: string; + newPassword: string; + confirmNewPassword: string; +}) => { try { - const existingUser = await prisma.user.findFirst({ - where: { email: email }, - }); + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + const { currentPassword, newPassword, confirmNewPassword } = data; if (!currentPassword || !newPassword || !confirmNewPassword) { - return { error: 'Password is required' }; + throw new ErrorHandler('Invalid Data', 'BAD_REQUEST'); } if (newPassword !== confirmNewPassword) { - return { error: 'Passwords do not match' }; + throw new ErrorHandler('Invalid Data', 'BAD_REQUEST'); } - if (!existingUser) return { error: 'User not found!' }; + const existingUser = await prisma.user.findFirst({ + where: { id: auth.user.id }, + }); + + if (!existingUser) throw new ErrorHandler('User Not Found', 'NOT_FOUND'); if (!existingUser.password) { - return { error: 'User password not found!' }; + throw new ErrorHandler('Invalid Credientials', 'AUTHENTICATION_FAILED'); } const matchPassword = await bcryptjs.compare( currentPassword, existingUser.password ); if (!matchPassword) { - return { error: 'Invalid credentials' }; + throw new ErrorHandler('Invalid Credientials', 'AUTHENTICATION_FAILED'); } const hashedPassword = await bcryptjs.hash(newPassword, 10); @@ -80,9 +91,15 @@ export const changePassword = async ( }, }); - return { success: 'Your password has been successfully updated.' }; - } catch (error) { - return { error: error }; + return new SuccessResponse( + 'Successfully Password Updated.', + 200 + ).serialize(); + } catch (_error) { + throw new ErrorHandler( + 'Something went wrong while changing password.', + 'INTERNAL_SERVER_ERROR' + ); } }; @@ -155,6 +172,7 @@ export const addUserSkills = withServerActionAsyncCatcher< skills: data.skills, }, }); + revalidatePath(`/newProfile/${auth.user.id}`); return new SuccessResponse('Skills updated successfully', 200).serialize(); } catch (_error) { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); @@ -177,14 +195,37 @@ export const addUserExperience = withServerActionAsyncCatcher< userId: auth.user.id, }, }); + revalidatePath(`/newProfile/${auth.user.id}`); return new SuccessResponse( - 'Experience updated successfully', + 'Experience added successfully', 200 ).serialize(); } catch (_error) { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); } }); +export const addUserEducation = withServerActionAsyncCatcher< + profileEducationType, + ServerActionReturnType +>(async (data) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + + try { + await prisma.education.create({ + data: { + ...data, + userId: auth.user.id, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Education added successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}); export const addUserProjects = withServerActionAsyncCatcher< projectSchemaType, @@ -202,6 +243,7 @@ export const addUserProjects = withServerActionAsyncCatcher< userId: auth.user.id, }, }); + revalidatePath(`/newProfile/${auth.user.id}`); return new SuccessResponse('Project updated successfully', 200).serialize(); } catch (_error) { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); @@ -244,8 +286,10 @@ export const addUserResume = async (resume: string) => { }, data: { resume: resume, + resumeUpdateDate: new Date(), }, }); + revalidatePath(`/newProfile/${auth.user.id}`); return new SuccessResponse('Resume SuccessFully Uploaded', 200).serialize(); } catch (_error) { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); @@ -317,3 +361,302 @@ export const getUserDetails = async () => { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); } }; + +export const getUserDetailsWithId = async (id: string) => { + try { + const res = await prisma.user.findFirst({ + where: { + id: id, + }, + select: { + name: true, + id: true, + skills: true, + education: true, + experience: true, + email: true, + contactEmail: true, + resume: true, + avatar: true, + aboutMe: true, + project: true, + resumeUpdateDate: true, + discordLink: true, + githubLink: true, + linkedinLink: true, + portfolioLink: true, + twitterLink: true, + }, + }); + if (!res) throw new ErrorHandler('User Not Found', 'NOT_FOUND'); + return new SuccessResponse( + 'User SuccessFully Fetched', + 200, + res + ).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; + +export const updateUserDetails = withSession< + ProfileSchemaType, + ServerActionReturnType +>(async (session, userData) => { + if (!session || !session?.user?.id) { + throw new ErrorHandler('Not Authrised', 'UNAUTHORIZED'); + } + const { success, data } = profileSchema.safeParse(userData); + if (!success) { + throw new ErrorHandler('Invalid Data', 'BAD_REQUEST'); + } + + if (data) { + await prisma.user.update({ + where: { + id: session.user.id, + }, + data: { + name: data.name, + email: data.email, + contactEmail: data.contactEmail, + aboutMe: data.aboutMe, + avatar: data.avatar, + discordLink: data.discordLink, + linkedinLink: data.linkedinLink, + twitterLink: data.twitterLink, + portfolioLink: data.portfolioLink, + githubLink: data.githubLink, + }, + }); + revalidatePath(`/newProfile/${session.user.id}`); + } + + return new SuccessResponse( + 'User profile updated successfully.', + 200 + ).serialize(); +}); + +export const updateAboutMe = withSession< + AboutMeSchemaType, + ServerActionReturnType +>(async (session, userData) => { + if (!session || !session?.user?.id) { + throw new ErrorHandler('Not Authrised', 'UNAUTHORIZED'); + } + const { success, data } = aboutMeSchema.safeParse(userData); + if (!success) { + throw new ErrorHandler('Invalid Data', 'BAD_REQUEST'); + } + + if (data) { + await prisma.user.update({ + where: { + id: session.user.id, + }, + data: { + aboutMe: data.aboutMe, + }, + }); + } + revalidatePath(`/newProfile/${session.user.id}`); + return new SuccessResponse('Successfully updated About Me.', 200).serialize(); +}); + +export const deleteResume = async () => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + // todo: delete file form cdn + await prisma.user.update({ + where: { + id: auth.user.id, + }, + data: { + resume: null, + resumeUpdateDate: null, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Resume Deleted Successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; +export const deleteProject = async (projectId: number) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + // todo: delete image file form cdn + await prisma.project.delete({ + where: { + id: projectId, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Project Deleted Successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; + +export const editProject = withServerActionAsyncCatcher< + { data: ProfileProjectType; id: number }, + ServerActionReturnType +>(async ({ data, id }) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + + try { + await prisma.project.update({ + where: { + id: id, + userId: auth.user.id, + }, + data: { + ...data, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Project updated successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}); +export const editExperience = withServerActionAsyncCatcher< + { data: expFormSchemaType; id: number }, + ServerActionReturnType +>(async ({ data, id }) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + + try { + await prisma.experience.update({ + where: { + id: id, + userId: auth.user.id, + }, + data: { + ...data, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse( + 'Experience updated successfully', + 200 + ).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}); +export const editEducation = withServerActionAsyncCatcher< + { data: profileEducationType; id: number }, + ServerActionReturnType +>(async ({ data, id }) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + + try { + await prisma.education.update({ + where: { + id: id, + userId: auth.user.id, + }, + data: { + ...data, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse( + 'Experience updated successfully', + 200 + ).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}); + +export const deleteExperience = async (experienceId: number) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + // todo: delete image file form cdn + await prisma.experience.delete({ + where: { + id: experienceId, + userId: auth.user.id, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Project Deleted Successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; +export const deleteEducation = async (educationId: number) => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + // todo: delete image file form cdn + await prisma.education.delete({ + where: { + id: educationId, + userId: auth.user.id, + }, + }); + revalidatePath(`/newProfile/${auth.user.id}`); + return new SuccessResponse('Project Deleted Successfully', 200).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; +export const getUserRecruiters = async () => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id || auth?.user?.role !== 'ADMIN') + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + const res = await prisma.user.findMany({ + where: { + role: 'HR', + }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + _count: { + select: { + jobs: true, + }, + }, + company: { + select: { + companyName: true, + companyEmail: true, + }, + }, + }, + }); + return new SuccessResponse('Recruiter SuccessFully Fetched', 200, { + recruiters: res, + }).serialize(); + } catch (_error) { + return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); + } +}; diff --git a/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx b/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx index a2bf0bc9..4a39b2fa 100644 --- a/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx +++ b/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx @@ -39,13 +39,19 @@ export const EmailVerificationLinkExpired = ({ token }: { token: string }) => { > {!isEmailSent ? ( - ) : ( - + )} diff --git a/src/app/(auth)/verify-email/[token]/page.tsx b/src/app/(auth)/verify-email/[token]/page.tsx index 0a64b44a..012eab75 100644 --- a/src/app/(auth)/verify-email/[token]/page.tsx +++ b/src/app/(auth)/verify-email/[token]/page.tsx @@ -29,7 +29,9 @@ const EmailVerifiedSuccess = () => { } > - + @@ -44,7 +46,9 @@ const EmailVerificationLinkNotFound = () => { description={'The verification link you used is invalid or not found.'} > - + diff --git a/src/app/[...404]/page.tsx b/src/app/[...404]/page.tsx index 1fec2b23..ddf9a334 100644 --- a/src/app/[...404]/page.tsx +++ b/src/app/[...404]/page.tsx @@ -43,6 +43,7 @@ const Custom404Page = () => { diff --git a/src/components/DeleteDialog.tsx b/src/components/DeleteDialog.tsx index 86423484..ff5713b6 100644 --- a/src/components/DeleteDialog.tsx +++ b/src/components/DeleteDialog.tsx @@ -4,6 +4,7 @@ import { Button } from './ui/button'; import { useToast } from './ui/use-toast'; import { toggleDeleteJobById } from '@/actions/job.action'; import { JobType } from '@/types/jobs.types'; +import icons from '@/lib/icons'; import { Dialog, DialogTrigger, @@ -13,7 +14,6 @@ import { DialogDescription, DialogFooter, } from './ui/dialog'; -import { ArchiveRestore, Trash } from 'lucide-react'; const JobDialog = ({ job }: { job: JobType }) => { const [dialogOpen, setDialogOpen] = useState(false); // State to manage dialog visibility @@ -42,7 +42,9 @@ const JobDialog = ({ job }: { job: JobType }) => { role="button" onClick={() => setDialogOpen(true)} > - {/* Icon for restoring the job */} + ) : ( { role="button" onClick={() => setDialogOpen(true)} > - {/* Icon for deleting the job */} + )} @@ -74,6 +78,7 @@ const JobDialog = ({ job }: { job: JobType }) => { className="mt-2" variant={job.deleted ? 'secondary' : 'destructive'} onClick={handelToggle} + aria-label="delete" > {job.deleted ? 'Restore' : 'Delete'} diff --git a/src/components/Faqs.tsx b/src/components/Faqs.tsx index 9958af8e..b6a5297a 100644 --- a/src/components/Faqs.tsx +++ b/src/components/Faqs.tsx @@ -36,6 +36,7 @@ export default function Faqs() { className="flex w-full justify-between items-center cursor-pointer focus:outline-none dark:bg-[#0F172A] bg-[#F1F5F9] p-4 rounded-xl" onClick={() => toggleExpand(i)} aria-expanded={expandedIndex === i} + aria-label="toggle-expand" >

{faq.question} diff --git a/src/components/FaqsGetintouchCard.tsx b/src/components/FaqsGetintouchCard.tsx index fbe1937c..af91f3be 100644 --- a/src/components/FaqsGetintouchCard.tsx +++ b/src/components/FaqsGetintouchCard.tsx @@ -6,7 +6,9 @@ export default function FaqsGetintouchCard() {

Can't find what you're looking for?

- + ); } diff --git a/src/components/JobManagement.tsx b/src/components/JobManagement.tsx index a0e6918d..53aaa29d 100644 --- a/src/components/JobManagement.tsx +++ b/src/components/JobManagement.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { getAllJobs } from '@/actions/job.action'; -import JobManagementHeader from './JobManagementHeader'; import JobManagementTable from './JobManagementTable'; import { JobQuerySchemaType } from '@/lib/validators/jobs.validator'; @@ -13,10 +12,10 @@ const JobManagement = async ({ if (!jobs.status) { return
Error {jobs.message}
; } + return ( -
- - +
+
); }; diff --git a/src/components/JobManagementHeader.tsx b/src/components/JobManagementHeader.tsx index 30035d79..fec842b5 100644 --- a/src/components/JobManagementHeader.tsx +++ b/src/components/JobManagementHeader.tsx @@ -13,7 +13,7 @@ const JobManagementHeader = () => {
-
diff --git a/src/components/JobManagementTable.tsx b/src/components/JobManagementTable.tsx index 564a4035..e92a69a4 100644 --- a/src/components/JobManagementTable.tsx +++ b/src/components/JobManagementTable.tsx @@ -1,3 +1,4 @@ +'use client'; import { Table, TableBody, @@ -6,107 +7,226 @@ import { TableHeader, TableRow, } from './ui/table'; -import { Edit } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; +import { Plus, Search } from 'lucide-react'; -import { getAllJobsAdditonalType } from '@/types/jobs.types'; -import { ServerActionReturnType } from '@/types/api.types'; +import { JobType, getAllJobsAdditonalType } from '@/types/jobs.types'; import { JobQuerySchemaType } from '@/lib/validators/jobs.validator'; import { DEFAULT_PAGE, JOBS_PER_PAGE } from '@/config/app.config'; -import { Pagination, PaginationContent, PaginationItem } from './ui/pagination'; -import { - PaginationNextButton, - PaginationPreviousButton, -} from './pagination-client'; -import APP_PATHS from '@/config/path.config'; -import { PaginationPages } from './ui/paginator'; + import DeleteDialog from './DeleteDialog'; import ToggleApproveJobButton from './ToggleApproveJobButton'; +import { Input } from './ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { Button } from './ui/button'; +import Link from 'next/link'; +import { useState, useEffect } from 'react'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from './ui/pagination'; type props = { searchParams: JobQuerySchemaType; - jobs: ServerActionReturnType; + jobs: getAllJobsAdditonalType | undefined; }; const JobManagementTable = ({ jobs, searchParams }: props) => { - if (!jobs.status) { - return
Error {jobs.message}
; - } + const [filteredJobs, setFilteredJobs] = useState([]); + const [statusFilter, setStatusFilter] = useState('All'); + const [orderFilter, setOrderFilter] = useState('latest'); + const [currentPage, setCurrentPage] = useState( + searchParams.page || DEFAULT_PAGE + ); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + if (jobs?.jobs) { + const filtered = jobs?.jobs.filter((job) => { + if (statusFilter === 'All') return true; + if (statusFilter === 'active') + return !job.deleted && !job.expired && job.isVerifiedJob; + if (statusFilter === 'deleted') return job.deleted; + if (statusFilter === 'closed') + return (job.expired || !job.isVerifiedJob) && !job.deleted; + return true; + }); + + const searched = filtered.filter((job) => { + const lowerCaseTitle = job.title.toLowerCase(); + const lowerCaseCompanyName = job.companyName.toLowerCase(); + return ( + lowerCaseTitle.includes(searchTerm.toLowerCase()) || + lowerCaseCompanyName.includes(searchTerm.toLowerCase()) + ); + }); + + const sorted = searched.sort((a, b) => a.title.localeCompare(b.title)); + + if (orderFilter === 'latest') { + sorted.sort( + (a, b) => + new Date(b.postedAt).getTime() - new Date(a.postedAt).getTime() + ); + } else { + sorted.sort( + (a, b) => + new Date(a.postedAt).getTime() - new Date(b.postedAt).getTime() + ); + } + + setFilteredJobs(sorted); + } + }, [jobs?.jobs, statusFilter, orderFilter, searchTerm]); const totalPages = - Math.ceil((jobs.additional?.totalJobs || 0) / JOBS_PER_PAGE) || - DEFAULT_PAGE; - const currentPage = searchParams.page || DEFAULT_PAGE; + Math.ceil(filteredJobs.length / JOBS_PER_PAGE) || DEFAULT_PAGE; + const startIndex = (currentPage - 1) * JOBS_PER_PAGE; + const currentJobs = filteredJobs.slice( + startIndex, + startIndex + JOBS_PER_PAGE + ); + + const handlePageChange = (pageNumber: number) => { + setCurrentPage(Math.min(Math.max(pageNumber, 1), totalPages)); + }; + return ( - <> -
- - - - Job Title - JobType - Location - isVerified - Actions - - - - {jobs.additional?.jobs?.map((job) => ( - - {job?.title} - {job?.workMode} - {job?.city} - - {job.deleted ? ( - Deleted - ) : ( +
+
+

Manage Jobs

+ + + +
+ +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + + +
+
+ +
+
+
+ + + Job Title + Company Name + Job Category + Job Type + Posted Date + Status + Verified + Action + + + + {currentJobs.map((job) => ( + + {job.title} + {job.companyName} + {job.category} + {job.type} + + {new Date(job.postedAt).toLocaleDateString()} + + + + {job.deleted + ? 'Deleted' + : job.expired || !job.isVerifiedJob + ? 'Closed' + : 'Active'} + + + - )} - - - - - - + + - - - - ))} - -
- - - {totalPages ? ( - - + + + ))} + + +
+ + +
+
+ + + handlePageChange(currentPage - 1)}> + - ) : null} - - {totalPages ? ( - - + {[...Array(totalPages)].map((_, index) => ( + handlePageChange(index + 1)} + > + {index + 1} + + ))} + handlePageChange(currentPage + 1)}> + - ) : null} - - + + +
- + ); }; diff --git a/src/components/ManageRecruiters.tsx b/src/components/ManageRecruiters.tsx new file mode 100644 index 00000000..7bf6251b --- /dev/null +++ b/src/components/ManageRecruiters.tsx @@ -0,0 +1,181 @@ +'use client'; +import React, { useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { Search, Trash2 } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { Input } from './ui/input'; +import { getAllRecruiters } from '@/types/recruiters.types'; +import { Button } from './ui/button'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from './ui/pagination'; +import { ServerActionReturnType } from '@/types/api.types'; + +type props = { + recruiters: ServerActionReturnType; +}; + +const ManageRecruiters = ({ recruiters }: props) => { + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + const itemsPerPage = 10; + + if (!recruiters.status) { + return
Error {recruiters.message}
; + } + + const recruiterList = recruiters.additional?.recruiters ?? []; + + const filteredRecruiters = recruiterList.filter( + (recruiter) => + recruiter.company?.companyName + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + recruiter.company?.companyEmail + .toLowerCase() + .includes(searchTerm.toLowerCase()) + ); + + const totalPages = Math.ceil(filteredRecruiters.length / itemsPerPage); + + const currentRecruiters = filteredRecruiters.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + const handlePageChange = (page: number) => { + if (page > 0 && page <= totalPages) { + setCurrentPage(page); + } + }; + return ( +
+
+

Manage Recruiters

+
+ +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+
+ +
+
+ + + + Company Name + Company Email + Jobs Posted + Created At + Action + + + + {currentRecruiters.length ? ( + currentRecruiters.map((recruiter) => ( + + + {recruiter.company?.companyName} + + + {recruiter.company?.companyEmail} + + + {recruiter._count.jobs} + + + {new Date(recruiter.createdAt).toLocaleDateString()} + + + + + + )) + ) : ( + + + No Recruiters + + + )} + +
+
+
+ +
+ + + + handlePageChange(currentPage - 1)} + className="border hover:border-blue-600 dark:bg-slate-400 dark:bg-opacity-5 dark:text-white text-black bg-slate-600 bg-opacity-15" + /> + + {[...Array(totalPages)].map((_, index) => ( + + handlePageChange(index + 1)} + className={`border hover:border-blue-600 dark:bg-slate-400 dark:bg-opacity-5 ${ + currentPage === index + 1 + ? 'bg-blue-600 text-white' + : 'text-black dark:text-white' + }`} + > + {index + 1} + + + ))} + + handlePageChange(currentPage + 1)} + className="border hover:border-blue-600 dark:bg-slate-400 dark:bg-opacity-5 dark:text-white text-black bg-slate-600 bg-opacity-15" + /> + + + +
+
+ ); +}; + +export default ManageRecruiters; diff --git a/src/components/ShareJobDialog.tsx b/src/components/ShareJobDialog.tsx index e718a5af..ac9de6c7 100644 --- a/src/components/ShareJobDialog.tsx +++ b/src/components/ShareJobDialog.tsx @@ -66,6 +66,7 @@ export const ShareJobDialog = ({ job }: { job: JobType }) => { variant="outline" size="sm" className="px-4 py-2 h-fit gap-2 flex items-center" + aria-label="share-job" > Share Job @@ -81,6 +82,7 @@ export const ShareJobDialog = ({ job }: { job: JobType }) => { variant="outline" className="w-full justify-start gap-2" onClick={() => option.shareFunction(job)} + aria-label="share-on-social" > {option.icon} Share on {option.name} diff --git a/src/components/ToggleApproveJobButton.tsx b/src/components/ToggleApproveJobButton.tsx index 78af648c..d12ef494 100644 --- a/src/components/ToggleApproveJobButton.tsx +++ b/src/components/ToggleApproveJobButton.tsx @@ -14,7 +14,7 @@ import { Button } from './ui/button'; import { useToast } from './ui/use-toast'; import { toggleApproveJob } from '@/actions/job.action'; import { JobType } from '@/types/jobs.types'; -import { Badge } from './ui/badge'; +import { Switch } from './ui/switch'; const ToggleApproveJobButton = ({ job }: { job: JobType }) => { const { toast } = useToast(); @@ -41,13 +41,7 @@ const ToggleApproveJobButton = ({ job }: { job: JobType }) => { return ( - - {isApproved ? 'Approved' : 'Unapproved'} - + @@ -66,6 +60,7 @@ const ToggleApproveJobButton = ({ job }: { job: JobType }) => { className="mt-2" variant={'secondary'} onClick={handleToggleJob} + aria-label="approve" > {isApproved ? 'Unapprove' : 'Approve'} diff --git a/src/components/auth/forgot-password.tsx b/src/components/auth/forgot-password.tsx index ad4b8f8b..838a06cb 100644 --- a/src/components/auth/forgot-password.tsx +++ b/src/components/auth/forgot-password.tsx @@ -52,7 +52,12 @@ export const ForgotPassword = () => { }} /> - diff --git a/src/components/auth/reset-password.tsx b/src/components/auth/reset-password.tsx index 7ffa25f0..0b79f786 100644 --- a/src/components/auth/reset-password.tsx +++ b/src/components/auth/reset-password.tsx @@ -78,7 +78,7 @@ export const ResetPassword = () => { {errorMessage ? (

{errorMessage}

) : null} - @@ -114,6 +114,7 @@ export const PasswordInput = ({ type="button" onClick={togglePasswordVisibility} className="absolute right-2 top-1/2 transform -translate-y-1/2 focus:outline-none" + aria-label="toggle-password" > {showPassword ? : } diff --git a/src/components/auth/signin.tsx b/src/components/auth/signin.tsx index 76d6f7f1..a5bc47e0 100644 --- a/src/components/auth/signin.tsx +++ b/src/components/auth/signin.tsx @@ -39,8 +39,13 @@ export const Signin = () => { try { const response = await signIn('signin', { ...data, redirect: false }); if (!response?.ok) { + const errorMessage = + response?.error?.includes('User') && response?.error?.includes('does not exist') + ? 'User does not exist' + : response?.error || 'Internal server error'; + return toast({ - title: response?.error || 'Internal server error', + title: errorMessage, variant: 'destructive', }); } @@ -48,7 +53,7 @@ export const Signin = () => { title: 'Login successful! Welcome back!', variant: 'success', }); - // const redirect = searchParams.get('next') || APP_PATHS.HOME; + const searchParams = new URLSearchParams(window.location.search); const redirect = searchParams.get('next') || APP_PATHS.HOME; router.push(redirect); @@ -60,7 +65,7 @@ export const Signin = () => { }); } } - + return (
@@ -107,6 +112,7 @@ export const Signin = () => { type="submit" disabled={form.formState.isSubmitting} className="w-full h-10" + aria-label="submit" > {form.formState.isSubmitting ? 'Please wait...' : 'Sign In'} diff --git a/src/components/auth/signup.tsx b/src/components/auth/signup.tsx index 7f66aa73..5319d393 100644 --- a/src/components/auth/signup.tsx +++ b/src/components/auth/signup.tsx @@ -119,6 +119,7 @@ export const Signup = () => { type="submit" disabled={form.formState.isSubmitting} className="w-full h-10" + aria-label="submit" > {form.formState.isSubmitting ? 'Please wait...' : 'Create Account'} diff --git a/src/components/auth/social-auth.tsx b/src/components/auth/social-auth.tsx index 7b7a8b09..8a7d5392 100644 --- a/src/components/auth/social-auth.tsx +++ b/src/components/auth/social-auth.tsx @@ -19,6 +19,7 @@ export const GoogleOauthButton = ({ label }: { label: string }) => ( signIn('google'); }} className="w-full h-10 bg-white border border-gray-300 text-gray-700 font-medium hover:bg-gray-50" + aria-label="google-oauth-button" > { variant="link" className="mt-4 text-primary underline" onClick={() => router.push(APP_PATHS.SIGNIN)} + aria-label="go-to-login" > Go to Login @@ -56,7 +57,12 @@ const CountdownButton = () => { }, [isDisabled, secondsRemaining]); return ( -
- - + + Explore Jobs + + + View Testimonials +
diff --git a/src/components/job-creation-success.tsx b/src/components/job-creation-success.tsx index cab36a59..90c3a9a9 100644 --- a/src/components/job-creation-success.tsx +++ b/src/components/job-creation-success.tsx @@ -9,7 +9,7 @@ const JobCreateSuccess = ({ isVerifiedJob }: { isVerifiedJob: boolean }) => {

Job created successfully!

{message}

- +
); }; diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index 0bb247fa..2a6c2959 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -566,6 +566,7 @@ const PostJobForm = () => {
-
diff --git a/src/components/job-landing.tsx b/src/components/job-landing.tsx index c87df6f8..c931bfb8 100644 --- a/src/components/job-landing.tsx +++ b/src/components/job-landing.tsx @@ -27,7 +27,10 @@ export const JobLanding = () => {
- diff --git a/src/components/pagination-client.tsx b/src/components/pagination-client.tsx index 69d0e0d1..26881fa9 100644 --- a/src/components/pagination-client.tsx +++ b/src/components/pagination-client.tsx @@ -19,9 +19,9 @@ const PaginationPreviousButton = ({ page: (currentPage - PAGE_INCREMENT).toString(), }) } + className=" border dark:bg-slate-400 dark:bg-opacity-5 dark:text-white text-black bg-slate-600 bg-opacity-15 " aria-disabled={currentPage - PAGE_INCREMENT < PAGE_INCREMENT} role="button" - className="aria-disabled:pointer-events-none aria-disabled:text-gray-400 dark:bg-neutral-900 rounded-full bg-neutral-100" /> ); }; @@ -43,8 +43,8 @@ const PaginationNextButton = ({ page: (currentPage + PAGE_INCREMENT).toString(), }) } + className=" border dark:bg-slate-400 dark:bg-opacity-5 dark:text-white text-black bg-slate-600 bg-opacity-15" aria-disabled={currentPage > totalPages - PAGE_INCREMENT} - className="aria-disabled:pointer-events-none aria-disabled:text-gray-400 dark:bg-neutral-900 rounded-full bg-neutral-100" /> ); }; diff --git a/src/components/password-input.tsx b/src/components/password-input.tsx index 2099ef12..2d0b0721 100644 --- a/src/components/password-input.tsx +++ b/src/components/password-input.tsx @@ -25,6 +25,7 @@ export const PasswordInput = ({ placeholder, field }: PasswordInputProps) => { type="button" onClick={togglePasswordVisibility} className="absolute right-2 top-1/2 transform -translate-y-1/2" + aria-label="password" > {showPassword ? : } diff --git a/src/components/profile-menu.tsx b/src/components/profile-menu.tsx index eb34a405..e5dc52a3 100644 --- a/src/components/profile-menu.tsx +++ b/src/components/profile-menu.tsx @@ -59,6 +59,7 @@ export function ProfileMenu() { variant="ghost" size="icon" className="focus-visible:outline-0 focus-visible:ring-0 focus-visible:ring-offset-0 py-2 h-8 w-8 px-0" + aria-label="profile" > Profile diff --git a/src/components/profile/AboutMe.tsx b/src/components/profile/AboutMe.tsx new file mode 100644 index 00000000..4c67013e --- /dev/null +++ b/src/components/profile/AboutMe.tsx @@ -0,0 +1,80 @@ +'use client'; +import { SquareUserRound, Pencil } from 'lucide-react'; +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; +import AboutMeForm from './forms/ReadMeForm'; + +const ProfileAboutMe = ({ + aboutMe, + isOwner, +}: { + aboutMe: string; + isOwner: boolean; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + + const title = + aboutMe.length === 0 + ? SHEETS.aboutMe.title + : SHEETS.aboutMe.title.replace('Add', 'Edit'); + + const handleClose = () => { + setIsSheetOpen(false); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + + return ( + <> +
+

About Me

+ {isOwner && ( + + )} +
+ {!aboutMe && ( + + )} + {aboutMe && ( +
+

{aboutMe}

+
+ )} + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileAboutMe; diff --git a/src/components/profile/ChangePassword.tsx b/src/components/profile/ChangePassword.tsx index 21edafd1..953d9461 100644 --- a/src/components/profile/ChangePassword.tsx +++ b/src/components/profile/ChangePassword.tsx @@ -3,7 +3,6 @@ import { useState, useTransition } from 'react'; import { useForm } from 'react-hook-form'; -import { useSession } from 'next-auth/react'; import { useToast } from '../ui/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -28,7 +27,6 @@ import { import Loader from '../loader'; export const ChangePassword = () => { - const session = useSession(); const { toast } = useToast(); const { register, watch } = useForm(); @@ -60,14 +58,19 @@ export const ChangePassword = () => { const handleFormSubmit = async (data: UserPasswordSchemaType) => { try { startTransition(() => { - changePassword(session.data?.user.email as string, data) + changePassword(data) .then((res) => { - res?.error - ? toast({ - title: (res.error as string) || 'something went wrong', - variant: 'destructive', - }) - : toast({ title: res.success as string, variant: 'success' }); + res?.status && + toast({ + title: res.message as string, + variant: 'success', + }); + }) + .catch((error) => { + toast({ + title: error.message as string, + variant: 'destructive', + }); }) .then(() => { form.reset(); @@ -158,6 +161,7 @@ export const ChangePassword = () => { type="button" onClick={togglePasswordVisibility} className="absolute right-2 top-1/2 transform -translate-y-1/2" + aria-label="password" > {showPassword ? : } @@ -172,6 +176,7 @@ export const ChangePassword = () => { diff --git a/src/components/profile/DeleteAccountDialog.tsx b/src/components/profile/DeleteAccountDialog.tsx index fee934ef..518929c2 100644 --- a/src/components/profile/DeleteAccountDialog.tsx +++ b/src/components/profile/DeleteAccountDialog.tsx @@ -24,7 +24,7 @@ import { } from '@/lib/validators/user.profile.validator'; import { deleteUser } from '@/actions/user.profile.actions'; -import { Trash, X } from 'lucide-react'; +import { X } from 'lucide-react'; import { FaSpinner } from 'react-icons/fa'; export const DeleteAccountDialog = () => { @@ -80,12 +80,12 @@ export const DeleteAccountDialog = () => { @@ -128,6 +128,7 @@ export const DeleteAccountDialog = () => { diff --git a/src/components/profile/EditProfile.tsx b/src/components/profile/EditProfile.tsx index bdaffb90..9be75b5d 100644 --- a/src/components/profile/EditProfile.tsx +++ b/src/components/profile/EditProfile.tsx @@ -174,6 +174,7 @@ export const EditProfile = ({ name, email }: Props) => { diff --git a/src/components/profile/EditProfilePicture.tsx b/src/components/profile/EditProfilePicture.tsx index 1f277256..f8653ea5 100644 --- a/src/components/profile/EditProfilePicture.tsx +++ b/src/components/profile/EditProfilePicture.tsx @@ -138,6 +138,7 @@ export const EditProfilePicture = () => { disabled={isPending} onClick={removeImage} className="flex items-center justify-center gap-3 text-xs text-red-400 bg-none border-none bg-transparent hover:bg-transparent" + aria-label="remove" > Remove diff --git a/src/components/profile/EducationDeleteDialog.tsx b/src/components/profile/EducationDeleteDialog.tsx new file mode 100644 index 00000000..353aad28 --- /dev/null +++ b/src/components/profile/EducationDeleteDialog.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { deleteEducation } from '@/actions/user.profile.actions'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; +import { useToast } from '../ui/use-toast'; + +export function EducationDeleteDialog({ + educationId, +}: { + educationId: number; +}) { + const { toast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleContinueClick = async () => { + try { + setIsLoading(true); + const response = await deleteEducation(educationId); + + if (!response.status) { + toast({ + title: response.message || 'Error', + variant: 'destructive', + }); + return; + } + + toast({ + title: response.message, + variant: 'success', + }); + + setIsOpen(false); + } catch (_error) { + toast({ + title: 'Something went wrong while deleting the resume', + description: 'Internal server error', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will delete your Education. + + + + setIsOpen(false)}> + Cancel + + + {isLoading ? 'Please wait...' : 'Continue'} + + + + + ); +} diff --git a/src/components/profile/ExperienceDeleteDialog.tsx b/src/components/profile/ExperienceDeleteDialog.tsx new file mode 100644 index 00000000..c55cd570 --- /dev/null +++ b/src/components/profile/ExperienceDeleteDialog.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { deleteExperience } from '@/actions/user.profile.actions'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; +import { useToast } from '../ui/use-toast'; + +export function ExperienceDeleteDialog({ + experienceId, +}: { + experienceId: number; +}) { + const { toast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleContinueClick = async () => { + try { + setIsLoading(true); + const response = await deleteExperience(experienceId); + + if (!response.status) { + toast({ + title: response.message || 'Error', + variant: 'destructive', + }); + return; + } + + toast({ + title: response.message, + variant: 'success', + }); + + setIsOpen(false); + } catch (_error) { + toast({ + title: 'Something went wrong while deleting the resume', + description: 'Internal server error', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will delete your Experience. + + + + setIsOpen(false)}> + Cancel + + + {isLoading ? 'Please wait...' : 'Continue'} + + + + + ); +} diff --git a/src/components/profile/ProfileEducation.tsx b/src/components/profile/ProfileEducation.tsx new file mode 100644 index 00000000..c9ddb673 --- /dev/null +++ b/src/components/profile/ProfileEducation.tsx @@ -0,0 +1,140 @@ +'use client'; +import { Circle, BookOpenCheck, Pencil, Plus } from 'lucide-react'; +import React, { useState } from 'react'; +import { Button } from '../ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import EducationForm from './forms/EducationForm'; +import { EducationType } from '@/types/user.types'; +import { format } from 'date-fns'; +import { EducationDeleteDialog } from './EducationDeleteDialog'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; + +const ProfileEducation = ({ + isOwner, + education, +}: { + isOwner: boolean; + education: EducationType[]; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [selectedEducation, setSelectedEducation] = + useState(null); + + const handleClose = () => { + setIsSheetOpen(false); + setSelectedEducation(null); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + const title = selectedEducation + ? SHEETS.education.title.replace('Add', 'Edit') + : SHEETS.education.title; + function formatDateRange(startDate: Date, endDate: Date | null): string { + const startFormatted = format(startDate, 'MMMM yy'); + const endFormatted = endDate ? format(endDate, 'MMMM yy') : 'Present'; + + return `${startFormatted} - ${endFormatted}`; + } + const handleEditClick = (education: EducationType) => { + setSelectedEducation(education); + handleOpen(); + }; + return ( + <> +
+

Education

+ {isOwner && ( + + )} +
+ {education.length === 0 && ( + + )} + {education.length !== 0 && ( +
+ {education.map((education) => ( +
+
+
+
+
+
+
+
+
+

+ {education.degree} +

+

+ {education.instituteName} + + {education.fieldOfStudy} +

+
+ {formatDateRange( + education.startDate, + education.endDate + )} +
+
+ {isOwner && ( +
+ + +
+ )} +
+
+
+
+ ))} +
+ )} + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileEducation; diff --git a/src/components/profile/ProfileExperience.tsx b/src/components/profile/ProfileExperience.tsx new file mode 100644 index 00000000..a877d1c2 --- /dev/null +++ b/src/components/profile/ProfileExperience.tsx @@ -0,0 +1,154 @@ +'use client'; +import { Circle, Building2, Pencil, Plus } from 'lucide-react'; +import React, { useState } from 'react'; +import { Button } from '../ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import ExperienceForm from './forms/ExperienceForm'; +import { ExperienceType } from '@/types/user.types'; +import { format } from 'date-fns'; +import { ExperienceDeleteDialog } from './ExperienceDeleteDialog'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; + +const ProfileExperience = ({ + isOwner, + experiences, +}: { + isOwner: boolean; + experiences: ExperienceType[]; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [selecetedExperience, setSelecetedExperience] = + useState(null); + + const handleEditClick = (experience: ExperienceType) => { + setSelecetedExperience(experience); + handleOpen(); + }; + + const handleClose = () => { + setIsSheetOpen(false); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + function formatDateRange(startDate: Date, endDate: Date | null): string { + const startFormatted = format(startDate, 'MMMM yy'); + const endFormatted = endDate ? format(endDate, 'MMMM yy') : 'Present'; + + return `${startFormatted} - ${endFormatted}`; + } + + const title = selecetedExperience + ? SHEETS.expierence.title.replace('Add', 'Edit') + : SHEETS.expierence.title; + + return ( + <> +
+

Work Experience

+ {isOwner && ( + + )} +
+ + {experiences.length === 0 && ( + + )} + + {experiences.length !== 0 && ( +
+ {experiences.map((experience) => ( +
+
+
+
+
+
+
+
+
+

+ {experience.designation} +

+

+ + {experience.companyName} + + + {experience.EmploymentType} + + {experience.workMode} +

+
+ {formatDateRange( + experience.startDate, + experience.endDate + )} +
+
+ {isOwner && ( +
+ + +
+ )} +
+

+ {experience.description} +

+
+
+
+ ))} +
+ )} + + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileExperience; diff --git a/src/components/profile/ProfileHeroSection.tsx b/src/components/profile/ProfileHeroSection.tsx new file mode 100644 index 00000000..71e7b6c4 --- /dev/null +++ b/src/components/profile/ProfileHeroSection.tsx @@ -0,0 +1,101 @@ +'use client'; +import React, { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Pencil, Settings, User } from 'lucide-react'; +import SheetWrapper from './sheets/SheetWrapper'; +import EditProfileForm from './forms/EditProfileForm'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import AccountSeetingForm from './forms/AccountSeetingForm'; +import { useSession } from 'next-auth/react'; +import { UserType } from '@/types/user.types'; +import ProfileSocials from './ProfileSocials'; +import { ProfileShareDialog } from './ProfileShare'; + +const ProfileHeroSection = ({ userdetails }: { userdetails: UserType }) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [isAccountOpen, setIsAccountOpen] = useState(false); + + const { status, data } = useSession(); + + const handleClose = () => { + setIsSheetOpen(false); + setIsAccountOpen(false); + }; + + const handleOpen = () => { + setIsSheetOpen(true); + }; + + return ( + <> +
+
+
+ + {userdetails.avatar && ( + + )} + + + + +
+ {status === 'authenticated' && data.user.id === userdetails.id && ( + <> + + + + )} + +
+
+

{userdetails.name}

+
+ +
+
+ {status === 'authenticated' && data.user.id === userdetails.id && ( + <> + + + + + + + + )} + + ); +}; + +export default ProfileHeroSection; diff --git a/src/components/profile/ProfileHireme.tsx b/src/components/profile/ProfileHireme.tsx new file mode 100644 index 00000000..7b889076 --- /dev/null +++ b/src/components/profile/ProfileHireme.tsx @@ -0,0 +1,49 @@ +import { ArrowRight, Mail } from 'lucide-react'; +import React from 'react'; +import Link from 'next/link'; + +const ProfileHireme = ({ + contactEmail, + email, + resume, +}: { + contactEmail: string; + email: string; + resume: string; +}) => { + return ( + <> +
+
+

+ Hire Me, Let’s Make Magic Happen! +

+

+ Searching for talent that can drive success? I’m ready to contribute + to your goals! +

+
+
+ +

Contact Me

+ + {resume && ( + + View Resume + + + )} +
+
+ + ); +}; + +export default ProfileHireme; diff --git a/src/components/profile/ProfileProject.tsx b/src/components/profile/ProfileProject.tsx new file mode 100644 index 00000000..b4d344e4 --- /dev/null +++ b/src/components/profile/ProfileProject.tsx @@ -0,0 +1,88 @@ +import { ProjectType } from '@/types/user.types'; +import Image from 'next/image'; +import React from 'react'; +import { ProjectDeleteDialog } from './projectDeleteDialog'; +import { Button } from '../ui/button'; +import { ArrowUpRight, Github, Pencil } from 'lucide-react'; +import Link from 'next/link'; + +const ProfileProject = ({ + project, + handleEditClick, + isOwner, +}: { + project: ProjectType; + handleEditClick: (project: ProjectType) => void; + isOwner: boolean; +}) => { + return ( +
+
+ project-image +
+
+
+

{project.projectName}

+ {isOwner && ( +
+ + +
+ )} +
+

+ {project.projectSummary} +

+
+ {project.stack} +
+
+
+ {project.projectLiveLink && ( + + View Project + + )} + {project.projectGithub && ( + + {' '} + View + + )} +
+
+ ); +}; + +export default ProfileProject; diff --git a/src/components/profile/ProfileProjects.tsx b/src/components/profile/ProfileProjects.tsx new file mode 100644 index 00000000..f913238c --- /dev/null +++ b/src/components/profile/ProfileProjects.tsx @@ -0,0 +1,136 @@ +'use client'; +import { ChevronDown, ChevronUp, FileStack, Plus } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import ProjectForm from './forms/ProjectForm'; +import { ProjectType } from '@/types/user.types'; +import ProfileProject from './ProfileProject'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; + +const ProfileProjects = ({ + isOwner, + projects, +}: { + isOwner: boolean; + projects: ProjectType[]; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [selectedProject, setSelectedProject] = useState( + null + ); + const [isSeeMore, setIsSeeMore] = useState(false); + + const handleClose = () => { + setIsSheetOpen(false); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + + const handleEditClick = (project: ProjectType) => { + setSelectedProject(project); + handleOpen(); + }; + + const handleSeeMore = () => { + setIsSeeMore(!isSeeMore); + }; + + const allProjects = useMemo(() => { + return projects + .filter((project) => { + if (!isSeeMore) { + return project.isFeature === true; + } + return true; + }) + .sort((a, b) => Number(b.isFeature) - Number(a.isFeature)); + }, [projects, isSeeMore]); + + const title = selectedProject + ? SHEETS.project.title.replace('Add New', 'Edit') + : SHEETS.project.title; + + return ( + <> +
+

Projects

+ {isOwner && ( + + )} +
+ + {projects.length === 0 && ( + + )} + {projects.length !== 0 && ( + <> +
+ {allProjects.map((project) => ( + + ))} +
+ + + )} + + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileProjects; diff --git a/src/components/profile/ProfileResume.tsx b/src/components/profile/ProfileResume.tsx new file mode 100644 index 00000000..2ddf9210 --- /dev/null +++ b/src/components/profile/ProfileResume.tsx @@ -0,0 +1,97 @@ +'use client'; +import { FileText, Pencil } from 'lucide-react'; +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import UploadResumeForm from './forms/UploadResumeForm'; +import { format } from 'date-fns'; +import { ResumeDeleteDialog } from './resumeDeleteDialog'; +import Link from 'next/link'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; + +const ProfileResume = ({ + isOwner, + resume, + name, + resumeUpdateDate, +}: { + isOwner: boolean; + resume: string; + name: string; + resumeUpdateDate: Date; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + + const handleClose = () => { + setIsSheetOpen(false); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + + return ( + <> +
+

Resume

+ {isOwner && ( + + )} +
+ + {resume.length === 0 && ( + + )} + {resume && ( +
+ +
+ +
+
+

+ {' '} + {name.replace(' ', '_') + '_resume'}{' '} +

+

+ {format(resumeUpdateDate, 'dd MMM yyyy')} +

+
+ + {isOwner && } +
+ )} + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileResume; diff --git a/src/components/profile/ProfileShare.tsx b/src/components/profile/ProfileShare.tsx new file mode 100644 index 00000000..ca1f7103 --- /dev/null +++ b/src/components/profile/ProfileShare.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Twitter, Linkedin, Share2, Copy } from 'lucide-react'; +import { useToast } from '../ui/use-toast'; + +interface ShareOption { + name: string; + icon: React.ReactNode; + shareFunction: () => void; +} + +export const ProfileShareDialog = () => { + const { toast } = useToast(); + + const shareOptions: ShareOption[] = [ + { + name: 'Twitter', + icon: , + shareFunction: () => { + const text = encodeURIComponent( + `Check out my new profile at 100xdevs Job-Board: ${window.location.href}` + ); + window.open(`https://twitter.com/intent/tweet?text=${text}`, '_blank'); + }, + }, + { + name: 'LinkedIn', + icon: , + shareFunction: () => { + const url = encodeURIComponent(window.location.href); + const title = encodeURIComponent('My New Profile'); + const summary = encodeURIComponent( + `Excited to share my new profile on 100xdevs Job-Board! Check it out here: ${url} #JobSearch #Hiring #OpenToWork` + ); + window.open( + `https://www.linkedin.com/sharing/share-offsite/?url=${url}&title=${title}&summary=${summary}`, + '_blank' + ); + }, + }, + { + name: 'Copy', + icon: , + shareFunction: () => { + window.navigator.clipboard.writeText(window.location.href); + toast({ + variant: 'success', + description: 'Successfully copied the Profile Url.', + }); + }, + }, + ]; + + return ( + + + + + + + Share Job + +
+ {shareOptions.map((option) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/components/profile/ProfileSkills.tsx b/src/components/profile/ProfileSkills.tsx new file mode 100644 index 00000000..19624368 --- /dev/null +++ b/src/components/profile/ProfileSkills.tsx @@ -0,0 +1,84 @@ +'use client'; +import { Info, Pencil } from 'lucide-react'; +import React, { useState } from 'react'; +import { Button } from '../ui/button'; +import SheetWrapper from './sheets/SheetWrapper'; +import { SHEETS } from '@/lib/constant/profile.constant'; +import { SkillsForm } from './forms/SkillsForm'; +import ProfileEmptyContainers from './emptycontainers/ProfileEmptyContainers'; + +const ProfileSkills = ({ + isOwner, + skills, +}: { + isOwner: boolean; + skills: string[]; +}) => { + const [isSheetOpen, setIsSheetOpen] = useState(false); + + const handleClose = () => { + setIsSheetOpen(false); + }; + const handleOpen = () => { + setIsSheetOpen(true); + }; + return ( + <> +
+

Skills

+ {isOwner && ( + + )} +
+ + {skills.length === 0 && ( + + )} + {skills.length !== 0 && ( +
+ {skills.map((title) => { + return ( +
+ {title} +
+ ); + })} +
+ )} + {isOwner && ( + + + + )} + + ); +}; + +export default ProfileSkills; diff --git a/src/components/profile/ProfileSocials.tsx b/src/components/profile/ProfileSocials.tsx new file mode 100644 index 00000000..b4d4d70d --- /dev/null +++ b/src/components/profile/ProfileSocials.tsx @@ -0,0 +1,81 @@ +import { UserType } from '@/types/user.types'; +import { Globe } from 'lucide-react'; +import Link from 'next/link'; +import React from 'react'; +import Icon from '../ui/icon'; + +const ProfileSocials = ({ userdetails }: { userdetails: UserType }) => { + return ( +
+ {userdetails.githubLink && ( + + + {userdetails.githubLink.split('/').filter(Boolean).pop()} + + )} + {userdetails.linkedinLink && ( + + + {userdetails.linkedinLink.split('/').filter(Boolean).pop()} + + )} + {userdetails.twitterLink && ( + + + {userdetails.twitterLink.split('/').filter(Boolean).pop()} + + )} + {userdetails.discordLink && ( + + + {userdetails.discordLink.split('/').filter(Boolean).pop()} + + )} + + {userdetails.portfolioLink && ( + + + Portfolio + + )} +
+ ); +}; + +export default ProfileSocials; diff --git a/src/components/profile/emptycontainers/ProfileEmptyContainers.tsx b/src/components/profile/emptycontainers/ProfileEmptyContainers.tsx new file mode 100644 index 00000000..025caf44 --- /dev/null +++ b/src/components/profile/emptycontainers/ProfileEmptyContainers.tsx @@ -0,0 +1,41 @@ +import { Button } from '@/components/ui/button'; +import React from 'react'; + +const ProfileEmptyContainers = ({ + title, + isOwner, + Icon, + description, + handleClick, + buttonText, +}: { + title: string; + Icon: React.ElementType; + description: string; + handleClick: () => void; + buttonText: string; + isOwner: boolean; +}) => { + return ( +
+ +
+

{title}

+

+ {description} +

+
+ {isOwner && ( + + )} +
+ ); +}; + +export default ProfileEmptyContainers; diff --git a/src/components/profile/forms/AccountSeetingForm.tsx b/src/components/profile/forms/AccountSeetingForm.tsx new file mode 100644 index 00000000..bccec13b --- /dev/null +++ b/src/components/profile/forms/AccountSeetingForm.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/form'; +import { + UserPasswordSchema, + UserPasswordSchemaType, +} from '@/lib/validators/user.profile.validator'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { TriangleAlert } from 'lucide-react'; +import { changePassword } from '@/actions/user.profile.actions'; +import { useToast } from '@/components/ui/use-toast'; +import { DeleteAccountDialog } from '../DeleteAccountDialog'; + +const AccountSeetingForm = ({ handleClose }: { handleClose: () => void }) => { + const form = useForm({ + resolver: zodResolver(UserPasswordSchema), + defaultValues: { + confirmNewPassword: '', + currentPassword: '', + newPassword: '', + }, + }); + + const { toast } = useToast(); + + const onSubmit = async (values: UserPasswordSchemaType) => { + try { + const response = await changePassword(values); + + if (!response.status) { + return toast({ + title: response.message || 'Error', + variant: 'destructive', + }); + } + toast({ + title: response.message, + variant: 'success', + }); + handleFormClose(); + } catch (_error) { + toast({ + variant: 'destructive', + title: 'Something went wrong while updating.', + }); + } + }; + + const handleFormClose = () => { + form.clearErrors(); + form.reset(); + handleClose(); + }; + + return ( +
+

Change password

+ + +
+ ( + + Current Password + + + + + )} + /> + ( + + New Password + + + + + )} + /> + ( + + Confirm New Password + + + + + )} + /> +
+ + +
+
+ + + +
+ +

+ Delete account +

+

+ Permanently delete your account and all associated data. This action + cannot be undone. +

+ +
+
+ ); +}; + +export default AccountSeetingForm; diff --git a/src/components/profile/forms/EditProfileForm.tsx b/src/components/profile/forms/EditProfileForm.tsx new file mode 100644 index 00000000..ae0380f5 --- /dev/null +++ b/src/components/profile/forms/EditProfileForm.tsx @@ -0,0 +1,377 @@ +import React, { useRef, useState } from 'react'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@/components/ui/form'; +import Image from 'next/image'; +import { FaFileUpload } from 'react-icons/fa'; +import { useToast } from '@/components/ui/use-toast'; +import { useForm } from 'react-hook-form'; +import { + profileSchema, + ProfileSchemaType, +} from '@/lib/validators/user.profile.validator'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { uploadFileAction } from '@/actions/upload-to-cdn'; +import { X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { UserType } from '@/types/user.types'; +import { updateUserDetails } from '@/actions/user.profile.actions'; + +const EditProfileForm = ({ + handleClose, + userdetails, +}: { + handleClose: () => void; + userdetails: UserType; +}) => { + const { toast } = useToast(); + + const [file, setFile] = useState(null); + const [previewImg, setPreviewImg] = useState( + userdetails.avatar || null + ); + + const form = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + aboutMe: userdetails.aboutMe || '', + email: userdetails.email || '', + contactEmail: userdetails.contactEmail || '', + avatar: userdetails.avatar || '', + name: userdetails.name || '', + discordLink: userdetails.discordLink || '', + linkedinLink: userdetails.linkedinLink || '', + twitterLink: userdetails.twitterLink || '', + githubLink: userdetails.githubLink || '', + portfolioLink: userdetails.portfolioLink || '', + }, + }); + + const submitImage = async (file: File | null) => { + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const uniqueFileName = `${Date.now()}-${file.name}`; + formData.append('uniqueFileName', uniqueFileName); + + const res = await uploadFileAction(formData, 'webp'); + if (!res) { + throw new Error('Failed to upload image'); + } + + const uploadRes = res; + return uploadRes.url; + } catch (error) { + console.error('Image upload failed:', error); + } + }; + + const onSubmit = async (data: ProfileSchemaType) => { + try { + if (file) { + data.avatar = (await submitImage(file)) ?? '/main.svg'; + } + const response = await updateUserDetails(data); + + if (!response.status) { + return toast({ + title: response.message || 'Error', + variant: 'destructive', + }); + } + toast({ + title: response.message, + variant: 'success', + }); + handleFormClose(); + } catch (_error) { + toast({ + variant: 'destructive', + title: 'Something went wrong while updating.', + }); + } + }; + + const handleFormClose = () => { + form.reset(); + form.clearErrors(); + handleClose(); + }; + const profileImageRef = useRef(null); + + const handleClick = () => { + if (previewImg) return; + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.click(); + } + }; + + const clearLogoImage = () => { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.value = ''; + } + setPreviewImg(null); + setFile(null); + form.setValue('avatar', ''); + }; + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files ? e.target.files[0] : null; + if (!selectedFile) { + return; + } + if (!selectedFile.type.includes('image')) { + toast({ + title: + 'Invalid file format. Please upload an image file (e.g., .png, .jpg, .jpeg, .svg ) for the company logo', + variant: 'destructive', + }); + return; + } + const reader = new FileReader(); + reader.onload = () => { + if (profileImageRef.current) { + profileImageRef.current.src = reader.result as string; + } + setPreviewImg(reader.result as string); + }; + reader.readAsDataURL(selectedFile); + if (selectedFile) { + setFile(selectedFile); + } + }; + + return ( +
+ +
+ Profile Picture +
+
+ {previewImg ? ( + Company Logo + ) : ( + + )} + {previewImg && ( + + )} +
+ + +
+ + ( + + Name + + + + + )} + /> + + ( + + Email + + + + + )} + /> + ( + + Contact Email + + + + + )} + /> + ( + + Portfolio Link + + + + + )} + /> + ( + + Github Link + + + + + )} + /> + ( + + X Link + + + + + )} + /> + ( + + Linkedin Link + + + + + )} + /> + ( + + Discord Link + + + + + )} + /> + ( + + About Me + +