diff --git a/netlify/functions/generateCv.mts b/netlify/functions/generateCv.mts index 3169953..42705e3 100644 --- a/netlify/functions/generateCv.mts +++ b/netlify/functions/generateCv.mts @@ -1,104 +1,5 @@ -import * as yup from 'yup'; import enhanceWithAi from '../functions/enhanceWithAi.mjs'; -import { isValidMonthYear, isAfter } from '../utils/date.js'; -import validateApiKey from '../utils/validations.js'; - -const cvSchema = yup.object().shape({ - apiKey: yup - .string() - .required('API key is required') - .test('is-valid-api-key', 'Invalid API key format', (value) => { - if (!value) return false; - return validateApiKey(value); - }), - personalInfo: yup.object().shape({ - fullName: yup.string().required('Full name is required'), - email: yup.string().email().required('Email is required'), - phone: yup.string().required('Phone number is required'), - github: yup.string().url().required('GitHub profile is required'), - linkedin: yup.string().url().required('LinkedIn profile is required'), - portfolio: yup.string().url().required('Portfolio is required'), - }), - professionalSummary: yup.object().shape({ - summary: yup.string().required('Professional summary is required'), - }), - transferableExperience: yup.object().shape({ - experience: yup.string().required('Experience is required'), - }), - projects: yup - .array() - .of( - yup.object().shape({ - name: yup.string().required('Project name is required'), - description: yup - .string() - .required('Description is required') - .test( - 'charCount', - 'Description must be more than 150 characters', - (value) => typeof value === 'string' && value.trim().length > 150 - ), - deployedWebsite: yup - .string() - .url('Deployed site must be a valid URL') - .required('Deployed website is required'), - githubLink: yup - .string() - .url('GitHub link must be a valid URL') - .matches( - /^https:\/\/github\.com\/.+/, - 'Must be a GitHub repository URL' - ) - .required('GitHub link is required'), - }) - ) - .required('Projects are required'), - education: yup - .array() - .of( - yup.object().shape({ - institution: yup.string().required('Institution is required'), - program: yup.string().required('Program is required'), - startDate: yup - .string() - .required('Start date is required') - .test( - 'valid-start-format', - "Start date must be in 'Month YYYY' format", - isValidMonthYear - ), - endDate: yup - .string() - .required('End date is required') - .test( - 'valid-or-current', - 'End date must be a valid date or "current"', - function (value) { - return ( - value?.toLowerCase() === 'current' || isValidMonthYear(value) - ); - } - ) - .test( - 'after-start', - 'End date must be after start date', - function (value) { - const { startDate } = this.parent; - if (!startDate || !value) return true; - if (value.toLowerCase() === 'current') return true; - - return isAfter(startDate, value); - } - ), - }) - ) - .required('Education is required'), - profileVsJobCriteria: yup.object().shape({ - jobcriteria: yup - .string() - .required('Comparison with job criteria is required'), - }), -}); +import cvSchema from '../utils/schemaValidation'; const generateCv = async (event) => { try { diff --git a/netlify/utils/schemaValidation.js b/netlify/utils/schemaValidation.js new file mode 100644 index 0000000..a45feab --- /dev/null +++ b/netlify/utils/schemaValidation.js @@ -0,0 +1,106 @@ +import * as yup from 'yup'; +import { isValidMonthYear, isAfter } from '../utils/date.js'; +import { formatToMonthYear } from '../../src/utils/date.js'; +import validateApiKey from '../utils/validations.js'; + +const cvSchema = yup.object().shape({ + apiKey: yup + .string() + .required('API key is required') + .test('is-valid-api-key', 'Invalid API key format', (value) => { + if (!value) return false; + return validateApiKey(value); + }), + personalInfo: yup.object().shape({ + fullName: yup.string().required('Full name is required'), + email: yup.string().email().required('Email is required'), + phone: yup.string().required('Phone number is required'), + github: yup.string().url().required('GitHub profile is required'), + linkedin: yup.string().url().required('LinkedIn profile is required'), + portfolio: yup.string().url().required('Portfolio is required'), + }), + professionalSummary: yup.object().shape({ + summary: yup.string().required('Professional summary is required'), + }), + transferableExperience: yup.object().shape({ + experience: yup.string().required('Experience is required'), + }), + projects: yup + .array() + .of( + yup.object().shape({ + name: yup.string().required('Project name is required'), + description: yup + .string() + .required('Description is required') + .test( + 'charCount', + 'Description must be more than 150 characters', + (value) => typeof value === 'string' && value.trim().length > 150 + ), + deployedWebsite: yup + .string() + .url('Deployed site must be a valid URL') + .required('Deployed website is required'), + githubLink: yup + .string() + .url('GitHub link must be a valid URL') + .matches( + /^https:\/\/github\.com\/.+/, + 'Must be a GitHub repository URL' + ) + .required('GitHub link is required'), + }) + ) + .required('Projects are required'), + education: yup + .array() + .of( + yup.object().shape({ + institution: yup.string().required('Institution is required'), + program: yup.string().required('Program is required'), + startDate: yup + .string() + .required('Start date is required') + .transform((value, originalValue) => formatToMonthYear(originalValue)) + .test( + 'valid-start-format', + "Start date must be in 'Month YYYY' format", + isValidMonthYear + ), + endDate: yup + .string() + .required('End date is required') + .transform((value, originalValue) => { + if (originalValue === '') return 'current'; + return formatToMonthYear(originalValue); + }) + .test( + 'valid-or-current', + 'End date must be a valid date or "current"', + (value) => + value?.toLowerCase() === 'current' || isValidMonthYear(value) + ) + .test( + 'after-start', + 'End date must be after start date', + function (value) { + const { startDate } = this.parent; + if (!startDate || !value) return true; + if (value.toLowerCase() === 'current') return true; + + return isAfter(startDate, value); + } + ), + }) + ) + .required('Education is required'), + + profileVsJobCriteria: yup.object().shape({ + jobcriteria: yup + .string() + .required('Comparison with job criteria is required'), + }), +}); + +export default cvSchema; diff --git a/package-lock.json b/package-lock.json index fe8b606..f9a9ba4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.0.0", "dependencies": { "@google/generative-ai": "^0.24.1", + "@hookform/resolvers": "^5.1.1", "@netlify/functions": "^4.1.5", "date-fns": "^4.1.0", "dotenv": "^16.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.58.1", "react-icons": "^5.5.0", "react-router": "^7.5.3", + "react-router-dom": "^7.6.3", "react-to-print": "^3.1.0", "yup": "^1.6.1" }, @@ -1050,6 +1053,17 @@ "node": ">=18.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz", + "integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1646,6 +1660,11 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -19948,6 +19967,21 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.58.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz", + "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -19966,9 +20000,9 @@ } }, "node_modules/react-router": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", - "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", + "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -19986,6 +20020,21 @@ } } }, + "node_modules/react-router-dom": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz", + "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==", + "dependencies": { + "react-router": "7.6.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-to-print": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.1.0.tgz", diff --git a/package.json b/package.json index 1704410..2d3dad5 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,16 @@ }, "dependencies": { "@google/generative-ai": "^0.24.1", + "@hookform/resolvers": "^5.1.1", "@netlify/functions": "^4.1.5", "date-fns": "^4.1.0", "dotenv": "^16.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.58.1", "react-icons": "^5.5.0", "react-router": "^7.5.3", + "react-router-dom": "^7.6.3", "react-to-print": "^3.1.0", "yup": "^1.6.1" }, diff --git a/smart-cv-builder.code-workspace b/smart-cv-builder.code-workspace new file mode 100644 index 0000000..d6c7df5 --- /dev/null +++ b/smart-cv-builder.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } +], + "settings": {} +} diff --git a/src/components/ApiKeyInput/ApiKeyInput.jsx b/src/components/ApiKeyInput/ApiKeyInput.jsx index 81bf7c5..9cac093 100644 --- a/src/components/ApiKeyInput/ApiKeyInput.jsx +++ b/src/components/ApiKeyInput/ApiKeyInput.jsx @@ -1,27 +1,40 @@ -import React, { useState } from 'react'; +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; import styles from './ApiKeyInput.module.css'; + +import { saveSettings, getSettings } from '../../utils/saveData.js'; +import { useNavigate } from 'react-router-dom'; + import validateApiKey from '../../netlify/utils/validations.js'; -const ApiKeyInput = ({ data, onApiKeySubmit }) => { - const [apiKey, setApiKey] = useState(data || ''); - const [error, setError] = useState(''); - const handleSubmit = () => { - if (!apiKey.trim()) { - setError('API key is required.'); - return; - } +const ApiKeyInput = ({ onApiKeySaved }) => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const navigate = useNavigate(); - if (!validateApiKey(apiKey)) { - setError('Invalid API key format.'); - return; + useEffect(() => { + const saved = getSettings(); + if (saved.apiKey) { + navigate('/form'); } + }, [navigate]); - setError(''); - onApiKeySubmit(apiKey); + const onSubmit = (data) => { + const key = data.apiKey?.trim(); + if (key) { + saveSettings({ apiKey: key }); + onApiKeySaved(); + navigate('/form'); + } }; + return ( -
+

Connect Your Gemini API Key

    @@ -43,18 +56,20 @@ const ApiKeyInput = ({ data, onApiKeySubmit }) => { setApiKey(e.target.value)} + {...register('apiKey', { + required: 'API Key is required', + validate: (value) => + validateApiKey(value) || 'Invalid API key format', + })} placeholder="Paste your Gemini API key here" className={styles.input} /> + {errors.apiKey &&

    {errors.apiKey.message}

    } - {error &&

    {error}

    } - - -
+ ); }; diff --git a/src/components/Education/Education.jsx b/src/components/Education/Education.jsx index 6257a6d..dc1fcbd 100644 --- a/src/components/Education/Education.jsx +++ b/src/components/Education/Education.jsx @@ -1,148 +1,78 @@ -import { useState, useEffect } from 'react'; import styles from './Education.module.css'; -import { formatToMonthYear, monthYearToYYYYMM } from '../../utils/date.js'; +import { useFormContext } from 'react-hook-form'; -export const Education = ({ data, onEducationChange, onErrorChange }) => { - const [education, setEducation] = useState({ - institution: data?.institution || '', - program: data?.program || '', - startDate: monthYearToYYYYMM(data?.startDate) || '', - endDate: monthYearToYYYYMM(data?.endDate) || '', - }); +const Education = () => { + const { + register, - const [error, setError] = useState({}); - - useEffect(() => { - setEducation({ - institution: data?.institution || '', - program: data?.program || '', - startDate: monthYearToYYYYMM(data?.startDate) || '', - endDate: monthYearToYYYYMM(data?.endDate) || '', - }); - }, [data]); - - const validateEducation = () => { - const newErrors = {}; - if (!education.institution.trim()) - newErrors.institution = 'Institution is required'; - if (!education.program.trim()) newErrors.program = 'Program is required'; - if (!education.startDate) newErrors.startDate = 'Start date is required'; - if (!education.endDate) newErrors.endDate = 'End date is required'; - return newErrors; - }; - - const handleChange = (e) => { - const { name, value } = e.target; - - const updatedEducation = { - ...education, - [name]: value, - }; - - setEducation(updatedEducation); - - const formattedEducation = { - ...updatedEducation, - startDate: formatToMonthYear(updatedEducation.startDate), - endDate: - updatedEducation.endDate.toLowerCase() === 'current' - ? 'current' - : formatToMonthYear(updatedEducation.endDate), - }; - - onEducationChange([formattedEducation]); - }; - - const handleBlur = () => { - const newErrors = validateEducation(); - setError(newErrors); - onErrorChange(Object.keys(newErrors).length > 0); - }; - - const handleFocus = (e) => { - const { name } = e.target; - setError((prev) => ({ - ...prev, - [name]: undefined, - })); - }; + formState: { errors }, + } = useFormContext(); return ( -
-
-

EDUCATION

-

Tell us about your educational background.

- - - - {error.institution && ( -

{error.institution}

- )} - - - - - {error.program &&

{error.program}

} - -
-
- - - - {error.startDate && ( -

{error.startDate}

- )} -
+
+

EDUCATION

+

Tell us about your educational background.

+ + + + {errors.education?.[0]?.institution && ( +

+ {errors.education[0].institution.message} +

+ )} + + + + {errors.education?.[0]?.program && ( +

{errors.education[0].program.message}

+ )} + +
+
+ + + {errors.education?.[0]?.startDate && ( +

+ {errors.education[0].startDate.message} +

+ )} +
-
- - - {error.endDate &&

{error.endDate}

} -
+
+ + + {errors.education?.[0]?.endDate && ( +

+ {errors.education[0].endDate.message} +

+ )}
- +
); }; + export default Education; diff --git a/src/components/MultiFormPage/MultiFormPage.jsx b/src/components/MultiFormPage/MultiFormPage.jsx index a223b08..d36c658 100644 --- a/src/components/MultiFormPage/MultiFormPage.jsx +++ b/src/components/MultiFormPage/MultiFormPage.jsx @@ -1,4 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useNavigate } from 'react-router-dom'; + import Header from '../Header/Header.jsx'; import LeftPane from '../LeftPane/LeftPane.jsx'; import PersonalInfoForm from '../PersonalInfo/PersonalInfoForm.jsx'; @@ -11,11 +15,17 @@ import IconSlide from '../IconSlide/IconSlide.jsx'; import Button from '../Button/Button.jsx'; import ApiKeyInput from '../ApiKeyInput/ApiKeyInput.jsx'; import ErrorState from '../ErrorState/ErrorState.jsx'; +import LoadingState from '../LoadingState/LoadingState.jsx'; + import { useSubmitPersonalInfo } from '../../hooks/useSubmitPersonalInfo.js'; +import { + getFormData, + saveFormData, + getSettings, +} from '../../utils/saveData.js'; +import cvSchema from '../../../netlify/utils/schemaValidation.js'; + import styles from './MultiFormPage.module.css'; -import { getFormData, saveFormData } from '../../utils/saveData.js'; -import LoadingState from '../LoadingState/LoadingState.jsx'; -import { useNavigate } from 'react-router'; const steps = [ 'PERSONAL INFO', @@ -25,159 +35,73 @@ const steps = [ 'PROJECTS', 'PROFILE VS JOB CRITERIA', ]; + +const fieldPaths = { + 'PERSONAL INFO': [ + 'personalInfo.fullName', + 'personalInfo.email', + 'personalInfo.phone', + 'personalInfo.github', + 'personalInfo.linkedin', + 'personalInfo.portfolio', + ], + 'PROFESSIONAL SUMMARY': ['professionalSummary.summary'], + EXPERIENCE: ['transferableExperience.experience'], + EDUCATION: ['education'], + PROJECTS: ['projects'], + 'PROFILE VS JOB CRITERIA': ['profileVsJobCriteria.jobcriteria'], +}; + const MultiFormPage = () => { + const navigate = useNavigate(); const savedData = getFormData(); - const [apiKey, setApiKey] = useState(savedData.apiKey || null); + const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [formData, setFormData] = useState(() => { - return { - apiKey: savedData.apiKey || '', - personalInfo: savedData.personalInfo || {}, - professionalSummary: savedData.professionalSummary || {}, - transferableExperience: savedData.transferableExperience || {}, - education: savedData.education || [ - { - institution: '', - program: '', - startDate: '', - endDate: '', - }, - ], - projects: savedData.projects || [ - { - name: '', - description: '', - deployedWebsite: '', - githubLink: '', - }, - ], - profileVsJobCriteria: savedData.profileVsJobCriteria || {}, - }; - }); + const [hasApiKey, setHasApiKey] = useState(null); - const [formErrors, setFormErrors] = useState({ - personalInfo: false, - professionalSummary: false, - transferableExperience: false, - education: false, - projects: false, - profileVsJobCriteria: false, + const methods = useForm({ + mode: 'onBlur', + defaultValues: savedData || {}, + resolver: yupResolver(cvSchema), }); - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') { - saveFormData(formData); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [formData]); - - useEffect(() => { - saveFormData(formData); - }, [formData]); + const { trigger, getValues, watch } = methods; + const watchedValues = watch(); const { submitPersonalInfo, loading, error, successMessage, clearError } = useSubmitPersonalInfo(); - const navigate = useNavigate(); - - const updateFormError = (step, hasError) => { - setFormErrors((prev) => ({ ...prev, [step]: hasError })); - }; const currentStep = steps[currentStepIndex]; - const isStepValid = () => { - switch (currentStep) { - case 'PERSONAL INFO': { - const info = formData.personalInfo; - return ( - info.fullName && - info.email && - info.phone && - info.github && - info.linkedin && - info.portfolio && - !formErrors.personalInfo - ); - } - - case 'PROFESSIONAL SUMMARY': { - return ( - formData.professionalSummary.summary?.trim().length >= 150 && - !formErrors.professionalSummary - ); - } - - case 'EXPERIENCE': { - const experienceText = - formData.transferableExperience?.experience || ''; - return ( - experienceText.trim().length >= 200 && - !formErrors.transferableExperience - ); - } - - case 'EDUCATION': { - const isValidData = formData.education.every((edu) => { - return ( - edu.institution.trim() && - edu.program.trim() && - edu.startDate && - edu.endDate - ); - }); - return isValidData && !formErrors.education; - } - - case 'PROJECTS': { - const isValidData = formData.projects.every((project) => { - return ( - project.name.trim() && - project.description.trim() && - project.githubLink.trim() && - !formErrors.projects - ); - }); - return isValidData; - } + useEffect(() => { + const settings = getSettings(); + setHasApiKey(!!settings?.apiKey?.trim()); + }, []); - case 'PROFILE VS JOB CRITERIA': { - const criteria = formData.profileVsJobCriteria?.jobcriteria; - return ( - criteria?.trim().length > 200 && !formErrors.profileVsJobCriteria - ); - } + const onApiKeySaved = () => setHasApiKey(true); - default: - return true; - } - }; + useEffect(() => { + const timer = setTimeout(() => { + saveFormData(watchedValues); + }, 500); + return () => clearTimeout(timer); + }, [watchedValues]); useEffect(() => { if (error) { setCurrentStepIndex(0); - const timer = setTimeout(() => { - clearError(); - }, 3000); - + const timer = setTimeout(() => clearError(), 3000); return () => clearTimeout(timer); } }, [error, clearError]); - const handleNext = () => { - if (!isStepValid()) { - alert('Please fill in all required fields before proceeding.'); + const handleNext = async () => { + const valid = await trigger(fieldPaths[currentStep]); + if (!valid) { + alert('Please fix the errors before continuing.'); return; } - if (currentStepIndex < steps.length - 1) { - setCurrentStepIndex((prev) => prev + 1); - } + setCurrentStepIndex((prev) => prev + 1); }; const handlePrevious = () => { @@ -187,183 +111,101 @@ const MultiFormPage = () => { }; const handleSubmit = async () => { - if (!isStepValid()) { - alert('Please fill in all required fields before proceeding.'); + const valid = await trigger(fieldPaths[currentStep]); + if (!valid) { + alert('Please fix the errors before submitting.'); return; } + try { - await submitPersonalInfo(formData, (cvData) => { - navigate('/preview', { state: { cvData, formData } }); + const data = getValues(); + const settings = getSettings(); + await submitPersonalInfo(data, (cvData) => { + navigate('/preview', { + state: { + cvData, + formData: data, + settings, + }, + }); }); - } catch (error) { - console.error('Form submission failed:', error); + } catch (err) { + console.error('Form submission failed:', err); alert('There was an issue submitting the form. Please try again.'); } }; - const handleProjectChange = useCallback( - (data) => { - setFormData((prev) => ({ - ...prev, - projects: Array.isArray(data) ? data : [data], - })); - }, - [setFormData] - ); - - const handleEducationChange = useCallback( - (data) => { - setFormData((prev) => ({ - ...prev, - education: Array.isArray(data) ? data : [data], - })); - }, - [setFormData] - ); - const renderStep = () => { switch (currentStep) { case 'PERSONAL INFO': - return ( - - setFormData((prev) => ({ ...prev, personalInfo: data })) - } - onErrorChange={(hasError) => - updateFormError('personalInfo', hasError) - } - /> - ); + return ; case 'PROFESSIONAL SUMMARY': - return ( - - setFormData((prev) => ({ ...prev, professionalSummary: data })) - } - onErrorChange={(hasError) => - updateFormError('professionalSummary', hasError) - } - /> - ); + return ; case 'EXPERIENCE': - return ( - - setFormData((prev) => ({ ...prev, transferableExperience: data })) - } - onErrorChange={(hasError) => - updateFormError('transferableExperience', hasError) - } - /> - ); + return ; case 'EDUCATION': - return ( - - ); + return ; case 'PROJECTS': - return ( - - ); - + return ; case 'PROFILE VS JOB CRITERIA': - return ( - - setFormData((prev) => ({ ...prev, profileVsJobCriteria: data })) - } - onErrorChange={(hasError) => - updateFormError('profileVsJobCriteria', hasError) - } - /> - ); + return ; default: return null; } }; - const Overlay = ( -
- -
- ); + + if (hasApiKey === null) return null; return ( -
- {!apiKey && ( - { - setApiKey(key); - setFormData((prev) => ({ - ...prev, - apiKey: key, - })); - }} - /> - )} - {apiKey && ( - <> -
+ +
+ {!hasApiKey ? ( + + ) : ( + <> +
+ + {error && ( +
+ +
+ )} - {error && ( -
- -
- )} +
+
+ +
-
-
- -
+
+ +
-
- +
+ {renderStep()} + +
+ {currentStepIndex > 0 && ( + + )} + {currentStepIndex < steps.length - 1 ? ( + + ) : ( + + )} +
+
-
- {renderStep()} - -
- {currentStepIndex > 0 && ( - - )} - {currentStepIndex < steps.length - 1 ? ( - - ) : ( - - )} + {loading && ( +
+
-
-
- {loading && Overlay} - {successMessage &&

{successMessage}

} - - )} - ; -
+ )} + {successMessage &&

{successMessage}

} + + )} +
+ ); }; diff --git a/src/components/PersonalInfo/PersonalInfoForm.jsx b/src/components/PersonalInfo/PersonalInfoForm.jsx index fe69c77..4510979 100644 --- a/src/components/PersonalInfo/PersonalInfoForm.jsx +++ b/src/components/PersonalInfo/PersonalInfoForm.jsx @@ -1,142 +1,88 @@ -import React, { useState } from 'react'; import styles from './PersonalInfo.module.css'; -import isValidUrl from '../../utils/validation.js'; +import { useFormContext } from 'react-hook-form'; -const PersonalInfoForm = ({ data, onPersonalInfoChange, onErrorChange }) => { - const [personalData, setPersonalData] = useState({ - fullName: data?.fullName || '', - email: data?.email || '', - phone: data?.phone || '', - github: data?.github || '', - linkedin: data?.linkedin || '', - portfolio: data?.portfolio || '', - }); - - const [error, setError] = useState(''); - - const handleChange = (e) => { - const updatedData = { ...personalData, [e.target.name]: e.target.value }; - setPersonalData(updatedData); - }; - - const validateInputs = () => { - if ( - !personalData.fullName.trim() || - !personalData.email.trim() || - !personalData.phone.trim() || - !personalData.github.trim() || - !personalData.linkedin.trim() || - !personalData.portfolio.trim() - ) { - return 'All fields are required.'; - } - if (!isValidUrl(personalData.github)) { - return 'Please enter a valid GitHub URL.'; - } - if (!isValidUrl(personalData.linkedin)) { - return 'Please enter a valid LinkedIn URL.'; - } - - if (!isValidUrl(personalData.portfolio)) { - return 'Please enter a valid Portfolio URL.'; - } - return ''; - }; - - const handleBlur = () => { - const validationError = validateInputs(); - setError(validationError); - onErrorChange(!!validationError); - - if (!validationError) { - onPersonalInfoChange(personalData); - } - }; +const PersonalInfoForm = () => { + const { + register, + formState: { errors }, + } = useFormContext(); return ( -
+

PERSONAL INFORMATION

- + + {errors.personalInfo?.fullName && ( +

{errors.personalInfo.fullName.message}

+ )} + {errors.personalInfo?.email && ( +

{errors.personalInfo.email.message}

+ )} + {errors.personalInfo?.phone && ( +

{errors.personalInfo.phone.message}

+ )} + {errors.personalInfo?.github && ( +

{errors.personalInfo.github.message}

+ )} + {errors.personalInfo?.linkedin && ( +

{errors.personalInfo.linkedin.message}

+ )} - - {error &&

{error}

} - + {errors.personalInfo?.portfolio && ( +

{errors.personalInfo.portfolio.message}

+ )} +
); }; diff --git a/src/components/ProfessionalSummary/ProfessionalSummary.jsx b/src/components/ProfessionalSummary/ProfessionalSummary.jsx index 408a92e..5013ee6 100644 --- a/src/components/ProfessionalSummary/ProfessionalSummary.jsx +++ b/src/components/ProfessionalSummary/ProfessionalSummary.jsx @@ -1,41 +1,16 @@ -import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import styles from './ProfessionalSummary.module.css'; import CharacterCount from '../CharacterCount/CharacterCount'; -const ProfessionalSummary = ({ data, onSummaryChange, onErrorChange }) => { - const [summary, setSummary] = useState(data?.summary || ''); - const [error, setError] = useState(''); +const ProfessionalSummary = () => { + const { + register, + watch, + formState: { errors }, + } = useFormContext(); - const validateSummary = () => { - if (!summary.trim()) { - setError('Please provide a professional summary.'); - } else if (summary.length < 150) { - setError('Summary must be at least 150 characters long.'); - } else { - setError(''); - } - }; - - const handleChange = (e) => { - setSummary(e.target.value); - }; - - const handleBlur = () => { - const errorMessage = validateSummary(); - setError(errorMessage); - onErrorChange(!!errorMessage); - - if (!errorMessage) { - onSummaryChange({ summary }); - } - validateSummary(); - }; - - const handleFocus = () => { - if (error) { - setError(''); - } - }; + const summaryValue = watch('professionalSummary.summary'); + const currentLength = summaryValue?.length || 0; return (
@@ -46,18 +21,21 @@ const ProfessionalSummary = ({ data, onSummaryChange, onErrorChange }) => {