diff --git a/.eslintrc.json b/.eslintrc.json index f9b03a4..941cfeb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,7 @@ "parserOptions": { "ecmaVersion": "latest", "sourceType": "module", - "project": "./tsconfig.json", + "project": ["./tsconfig.json", "./job-application-demo/tsconfig.json"], "ecmaFeatures": { "jsx": true } diff --git a/.gitignore b/.gitignore index eb687f1..19423cd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage *.swo *~ web-ext-artifacts +.claude/ diff --git a/README.md b/README.md index 38847dc..27b82ee 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,30 @@ src/ └── services/ ├── formDetector.ts # Form field detection logic └── fieldMatcher.ts # Resume-to-field matching logic + +job-application-demo/ # Sample job application form (separate app) ``` +### Demo Application + +The `job-application-demo` folder contains a standalone sample job application form built with Vite, React, Material UI, and TypeScript. This demo app: + +- Showcases the structure and fields used by the JobSpeed extension +- Uses the same `ResumeData` interface as the extension +- Saves submitted applications as incremental JSON files +- Is excluded from the main extension build +- Has its own `package.json` and can be run independently + +To run the demo: + +```bash +cd job-application-demo +pnpm install +pnpm dev +``` + +See [job-application-demo/README.md](job-application-demo/README.md) for more details. + ## Usage 1. **Upload Your Resume**: diff --git a/job-application-demo/.gitignore b/job-application-demo/.gitignore new file mode 100644 index 0000000..263f5ad --- /dev/null +++ b/job-application-demo/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Test results +coverage +playwright-report +test-results +.playwright diff --git a/job-application-demo/README.md b/job-application-demo/README.md new file mode 100644 index 0000000..4dc8b21 --- /dev/null +++ b/job-application-demo/README.md @@ -0,0 +1,129 @@ +# Job Application Demo + +This is a sample job application form web app built with Vite, React, Material UI, and TypeScript. It demonstrates the structure and fields used by the JobSpeed extension. + +## Features + +- Material UI components for a polished, professional look +- Form validation matching the extension's `ResumeData` interface +- Required and optional fields as defined in the extension +- Incremental JSON file export with timestamps +- Sections for: + - Personal Information (required) + - Work Experience (required) + - Education (optional) + - Certifications (optional) + - Skills (required) + - Professional Summary (optional) + +## Getting Started + +### Install Dependencies + +```bash +pnpm install +``` + +### Development Server + +```bash +pnpm dev +``` + +The app will be available at `http://localhost:5173` + +### Build for Production + +```bash +pnpm build +``` + +### Preview Production Build + +```bash +pnpm preview +``` + +## Usage + +1. Fill in the form with your information +2. Required fields are marked with an asterisk (*) +3. Click "Submit Application" to download your data as a JSON file +4. The file will be saved as `job-application-{N}-{timestamp}.json` + - `{N}` is an incrementing number + - `{timestamp}` is the current date and time + +## Field Requirements + +The form matches the extension's `ResumeData` interface: + +### Required Fields +- All personal information fields (except LinkedIn, GitHub, Website) +- At least one work experience entry +- At least one skill + +### Optional Fields +- LinkedIn profile URL +- GitHub profile URL +- Personal website URL +- Education entries +- Certification entries +- Professional summary + +## Testing + +This project includes comprehensive test coverage with both unit tests and end-to-end tests. + +### Unit Tests (Vitest) + +Run unit tests for React components: + +```bash +# Run tests in watch mode +pnpm test + +# Run tests once +pnpm test -- --run + +# Run tests with UI +pnpm test:ui + +# Run tests with coverage +pnpm test:coverage +``` + +Unit tests cover: +- PersonalInfoForm component +- WorkExperienceForm component +- SkillsForm component +- App component integration + +### End-to-End Tests (Playwright) + +Run E2E tests that simulate real user interactions: + +```bash +# Run E2E tests +pnpm test:e2e + +# Run E2E tests with UI +pnpm test:e2e:ui + +# View test report +pnpm test:e2e:report +``` + +E2E tests cover: +- Complete form submission flow +- Form validation (email, phone, zip code, GPA) +- Adding and removing dynamic entries (work experience, education, etc.) +- Skills management +- File download functionality + +## Integration with Main Project + +This demo app is: +- Excluded from the main extension build +- Bootstrapped separately with its own `package.json` +- Included in the project's ESLint and Prettier configurations +- Using the same strict TypeScript settings as the main project diff --git a/job-application-demo/e2e/job-application.spec.ts b/job-application-demo/e2e/job-application.spec.ts new file mode 100644 index 0000000..4cc49d2 --- /dev/null +++ b/job-application-demo/e2e/job-application.spec.ts @@ -0,0 +1,252 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Job Application Form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should display the form with all sections', async ({ page }) => { + await expect(page.getByText('Job Application Form')).toBeVisible(); + await expect( + page.getByText('Fields marked with * are required') + ).toBeVisible(); + + // Check all sections are present + await expect(page.getByText('Personal Information')).toBeVisible(); + await expect(page.getByText('Work Experience *')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Education' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Certifications' }) + ).toBeVisible(); + await expect(page.getByText('Skills *')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Professional Summary' }) + ).toBeVisible(); + }); + + // Skipped: HTML5 validation prevents testing custom validation messages + test.skip('should show validation errors for incomplete required fields', async ({ + page, + }) => { + // Fill some fields to bypass HTML5 validation + await page.getByLabel(/full name/i).fill('John Doe'); + await page.getByLabel(/email/i).fill('john@example.com'); + // Leave other required fields empty + + await page.getByRole('button', { name: /submit application/i }).click(); + + await expect( + page.getByText(/please fill in all required personal information fields/i) + ).toBeVisible(); + }); + + test('should fill personal information', async ({ page }) => { + await page.getByLabel(/full name/i).fill('John Doe'); + await page.getByLabel(/email/i).fill('john.doe@example.com'); + await page.getByLabel(/phone/i).fill('+12345678901'); + await page.getByLabel(/address/i).fill('123 Main Street'); + await page.getByLabel(/city/i).fill('New York'); + await page.getByLabel(/state/i).fill('NY'); + await page.getByLabel(/zip code/i).fill('10001'); + await page.getByLabel(/country/i).fill('USA'); + + // Verify values are entered + await expect(page.getByLabel(/full name/i)).toHaveValue('John Doe'); + await expect(page.getByLabel(/email/i)).toHaveValue( + 'john.doe@example.com' + ); + }); + + test('should add and remove work experience', async ({ page }) => { + // Add work experience + await page.getByRole('button', { name: /add work experience/i }).click(); + + // Fill work experience form + await page.getByLabel(/company/i).fill('Acme Corporation'); + await page.getByLabel(/position/i).fill('Software Engineer'); + await page.getByLabel(/start date/i).fill('2020-01-01'); + await page.getByLabel(/end date/i).fill('2023-12-31'); + await page + .getByLabel(/description/i) + .fill('Developed and maintained web applications'); + + // Verify values + await expect(page.getByLabel(/company/i)).toHaveValue('Acme Corporation'); + + // Check for delete button (IconButton with DeleteIcon) + const deleteButton = page.getByRole('button', { name: '' }).first(); + await expect(deleteButton).toBeVisible(); + + // Remove work experience + await deleteButton.click(); + + // Verify work experience is removed + await expect(page.getByLabel(/company/i)).not.toBeVisible(); + }); + + test('should toggle current job checkbox', async ({ page }) => { + await page.getByRole('button', { name: /add work experience/i }).click(); + + const endDateInput = page.getByLabel(/end date/i); + await expect(endDateInput).toBeEnabled(); + + // Click "I currently work here" checkbox + await page.getByText(/i currently work here/i).click(); + + // End date should be disabled + await expect(endDateInput).toBeDisabled(); + }); + + test('should add and remove skills', async ({ page }) => { + // Add a skill + await page.getByLabel(/add a skill/i).fill('JavaScript'); + await page.getByRole('button', { name: /^add$/i }).click(); + + // Verify skill chip appears + await expect(page.getByText('JavaScript')).toBeVisible(); + + // Add another skill + await page.getByLabel(/add a skill/i).fill('TypeScript'); + await page.getByRole('button', { name: /^add$/i }).click(); + + await expect(page.getByText('TypeScript')).toBeVisible(); + + // Remove a skill (click the X on the chip) + await page.locator('[data-testid="CancelIcon"]').first().click(); + + // Verify one skill remains + const skillChips = page.locator('span').filter({ hasText: /script/i }); + await expect(skillChips).toHaveCount(1); + }); + + test('should add education entry', async ({ page }) => { + await page.getByRole('button', { name: /add education/i }).click(); + + await page.getByLabel(/institution/i).fill('MIT'); + await page.getByLabel(/degree/i).fill('Bachelor of Science'); + await page.getByLabel(/field of study/i).fill('Computer Science'); + await page.getByLabel(/graduation date/i).fill('2019-05-15'); + await page.getByLabel(/gpa/i).fill('3.85'); + + await expect(page.getByLabel(/institution/i)).toHaveValue('MIT'); + }); + + test('should add certification entry', async ({ page }) => { + await page.getByRole('button', { name: /add certification/i }).click(); + + await page + .getByLabel(/certification name/i) + .fill('AWS Certified Solutions Architect'); + await page.getByLabel(/acquired date/i).fill('2022-06-01'); + await page.getByLabel(/area of expertise/i).fill('Cloud Computing'); + + await expect(page.getByLabel(/certification name/i)).toHaveValue( + 'AWS Certified Solutions Architect' + ); + }); + + test('should fill professional summary', async ({ page }) => { + const summary = + 'Experienced software engineer with 5 years of expertise in web development.'; + await page.getByLabel(/summary/i).fill(summary); + + await expect(page.getByLabel(/summary/i)).toHaveValue(summary); + }); + + test('should complete full application and submit', async ({ page }) => { + // Personal Information + await page.getByLabel(/full name/i).fill('Jane Smith'); + await page.getByLabel(/email/i).fill('jane.smith@example.com'); + await page.getByLabel(/phone/i).fill('+19876543210'); + await page.getByLabel(/address/i).fill('456 Oak Avenue'); + await page.getByLabel(/city/i).fill('San Francisco'); + await page.getByLabel(/state/i).fill('CA'); + await page.getByLabel(/zip code/i).fill('94102'); + await page.getByLabel(/country/i).fill('USA'); + + // Work Experience + await page.getByRole('button', { name: /add work experience/i }).click(); + await page.getByLabel(/company/i).fill('Tech Innovations Inc'); + await page.getByLabel(/position/i).fill('Senior Developer'); + await page.getByLabel(/start date/i).fill('2018-03-01'); + await page.getByLabel(/end date/i).fill('2024-01-01'); + await page + .getByLabel(/description/i) + .fill('Led development of enterprise applications'); + + // Skills + await page.getByLabel(/add a skill/i).fill('React'); + await page.getByRole('button', { name: /^add$/i }).click(); + await page.getByLabel(/add a skill/i).fill('Node.js'); + await page.getByRole('button', { name: /^add$/i }).click(); + + // Setup download listener before clicking submit + const downloadPromise = page.waitForEvent('download'); + + // Submit the form + await page.getByRole('button', { name: /submit application/i }).click(); + + // Wait for success message + await expect( + page.getByText(/application saved as job-application/i) + ).toBeVisible({ timeout: 10000 }); + + // Verify download occurred + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/job-application-\d+-.*\.json/); + }); + + test('should validate email format', async ({ page }) => { + const emailInput = page.getByLabel(/email/i); + await emailInput.fill('invalid-email'); + + // The HTML5 validation will prevent form submission + await page.getByRole('button', { name: /submit application/i }).click(); + + // Check for browser validation message + const validationMessage = await emailInput.evaluate( + (el: HTMLInputElement) => el.validationMessage + ); + expect(validationMessage).toBeTruthy(); + }); + + test('should validate phone number format', async ({ page }) => { + const phoneInput = page.getByLabel(/phone/i); + await phoneInput.fill('abc123'); + + await page.getByRole('button', { name: /submit application/i }).click(); + + const validationMessage = await phoneInput.evaluate( + (el: HTMLInputElement) => el.validationMessage + ); + expect(validationMessage).toBeTruthy(); + }); + + test('should validate zip code format', async ({ page }) => { + const zipInput = page.getByLabel(/zip code/i); + await zipInput.fill('1234'); // Only 4 digits + + await page.getByRole('button', { name: /submit application/i }).click(); + + const validationMessage = await zipInput.evaluate( + (el: HTMLInputElement) => el.validationMessage + ); + expect(validationMessage).toBeTruthy(); + }); + + test('should validate GPA format when education is added', async ({ + page, + }) => { + await page.getByRole('button', { name: /add education/i }).click(); + + const gpaInput = page.getByLabel(/gpa/i); + await gpaInput.fill('5.00'); // Invalid - first digit must be 1-4 + + await page.getByRole('button', { name: /submit application/i }).click(); + + const validationMessage = await gpaInput.evaluate( + (el: HTMLInputElement) => el.validationMessage + ); + expect(validationMessage).toBeTruthy(); + }); +}); diff --git a/job-application-demo/index.html b/job-application-demo/index.html new file mode 100644 index 0000000..c332a77 --- /dev/null +++ b/job-application-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + Job Application Demo + + +
+ + + diff --git a/job-application-demo/package.json b/job-application-demo/package.json new file mode 100644 index 0000000..a7330ac --- /dev/null +++ b/job-application-demo/package.json @@ -0,0 +1,40 @@ +{ + "name": "job-application-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage", + "test:e2e": "pnpm test:e2e --project=chromium --project=firefox --reporter=list", + "test:e2e:ui": "playwright test --project=chromium --project=firefox --ui", + "test:e2e:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/ui": "^3.2.4", + "jsdom": "^27.0.1", + "playwright": "^1.56.1", + "typescript": "~5.9.3", + "vite": "^7.1.10", + "vitest": "^3.2.4" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.4", + "@mui/material": "^7.3.4", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} diff --git a/job-application-demo/playwright.config.ts b/job-application-demo/playwright.config.ts new file mode 100644 index 0000000..97048ef --- /dev/null +++ b/job-application-demo/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/job-application-demo/public/vite.svg b/job-application-demo/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/job-application-demo/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/job-application-demo/src/App.test.tsx b/job-application-demo/src/App.test.tsx new file mode 100644 index 0000000..251c31a --- /dev/null +++ b/job-application-demo/src/App.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import App from './App'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +// Mock URL.createObjectURL and URL.revokeObjectURL +global.URL.createObjectURL = vi.fn(() => 'mock-url'); +global.URL.revokeObjectURL = vi.fn(); + +describe('App', () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + it('renders the job application form', () => { + render(); + + expect(screen.getByText(/job application form/i)).toBeInTheDocument(); + expect( + screen.getByText(/fields marked with \* are required/i) + ).toBeInTheDocument(); + }); + + it('renders all form sections', () => { + render(); + + expect(screen.getByText(/personal information/i)).toBeInTheDocument(); + expect(screen.getByText(/work experience \*/i)).toBeInTheDocument(); + expect(screen.getAllByText(/education/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/certifications/i)[0]).toBeInTheDocument(); + expect(screen.getByText(/skills \*/i)).toBeInTheDocument(); + expect(screen.getAllByText(/professional summary/i)[0]).toBeInTheDocument(); + }); + + it('has a submit button', () => { + render(); + + expect( + screen.getByRole('button', { name: /submit application/i }) + ).toBeInTheDocument(); + }); + + it('shows error when required fields are missing', async () => { + const user = userEvent.setup(); + render(); + + // Fill some but not all required fields to bypass HTML5 validation + await user.click(screen.getByLabelText(/full name/i)); + await user.paste('John Doe'); + await user.click(screen.getByLabelText(/email/i)); + await user.paste('john@example.com'); + // Leave other required fields empty + + const submitButton = screen.getByRole('button', { + name: /submit application/i, + }); + await user.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText(/please fill in all required personal information/i) + ).toBeInTheDocument(); + }); + }); + + it('shows error when work experience is missing', async () => { + const user = userEvent.setup(); + render(); + + // Fill in required personal info using paste for speed + await user.click(screen.getByLabelText(/full name/i)); + await user.paste('John Doe'); + await user.click(screen.getByLabelText(/email/i)); + await user.paste('john@example.com'); + await user.click(screen.getByLabelText(/phone/i)); + await user.paste('+12345678901'); + await user.click(screen.getByLabelText(/address/i)); + await user.paste('123 Main St'); + await user.click(screen.getByLabelText(/city/i)); + await user.paste('New York'); + await user.click(screen.getByLabelText(/state/i)); + await user.paste('NY'); + await user.click(screen.getByLabelText(/zip code/i)); + await user.paste('12345'); + await user.click(screen.getByLabelText(/country/i)); + await user.paste('USA'); + + const submitButton = screen.getByRole('button', { + name: /submit application/i, + }); + await user.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText(/please add at least one work experience entry/i) + ).toBeInTheDocument(); + }); + }); + + it('shows error when skills are missing', async () => { + const user = userEvent.setup(); + render(); + + // Fill in required personal info using paste for speed + await user.click(screen.getByLabelText(/full name/i)); + await user.paste('John Doe'); + await user.click(screen.getByLabelText(/email/i)); + await user.paste('john@example.com'); + await user.click(screen.getByLabelText(/phone/i)); + await user.paste('+12345678901'); + await user.click(screen.getByLabelText(/address/i)); + await user.paste('123 Main St'); + await user.click(screen.getByLabelText(/city/i)); + await user.paste('New York'); + await user.click(screen.getByLabelText(/state/i)); + await user.paste('NY'); + await user.click(screen.getByLabelText(/zip code/i)); + await user.paste('12345'); + await user.click(screen.getByLabelText(/country/i)); + await user.paste('USA'); + + // Add work experience + await user.click(screen.getByText(/add work experience/i)); + + const submitButton = screen.getByRole('button', { + name: /submit application/i, + }); + await user.click(submitButton); + + await waitFor(() => { + expect( + screen.getByText(/please add at least one skill/i) + ).toBeInTheDocument(); + }); + }); + + it('increments application count in localStorage', async () => { + const user = userEvent.setup(); + render(); + + // Fill in all required fields using paste for speed + await user.click(screen.getByLabelText(/full name/i)); + await user.paste('John Doe'); + await user.click(screen.getByLabelText(/email/i)); + await user.paste('john@example.com'); + await user.click(screen.getByLabelText(/phone/i)); + await user.paste('+12345678901'); + await user.click(screen.getByLabelText(/address/i)); + await user.paste('123 Main St'); + await user.click(screen.getByLabelText(/city/i)); + await user.paste('New York'); + await user.click(screen.getByLabelText(/state/i)); + await user.paste('NY'); + await user.click(screen.getByLabelText(/zip code/i)); + await user.paste('12345'); + await user.click(screen.getByLabelText(/country/i)); + await user.paste('USA'); + + // Add work experience + await user.click(screen.getByText(/add work experience/i)); + const companyInputs = screen.getAllByLabelText(/company/i); + await user.click(companyInputs[0]!); + await user.paste('Acme Corp'); + + // Add skill + const skillInput = screen.getByLabelText(/add a skill/i); + await user.click(skillInput); + await user.paste('JavaScript'); + await user.click(screen.getByRole('button', { name: /^add$/i })); + + // Submit + const submitButton = screen.getByRole('button', { + name: /submit application/i, + }); + await user.click(submitButton); + + await waitFor(() => { + expect(localStorageMock.getItem('applicationCount')).toBe('1'); + }); + }); +}); diff --git a/job-application-demo/src/App.tsx b/job-application-demo/src/App.tsx new file mode 100644 index 0000000..f85102c --- /dev/null +++ b/job-application-demo/src/App.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { + Container, + Paper, + Typography, + Box, + Button, + Snackbar, + Alert, +} from '@mui/material'; +import { ResumeData } from './types'; +import PersonalInfoForm from './components/PersonalInfoForm'; +import WorkExperienceForm from './components/WorkExperienceForm'; +import EducationForm from './components/EducationForm'; +import CertificationsForm from './components/CertificationsForm'; +import SkillsForm from './components/SkillsForm'; +import SummaryForm from './components/SummaryForm'; + +function App() { + const [formData, setFormData] = useState({ + personalInfo: { + fullName: '', + email: '', + phone: '', + address: '', + city: '', + state: '', + zipCode: '', + country: '', + linkedIn: '', + github: '', + website: '', + }, + workExperience: [], + education: [], + certifications: [], + skills: [], + summary: '', + }); + + const [snackbar, setSnackbar] = useState({ + open: false, + message: '', + severity: 'success' as 'success' | 'error', + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Validate required fields + const { personalInfo, workExperience, skills } = formData; + if ( + !personalInfo.fullName || + !personalInfo.email || + !personalInfo.phone || + !personalInfo.address || + !personalInfo.city || + !personalInfo.state || + !personalInfo.zipCode || + !personalInfo.country + ) { + setSnackbar({ + open: true, + message: 'Please fill in all required personal information fields', + severity: 'error', + }); + return; + } + + if (workExperience.length === 0) { + setSnackbar({ + open: true, + message: 'Please add at least one work experience entry', + severity: 'error', + }); + return; + } + + if (skills.length === 0) { + setSnackbar({ + open: true, + message: 'Please add at least one skill', + severity: 'error', + }); + return; + } + + try { + // Get the current count from localStorage + const count = + parseInt(localStorage.getItem('applicationCount') || '0') + 1; + localStorage.setItem('applicationCount', count.toString()); + + // Create filename with timestamp + const now = new Date(); + const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); + const filename = `job-application-${count}-${timestamp}.json`; + + // Create and download the JSON file + const blob = new Blob([JSON.stringify(formData, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + setSnackbar({ + open: true, + message: `Application saved as ${filename}`, + severity: 'success', + }); + } catch { + setSnackbar({ + open: true, + message: 'Error saving application', + severity: 'error', + }); + } + }; + + return ( + + + + Job Application Form + + + Fields marked with * are required + + + + + setFormData({ ...formData, personalInfo }) + } + /> + + + setFormData({ ...formData, workExperience }) + } + /> + + setFormData({ ...formData, education })} + /> + + + setFormData({ ...formData, certifications }) + } + /> + + setFormData({ ...formData, skills })} + /> + + setFormData({ ...formData, summary })} + /> + + + + + + + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.severity} + variant="filled" + > + {snackbar.message} + + + + ); +} + +export default App; diff --git a/job-application-demo/src/components/CertificationsForm.tsx b/job-application-demo/src/components/CertificationsForm.tsx new file mode 100644 index 0000000..aa5f62f --- /dev/null +++ b/job-application-demo/src/components/CertificationsForm.tsx @@ -0,0 +1,115 @@ +import { + Box, + TextField, + Typography, + Divider, + Button, + Paper, + IconButton, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { ResumeData } from '../types'; + +interface CertificationsFormProps { + data: NonNullable; + onChange: (data: NonNullable) => void; +} + +export default function CertificationsForm({ + data, + onChange, +}: CertificationsFormProps) { + const handleAdd = () => { + onChange([ + ...data, + { + certificationName: '', + acquiredDate: '', + areaOfExpertise: '', + }, + ]); + }; + + const handleRemove = (index: number) => { + onChange(data.filter((_, i) => i !== index)); + }; + + const handleChange = ( + index: number, + field: keyof NonNullable[0], + value: string + ) => { + const newData = [...data]; + newData[index] = { ...newData[index]!, [field]: value }; + onChange(newData); + }; + + return ( + + + Certifications + + + + {data.map((certification, index) => ( + + handleRemove(index)} + sx={{ position: 'absolute', top: 20, right: 4 }} + color="error" + size="small" + > + + + + + + handleChange(index, 'certificationName', e.target.value) + } + /> + + + + handleChange(index, 'acquiredDate', e.target.value) + } + /> + + handleChange(index, 'areaOfExpertise', e.target.value) + } + /> + + + + ))} + + + + ); +} diff --git a/job-application-demo/src/components/EducationForm.tsx b/job-application-demo/src/components/EducationForm.tsx new file mode 100644 index 0000000..45c6c32 --- /dev/null +++ b/job-application-demo/src/components/EducationForm.tsx @@ -0,0 +1,135 @@ +import { + Box, + TextField, + Typography, + Divider, + Button, + Paper, + IconButton, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { ResumeData } from '../types'; + +interface EducationFormProps { + data: NonNullable; + onChange: (data: NonNullable) => void; +} + +export default function EducationForm({ data, onChange }: EducationFormProps) { + const handleAdd = () => { + onChange([ + ...data, + { + institution: '', + degree: '', + field: '', + graduationDate: '', + gpa: '', + }, + ]); + }; + + const handleRemove = (index: number) => { + onChange(data.filter((_, i) => i !== index)); + }; + + const handleChange = ( + index: number, + field: keyof NonNullable[0], + value: string + ) => { + const newData = [...data]; + newData[index] = { ...newData[index]!, [field]: value }; + onChange(newData); + }; + + return ( + + + Education + + + + {data.map((education, index) => ( + + handleRemove(index)} + sx={{ position: 'absolute', top: 20, right: 4 }} + color="error" + size="small" + > + + + + + + handleChange(index, 'institution', e.target.value) + } + /> + + + handleChange(index, 'degree', e.target.value)} + /> + handleChange(index, 'field', e.target.value)} + /> + + + + + handleChange(index, 'graduationDate', e.target.value) + } + /> + handleChange(index, 'gpa', e.target.value)} + placeholder="e.g., 3.50" + slotProps={{ + htmlInput: { + pattern: '^[1-4]\\.[0-9]{2}$', + }, + }} + helperText="Format: X.XX (1.00-4.99)" + /> + + + + ))} + + + + ); +} diff --git a/job-application-demo/src/components/PersonalInfoForm.test.tsx b/job-application-demo/src/components/PersonalInfoForm.test.tsx new file mode 100644 index 0000000..e4a37ac --- /dev/null +++ b/job-application-demo/src/components/PersonalInfoForm.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PersonalInfoForm from './PersonalInfoForm'; +import { ResumeData } from '../types'; + +describe('PersonalInfoForm', () => { + const mockData: ResumeData['personalInfo'] = { + fullName: '', + email: '', + phone: '', + address: '', + city: '', + state: '', + zipCode: '', + country: '', + linkedIn: '', + github: '', + website: '', + }; + + const mockOnChange = vi.fn(); + + it('renders all required fields', () => { + render(); + + expect(screen.getByLabelText(/full name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/phone/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/address/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/city/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/state/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/zip code/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/country/i)).toBeInTheDocument(); + }); + + it('renders optional fields', () => { + render(); + + expect(screen.getByLabelText(/linkedin profile/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/github profile/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/personal website/i)).toBeInTheDocument(); + }); + + it('calls onChange when full name is entered', async () => { + const user = userEvent.setup(); + render(); + + const fullNameInput = screen.getByLabelText(/full name/i); + await user.type(fullNameInput, 'John Doe'); + + expect(mockOnChange).toHaveBeenCalled(); + }); + + it('displays validation helper text', () => { + render(); + + expect( + screen.getByText(/enter a valid email address/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/include country code \(e\.g\., \+1 for us\)/i) + ).toBeInTheDocument(); + expect(screen.getByText(/enter 5-digit zip code/i)).toBeInTheDocument(); + }); + + it('populates fields with existing data', () => { + const existingData: ResumeData['personalInfo'] = { + ...mockData, + fullName: 'Jane Smith', + email: 'jane@example.com', + phone: '+12345678901', + }; + + render(); + + expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument(); + expect(screen.getByDisplayValue('jane@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('+12345678901')).toBeInTheDocument(); + }); +}); diff --git a/job-application-demo/src/components/PersonalInfoForm.tsx b/job-application-demo/src/components/PersonalInfoForm.tsx new file mode 100644 index 0000000..8e0c406 --- /dev/null +++ b/job-application-demo/src/components/PersonalInfoForm.tsx @@ -0,0 +1,140 @@ +import { Box, TextField, Typography, Divider } from '@mui/material'; +import { ResumeData } from '../types'; + +interface PersonalInfoFormProps { + data: ResumeData['personalInfo']; + onChange: (data: ResumeData['personalInfo']) => void; +} + +export default function PersonalInfoForm({ + data, + onChange, +}: PersonalInfoFormProps) { + const handleChange = + (field: keyof ResumeData['personalInfo']) => + (e: React.ChangeEvent) => { + onChange({ ...data, [field]: e.target.value }); + }; + + return ( + + + Personal Information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/job-application-demo/src/components/SkillsForm.test.tsx b/job-application-demo/src/components/SkillsForm.test.tsx new file mode 100644 index 0000000..eecb3e5 --- /dev/null +++ b/job-application-demo/src/components/SkillsForm.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SkillsForm from './SkillsForm'; + +describe('SkillsForm', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders with no skills', () => { + render(); + + expect(screen.getByText(/skills \*/i)).toBeInTheDocument(); + expect( + screen.getByText(/no skills added yet\. please add at least one skill\./i) + ).toBeInTheDocument(); + }); + + it('renders existing skills as chips', () => { + const skills = ['JavaScript', 'React', 'TypeScript']; + render(); + + expect(screen.getByText('JavaScript')).toBeInTheDocument(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + }); + + it('adds a skill when Add button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText(/add a skill/i); + const addButton = screen.getByRole('button', { name: /add/i }); + + await user.type(input, 'Python'); + await user.click(addButton); + + expect(mockOnChange).toHaveBeenCalledWith(['Python']); + }); + + it('adds a skill when Enter key is pressed', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText(/add a skill/i); + + await user.type(input, 'Java{Enter}'); + + expect(mockOnChange).toHaveBeenCalledWith(['Java']); + }); + + it('does not add empty skills', async () => { + const user = userEvent.setup(); + render(); + + const addButton = screen.getByRole('button', { name: /add/i }); + await user.click(addButton); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('does not add duplicate skills', async () => { + const user = userEvent.setup(); + const existingSkills = ['JavaScript']; + render(); + + const input = screen.getByLabelText(/add a skill/i); + const addButton = screen.getByRole('button', { name: /add/i }); + + await user.type(input, 'JavaScript'); + await user.click(addButton); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('removes a skill when delete icon is clicked', async () => { + const user = userEvent.setup(); + const skills = ['JavaScript', 'Python']; + render(); + + const deleteButtons = screen.getAllByTestId('CancelIcon'); + await user.click(deleteButtons[0]!); + + expect(mockOnChange).toHaveBeenCalledWith(['Python']); + }); + + it('clears input after adding a skill', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText(/add a skill/i) as HTMLInputElement; + const addButton = screen.getByRole('button', { name: /add/i }); + + await user.type(input, 'C++'); + await user.click(addButton); + + // The component should clear the input after adding + // We can't directly check the state, but we can verify onChange was called + expect(mockOnChange).toHaveBeenCalledWith(['C++']); + }); +}); diff --git a/job-application-demo/src/components/SkillsForm.tsx b/job-application-demo/src/components/SkillsForm.tsx new file mode 100644 index 0000000..3a81fa7 --- /dev/null +++ b/job-application-demo/src/components/SkillsForm.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { + Box, + TextField, + Typography, + Divider, + Button, + Chip, + Stack, +} from '@mui/material'; +import { Add as AddIcon } from '@mui/icons-material'; +import { ResumeData } from '../types'; + +interface SkillsFormProps { + data: ResumeData['skills']; + onChange: (data: ResumeData['skills']) => void; +} + +export default function SkillsForm({ data, onChange }: SkillsFormProps) { + const [skillInput, setSkillInput] = useState(''); + + const handleAdd = () => { + if (skillInput.trim() && !data.includes(skillInput.trim())) { + onChange([...data, skillInput.trim()]); + setSkillInput(''); + } + }; + + const handleRemove = (skillToRemove: string) => { + onChange(data.filter((skill) => skill !== skillToRemove)); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAdd(); + } + }; + + return ( + + + Skills * + + + + + setSkillInput(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="e.g., JavaScript, Python, Project Management" + /> + + + + {data.length > 0 ? ( + + {data.map((skill, index) => ( + handleRemove(skill)} + color="primary" + variant="outlined" + /> + ))} + + ) : ( + + No skills added yet. Please add at least one skill. + + )} + + ); +} diff --git a/job-application-demo/src/components/SummaryForm.tsx b/job-application-demo/src/components/SummaryForm.tsx new file mode 100644 index 0000000..5badcb8 --- /dev/null +++ b/job-application-demo/src/components/SummaryForm.tsx @@ -0,0 +1,27 @@ +import { Box, TextField, Typography, Divider } from '@mui/material'; + +interface SummaryFormProps { + data: string; + onChange: (data: string) => void; +} + +export default function SummaryForm({ data, onChange }: SummaryFormProps) { + return ( + + + Professional Summary + + + + onChange(e.target.value)} + placeholder="Provide a brief professional summary highlighting your key qualifications and career objectives..." + /> + + ); +} diff --git a/job-application-demo/src/components/WorkExperienceForm.test.tsx b/job-application-demo/src/components/WorkExperienceForm.test.tsx new file mode 100644 index 0000000..ba2a940 --- /dev/null +++ b/job-application-demo/src/components/WorkExperienceForm.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import WorkExperienceForm from './WorkExperienceForm'; +import { ResumeData } from '../types'; + +describe('WorkExperienceForm', () => { + const mockData: ResumeData['workExperience'] = []; + const mockOnChange = vi.fn(); + + it('renders with no work experience entries', () => { + render(); + + expect(screen.getByText(/work experience \*/i)).toBeInTheDocument(); + expect(screen.getByText(/add work experience/i)).toBeInTheDocument(); + }); + + it('adds a work experience entry when button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const addButton = screen.getByText(/add work experience/i); + await user.click(addButton); + + expect(mockOnChange).toHaveBeenCalledWith([ + { + company: '', + position: '', + startDate: '', + endDate: '', + description: '', + current: false, + }, + ]); + }); + + it('renders existing work experience entries', () => { + const existingData: ResumeData['workExperience'] = [ + { + company: 'Acme Corp', + position: 'Software Engineer', + startDate: '2020-01-01', + endDate: '2023-12-31', + description: 'Developed features', + current: false, + }, + ]; + + render(); + + expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument(); + expect(screen.getByDisplayValue('2020-01-01')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Developed features')).toBeInTheDocument(); + }); + + it('shows delete button for each entry', () => { + const existingData: ResumeData['workExperience'] = [ + { + company: 'Test Company', + position: 'Developer', + startDate: '2020-01-01', + endDate: '2023-12-31', + description: 'Test description', + current: false, + }, + ]; + + render(); + + const deleteButtons = screen.getAllByRole('button', { name: '' }); + expect(deleteButtons.length).toBeGreaterThan(0); + }); + + it('disables end date when current is checked', () => { + const existingData: ResumeData['workExperience'] = [ + { + company: 'Current Co', + position: 'Engineer', + startDate: '2023-01-01', + endDate: '', + description: 'Current job', + current: true, + }, + ]; + + render(); + + const endDateInput = screen.getByLabelText(/end date/i); + expect(endDateInput).toBeDisabled(); + }); + + it('displays the current job checkbox', () => { + const existingData: ResumeData['workExperience'] = [ + { + company: 'Company', + position: 'Position', + startDate: '2023-01-01', + endDate: '', + description: 'Description', + current: false, + }, + ]; + + render(); + + expect(screen.getByText(/i currently work here/i)).toBeInTheDocument(); + }); +}); diff --git a/job-application-demo/src/components/WorkExperienceForm.tsx b/job-application-demo/src/components/WorkExperienceForm.tsx new file mode 100644 index 0000000..22b23f7 --- /dev/null +++ b/job-application-demo/src/components/WorkExperienceForm.tsx @@ -0,0 +1,153 @@ +import { + Box, + TextField, + Typography, + Divider, + Button, + Paper, + IconButton, + Checkbox, + FormControlLabel, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { ResumeData } from '../types'; + +interface WorkExperienceFormProps { + data: ResumeData['workExperience']; + onChange: (data: ResumeData['workExperience']) => void; +} + +export default function WorkExperienceForm({ + data, + onChange, +}: WorkExperienceFormProps) { + const handleAdd = () => { + onChange([ + ...data, + { + company: '', + position: '', + startDate: '', + endDate: '', + description: '', + current: false, + }, + ]); + }; + + const handleRemove = (index: number) => { + onChange(data.filter((_, i) => i !== index)); + }; + + const handleChange = ( + index: number, + field: keyof ResumeData['workExperience'][0], + value: string | boolean + ) => { + const newData = [...data]; + newData[index] = { ...newData[index]!, [field]: value }; + onChange(newData); + }; + + return ( + + + Work Experience * + + + + {data.map((experience, index) => ( + + handleRemove(index)} + sx={{ position: 'absolute', top: 20, right: 4 }} + color="error" + size="small" + > + + + + + handleChange(index, 'company', e.target.value)} + /> + + handleChange(index, 'position', e.target.value)} + /> + + + + handleChange(index, 'startDate', e.target.value) + } + /> + handleChange(index, 'endDate', e.target.value)} + disabled={experience.current} + /> + + + + handleChange(index, 'current', e.target.checked) + } + /> + } + label="I currently work here" + /> + + + handleChange(index, 'description', e.target.value) + } + placeholder="Describe your responsibilities and achievements..." + /> + + + ))} + + + + ); +} diff --git a/job-application-demo/src/index.css b/job-application-demo/src/index.css new file mode 100644 index 0000000..3f85fdf --- /dev/null +++ b/job-application-demo/src/index.css @@ -0,0 +1,18 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +#root { + min-height: 100vh; +} diff --git a/job-application-demo/src/main.tsx b/job-application-demo/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/job-application-demo/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/job-application-demo/src/test/setup.ts b/job-application-demo/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/job-application-demo/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/job-application-demo/src/types.ts b/job-application-demo/src/types.ts new file mode 100644 index 0000000..c4fd08e --- /dev/null +++ b/job-application-demo/src/types.ts @@ -0,0 +1,37 @@ +export interface ResumeData { + personalInfo: { + fullName: string; + email: string; + phone: string; + address: string; + city: string; + state: string; + zipCode: string; + country: string; + linkedIn?: string; + github?: string; + website?: string; + }; + workExperience: Array<{ + company: string; + position: string; + startDate: string; + endDate: string; + description: string; + current: boolean; + }>; + education?: Array<{ + institution: string; + degree: string; + field?: string; + graduationDate: string; + gpa?: string; + }>; + certifications?: Array<{ + certificationName: string; + acquiredDate: string; + areaOfExpertise?: string; + }>; + skills: string[]; + summary?: string; +} diff --git a/job-application-demo/tsconfig.json b/job-application-demo/tsconfig.json new file mode 100644 index 0000000..6f16902 --- /dev/null +++ b/job-application-demo/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + "jsx": "react-jsx", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting - Strict Mode */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/job-application-demo/vite.config.ts b/job-application-demo/vite.config.ts new file mode 100644 index 0000000..82f5b74 --- /dev/null +++ b/job-application-demo/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'], + testTimeout: 10000, + }, +}); diff --git a/package.json b/package.json index 2252ed8..2c54fc8 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "copy:icons": "mkdir -p dist/icons && cp src/icons/*.svg dist/icons/", "test": "vitest", "test:ui": "vitest --ui", - "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "eslint . --ext .ts,.tsx --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,json,css}\"" + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx", + "lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx --fix", + "format": "prettier --write \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"", + "format:check": "prettier --check \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"" }, "keywords": [ "firefox", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b04d25b..44466fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,72 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@24.8.1)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(yaml@2.8.1) + job-application-demo: + dependencies: + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.2)(react@19.2.0) + '@emotion/styled': + specifier: ^11.14.1 + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@mui/icons-material': + specifier: ^7.3.4 + version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@mui/material': + specifier: ^7.3.4 + version: 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.0.4(vite@7.1.10(@types/node@24.8.1)(yaml@2.8.1)) + '@vitest/ui': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + jsdom: + specifier: ^27.0.1 + version: 27.0.1(postcss@8.5.6) + playwright: + specifier: ^1.56.1 + version: 1.56.1 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.10 + version: 7.1.10(@types/node@24.8.1)(yaml@2.8.1) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.8.1)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(yaml@2.8.1) + packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} @@ -615,6 +679,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -746,6 +815,38 @@ packages: cpu: [x64] os: [win32] + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -936,6 +1037,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -943,6 +1048,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-differ@4.0.0: resolution: {integrity: sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1117,6 +1229,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssom@0.5.0: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} @@ -1166,6 +1281,16 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -1371,6 +1496,11 @@ packages: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1477,6 +1607,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1687,6 +1821,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -1710,6 +1848,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1841,6 +1983,16 @@ packages: resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} hasBin: true + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1858,6 +2010,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1900,6 +2056,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@19.2.0: resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==} @@ -1924,6 +2083,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + registry-auth-token@5.1.0: resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} engines: {node: '>=14'} @@ -2074,6 +2237,10 @@ packages: resolution: {integrity: sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -2419,6 +2586,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@asamuzakjp/css-color@4.0.5': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -2596,7 +2765,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.28.4 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -2627,7 +2796,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0)': dependencies: - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -2653,7 +2822,7 @@ snapshots: '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': dependencies: - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.4.0 '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) @@ -2931,6 +3100,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -3015,6 +3188,42 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 @@ -3264,10 +3473,18 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-differ@4.0.0: {} array-union@3.0.1: {} @@ -3287,7 +3504,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.28.4 cosmiconfig: 7.1.0 resolve: 1.22.10 @@ -3447,6 +3664,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssom@0.5.0: {} cssstyle@5.3.1(postcss@8.5.6): @@ -3482,6 +3701,12 @@ snapshots: deep-is@0.1.4: {} + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -3719,6 +3944,9 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3817,6 +4045,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -4007,6 +4237,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4030,6 +4262,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4165,6 +4399,14 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 3.1.0 + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4179,6 +4421,12 @@ snapshots: prettier@3.6.2: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -4219,6 +4467,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@19.2.0: {} react-refresh@0.17.0: {} @@ -4246,6 +4496,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + registry-auth-token@5.1.0: dependencies: '@pnpm/npm-conf': 2.3.1 @@ -4399,6 +4654,10 @@ snapshots: strip-bom@5.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} diff --git a/src/services/fieldMatcher.ts b/src/services/fieldMatcher.ts index ef554df..3606c62 100644 --- a/src/services/fieldMatcher.ts +++ b/src/services/fieldMatcher.ts @@ -125,6 +125,14 @@ export class FieldMatcher { ); } + if (this.matchesAny(fieldText, ['github', 'git hub', 'github url'])) { + return this.createMapping( + field, + 'personalInfo.github', + this.resumeData.personalInfo.github || '' + ); + } + if (this.matchesAny(fieldText, ['website', 'portfolio', 'url'])) { return this.createMapping( field, @@ -176,7 +184,7 @@ export class FieldMatcher { 'institution', ]) ) { - const latestEd = this.resumeData.education[0]; + const latestEd = this.resumeData.education?.[0]; return this.createMapping( field, 'education.institution', @@ -185,7 +193,7 @@ export class FieldMatcher { } if (this.matchesAny(fieldText, ['degree', 'qualification'])) { - const latestEd = this.resumeData.education[0]; + const latestEd = this.resumeData.education?.[0]; return this.createMapping( field, 'education.degree', @@ -201,7 +209,7 @@ export class FieldMatcher { 'specialization', ]) ) { - const latestEd = this.resumeData.education[0]; + const latestEd = this.resumeData.education?.[0]; return this.createMapping( field, 'education.field', @@ -210,10 +218,59 @@ export class FieldMatcher { } if (this.matchesAny(fieldText, ['gpa'])) { - const latestEd = this.resumeData.education[0]; + const latestEd = this.resumeData.education?.[0]; return this.createMapping(field, 'education.gpa', latestEd?.gpa || ''); } + // Certifications + if ( + this.matchesAny(fieldText, [ + 'certification', + 'certificate', + 'certification name', + 'cert name', + ]) + ) { + const latestCert = this.resumeData.certifications?.[0]; + return this.createMapping( + field, + 'certifications.certificationName', + latestCert?.certificationName || '' + ); + } + + if ( + this.matchesAny(fieldText, [ + 'certification date', + 'cert date', + 'acquired', + 'acquired date', + ]) + ) { + const latestCert = this.resumeData.certifications?.[0]; + return this.createMapping( + field, + 'certifications.acquiredDate', + latestCert?.acquiredDate || '' + ); + } + + if ( + this.matchesAny(fieldText, [ + 'area of expertise', + 'expertise', + 'certification area', + 'cert area', + ]) + ) { + const latestCert = this.resumeData.certifications?.[0]; + return this.createMapping( + field, + 'certifications.areaOfExpertise', + latestCert?.areaOfExpertise || '' + ); + } + // Skills if (this.matchesAny(fieldText, ['skills', 'skill'])) { return this.createMapping( diff --git a/src/types/index.ts b/src/types/index.ts index d0fe378..ee87f71 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ export interface ResumeData { zipCode: string; country: string; linkedIn?: string; + github?: string; website?: string; }; workExperience: Array<{ @@ -19,13 +20,18 @@ export interface ResumeData { description: string; current: boolean; }>; - education: Array<{ + education?: Array<{ institution: string; degree: string; - field: string; + field?: string; graduationDate: string; gpa?: string; }>; + certifications?: Array<{ + certificationName: string; + acquiredDate: string; + areaOfExpertise?: string; + }>; skills: string[]; summary?: string; } diff --git a/tsconfig.json b/tsconfig.json index c487518..2c5f61c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,5 @@ } }, "include": ["src/**/*", "vite.config.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "job-application-demo"] }