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 ( -