From 87737809b1218250e7654dc2efb7472f5dfb19f2 Mon Sep 17 00:00:00 2001 From: leini8891 Date: Sat, 27 Jun 2026 21:55:38 +0800 Subject: [PATCH 1/3] Clean public positioning and demo defaults --- README.md | 10 +- apps/extension/src/manifest.ts | 3 +- apps/extension/src/popup/popup.tsx | 41 ----- apps/extension/src/tinyfish/auto-apply.ts | 83 --------- apps/extension/src/tinyfish/client.ts | 163 ------------------ apps/extension/src/tinyfish/orchestrator.ts | 155 ----------------- apps/extension/src/tinyfish/session.ts | 71 -------- .../(dashboard)/applications/[id]/page.tsx | 2 +- apps/web/src/components/manual-job-form.tsx | 4 +- apps/web/src/components/preferences-form.tsx | 2 +- apps/web/src/server/services/ai.ts | 81 ++++++++- apps/web/src/server/services/app-service.ts | 14 +- docs/assets/application-workflow.svg | 12 +- docs/demo-flow.md | 18 +- packages/config/src/index.ts | 2 - packages/domain/src/demo.ts | 78 ++++----- packages/domain/src/helpers.test.ts | 10 +- packages/domain/src/schemas.ts | 3 +- tests/app-service.test.ts | 52 +++--- tests/e2e/linkedin-fixture.spec.ts | 3 +- tests/fixtures/linkedin-easy-apply.html | 14 +- tests/knowledge-base.test.ts | 28 +-- 22 files changed, 201 insertions(+), 648 deletions(-) delete mode 100644 apps/extension/src/tinyfish/auto-apply.ts delete mode 100644 apps/extension/src/tinyfish/client.ts delete mode 100644 apps/extension/src/tinyfish/orchestrator.ts delete mode 100644 apps/extension/src/tinyfish/session.ts diff --git a/README.md b/README.md index ddf72fc..be4eb88 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The product is intentionally single-user and local-first: | --- | --- | --- | | ![Knowledge Base](docs/assets/knowledge-base.png) | ![Daily Picks](docs/assets/daily-picks.png) | ![Application Tracker](docs/assets/application-tracker.png) | -### Application Workflow V0 +### Application Workflow ![Application Workflow](docs/assets/application-workflow.svg) @@ -45,11 +45,11 @@ The product is intentionally single-user and local-first: | Capability | What it means | | --- | --- | -| Role matching V0 | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | +| Role matching | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | | Resume and story retrieval | Retrieves resume evidence plus reusable stories, interview notes, job profiles, and answer playbooks for a role. | | Local Markdown/JSON knowledge base | Reads structured Markdown, JSON sidecars, standalone JSON entries, and private local-only entries. | | Daily Picks | Ranks saved jobs and attaches prep assets under each role so review starts with evidence, not a blank page. | -| Application workflow V0 | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | +| Application workflow | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | | Application tracker sync | Manual job saves create `drafted` tracker records, and repeat saves do not reset existing statuses. | | Human-in-the-loop automation | The Chrome extension can assist LinkedIn and MyCareersFuture flows, while risky cases route to review. | @@ -166,8 +166,6 @@ pnpm build # production build for all workspace packages pnpm lint # typecheck and lint ``` -The current portfolio packaging lives on `codex/application-workflow-v0-new`. - ## Privacy Boundary Commit only sanitized, reusable material under `knowledge_base/`. Keep private recruiter details, interview schedules, exact compensation expectations, personal documents, local resume paths, and sensitive application notes under `local_workspace/knowledge_base_private/`. @@ -176,7 +174,7 @@ Commit only sanitized, reusable material under `knowledge_base/`. Keep private r ## Project Status -This is a complete portfolio-ready V0 of the knowledge-backed job-search workflow: +This is a portfolio-ready slice of the knowledge-backed job-search workflow: - Match saved jobs. - Retrieve resume evidence and career-story assets. diff --git a/apps/extension/src/manifest.ts b/apps/extension/src/manifest.ts index ce33e07..4c5c291 100644 --- a/apps/extension/src/manifest.ts +++ b/apps/extension/src/manifest.ts @@ -5,11 +5,10 @@ export default defineManifest({ name: 'ApplyPilot', version: '0.1.0', description: 'Job application copilot for LinkedIn and MyCareersFuture', - permissions: ['storage', 'tabs', 'activeTab', 'scripting', 'cookies'], + permissions: ['storage', 'tabs', 'activeTab', 'scripting'], host_permissions: [ 'https://www.linkedin.com/*', 'https://www.mycareersfuture.gov.sg/*', - 'https://agent.tinyfish.ai/*', 'http://localhost:3000/*', ], background: { diff --git a/apps/extension/src/popup/popup.tsx b/apps/extension/src/popup/popup.tsx index 64cb107..5335f3d 100644 --- a/apps/extension/src/popup/popup.tsx +++ b/apps/extension/src/popup/popup.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { extensionEnv } from '../shared/env'; import type { ExtensionMessage, PopupState } from '../shared/messages'; -import { getTinyFishApiKey, saveTinyFishApiKey } from '../tinyfish/client'; const defaultState: PopupState = { runStatus: 'idle', @@ -25,8 +24,6 @@ export function PopupApp() { const [extensionState, setExtensionState] = useState(defaultState); const [summary, setSummary] = useState(null); const [targetCount, setTargetCount] = useState(10); - const [tinyFishApiKey, setTinyFishApiKey] = useState(''); - const [tinyFishSaved, setTinyFishSaved] = useState(false); const [error, setError] = useState(''); useEffect(() => { @@ -47,14 +44,6 @@ export function PopupApp() { } catch { setError('Dashboard API is unavailable.'); } - - try { - const savedApiKey = await getTinyFishApiKey(); - setTinyFishApiKey(savedApiKey); - setTinyFishSaved(true); - } catch { - setTinyFishSaved(false); - } }; void bootstrap(); @@ -126,17 +115,6 @@ export function PopupApp() { } }; - const saveTinyFishKey = async () => { - setError(''); - try { - await saveTinyFishApiKey(tinyFishApiKey); - setTinyFishSaved(true); - } catch (error) { - setTinyFishSaved(false); - setError(error instanceof Error ? error.message : 'Could not save the TinyFish API key.'); - } - }; - return (
@@ -174,25 +152,6 @@ export function PopupApp() { /> - - -
- -
-
diff --git a/tests/knowledge-base.test.ts b/tests/knowledge-base.test.ts index b9f85bf..eee2807 100644 --- a/tests/knowledge-base.test.ts +++ b/tests/knowledge-base.test.ts @@ -224,36 +224,36 @@ Shows product judgment around operational reliability. await writeKnowledgeFile( tempDir, - 'knowledge_base/stories/payments_platform_story.md', - `# Payments Platform Story + 'knowledge_base/stories/workflow_automation_story.md', + `# Workflow Automation Story ## Context -Use this story for payments platform and onboarding roles. +Use this story for workflow automation and onboarding roles. ## Core facts -- Improved payment reliability. +- Improved onboarding completion. ## Interview value -Shows practical judgment around payment operations. +Shows practical judgment around workflow design and customer activation. ## Reusable answer points -- Start with merchant trust and transaction reliability. -- Connect controls, reconciliation, and support recovery. +- Start with customer activation and operational repeatability. +- Connect workflow templates, analytics, and support recovery. ## Related roles -- Senior Product Manager, Payments -- Payments Product Manager +- Product Manager, Workflow Automation +- Product Lead ## Tags -- payments -- KYC -- merchant experience +- workflow automation +- analytics +- onboarding `, ); @@ -294,8 +294,8 @@ Sales-focused. const matches = matchKnowledgeEntriesForJob({ entries, job, score }); expect(matches).toHaveLength(1); - expect(matches[0]?.title).toBe('Payments Platform Story'); + expect(matches[0]?.title).toBe('Workflow Automation Story'); expect(matches[0]?.reason).toContain('Related role match'); - expect(matches[0]?.answerPoints).toContain('Start with merchant trust and transaction reliability.'); + expect(matches[0]?.answerPoints).toContain('Start with customer activation and operational repeatability.'); }); }); From a71cbc449c8ababb9e0e6ff49a4ef234e4d9e97f Mon Sep 17 00:00:00 2001 From: leini8891 Date: Sat, 27 Jun 2026 22:16:01 +0800 Subject: [PATCH 2/3] Add configurable preferences and local file store --- .env.example | 1 + README.md | 7 +- apps/extension/src/content/linkedin.ts | 126 ++++++++++++----- apps/extension/src/content/mycareersfuture.ts | 128 +++++++++++++----- apps/web/src/components/preferences-form.tsx | 90 ++++++++++++ apps/web/src/server/services/app-service.ts | 13 +- apps/web/src/server/services/store.ts | 105 +++++++++++++- docs/demo-flow.md | 3 +- packages/config/src/index.ts | 1 + packages/domain/src/demo.ts | 7 + packages/domain/src/helpers.ts | 5 +- packages/domain/src/schemas.ts | 7 + supabase/migrations/0001_init.sql | 10 +- .../0002_job_preference_screening_config.sql | 11 ++ supabase/seed.sql | 16 +-- tests/store.test.ts | 43 +++++- 16 files changed, 480 insertions(+), 93 deletions(-) create mode 100644 supabase/migrations/0002_job_preference_screening_config.sql diff --git a/.env.example b/.env.example index c9aae7d..7ff06dc 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ SUPABASE_SERVICE_ROLE_KEY= SUPABASE_STORAGE_BUCKET=applypilot-assets OPENAI_API_KEY= OPENAI_MODEL=gpt-4.1-mini +APPLYPILOT_LOCAL_STORE_PATH= ENABLE_DEMO_DATA=false VITE_DASHBOARD_URL=http://localhost:3000 VITE_API_BASE_URL=http://localhost:3000 diff --git a/README.md b/README.md index be4eb88..e8c3db4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The product is intentionally single-user and local-first: - Public-safe career stories live in `knowledge_base/`. - Private interview notes can live in `local_workspace/knowledge_base_private/`, which is ignored by Git. -- The app works with an in-memory demo store by default, so it runs without Supabase or OpenAI. +- The app works with a gitignored local JSON store by default, so it runs without Supabase or OpenAI. - AI calls have deterministic fallbacks, keeping the workflow usable without external services. ## Product Tour @@ -156,7 +156,8 @@ pnpm dev:extension Open [http://localhost:3000](http://localhost:3000). -For a persistent database-backed setup, copy `.env.example` to `.env.local`, fill in Supabase/OpenAI values as needed, and apply the SQL migrations under `supabase/migrations/`. +Without Supabase, saved local data is written to `local_workspace/applypilot-store.json`. +For a database-backed setup, copy `.env.example` to `.env.local`, fill in Supabase/OpenAI values as needed, and apply the SQL migrations under `supabase/migrations/`. ## Verification @@ -170,7 +171,7 @@ pnpm lint # typecheck and lint Commit only sanitized, reusable material under `knowledge_base/`. Keep private recruiter details, interview schedules, exact compensation expectations, personal documents, local resume paths, and sensitive application notes under `local_workspace/knowledge_base_private/`. -`local_workspace/` is ignored by Git. +`local_workspace/` is ignored by Git, including the fallback store at `local_workspace/applypilot-store.json`. ## Project Status diff --git a/apps/extension/src/content/linkedin.ts b/apps/extension/src/content/linkedin.ts index b070bdc..426106d 100644 --- a/apps/extension/src/content/linkedin.ts +++ b/apps/extension/src/content/linkedin.ts @@ -6,18 +6,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - } | null; - preference?: { - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - } | null; -}; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; + }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -1152,22 +1160,44 @@ const getPreferredLocationValue = ({ ); const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (!preference?.minSalary || preference.minSalary <= 0) { - return ''; + if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { + return String(preference.applicationSalaryAmount); } - const monthly = Math.max(1, Math.round(preference.minSalary / 12)); - return String(monthly); + if (preference?.minSalary && preference.minSalary > 0) { + return String(Math.max(1, Math.round(preference.minSalary / 12))); + } + + return ''; +}; + +const getPreferredYearsValue = ( + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { + const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; + + return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; +}; + +const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { + const weeks = preference?.noticePeriodWeeks; + + return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; }; -const inferYearsAnswer = (context: string) => { +const inferYearsAnswer = ( + context: string, + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { const normalized = normalizeToken(context); if (!/(years|experience|经验|多久)/.test(normalized)) { return ''; } - return '8'; + return getPreferredYearsValue(preference, profile); }; const looksLikeSuspiciousFullName = (value: string) => @@ -1223,7 +1253,7 @@ const fillKnownTextFields = ({ continue; } - const inferredYears = inferYearsAnswer(context); + const inferredYears = inferYearsAnswer(context, preference, profile); if (inferredYears && /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text')) { setFieldValue(control, inferredYears); } @@ -1290,7 +1320,13 @@ const getRadioLabelText = (radio: HTMLInputElement) => ), ); -const chooseRadioCandidate = (radios: HTMLInputElement[]) => { +const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => + value === 'yes' || value === 'no' ? [value] : []; + +const chooseRadioCandidate = ( + radios: HTMLInputElement[], + preference?: BootstrapPayload['preference'] | null, +) => { const questionText = normalizeToken( firstNonEmpty( textOf(radios[0]?.closest('fieldset')), @@ -1299,12 +1335,20 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { ), ); - const preferredTokens = /sponsor|sponsorship|visa|work authorization|work authorisation/.test(questionText) - ? ['no'] - : /degree|education|bachelor|master|phd|completed|complete the following level/.test(questionText) + const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) + ? choiceTokens(preference?.requiresVisaSponsorship) + : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) + ? choiceTokens(preference?.workAuthorization) + : /relocat|willing to move/.test(questionText) + ? choiceTokens(preference?.willingToRelocate) + : /degree|education|bachelor|master|phd|completed|complete the following level/.test(questionText) ? ['yes'] : ['yes', 'no']; + if (preferredTokens.length === 0) { + return null; + } + for (const token of preferredTokens) { const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); if (candidate) { @@ -1339,6 +1383,7 @@ const getFallbackFieldValue = ({ const context = getFieldContextText(element); const preferredLocation = getPreferredLocationValue({ profile, preference, jobLocation }); const preferredSalary = getPreferredSalaryValue(preference); + const noticePeriod = getNoticePeriodValue(preference); if (/email/.test(context) && profile?.email) { return profile.email; @@ -1353,22 +1398,22 @@ const getFallbackFieldValue = ({ } if (/salary|compensation|pay|薪资|薪酬/.test(context)) { - return preferredSalary || '10000'; + return preferredSalary; } if (/notice/.test(context)) { - return '2'; + return noticePeriod; } if (/(years|experience|经验|多久)/.test(context)) { - return '8'; + return inferYearsAnswer(context, preference, profile); } if (element instanceof HTMLInputElement && element.type === 'number') { - return '1'; + return ''; } - return '1'; + return ''; }; const answerKnockoutQuestions = ({ @@ -1392,15 +1437,28 @@ const answerKnockoutQuestions = ({ } const dialog = getEasyApplyDialog() ?? document; + const preferredYears = Number(getPreferredYearsValue(preference, profile)); const requiredGroups = Array.from(dialog.querySelectorAll('fieldset')).filter((fieldset) => isVisible(fieldset) && - fieldset.textContent?.toLowerCase().includes('years'), + fieldset.textContent?.toLowerCase().includes('years') && + Number.isFinite(preferredYears) && + preferredYears > 0, ); requiredGroups.forEach((group) => { - const preferred = group.querySelector( - 'input[type="radio"][value*="5"], input[type="radio"][value*="10"]', - ); + const preferred = + Array.from(group.querySelectorAll('input[type="radio"]')) + .map((radio) => { + const label = getRadioLabelText(radio); + const numericValue = Number(label.match(/\d+/)?.[0] ?? radio.value.match(/\d+/)?.[0] ?? NaN); + return { + radio, + numericValue, + }; + }) + .filter((candidate) => Number.isFinite(candidate.numericValue) && candidate.numericValue <= preferredYears) + .sort((left, right) => right.numericValue - left.numericValue)[0]?.radio ?? null; + preferred?.click(); }); @@ -1409,7 +1467,7 @@ const answerKnockoutQuestions = ({ return; } - const candidate = chooseRadioCandidate(radios); + const candidate = chooseRadioCandidate(radios, preference); candidate?.click(); }); @@ -1422,7 +1480,7 @@ const answerKnockoutQuestions = ({ return; } - const inferredYears = inferYearsAnswer(getFieldContextText(element)); + const inferredYears = inferYearsAnswer(getFieldContextText(element), preference, profile); if (inferredYears) { setFieldValue(element, inferredYears); } diff --git a/apps/extension/src/content/mycareersfuture.ts b/apps/extension/src/content/mycareersfuture.ts index a3081f3..2146907 100644 --- a/apps/extension/src/content/mycareersfuture.ts +++ b/apps/extension/src/content/mycareersfuture.ts @@ -13,18 +13,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - } | null; - preference?: { - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - } | null; -}; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; + }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -238,41 +246,63 @@ const getPreferredLocationValue = ({ profile: BootstrapPayload['profile']; preference?: BootstrapPayload['preference'] | null; jobLocation: string; -}) => - firstNonEmpty( - preference?.regions?.[0], - jobLocation, - profile?.location, - 'Singapore', - ); + }) => + firstNonEmpty( + preference?.regions?.[0], + jobLocation, + profile?.location, + ); const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (typeof preference?.minSalary === 'number') { + if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { + return String(preference.applicationSalaryAmount); + } + + if (preference?.minSalary && preference.minSalary > 0) { return String(preference.minSalary); } - return '10000'; + return ''; }; -const inferYearsAnswer = (context: string) => { +const getPreferredYearsValue = ( + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { + const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; + + return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; +}; + +const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { + const weeks = preference?.noticePeriodWeeks; + + return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; +}; + +const inferYearsAnswer = ( + context: string, + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { const normalized = normalizeToken(context); if (!normalized) { return ''; } if (/notice period/.test(normalized)) { - return '2'; + return getNoticePeriodValue(preference); } if (/salary|pay|compensation/.test(normalized)) { - return '10000'; + return getPreferredSalaryValue(preference); } if (/year|experience/.test(normalized)) { - return '5'; + return getPreferredYearsValue(preference, profile); } - return '1'; + return ''; }; const fillKnownTextFields = ({ @@ -351,7 +381,13 @@ const getRadioLabelText = (radio: HTMLInputElement) => ), ); -const chooseRadioCandidate = (radios: HTMLInputElement[]) => { +const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => + value === 'yes' || value === 'no' ? [value] : []; + +const chooseRadioCandidate = ( + radios: HTMLInputElement[], + preference?: BootstrapPayload['preference'] | null, +) => { const questionText = normalizeToken( firstNonEmpty( textOf(radios[0]?.closest('fieldset')), @@ -360,11 +396,19 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { ), ); - const preferredTokens = /sponsor|sponsorship|visa|work authorization|work authorisation/.test(questionText) - ? ['no'] - : /comfortable|onsite|on-site|willing|degree|education|completed/.test(questionText) - ? ['yes', 'no'] - : ['yes', 'no']; + const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) + ? choiceTokens(preference?.requiresVisaSponsorship) + : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) + ? choiceTokens(preference?.workAuthorization) + : /relocat|willing to move/.test(questionText) + ? choiceTokens(preference?.willingToRelocate) + : /comfortable|onsite|on-site|willing|degree|education|completed/.test(questionText) + ? ['yes', 'no'] + : ['yes', 'no']; + + if (preferredTokens.length === 0) { + return null; + } for (const token of preferredTokens) { const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); @@ -376,7 +420,13 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { return radios[0] ?? null; }; -const answerQuestions = () => { +const answerQuestions = ({ + profile, + preference, +}: { + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => { const controls = getFormControls(); controls.forEach((element) => { @@ -391,7 +441,7 @@ const answerQuestions = () => { return; } - const candidate = chooseRadioCandidate(radios); + const candidate = chooseRadioCandidate(radios, preference); candidate?.click(); }); @@ -404,13 +454,17 @@ const answerQuestions = () => { return; } - const inferred = inferYearsAnswer(getFieldContextText(element)); + const inferred = inferYearsAnswer(getFieldContextText(element), preference, profile); if (inferred) { setFieldValue(element, inferred); } }); - return controls.every((element) => { + const unresolvedRequiredRadioGroups = getRadioGroups().filter( + (radios) => radios.some((radio) => isRequiredControl(radio)) && !radios.some((radio) => radio.checked), + ); + + return unresolvedRequiredRadioGroups.length === 0 && controls.every((element) => { if (!isRequiredControl(element)) { return true; } @@ -434,7 +488,7 @@ const fillCurrentStep = ({ }) => { fillStandardFields(profile); fillKnownTextFields({ profile, preference, jobLocation }); - return answerQuestions(); + return answerQuestions({ profile, preference }); }; const uploadResumeIfNeeded = async ({ diff --git a/apps/web/src/components/preferences-form.tsx b/apps/web/src/components/preferences-form.tsx index bfe716b..945523b 100644 --- a/apps/web/src/components/preferences-form.tsx +++ b/apps/web/src/components/preferences-form.tsx @@ -12,11 +12,18 @@ type PreferencesFormProps = { export function PreferencesForm({ preference }: PreferencesFormProps) { const router = useRouter(); const [formState, setFormState] = useState({ + targetRoles: preference?.targetRoles.join(', ') ?? '', keywords: preference?.keywords.join(', ') ?? '', industries: preference?.industries.join(', ') ?? '', regions: preference?.regions.join(', ') ?? '', minSalary: String(preference?.minSalary ?? 120000), salaryCurrency: preference?.salaryCurrency ?? 'USD', + applicationSalaryAmount: String(preference?.applicationSalaryAmount ?? 0), + yearsExperienceOverride: String(preference?.yearsExperienceOverride ?? ''), + noticePeriodWeeks: String(preference?.noticePeriodWeeks ?? ''), + workAuthorization: preference?.workAuthorization ?? 'unknown', + requiresVisaSponsorship: preference?.requiresVisaSponsorship ?? 'unknown', + willingToRelocate: preference?.willingToRelocate ?? 'unknown', dailyTarget: String(preference?.dailyTarget ?? 25), vipCompanies: preference?.vipCompanies.join(', ') ?? '', remotePolicy: preference?.remotePolicy ?? 'any', @@ -42,11 +49,20 @@ export function PreferencesForm({ preference }: PreferencesFormProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ + targetRoles: formState.targetRoles.split(',').map((item) => item.trim()).filter(Boolean), keywords: formState.keywords.split(',').map((item) => item.trim()).filter(Boolean), industries: formState.industries.split(',').map((item) => item.trim()).filter(Boolean), regions: formState.regions.split(',').map((item) => item.trim()).filter(Boolean), minSalary: Number(formState.minSalary), salaryCurrency: formState.salaryCurrency, + applicationSalaryAmount: Number(formState.applicationSalaryAmount), + yearsExperienceOverride: formState.yearsExperienceOverride + ? Number(formState.yearsExperienceOverride) + : null, + noticePeriodWeeks: formState.noticePeriodWeeks ? Number(formState.noticePeriodWeeks) : null, + workAuthorization: formState.workAuthorization, + requiresVisaSponsorship: formState.requiresVisaSponsorship, + willingToRelocate: formState.willingToRelocate, dailyTarget: Number(formState.dailyTarget), vipCompanies: formState.vipCompanies.split(',').map((item) => item.trim()).filter(Boolean), remotePolicy: formState.remotePolicy, @@ -66,6 +82,14 @@ export function PreferencesForm({ preference }: PreferencesFormProps) { return (
+