From a8d636418fa65506e57410d907423b5a5f717f51 Mon Sep 17 00:00:00 2001 From: Dev Sharma <109821078+devsharmagit@users.noreply.github.com> Date: Fri, 1 Nov 2024 22:10:15 +0530 Subject: [PATCH] Feat job seeker UI (#555) * added the forms ui * refactor * updated schema * attached forms * laoding skeleton responsivness * added delete account * added 404 page * updated navbar * all checked passed * added socials * social url optional * added share button + edge cases * fix error build * fix conflicts and build --- next.config.js | 6 +- package.json | 2 + .../migration.sql | 66 +++ .../20241025095014_user_updated/migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 11 + .../20241031064849_company/migration.sql | 26 ++ prisma/schema.prisma | 40 ++ prisma/seed.ts | 21 +- src/actions/user.profile.actions.ts | 347 +++++++++++++++- src/app/profile/[userId]/loading.tsx | 29 ++ src/app/profile/[userId]/page.tsx | 64 +++ src/app/profile/bookmarks/page.tsx | 71 ---- src/app/profile/edit/page.tsx | 27 -- src/app/profile/experience/page.tsx | 42 -- src/app/profile/layout.tsx | 38 +- src/app/profile/page.tsx | 36 -- src/app/profile/projects/page.tsx | 42 -- src/app/profile/resume/page.tsx | 23 -- src/app/profile/settings/page.tsx | 25 -- src/app/profile/skills/page.tsx | 42 -- src/components/profile/AboutMe.tsx | 80 ++++ src/components/profile/ChangePassword.tsx | 21 +- .../profile/DeleteAccountDialog.tsx | 9 +- .../profile/EducationDeleteDialog.tsx | 85 ++++ .../profile/ExperienceDeleteDialog.tsx | 85 ++++ src/components/profile/ProfileEducation.tsx | 140 +++++++ src/components/profile/ProfileExperience.tsx | 154 +++++++ src/components/profile/ProfileHeroSection.tsx | 101 +++++ src/components/profile/ProfileHireme.tsx | 49 +++ src/components/profile/ProfileProject.tsx | 88 ++++ src/components/profile/ProfileProjects.tsx | 136 +++++++ src/components/profile/ProfileResume.tsx | 97 +++++ src/components/profile/ProfileShare.tsx | 88 ++++ src/components/profile/ProfileSkills.tsx | 84 ++++ src/components/profile/ProfileSocials.tsx | 81 ++++ .../ProfileEmptyContainers.tsx | 41 ++ .../profile/forms/AccountSeetingForm.tsx | 161 ++++++++ .../profile/forms/EditProfileForm.tsx | 377 ++++++++++++++++++ .../profile/forms/EducationForm.tsx | 243 +++++++++++ .../profile/forms/ExperienceForm.tsx | 319 +++++++++++++++ src/components/profile/forms/ProjectForm.tsx | 319 +++++++++++++++ src/components/profile/forms/ReadMeForm.tsx | 116 ++++++ src/components/profile/forms/SkillsForm.tsx | 97 +++++ .../profile/forms/UploadResumeForm.tsx | 159 ++++++++ .../profile/profile-skills-combobox.tsx | 122 ++++++ src/components/profile/profileComboBox.tsx | 109 +++++ .../profile/projectDeleteDialog.tsx | 81 ++++ src/components/profile/resumeDeleteDialog.tsx | 81 ++++ .../profile/sheets/SheetWrapper.tsx | 39 ++ src/components/ui/alert-dialog.tsx | 141 +++++++ .../user-multistep-form/add-project-form.tsx | 3 +- src/layouts/header.tsx | 5 +- src/lib/constant/profile.constant.ts | 54 +++ src/lib/icons.ts | 2 + src/lib/utils.ts | 23 ++ src/lib/validators/user.profile.validator.ts | 117 +++++- src/types/user.types.ts | 57 +++ 58 files changed, 4529 insertions(+), 398 deletions(-) create mode 100644 prisma/migrations/20241024174828_profileupdate/migration.sql create mode 100644 prisma/migrations/20241025095014_user_updated/migration.sql create mode 100644 prisma/migrations/20241025120951_resume_update_date/migration.sql create mode 100644 prisma/migrations/20241031043344_username_remove/migration.sql create mode 100644 prisma/migrations/20241031064849_company/migration.sql create mode 100644 src/app/profile/[userId]/loading.tsx create mode 100644 src/app/profile/[userId]/page.tsx delete mode 100644 src/app/profile/bookmarks/page.tsx delete mode 100644 src/app/profile/edit/page.tsx delete mode 100644 src/app/profile/experience/page.tsx delete mode 100644 src/app/profile/page.tsx delete mode 100644 src/app/profile/projects/page.tsx delete mode 100644 src/app/profile/resume/page.tsx delete mode 100644 src/app/profile/settings/page.tsx delete mode 100644 src/app/profile/skills/page.tsx create mode 100644 src/components/profile/AboutMe.tsx create mode 100644 src/components/profile/EducationDeleteDialog.tsx create mode 100644 src/components/profile/ExperienceDeleteDialog.tsx create mode 100644 src/components/profile/ProfileEducation.tsx create mode 100644 src/components/profile/ProfileExperience.tsx create mode 100644 src/components/profile/ProfileHeroSection.tsx create mode 100644 src/components/profile/ProfileHireme.tsx create mode 100644 src/components/profile/ProfileProject.tsx create mode 100644 src/components/profile/ProfileProjects.tsx create mode 100644 src/components/profile/ProfileResume.tsx create mode 100644 src/components/profile/ProfileShare.tsx create mode 100644 src/components/profile/ProfileSkills.tsx create mode 100644 src/components/profile/ProfileSocials.tsx create mode 100644 src/components/profile/emptycontainers/ProfileEmptyContainers.tsx create mode 100644 src/components/profile/forms/AccountSeetingForm.tsx create mode 100644 src/components/profile/forms/EditProfileForm.tsx create mode 100644 src/components/profile/forms/EducationForm.tsx create mode 100644 src/components/profile/forms/ExperienceForm.tsx create mode 100644 src/components/profile/forms/ProjectForm.tsx create mode 100644 src/components/profile/forms/ReadMeForm.tsx create mode 100644 src/components/profile/forms/SkillsForm.tsx create mode 100644 src/components/profile/forms/UploadResumeForm.tsx create mode 100644 src/components/profile/profile-skills-combobox.tsx create mode 100644 src/components/profile/profileComboBox.tsx create mode 100644 src/components/profile/projectDeleteDialog.tsx create mode 100644 src/components/profile/resumeDeleteDialog.tsx create mode 100644 src/components/profile/sheets/SheetWrapper.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/lib/constant/profile.constant.ts create mode 100644 src/types/user.types.ts 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 9a420bdb..1cc0363b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,20 @@ model User { blockedByAdmin DateTime? onBoard Boolean @default(false) bookmark Bookmark[] + + 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]) } @@ -121,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 @@ -131,6 +156,7 @@ model Project { stack ProjectStack @default(OTHERS) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + isFeature Boolean @default(false) } enum ProjectStack { @@ -166,3 +192,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 e2ba7cdb..4d08dfc2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -17,8 +17,6 @@ const users = [ { 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 }, - - ]; @@ -75,7 +73,6 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: false, - }, { id: '3', @@ -99,7 +96,7 @@ let jobs = [ minSalary: 90000, maxSalary: 120000, isVerifiedJob: true, - deleted: true + deleted: true, }, { id: '4', @@ -148,7 +145,7 @@ let jobs = [ minSalary: 110000, maxSalary: 150000, isVerifiedJob: true, - deleted: true + deleted: true, }, { id: '6', @@ -174,7 +171,6 @@ let jobs = [ minSalary: 80000, maxSalary: 100000, isVerifiedJob: false, - }, { id: '7', @@ -199,8 +195,7 @@ let jobs = [ minSalary: 70000, maxSalary: 90000, isVerifiedJob: false, - delted: true - + delted: true, }, { id: '8', @@ -225,8 +220,7 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: true, - deleted: true - + deleted: true, }, { id: '9', @@ -249,7 +243,6 @@ let jobs = [ minSalary: 100000, maxSalary: 130000, isVerifiedJob: true, - }, { id: '10', @@ -274,7 +267,6 @@ let jobs = [ minSalary: 75000, maxSalary: 95000, isVerifiedJob: false, - }, { id: '11', @@ -296,7 +288,6 @@ let jobs = [ minSalary: 25000, maxSalary: 50000, isVerifiedJob: true, - }, { id: '12', @@ -321,7 +312,7 @@ let jobs = [ minSalary: null, maxSalary: null, isVerifiedJob: true, - delted: false + delted: false, }, ]; @@ -441,4 +432,4 @@ async function main() { await seedJobs(); } -main(); \ No newline at end of file +main(); diff --git a/src/actions/user.profile.actions.ts b/src/actions/user.profile.actions.ts index d68e0857..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'); @@ -318,6 +362,269 @@ export const getUserDetails = async () => { } }; +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); diff --git a/src/app/profile/[userId]/loading.tsx b/src/app/profile/[userId]/loading.tsx new file mode 100644 index 00000000..813243f7 --- /dev/null +++ b/src/app/profile/[userId]/loading.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; + +const Loading = () => { + return ( + <> +
+
+
+ + +
+ + +
+
+
+ {Array.from({ length: 12 }).map((value, index) => { + if (index % 2 === 0) { + return ; + } else { + return ; + } + })} + + ); +}; + +export default Loading; diff --git a/src/app/profile/[userId]/page.tsx b/src/app/profile/[userId]/page.tsx new file mode 100644 index 00000000..6384616f --- /dev/null +++ b/src/app/profile/[userId]/page.tsx @@ -0,0 +1,64 @@ +import { getUserDetailsWithId } from '@/actions/user.profile.actions'; +import Custom404Page from '@/app/[...404]/page'; +import ProfileAboutMe from '@/components/profile/AboutMe'; +import ProfileEducation from '@/components/profile/ProfileEducation'; +import ProfileExperience from '@/components/profile/ProfileExperience'; +import ProfileHeroSection from '@/components/profile/ProfileHeroSection'; +import ProfileHireme from '@/components/profile/ProfileHireme'; +import ProfileProjects from '@/components/profile/ProfileProjects'; +import ProfileResume from '@/components/profile/ProfileResume'; +import ProfileSkills from '@/components/profile/ProfileSkills'; +import { authOptions } from '@/lib/authOptions'; +import { getServerSession } from 'next-auth'; + +const Page = async ({ params: { userId } }: { params: { userId: string } }) => { + const session = await getServerSession(authOptions); + + const isOwner = session?.user.id === userId; + let userDetails; + const res = await getUserDetailsWithId(userId); + if (res.status) { + userDetails = res.additional; + } + + if (!res.status) { + return ; + } + + return ( + <> + {userDetails && ( + <> + + + + + + + + + + )} + + ); +}; + +export default Page; diff --git a/src/app/profile/bookmarks/page.tsx b/src/app/profile/bookmarks/page.tsx deleted file mode 100644 index ac0f826c..00000000 --- a/src/app/profile/bookmarks/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import { GetBookmarkByUserId } from '@/actions/job.action'; -import BookmarkCardSkeleton from '@/components/BookmarkCardSkeletion'; -import JobCard from '@/components/Jobcard'; - -import { JobType } from '@/types/jobs.types'; -import { useEffect, useState } from 'react'; - -export default function BookmarkPage() { - const [loading, setLoading] = useState(true); - const [errorNoPost, setErrorNoPost] = useState(false); - const [bookmarkedJobs, setBookmarkedJobs] = useState< - { - job: JobType; - }[] - >(); - - useEffect(() => { - const getBookmark = async () => { - setLoading(true); - try { - const response = await GetBookmarkByUserId(); - if (response.status !== 200 || !response.data) { - return setErrorNoPost(true); - } - setBookmarkedJobs(response.data); - } finally { - setLoading(false); - } - }; - getBookmark(); - }, []); - - return ( -
-
- Bookmarks -
- - {loading ? ( -
- {[1, 2, 3, 4, 5].map((e: number) => ( - - ))} -
- ) : ( - <> - {errorNoPost ? ( -
-

No Bookmarks found

-
- ) : ( -
- {bookmarkedJobs?.map(({ job }, index) => { - return ( - - ); - })} -
- )} - - )} -
- ); -} diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx deleted file mode 100644 index e5b60817..00000000 --- a/src/app/profile/edit/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'use client'; -import { EditProfile } from '@/components/profile/EditProfile'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -const EditProfilePage = () => { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - const user = session.data?.user; - - return ( -
-
- Edit Profile -
- -
- ); -}; - -export default EditProfilePage; diff --git a/src/app/profile/experience/page.tsx b/src/app/profile/experience/page.tsx deleted file mode 100644 index e657a7a9..00000000 --- a/src/app/profile/experience/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import { UserExperience } from '@/components/profile/UserExperience'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { AddExperience } from '@/components/user-multistep-form/addExperience-form'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -export default function AccountExperiencePage() { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - return ( -
-
- Experience - - Add more - - - Add Experience - -
- -
-
-
-
- -
- ); -} diff --git a/src/app/profile/layout.tsx b/src/app/profile/layout.tsx index 738f71d2..a0c6ac42 100644 --- a/src/app/profile/layout.tsx +++ b/src/app/profile/layout.tsx @@ -1,42 +1,10 @@ 'use client'; -import { getUserDetails } from '@/actions/user.profile.actions'; -import Sidebar from '@/components/profile/sidebar'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; +import React from 'react'; const ProfileLayout = ({ children }: { children: React.ReactNode }) => { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - useEffect(() => { - async function fetchUserDetails() { - try { - const res = await getUserDetails(); - if (res.status) { - localStorage.setItem( - 'skills', - JSON.stringify(res.additional?.skills) - ); - localStorage.setItem( - 'resume', - JSON.stringify(res.additional?.resume) - ); - } - } catch (_error) {} - } - fetchUserDetails(); - }, []); return ( -
- -
- {children} -
+
+ {children}
); }; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx deleted file mode 100644 index 30509244..00000000 --- a/src/app/profile/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; -import { ProfileInfo } from '@/components/profile/ProfileInfo'; -import APP_PATHS from '@/config/path.config'; -import { Edit } from 'lucide-react'; -import { useSession } from 'next-auth/react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -const ProfilePage = () => { - const session = useSession(); - const router = useRouter(); - - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/create`); - }, [session.status, router]); - - return ( -
-
- My Account - - - Edit Profile - -
- -
- ); -}; - -export default ProfilePage; diff --git a/src/app/profile/projects/page.tsx b/src/app/profile/projects/page.tsx deleted file mode 100644 index 517a3480..00000000 --- a/src/app/profile/projects/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import { UserProjects } from '@/components/profile/UserProject'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { AddProject } from '@/components/user-multistep-form/add-project-form'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -export default function AccountProjectPage() { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - return ( -
-
- Projects - - Add more - - - Add Project - -
- -
-
-
-
- -
- ); -} diff --git a/src/app/profile/resume/page.tsx b/src/app/profile/resume/page.tsx deleted file mode 100644 index f9efcefe..00000000 --- a/src/app/profile/resume/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; -import { UserResume } from '@/components/profile/UserResume'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -export default function AccountResumePage() { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - return ( -
-
- Resume -
- -
- ); -} diff --git a/src/app/profile/settings/page.tsx b/src/app/profile/settings/page.tsx deleted file mode 100644 index 9cd66ff1..00000000 --- a/src/app/profile/settings/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; -import { AccountSettings } from '@/components/profile/AccountSettings'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -const AccountSettingsPage = () => { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - return ( -
-
- Account Settings -
- -
- ); -}; - -export default AccountSettingsPage; diff --git a/src/app/profile/skills/page.tsx b/src/app/profile/skills/page.tsx deleted file mode 100644 index c9fd1b2b..00000000 --- a/src/app/profile/skills/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import { UserSkills } from '@/components/profile/UserSkills'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { AddSkills } from '@/components/user-multistep-form/add-skills-form'; -import APP_PATHS from '@/config/path.config'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React, { useEffect } from 'react'; - -export default function AccountResumePage() { - const router = useRouter(); - const session = useSession(); - useEffect(() => { - if (session.status !== 'loading' && session.status === 'unauthenticated') - router.push(`${APP_PATHS.SIGNIN}?redirectTo=/profile`); - }, [session.status, router]); - return ( -
-
- Skills - - Add more - - - Add Skills - -
- -
-
-
-
- -
- ); -} 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 4b253568..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(); diff --git a/src/components/profile/DeleteAccountDialog.tsx b/src/components/profile/DeleteAccountDialog.tsx index 4b3950f1..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,13 +80,12 @@ export const DeleteAccountDialog = () => { 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 + +