diff --git a/next.config.mjs b/next.config.mjs index 4678774..a58e239 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,4 @@ /** @type {import('next').NextConfig} */ const nextConfig = {}; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c3870f2..85fe533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.53.0", - "react-icons": "^5.3.0", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", @@ -3282,14 +3281,6 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, - "node_modules/react-icons": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", - "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-remove-scroll": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", diff --git a/package.json b/package.json index 67ce366..45c17bd 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.53.0", - "react-icons": "^5.3.0", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", diff --git a/src/api/api_types.ts b/src/api/api_types.ts index bdfa719..4379c62 100644 --- a/src/api/api_types.ts +++ b/src/api/api_types.ts @@ -6,8 +6,11 @@ import type PocketBase from 'pocketbase' import type { RecordService } from 'pocketbase' export enum Collections { + Applications = "applications", + Chats = "chats", Company = "company", Experience = "experience", + Messages = "messages", Response = "response", Resume = "resume", Users = "users", @@ -38,12 +41,30 @@ export type AuthSystemFields = { // Record types for each collection +export enum ApplicationsStatusOptions { + "created" = "created", + "accepted" = "accepted", + "archived" = "archived", +} +export type ApplicationsRecord = { + resume: RecordIdString + status: ApplicationsStatusOptions + vacancy: RecordIdString +} + +export type ChatsRecord = { + is_group?: boolean + title?: string + users?: RecordIdString[] +} + export type CompanyRecord = { description?: HTMLString email?: string field?: string name?: string phone?: string + user?: RecordIdString vacansies?: RecordIdString[] website?: string } @@ -52,10 +73,17 @@ export type ExperienceRecord = { company_name?: string description?: HTMLString end_date?: IsoDateString - field?: RecordIdString + resume?: RecordIdString start_date?: IsoDateString } +export type MessagesRecord = { + attachments?: string[] + chat?: RecordIdString + text: string + user?: RecordIdString +} + export type ResponseRecord = { resume?: RecordIdString vacancy?: RecordIdString @@ -79,6 +107,7 @@ export enum ResumeEmploymentTypeOptions { } export type ResumeRecord = { about?: HTMLString + age?: number city?: string education?: string education_levels?: ResumeEducationLevelsOptions @@ -86,6 +115,7 @@ export type ResumeRecord = { employment_type?: ResumeEmploymentTypeOptions experience?: RecordIdString[] full_name?: string + img?: string phone_number?: string salary?: number skills?: string @@ -97,9 +127,11 @@ export enum UsersRoleOptions { } export type UsersRecord = { avatar?: string + chats?: RecordIdString[] company?: RecordIdString + messages?: RecordIdString[] resume?: RecordIdString - role?: UsersRoleOptions + role: UsersRoleOptions } export enum VacancyExperienceOptions { @@ -129,12 +161,15 @@ export type VacancyRecord = { publication_date?: IsoDateString remote?: boolean skills?: string - title?: string + title: string } // Response types include system fields and match responses from the PocketBase API +export type ApplicationsResponse = Required & BaseSystemFields +export type ChatsResponse = Required & BaseSystemFields export type CompanyResponse = Required & BaseSystemFields export type ExperienceResponse = Required & BaseSystemFields +export type MessagesResponse = Required & BaseSystemFields export type ResponseResponse = Required & BaseSystemFields export type ResumeResponse = Required & BaseSystemFields export type UsersResponse = Required & AuthSystemFields @@ -143,8 +178,11 @@ export type VacancyResponse = Required & BaseS // Types containing all Records and Responses, useful for creating typing helper functions export type CollectionRecords = { + applications: ApplicationsRecord + chats: ChatsRecord company: CompanyRecord experience: ExperienceRecord + messages: MessagesRecord response: ResponseRecord resume: ResumeRecord users: UsersRecord @@ -152,8 +190,11 @@ export type CollectionRecords = { } export type CollectionResponses = { + applications: ApplicationsResponse + chats: ChatsResponse company: CompanyResponse experience: ExperienceResponse + messages: MessagesResponse response: ResponseResponse resume: ResumeResponse users: UsersResponse @@ -164,8 +205,11 @@ export type CollectionResponses = { // https://github.com/pocketbase/js-sdk#specify-typescript-definitions export type TypedPocketBase = PocketBase & { + collection(idOrName: 'applications'): RecordService + collection(idOrName: 'chats'): RecordService collection(idOrName: 'company'): RecordService collection(idOrName: 'experience'): RecordService + collection(idOrName: 'messages'): RecordService collection(idOrName: 'response'): RecordService collection(idOrName: 'resume'): RecordService collection(idOrName: 'users'): RecordService diff --git a/src/api/resume.ts b/src/api/resume.ts index 6bb86c7..a1b52b0 100644 --- a/src/api/resume.ts +++ b/src/api/resume.ts @@ -1,17 +1,94 @@ +"use server"; +import { permanentRedirect } from "next/navigation"; import { pocketbase } from "./pocketbase"; export const getResume = async (id: string) => { const pb = pocketbase(); - const resume = await pb.collection("resume").getOne(id); + const resume = await pb.collection("resume").getOne(id, { + expand: "experience", + }); return resume; }; +export const getUserByResume = async (id: string) => { + const pb = pocketbase(); + const user = await pb.collection("users").getFirstListItem(`resume="${id}"`); + return user; +} + +export const getExperience = async (id: string) => { + const pb = pocketbase(); + const experience = await pb.collection("experience").getFullList({ + filter: `resume="${id}"`, + }); + return experience; +} + +export const getImgUrl = async (id: string) => { + try { + const pb = pocketbase(); + const record = await pb.collection('resume').getOne(id); + const firstFilename = record.img[0]; + const url = pb.files.getUrl(record, firstFilename); + // const url = pocketbase.records.getFileUrl(record, record.imageField); + return url; + } catch (error) { + console.log(error); + return null; + } +} + +export const userToResume = async (userId: string, resume: string) => { + const pb = pocketbase(); + const updatedUser = await pb.collection("users").update(userId, { + resume: resume, + }); + return updatedUser; +}; + +export const resumeRedirect = async (resumeId: string) => { + const pb = pocketbase(); + if (resumeId) { + permanentRedirect(`/resume/${resumeId}`); + } +}; export const createResume = async (resume: any) => { const pb = pocketbase(); + const data = await pb.collection("resume").create(resume); return data; }; +export const createExperience = async (experiences: any) => { + const pb = pocketbase(); + + const experiencePromises = experiences.map(async (experience: any) => { + return await pb.collection("experience").create(experience); + }); + + const results = await Promise.all(experiencePromises); + return results; +}; + +export const createSingleExperience = async ( + id: string, + company: string, + description: string, + startDate: string, + endDate: string +) => { + const pb = pocketbase(); + const experience = { + id, + company, + description, + startDate, + endDate, + }; + await pb.collection("experience").create(experience); +}; + + export const hasResume = async (userId: string) => { const pb = pocketbase(); const resumes = await pb.collection("users").getList(1, 1, { diff --git a/src/app/(with-nav)/resume/[id]/page.tsx b/src/app/(with-nav)/resume/[id]/page.tsx index 5b1daa9..777497f 100644 --- a/src/app/(with-nav)/resume/[id]/page.tsx +++ b/src/app/(with-nav)/resume/[id]/page.tsx @@ -1,37 +1,136 @@ +"use client"; +import { getUser } from "@/api/auth"; +import { + getExperience, + getImgUrl, + getResume, + getUserByResume, +} from "@/api/resume"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import Image from "next/image"; -import React from "react"; -import { GoTriangleLeft, GoTriangleRight } from "react-icons/go"; +import { useParams, useRouter } from "next/navigation"; +import React, { use, useEffect, useState } from "react"; -const data = { - fullName: "John Doe", - age: 25, - workExperience: [ - { - company: "Tech Company", - startDate: "2020-01-15", - endDate: "2022-05-20", - jobDescription: - "Worked as a full-stack developer, building web applications using Remix.", - }, - ], - education: "Bachelor of Computer Science", - placesOfStudy: ["MIT", "Community College"], - skills: ["JavaScript", "React", "Remix"], - expectedSalary: 50000, - typeOfEmployment: "Full-time", - img: "/placeholder-user_2.jpg", - aboutMyself: - "I am a passionate developer with a strong interest in web technologies and software development.", - phone: "1234567890", - email: "example@mail.com", - viewed: 123, - suitable: 123, -}; +// const data = { +// fullName: "John Doe", +// age: 25, +// workExperience: [ +// { +// company: "Tech Company", +// startDate: "2020-01-15", +// endDate: "2022-05-20", +// jobDescription: +// "Worked as a full-stack developer, building web applications using Remix.", +// }, +// ], +// education: "Bachelor of Computer Science", +// placesOfStudy: ["MIT", "Community College"], +// skills: ["JavaScript", "React", "Remix"], +// expectedSalary: 50000, +// typeOfEmployment: "Full-time", +// img: "/placeholder-user_2.jpg", +// aboutMyself: +// "I am a passionate developer with a strong interest in web technologies and software development.", +// phone: "1234567890", +// email: "example@mail.com", +// viewed: 123, +// suitable: 123, +// }; + +interface Resume { + id: string; + fullName: string; + age: number | string; + skills: string; + expectedSalary: number | string; + city: string; + education: string; + placesOfStudy: string; + typeOfEmployment: string; + email: string; + img: any; + phoneNumber: string; + aboutMyself: string; +} + +// interface Experience { +// workExperience: Array<{ +// id: string; +// company: string; +// jobDescription: string; +// startDate: string; +// endDate: string; +// }>; +// } + +interface Experience { + workExperience: Array<{ + id: string; + company: string; + jobDescription: string; + startDate: string; + endDate: string; + }>; +} + +interface User { + id: string; + email: string; +} const resumePage = () => { + const [data, setData] = useState(); + const [user, setUser] = useState(); + const [workExperience, setWorkExperience] = useState(); + const router = useRouter(); + const id = useParams<{ id: string }>().id; + + useEffect(() => { + const fetchResume = async () => { + const resumeResponse = await getResume(id); + const avatar = await getImgUrl(resumeResponse.id); + const resume: Resume = { + id: resumeResponse.id, + fullName: resumeResponse.full_name, + age: resumeResponse.age, + skills: resumeResponse.skills, + expectedSalary: resumeResponse.salary, + city: resumeResponse.city, + education: resumeResponse.education_levels, + placesOfStudy: resumeResponse.education, + typeOfEmployment: resumeResponse.employment_type, + email: resumeResponse.email, + img: avatar, + phoneNumber: resumeResponse.phone_number, + aboutMyself: resumeResponse.about, + // workExperience: resumeResponse.experience.map((exp: any) => ({ + // company: exp.company, + // jobDescription: exp.job_description, + // startDate: exp.start_date, + // endDate: exp.end_date, + // })), + }; + + const experienceResponse = await getExperience(resume.id); + const experience = experienceResponse.map((exp: any) => ({ + id: exp.id, + company: exp.company_name, + jobDescription: exp.description, + startDate: exp.start_date, + endDate: exp.end_date, + })); + setWorkExperience({ workExperience: experience }); + console.log("workExperience", workExperience); + const user = await getUserByResume(resume.id); + console.log("user", user); + console.log("data", resume); + setUser(user); + setData(resume); + }; + fetchResume(); + }, [id]); return (
@@ -76,7 +175,7 @@ const resumePage = () => {
Profile picture {

{"Name: "}

-

{` ${data.fullName}`}

+

{` ${data?.fullName}`}

{/* {

{"Age: "}

-

{data.age}

+

{data?.age}

{/* */}
@@ -133,11 +232,12 @@ const resumePage = () => { alt="" width={400} height={400} - className="absolute bottom-0 right-5 " + className="absolute bottom-0 right-5 img-nondragable" /> + {/* TODO: Add stats

{`Suitable vacancies: ${data.suitable} • Viewed: ${data.viewed}`}

-
+
*/}
@@ -161,8 +261,9 @@ const resumePage = () => { - {data.workExperience.length > 0 ? ( - data.workExperience.map((job, index) => ( + {Array.isArray(workExperience?.workExperience) && + workExperience.workExperience.length > 0 ? ( + workExperience?.workExperience.map((job, index) => (
@@ -209,12 +310,14 @@ const resumePage = () => {
- {data.skills.length > 0 ? ( - data.skills.map((skill, index) => ( -
- {skill} -
- )) + {data?.skills && data.skills.split(",").length > 0 ? ( + data?.skills + .split(",") + .map((skill: string, index: number) => ( +
+ {skill} +
+ )) ) : (

No skills added

)} @@ -239,21 +342,24 @@ const resumePage = () => { - {data.education.length > 0 ? ( + {data?.education && data?.education.length > 0 ? ( -

{data.education}

+

{data?.education}

- {data.placesOfStudy.length > 0 ? ( - data.placesOfStudy.map((place, index) => ( -
-

{`${index + 1}. ${place}`}

-
- )) + {data?.placesOfStudy && + data?.placesOfStudy.split(", ").length > 0 ? ( + data?.placesOfStudy + .split(", ") + .map((place, index) => ( +
+

{`${index + 1}. ${place}`}

+
+ )) ) : (

No places added

)} @@ -290,12 +396,12 @@ const resumePage = () => { -

{data.expectedSalary}

+

{data?.expectedSalary}

-

{data.typeOfEmployment}

+

{data?.typeOfEmployment}

-

{data.aboutMyself}

+

{data?.aboutMyself}

@@ -323,10 +429,10 @@ const resumePage = () => { - -

{data.phone}

+ {/* +

{user?.phone}

*/} -

{data.email}

+

{user?.email}

diff --git a/src/app/(with-nav)/resume/create/page.tsx b/src/app/(with-nav)/resume/create/page.tsx index ffb1a6b..e3d992d 100644 --- a/src/app/(with-nav)/resume/create/page.tsx +++ b/src/app/(with-nav)/resume/create/page.tsx @@ -8,7 +8,7 @@ import { resumeCreationSchema, ResumeCreationSchema, } from "@/lib/formValidationSchemas"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -16,15 +16,26 @@ import { Textarea } from "@/components/ui/textarea"; import Image from "next/image"; import ExperienceForm from "@/components/ExperienceForm"; import { Experience } from "@/types/resume"; +import { + createExperience, + createResume, + resumeRedirect, + userToResume, +} from "@/api/resume"; +import { redirect, useRouter, useSearchParams } from "next/navigation"; +import { getUser } from "@/api/auth"; const ResumeForm = () => { const { register, handleSubmit, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(resumeCreationSchema), }); + const router = useRouter(); + const searchParams = useSearchParams(); const [imagePreview, setImagePreview] = useState(null); const [options, setOptions] = useState([]); @@ -32,9 +43,118 @@ const ResumeForm = () => { const [skills, setSkills] = useState([]); const [skillInput, setSkillInput] = useState(""); const [experience, setExperience] = useState([]); + const [user, setUser] = useState(null); + + // const fileInput = document.getElementById("file-input") as HTMLInputElement; + + // TODO: добавить рабочий редирект для резюме + // useEffect(() => { + // const fetchUser = async () => { + // const user = await getUser(); + // await resumeRedirect(user.resume_id, router); + // }; + // fetchUser(); + // }, [router]); + // useEffect(() => { + // const redirectUser = async () => { + // const user = await getUser(); + // if (user.resume_id) { + // router.push(`/resume/${user.resume_id}`); + // } + // }; + // redirectUser(); + // }, [router]); + const onSubmit = async (data: ResumeCreationSchema) => { + console.log("resume data: ", data); + console.log("experience data: ", experience); + try { + // const formattedData = (resume: any) => ({ + // full_name: resume.fullName, + // skills: resume.skills.join(", "), + // salary: resume.expectedSalary, + // city: resume.city, + // education_levels: resume.education, + // education: resume.placesOfStudy.join(", "), + // employment_type: resume.typeOfEmployment, + // email: resume.email, + // phone_number: resume.phone, + // about: resume.aboutMyself, + // img: resume.img, + // }); + const formattedData = (resume: any) => { + const formData = new FormData(); + formData.append("full_name", resume.fullName); + formData.append("age", resume.age); + formData.append("skills", resume.skills.join(", ")); + formData.append("salary", resume.expectedSalary); + formData.append("city", resume.city); + formData.append("education_levels", resume.education); + formData.append("education", resume.placesOfStudy.join(", ")); + formData.append("employment_type", resume.typeOfEmployment); + formData.append("about", resume.aboutMyself); + // formData.append("img", resume.img.files[0]); + + //TODO: сделать добавление картинки к резюме + if (resume.img) { + formData.append("img", resume.img); + } - const onSubmit = (data: ResumeCreationSchema) => {}; + // fileInput.addEventListener("change", function () { + // if (fileInput.files && fileInput.files.length > 0) { + // const file = fileInput.files[0]; + // formData.append("img", file); + // } + // }); + console.log(formData.get("img")); + return formData; + }; + + const resume = await createResume(formattedData(data)); + console.log("submitted resume data: ", resume); + const experiences = experience.map((exp) => ({ + resume: resume.id, + company_name: exp.company, + description: exp.jobDescription, + start_date: exp.startDate, + end_date: exp.endDate, + })); + const experienceData = await createExperience(experiences); + const user = await getUser(); + // console.log("user: ", user.id); + const userData = await userToResume(user.id, resume.id); + console.log("submitted experience data: ", experienceData); + console.log("test: ", experiences); + // Перенос на предыдущую страницу + const from = searchParams.get("from") || "/"; + router.push(from); + // router.back(); + } catch (error) { + console.log(error); + } + }; + // async function imageToBlob(imageFile: File): Promise { + // return new Promise((resolve, reject) => { + // const reader = new FileReader(); + + // reader.onloadend = () => { + // const result = reader.result; + // if (result instanceof ArrayBuffer) { + // resolve(new Blob([result])); + // } else if (result) { + // resolve(new Blob([new Uint8Array(result as ArrayBufferLike)])); + // } else { + // reject(new Error("Ошибка при чтении файла изображения.")); + // } + // }; + + // reader.onerror = () => { + // reject(new Error("Ошибка при чтении файла изображения.")); + // }; + + // reader.readAsArrayBuffer(imageFile); + // }); + // } const submitWorkExperience = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -44,7 +164,7 @@ const ResumeForm = () => { }; const convertToExperienceList = (obj: any): Experience[] => { - /* + /* we have have object with entries workExperience..company workExperience..endDate @@ -105,18 +225,30 @@ const ResumeForm = () => { setImagePreview(reader.result as string); }; reader.readAsDataURL(file); + setValue("img", file); + console.log("file", file); } }; + // ХУЙНЯ + // const handleUploadedFile = (event: React.ChangeEvent) => { + // const file = event?.target?.files?.[0]; + + // const urlImage = URL.createObjectURL(file: any); + + // setPreview(urlImage); + // }; + const triggerFileSelect = () => { document.getElementById("file-input")?.click(); }; - + // encType="multipart/form-data" return (
@@ -250,7 +382,7 @@ const ResumeForm = () => { alt="" width={200} height={200} - className="absolute bottom-0 right-5 " + className="absolute bottom-0 right-5 img-nondragable" priority={true} />
@@ -404,10 +536,11 @@ const ResumeForm = () => { className="ring-1 ring-gray-300 p-2 rounded-md text-sm w-full" {...register("education")} > - - - - + + + + + {errors.typeOfEmployment?.message && (

@@ -528,10 +661,11 @@ const ResumeForm = () => { className="ring-[1px] ring-gray-300 p-2 rounded-md text-sm w-full" {...register("typeOfEmployment")} > - - - - + + + + + {errors.typeOfEmployment?.message && (

diff --git a/src/app/globals.css b/src/app/globals.css index 1127985..11a8e7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -104,4 +104,14 @@ input:-webkit-autofill:active{ body { @apply bg-background text-foreground; } +} + +@layer components { + .img-nondragable { + -webkit-user-drag: none; + -khtml-user-drag: none; + -moz-user-drag: none; + -o-user-drag: none; + user-drag: none; + } } \ No newline at end of file diff --git a/src/lib/formValidationSchemas.ts b/src/lib/formValidationSchemas.ts index 733d90a..04188ae 100644 --- a/src/lib/formValidationSchemas.ts +++ b/src/lib/formValidationSchemas.ts @@ -35,13 +35,9 @@ export const signUpSchema = z export type SignUpSchema = z.infer; -const MAX_FILE_SIZE = 20000000; -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/webp", -]; + +const MAX_FILE_SIZE = 200000000; +const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; export const resumeCreationSchema = z.object({ fullName: z.string().nonempty({ message: "Full name is required!" }), @@ -86,34 +82,37 @@ export const resumeCreationSchema = z.object({ .optional() .default([]), - education: z.string().nonempty({ message: "Education is required!" }), - placesOfStudy: z.array(z.string()).optional().default([]), - skills: z.array(z.string()).optional().default([]), - expectedSalary: z.preprocess( - (value) => Number(value), - z + education: z.enum(["bachelor", "masters", "high school", "doctorate","college"], + { + errorMap: () => ({message: "Please select a valud type of employment"}) + } + ), + placesOfStudy: z.array(z.string()).optional().default([]), + skills: z.array(z.string()).optional().default([]), + expectedSalary: z.preprocess((value) => Number(value), z .number({ invalid_type_error: "Salary must be a number" }) .positive({ message: "Salary must be a positive number" }) - ), - typeOfEmployment: z.enum( - ["Full-time", "Part-time", "Freelance", "Contract"], - { - errorMap: () => ({ message: "Please select a valid type of employment" }), - } - ), - img: z - .any() - .refine((file) => file?.size <= MAX_FILE_SIZE, { - message: `Max file size is${MAX_FILE_SIZE / 1000000}MB`, - }) - .refine( - (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type), - "Only .jpg, .jpeg, .png and .webp formats are supported." - ) - .optional(), - aboutMyself: z - .string() - .min(10, { message: "About myself must be at least 10 characters" }), + ), + typeOfEmployment: z.enum( + ["full_time", "part_time", "project", "voluntary", "internship"], + { + errorMap: () => ({ message: "Please select a valid type of employment" }), + } + ), + img: z + .instanceof(File).optional() + // .refine((file) => file?.size <= MAX_FILE_SIZE, { + // message: `Max file size is${MAX_FILE_SIZE / 1000000}MB`, + // }) + // .refine( + // (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type), + // "Only .jpg, .jpeg, .png and .webp formats are supported." + // ) + // .optional() + , + aboutMyself: z + .string() + .min(10, { message: "About myself must be at least 10 characters" }), }); export type ResumeCreationSchema = z.infer;