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)
+ }
+ />
+
+
+
+ ))}
+
+ }
+ onClick={handleAdd}
+ variant="outlined"
+ fullWidth
+ >
+ Add Certification
+
+
+ );
+}
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)"
+ />
+
+
+
+ ))}
+
+ }
+ onClick={handleAdd}
+ variant="outlined"
+ fullWidth
+ >
+ Add Education
+
+
+ );
+}
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"
+ />
+ }
+ sx={{ minWidth: 100 }}
+ >
+ Add
+
+
+
+ {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..."
+ />
+
+
+ ))}
+
+ }
+ onClick={handleAdd}
+ variant="outlined"
+ fullWidth
+ >
+ Add Work Experience
+
+
+ );
+}
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"]
}