qiksy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/ARCHITECTURE.md +78 -0
  2. package/INSTALL-PROMPT.md +53 -0
  3. package/bin/qiksy.mjs +302 -0
  4. package/package.json +32 -0
  5. package/tools/client-finder/vendor/README.md +70 -0
  6. package/tools/client-finder/vendor/find.mjs +121 -0
  7. package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
  8. package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
  9. package/tools/client-finder/vendor/knowledge/assist.md +68 -0
  10. package/tools/client-finder/vendor/knowledge/custom.md +36 -0
  11. package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
  12. package/tools/client-finder/vendor/knowledge/pay.md +26 -0
  13. package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
  14. package/tools/client-finder/vendor/knowledge/travel.md +43 -0
  15. package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
  16. package/tools/client-finder/vendor/lib/access.mjs +108 -0
  17. package/tools/client-finder/vendor/lib/agent.mjs +204 -0
  18. package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
  19. package/tools/client-finder/vendor/lib/browser.mjs +108 -0
  20. package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
  21. package/tools/client-finder/vendor/lib/channels.mjs +54 -0
  22. package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
  23. package/tools/client-finder/vendor/lib/engine.mjs +283 -0
  24. package/tools/client-finder/vendor/lib/formats.mjs +108 -0
  25. package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
  26. package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
  27. package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
  28. package/tools/client-finder/vendor/lib/report.mjs +75 -0
  29. package/tools/client-finder/vendor/lib/scope.mjs +162 -0
  30. package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
  31. package/tools/client-finder/vendor/lib/verify.mjs +96 -0
  32. package/tools/client-finder/vendor/package.json +8 -0
  33. package/tools/client-finder/vendor/server.mjs +390 -0
  34. package/tools/travel/vendor/README.md +58 -0
  35. package/tools/travel/vendor/docs/CONTRACT.md +174 -0
  36. package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
  37. package/tools/travel/vendor/lib/agent.mjs +190 -0
  38. package/tools/travel/vendor/lib/engine.mjs +283 -0
  39. package/tools/travel/vendor/lib/jobs.mjs +125 -0
  40. package/tools/travel/vendor/lib/license.mjs +41 -0
  41. package/tools/travel/vendor/lib/plans.mjs +28 -0
  42. package/tools/travel/vendor/lib/prompts.mjs +394 -0
  43. package/tools/travel/vendor/lib/proposal.mjs +207 -0
  44. package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
  45. package/tools/travel/vendor/lib/store.mjs +75 -0
  46. package/tools/travel/vendor/package.json +12 -0
  47. package/tools/travel/vendor/public/app.js +996 -0
  48. package/tools/travel/vendor/public/index.html +112 -0
  49. package/tools/travel/vendor/public/styles.css +760 -0
  50. package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
  51. package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
  52. package/tools/travel/vendor/server.mjs +755 -0
  53. package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
  54. package/tools/work-finder/vendor/.mcp.json +9 -0
  55. package/tools/work-finder/vendor/CLAUDE.md +24 -0
  56. package/tools/work-finder/vendor/README.md +44 -0
  57. package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
  58. package/tools/work-finder/vendor/lib/agent.mjs +159 -0
  59. package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
  60. package/tools/work-finder/vendor/lib/channels.mjs +73 -0
  61. package/tools/work-finder/vendor/lib/engine.mjs +283 -0
  62. package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
  63. package/tools/work-finder/vendor/lib/license.mjs +60 -0
  64. package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
  65. package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
  66. package/tools/work-finder/vendor/lib/plans.mjs +33 -0
  67. package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
  68. package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
  69. package/tools/work-finder/vendor/lib/store.mjs +125 -0
  70. package/tools/work-finder/vendor/package.json +41 -0
  71. package/tools/work-finder/vendor/public/app.js +1863 -0
  72. package/tools/work-finder/vendor/public/index.html +518 -0
  73. package/tools/work-finder/vendor/public/styles.css +930 -0
  74. package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
  75. package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
  76. package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
  77. package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
  78. package/tools/work-finder/vendor/server.mjs +1006 -0
@@ -0,0 +1,1863 @@
1
+ /* LinkedIn hands out `www.linkedin.com/in/name` (no scheme) and country subdomains
2
+ (ua.linkedin.com). The old check demanded https://[www.] and called a real pasted
3
+ profile URL invalid. Accept what LinkedIn gives; normalise to one shape. */
4
+ function normalizeLinkedin(raw) {
5
+ const bare = String(raw || '').trim().replace(/^https?:\/\//i, '').replace(/^www\./i, '');
6
+ const m = /^(?:[a-z]{2,3}\.)?linkedin\.com\/in\/([^/?#\s]+)/i.exec(bare);
7
+ return m ? 'https://www.linkedin.com/in/' + m[1] : null;
8
+ }
9
+ /* Work Finder UI — job-board layout: chips → status tabs → grid with zoomable cards. */
10
+
11
+ /* тема: применяем до первого рендера, чтобы не мигало */
12
+ const THEME_KEY = 'wf-theme';
13
+ if (localStorage.getItem(THEME_KEY) === 'dark') document.documentElement.dataset.theme = 'dark';
14
+
15
+ /* embed mode: hosted in the Qiksy Studio hub (?embed=1). Hide our own top bar early
16
+ so it never flashes before render; the hub provides the single unified header. */
17
+ const EMBED = new URLSearchParams(location.search).get('embed') === '1';
18
+ if (EMBED) document.body.classList.add('embed');
19
+
20
+ /* embed mode follows the Qiksy hub's light/dark MODE so a light page never sits
21
+ inside a dark shell. Light is the default (no data-theme attribute); "dark"
22
+ sets it. applyEmbedTheme never reloads and is reused by the live
23
+ {qiksy:"mode"} message below. Standalone theme handling (localStorage +
24
+ toggle button) is left untouched — this path only runs when EMBED. */
25
+ function applyEmbedTheme(mode) {
26
+ if (mode === 'dark') document.documentElement.dataset.theme = 'dark';
27
+ else if (mode === 'light') delete document.documentElement.dataset.theme;
28
+ }
29
+ if (EMBED) applyEmbedTheme(new URLSearchParams(location.search).get('theme'));
30
+
31
+ /* i18n: EN default, optional RU. UI preference only — kept in a module var, never
32
+ in the server state object (refresh() replaces `state` wholesale on every poll,
33
+ which would otherwise clobber it). Read the persisted choice before first paint
34
+ so <html lang> and the static strings are correct immediately. */
35
+ const LANG_KEY = 'workFinder_lang';
36
+ let lang = localStorage.getItem(LANG_KEY) === 'ru' ? 'ru' : 'en';
37
+ document.documentElement.lang = lang;
38
+ const dateLocale = () => (lang === 'ru' ? 'ru-RU' : 'en-US');
39
+
40
+ /* KEY GATE: a purely client-side onboarding token. The presence of a non-empty
41
+ Anthropic key in our OWN localStorage decides landing-vs-app — it is NOT sent to
42
+ the server or wired to any request in this build. Derive the gate flag from
43
+ presence (no second boolean) to avoid drift, and toggle body.gated before first
44
+ paint (like body.embed / the theme) so app chrome never flashes before the gate
45
+ renders. CSS keyed off body.gated forces landing-only, so the 2.5s poll cannot
46
+ clobber it (renderJobsWidget may drop the hidden attr, but the CSS !important
47
+ still hides the widget while gated). */
48
+ const KEY_STORE = 'workFinder_anthropicKey';
49
+ const getKey = () => localStorage.getItem(KEY_STORE) || '';
50
+ const hasKey = () => !!getKey().trim();
51
+ document.body.classList.toggle('gated', !hasKey());
52
+
53
+ let state = null;
54
+ let tab = 'jobs';
55
+ let statusTab = 'active';
56
+ let textFilter = '';
57
+ let profileFilter = ''; // '' = все люди
58
+ let historyFilter = null; // id записи истории поиска; null = «сейчас» (по включённым лейблам)
59
+ const filters = { geo: '', eng: '', apply: '', salary: '', fresh: '' };
60
+ let selectedVacancyId = null;
61
+ let widgetCollapsed = false;
62
+ let lastJobsFingerprint = '';
63
+
64
+ /* Plan comes from the backend's effective (license-driven) billing — no client
65
+ flag, no credits. Usage runs on the user's own Anthropic key. */
66
+ const LICENSE_API = 'https://qiksy-licenses.qiksy.workers.dev';
67
+ const buyUrl = plan => `${LICENSE_API}/buy?product=workfinder&plan=${plan}`;
68
+ const billing = () => (state && state.billing) || {};
69
+ const planId = () => billing().plan || 'free';
70
+ const isPro = () => planId() === 'pro';
71
+ const maxProfiles = () => { const m = billing().maxProfiles; return m == null ? Infinity : m; };
72
+
73
+ /* resume-builder UI state lives in module vars (not in `state`) so the 2.5s poll,
74
+ which replaces `state` wholesale, never clobbers unsaved builder input. The
75
+ overlay markup is static and refresh() never re-renders it. */
76
+ let builderOpen = false;
77
+ let builderModel = null;
78
+ let builderBusy = false;
79
+ let pendingUpgradeAction = null; // resume this after a demo upgrade in the upsell modal
80
+
81
+ /* settings.model aliases → real API ids; the builder uses a small model directly. */
82
+ const BUILDER_MODEL = 'claude-haiku-4-5';
83
+
84
+ const $ = sel => document.querySelector(sel);
85
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
86
+
87
+ /* ─────────── i18n dictionary + resolver ───────────
88
+ Every user-visible string lives here keyed by a stable id. Plural entries are
89
+ objects: EN {one, other}; RU {one, few, many}. Logic keys (status enums,
90
+ engagement values, data-* values, matchers) are NEVER translated — only the
91
+ label shown for them, resolved through t() at render time. */
92
+ const I18N = {
93
+ en: {
94
+ 'nav.jobs': 'Jobs', 'nav.leads': 'Leads', 'nav.profiles': 'Profiles', 'nav.settings': 'Settings',
95
+ 'topbar.themeToggle': 'Toggle theme',
96
+ 'search.addPositionTitle': 'Add position', 'search.addPosition': '+ position',
97
+ 'posform.titlePlaceholder': 'Custom position wording', 'posform.keywordsPlaceholder': 'Keywords',
98
+ 'engagement.opt.both': 'staff or contract', 'engagement.opt.staff': 'staff only', 'engagement.opt.contract': 'contract only',
99
+ 'common.add': 'Add', 'common.cancel': 'Cancel', 'common.save': 'Save', 'common.delete': 'Delete',
100
+ 'search.findJobs': '🔎 Find jobs',
101
+ 'search.freshnessTitle': 'The agent will only take jobs published within this period',
102
+ 'search.freshness.1': 'no older than 1 day', 'search.freshness.2': 'no older than 2 days',
103
+ 'search.freshness.3': 'no older than 3 days', 'search.freshness.5': 'no older than 5 days',
104
+ 'search.textFilterPlaceholder': 'Filter: company, role, stack…',
105
+ 'filter.geo.all': 'Geography: all', 'filter.geo.global': '🌍 Global — worldwide', 'filter.geo.remote': '🏠 Remote — any',
106
+ 'filter.geo.region': 'Remote — region (EU/EMEA…)', 'filter.geo.country': 'Remote — single country', 'filter.geo.onsite': '🏢 Office / hybrid',
107
+ 'filter.eng.all': 'Format: all', 'filter.eng.staff': 'Staff', 'filter.eng.contract': 'Contract / B2B',
108
+ 'filter.apply.all': 'Apply: all', 'filter.apply.form': 'Form on site',
109
+ 'filter.salary.all': 'Salary: all', 'filter.salary.yes': 'With a range only',
110
+ 'filter.fresh.any': 'Freshness: any', 'filter.fresh.1': 'past 1 day', 'filter.fresh.2': 'past 2 days',
111
+ 'filter.fresh.3': 'past 3 days', 'filter.fresh.5': 'past 5 days',
112
+ 'filter.reset': 'Reset',
113
+ 'filter.validateLinksTitle': 'Scan the database and hide jobs with dead links (free, no tokens)',
114
+ 'filter.validateLinks': '🧹 Check links',
115
+ 'leads.heading': 'Leads — companies with a hiring signal', 'leads.findLeads': '🔎 Find leads (funding rounds)',
116
+ 'leads.hint': 'Product companies that raised a round in the last ~60 days — they are hiring (or will be soon), even without public openings. The agent finds the founder/CTO contact, and “Pitch” writes a cold email tailored to the candidate profile.',
117
+ 'profiles.heading': 'Profiles and resumes', 'profiles.addCandidate': '+ Add candidate',
118
+ 'profiles.hint': 'A name and LinkedIn are enough: the resume imports itself, roles are picked automatically, and the search starts right away.',
119
+ 'onboard.title': 'New candidate', 'onboard.namePlaceholder': 'First and last name *',
120
+ 'onboard.linkedinPlaceholder': 'LinkedIn URL — https://www.linkedin.com/in/…',
121
+ 'onboard.rolesLabel': 'Roles — optional, they’ll be picked from the resume automatically:',
122
+ 'onboard.noLinkedin': 'No LinkedIn — paste resume as text',
123
+ 'onboard.resumePlaceholder': 'Resume in any form — the agent will tidy it up',
124
+ 'onboard.submit': '🚀 Add and find jobs',
125
+ 'settings.plan': 'Plan',
126
+ 'settings.plan.free': 'Free — 1 candidate', 'settings.plan.start': 'Start',
127
+ 'settings.plan.pro': 'Pro — 10 candidates, auto-apply, leads',
128
+ 'settings.plan.wallet': 'Wallet — pay as you go', 'settings.plan.dev': 'Dev — no limits (self-hosted instance)',
129
+ 'settings.license': 'License key', 'settings.licensePlaceholder': 'wf_… — paste the key from your purchase email',
130
+ 'settings.licenseApply': 'Apply', 'settings.buyPro': 'Get Pro — $99/yr',
131
+ 'settings.licenseNone': 'Free plan — 1 candidate. Pro ($99/yr): unlimited candidates, auto-apply, hiring leads.',
132
+ 'settings.licenseChecking': 'Checking the key…',
133
+ 'settings.licenseActive': '{plan} license active · until {until}',
134
+ 'settings.licenseInvalid': 'This key isn’t valid for Work Finder (or has expired).',
135
+ 'settings.licenseAppliedToast': '{plan} unlocked ✓', 'settings.licenseInvalidToast': 'Key not valid — check it and try again.',
136
+ 'settings.licenseRemovedToast': 'License removed — back to Free.',
137
+ 'topbar.planTitle': 'Plan {planName}. Usage runs on your own Anthropic key — no per-search limits.',
138
+ 'settings.wallet': 'Wallet', 'settings.walletTopup': '+ Top up', 'settings.markup': 'markup ×',
139
+ 'settings.markupTitle': 'Multiplier on token cost',
140
+ 'settings.model': 'Agent model', 'settings.model.haiku': 'haiku — fast and cheap',
141
+ 'settings.model.sonnet': 'sonnet — balanced (recommended)', 'settings.model.opus': 'opus — top quality',
142
+ 'settings.resultsPerSearch': 'Jobs per search',
143
+ 'settings.autoSubmit': 'Auto-submit applications — the agent submits the form itself, without your review',
144
+ 'settings.searchViaBrowser': 'Search via browser — deeper (djinni/DOU while logged in), but slower',
145
+ 'settings.excludeBoards': 'No job boards (djinni, DOU, RemoteOK…) — only direct jobs from company sites and ATS',
146
+ 'settings.searchExtra': 'Extra search preferences (salary, countries, exclusions…)',
147
+ 'settings.searchExtraPlaceholder': 'e.g.: only companies paying $4000+, no gambling',
148
+ 'widget.working': 'Agents working…',
149
+ 'status.new': 'new', 'status.prepared': 'reply ready', 'status.ready': 'awaiting send', 'status.sent': 'sent',
150
+ 'status.answered': 'replied', 'status.interview': 'interview', 'status.rejected': 'rejected', 'status.hidden': 'hidden',
151
+ 'engagement.staff': 'staff', 'engagement.contract': 'contract', 'engagement.both': 'staff/contract',
152
+ 'statustab.active': 'All', 'statustab.new': 'New', 'statustab.prepared': 'Reply ready', 'statustab.ready': 'Awaiting send',
153
+ 'statustab.sent': 'Sent', 'statustab.response': 'Replies', 'statustab.archive': 'Archive',
154
+ 'time.today': 'today', 'time.yesterday': 'yesterday',
155
+ 'time.daysAgo': { one: '{count} day ago', other: '{count} days ago' },
156
+ 'topbar.browserOn': '🌐 browser connected', 'topbar.browserOff': '🌐 browser off',
157
+ 'topbar.balance': '💰 balance: ${b}', 'topbar.walletTitle': 'Pay as you go: token cost × {markup}',
158
+ 'topbar.credits': '⚡ credits: {remaining}/{limit}',
159
+ 'topbar.creditsTitle': 'Plan {planName}. Search = 1 credit, leads = 2, auto-apply = 1. Letters are free.',
160
+ 'search.allPeople': '👥 All', 'search.searching': 'Searching… ({n}) — stop',
161
+ 'search.findJobsN': { one: '🔎 Find jobs ({count} position)', other: '🔎 Find jobs ({count} positions)' },
162
+ 'history.now': '🕐 Now — by labels',
163
+ 'card.scope.region': ' · region', 'card.scope.country': ' · country',
164
+ 'card.posted': 'posted {date}', 'card.found': 'found {ago}', 'card.foundVia': ' · found via {source}',
165
+ 'card.postedTag': '📅 posted {date}', 'card.linkDead': '✂ link dead: {reason}',
166
+ 'card.eligibility': 'Geography / right to work', 'card.whyFit': 'Why it fits', 'card.openJob': 'Open job ↗',
167
+ 'card.rewriteLetter': 'Rewrite letter', 'card.prepareApply': 'Prepare application', 'card.autoApply': '🤖 Auto-apply',
168
+ 'card.email': '📧 Email', 'card.autoApplyResult': 'Auto-apply result', 'card.coverLetter': 'Cover letter',
169
+ 'card.saveLetter': '💾 Save letter', 'card.statusLabel': 'Status:', 'card.expand': 'Expand', 'card.collapse': 'Collapse',
170
+ 'list.emptyTitle': 'Nothing here yet.', 'list.emptyHint': 'Select positions above and click “Find jobs”.',
171
+ 'leadstatus.new': 'new', 'leadstatus.ready': 'pitch ready', 'leadstatus.sent': 'sent',
172
+ 'leadstatus.answered': 'replied', 'leadstatus.archive': 'archive',
173
+ 'leads.searching': 'Finding leads…', 'leads.contact': '🔗 Contact', 'leads.source': 'source',
174
+ 'leads.alreadyHiring': '🟢 already hiring ↗', 'leads.rewritePitch': 'Rewrite pitch', 'leads.writePitch': 'Write pitch',
175
+ 'leads.pitch': 'Pitch', 'leads.empty': 'Nothing yet — click “Find leads”.',
176
+ 'profiles.namePlaceholder': 'Name', 'profiles.locationPlaceholder': 'Location, e.g. Ukraine',
177
+ 'profiles.locationTitle': 'The agent checks the right to work remotely from this specific country',
178
+ 'profiles.resume': 'Resume',
179
+ 'profiles.resumeChars': { one: '({count} character)', other: '({count} characters)' },
180
+ 'profiles.resumeEmpty': '(empty — import from LinkedIn)', 'profiles.importLinkedin': '⬇️ Import from LinkedIn',
181
+ 'onboard.noSeniority': 'don’t specify level', 'posform.customRole': 'Custom role…', 'posform.noLevel': 'no level',
182
+ 'settings.usageLine': 'Spent on your Anthropic key so far: ≈ ${cost}. No per-search limits — your Claude plan sets the pace.',
183
+ 'settings.walletStats': 'Charged to client: ${spent} · token cost: ${cost} · your margin: ${margin}',
184
+ 'widget.workingN': 'Agents working: {n}', 'widget.finished': '✅ Agents finished', 'widget.cancelTitle': 'Cancel',
185
+ 'dialog.applyAuto': 'The agent will fill the form AND SUBMIT the application. Continue?',
186
+ 'dialog.applyManual': 'The agent will fill the application form in the browser and stop before submitting. Continue?',
187
+ 'dialog.delVacancy': 'Delete this job from the database?',
188
+ 'dialog.importLinkedin': 'LinkedIn profile URL (a logged-in browser is required):',
189
+ 'dialog.delProfile': 'Delete profile?', 'dialog.enterRole': 'Enter your role name',
190
+ 'dialog.topup': 'Top-up amount, USD (demo — no real payment):',
191
+ 'dialog.candidateAdded': 'Candidate added. The resume is being imported; roles and search will start automatically — watch the agents widget.',
192
+ 'landing.badge': 'AI job-hunt agents · every IT role',
193
+ 'landing.heroTitle': 'Your job hunt, on autopilot.',
194
+ 'landing.heroSub': 'Work Finder runs Claude agents that scan the whole market, match roles to your profile and prepare tailored applications — for every IT profession: developers, QA, project managers, designers, DevOps — while you get on with your day.',
195
+ 'landing.heroCta': 'Connect your key & start',
196
+ 'landing.heroNote': 'Bring your own Anthropic key · runs locally in your browser',
197
+ 'landing.pipeScan': 'Scanning companies that are hiring',
198
+ 'landing.pipeMatch': 'Matching roles to the profile',
199
+ 'landing.pipeDraft': '12 roles found · 4 letters drafted',
200
+ 'landing.howTitle': 'How it works',
201
+ 'landing.step1Title': 'Add a candidate',
202
+ 'landing.step1Text': 'Drop in a name and a LinkedIn — the resume imports itself and roles are picked automatically.',
203
+ 'landing.step2Title': 'Agents scan the market',
204
+ 'landing.step2Text': 'Claude agents comb company career pages, ATS systems and job boards for fresh, matching openings.',
205
+ 'landing.step2Mock': 'Scanning 1,240 sources…',
206
+ 'landing.step3Title': 'Review matched roles',
207
+ 'landing.step3Text': 'Every hit lands as a card with why-it-fits notes, salary, location and freshness — filter to taste.',
208
+ 'landing.step4Title': 'Apply on autopilot',
209
+ 'landing.step4Text': 'The agent writes a tailored cover letter and can fill and submit the application form for you.',
210
+ 'landing.roleBackend': 'Senior Backend Engineer',
211
+ 'landing.roleDevops': 'DevOps Engineer',
212
+ 'landing.roleDesigner': 'Product Designer',
213
+ 'landing.roleData': 'Data Engineer',
214
+ 'landing.searchTitle': 'How the search works',
215
+ 'landing.searchLead': 'Work Finder does not stop at one job board. Agents sweep the entire market and fold everything into a single, filterable list.',
216
+ 'landing.searchCol1Title': 'Every company that is hiring',
217
+ 'landing.searchCol1Text': 'Career pages and ATS systems (Greenhouse, Lever, Ashby and the like) — direct openings straight from the source, including roles that never reach the big boards.',
218
+ 'landing.searchCol2Title': 'Independent boards & aggregators',
219
+ 'landing.searchCol2Text': 'Public job boards and aggregators are scanned in parallel, then de-duplicated against the direct hits so you never see the same role twice.',
220
+ 'landing.searchFiltersTitle': 'Filter everything by',
221
+ 'landing.filterCompany': 'Company',
222
+ 'landing.filterGeo': 'Geography',
223
+ 'landing.filterFormat': 'Format',
224
+ 'landing.filterFreshness': 'Freshness',
225
+ 'landing.benefitsTitle': 'Why Work Finder',
226
+ 'landing.benefit1Title': 'Autopilot agents',
227
+ 'landing.benefit1Text': 'Kick off a search and walk away — agents keep working and results drop in as they are confirmed.',
228
+ 'landing.benefit2Title': 'Tailored applications',
229
+ 'landing.benefit2Text': 'Cover letters are written per role from the candidate profile — no copy-paste, no generic templates.',
230
+ 'landing.benefit3Title': 'One place for everything',
231
+ 'landing.benefit3Text': 'Candidates, roles, letters and application status live together in a single pipeline.',
232
+ 'landing.benefit4Title': 'Your key, your data',
233
+ 'landing.benefit4Text': 'Runs on your own Anthropic key and stays on your machine — nothing is sent to us.',
234
+ 'landing.connectTitle': 'Connect your Anthropic API key',
235
+ 'landing.connectLead': 'Work Finder runs on your own Anthropic key, so you pay Anthropic directly for exactly what the agents use.',
236
+ 'landing.connectStep1': 'Sign in to the Anthropic Console.',
237
+ 'landing.connectStep2': 'Open API Keys and click Create key.',
238
+ 'landing.connectStep3': 'Copy the key (it starts with sk-ant-) and paste it below.',
239
+ 'landing.consoleLink': 'Open Anthropic Console ↗',
240
+ 'landing.keyPlaceholder': 'sk-ant-…',
241
+ 'landing.connectBtn': 'Connect',
242
+ 'landing.keyNote': 'The key is stored only in this browser (localStorage) and is never sent to our servers.',
243
+ 'landing.keyInvalid': 'That does not look like an Anthropic key — it should start with “sk-”.',
244
+ 'landing.connected': 'Key connected — Work Finder is ready.',
245
+ 'settings.apiKey': 'Anthropic API key',
246
+ 'settings.keyPlaceholder': 'Paste a new key to replace (sk-ant-…)',
247
+ 'settings.disconnect': 'Disconnect',
248
+ 'settings.keyConnected': 'Connected — agents run on your key. Paste a new one to replace it.',
249
+ 'settings.keyMissing': 'No key connected.',
250
+ 'settings.keySaved': 'Key updated.',
251
+ 'settings.keyRemoved': 'Key disconnected.',
252
+ /* slice 2 — linear onboarding */
253
+ 'jobsonb.title': 'Create your profile to start',
254
+ 'jobsonb.sub': 'Add a candidate and the agents start scanning the market right away — one step, then the results roll in.',
255
+ 'jobsonb.namePlaceholder': 'First and last name *',
256
+ 'jobsonb.linkedinPlaceholder': 'LinkedIn URL — https://www.linkedin.com/in/…',
257
+ 'jobsonb.submit': '🚀 Add and find jobs',
258
+ 'jobsonb.builderLink': '🧩 Build a full resume instead',
259
+ 'jobsonb.needLinkedin': 'Add a LinkedIn URL here, or use the resume builder to create a candidate from scratch.',
260
+ 'jobsonb.badLinkedin': 'That LinkedIn URL should look like https://www.linkedin.com/in/…',
261
+ 'onboard.needSource': 'Add a LinkedIn URL or paste a resume so the agents have something to work with.',
262
+ 'common.createFailed': 'Could not add the candidate — check the details and try again.',
263
+ /* slice 2 — tiers / upsell */
264
+ 'plan.free': 'Free', 'plan.pro': 'Pro',
265
+ 'upsell.title': 'Manage more than one candidate with Pro',
266
+ 'upsell.sub': 'Free covers a single candidate — perfect for your own job hunt. Recruiters and headhunters run several candidates at once, each with its own searches and pipeline.',
267
+ 'upsell.price': '$99/yr', 'upsell.priceNote': 'billed yearly · secure card payment',
268
+ 'upsell.benefit1': 'Unlimited candidate profiles',
269
+ 'upsell.benefit2': 'A separate search pipeline for each person',
270
+ 'upsell.benefit3': 'Auto-apply and funding-round leads',
271
+ 'upsell.upgrade': 'Upgrade to Pro', 'upsell.later': 'Maybe later',
272
+ 'upsell.upgraded': 'You are on Pro — add as many candidates as you like.',
273
+ /* slice 2 — resume builder */
274
+ 'builder.open': '🧩 Resume builder', 'builder.openEdit': 'Build / edit resume',
275
+ 'builder.newTitle': 'Resume builder', 'builder.editTitle': 'Edit resume',
276
+ 'builder.contact': 'Contact', 'builder.headlineLabel': 'Headline & summary',
277
+ 'builder.experience': 'Experience', 'builder.skillsLabel': 'Skills', 'builder.education': 'Education',
278
+ 'builder.secSummary': 'Summary',
279
+ 'builder.import': 'Import an existing resume', 'builder.preview': 'Preview',
280
+ 'builder.previewEmpty': 'Your formatted resume appears here as you fill in the fields.',
281
+ 'builder.namePh': 'First and last name *', 'builder.locationPh': 'Location, e.g. Ukraine',
282
+ 'builder.emailPh': 'Email', 'builder.linkedinPh': 'LinkedIn URL',
283
+ 'builder.headlinePh': 'Headline, e.g. Senior Backend Engineer', 'builder.summaryPh': 'A short professional summary',
284
+ 'builder.expRolePh': 'Role', 'builder.expCompanyPh': 'Company', 'builder.expDatesPh': 'Dates, e.g. 2021–2024',
285
+ 'builder.expBulletsPh': 'Key achievements — one per line',
286
+ 'builder.skillsPh': 'Skills, comma-separated',
287
+ 'builder.eduDegreePh': 'Degree', 'builder.eduSchoolPh': 'School', 'builder.eduDatesPh': 'Years',
288
+ 'builder.importPh': 'Paste an existing resume here, then Parse with Claude — or keep it as-is.',
289
+ 'builder.addExp': '+ Add role', 'builder.addEdu': '+ Add education', 'builder.removeRow': 'Remove',
290
+ 'builder.genSummary': '✨ Generate summary', 'builder.polishSummary': '✨ Polish',
291
+ 'builder.parseImport': '✨ Parse with Claude', 'builder.importFile': 'Load .txt file',
292
+ 'builder.save': '💾 Save candidate', 'builder.saveEdit': '💾 Save resume', 'builder.cancel': 'Cancel',
293
+ 'builder.working': 'Working…',
294
+ 'builder.saved': 'Resume saved to the candidate.',
295
+ 'builder.summaryDone': 'Summary generated — edit it if you like.',
296
+ 'builder.polishEmpty': 'Write a first draft of the summary, then polish it.',
297
+ 'builder.parseEmpty': 'Paste a resume above first.',
298
+ 'builder.parsed': 'Resume parsed into the fields — review before saving.',
299
+ 'builder.parseFailed': 'Could not parse automatically — kept the text in the summary so nothing is lost.',
300
+ 'builder.needName': 'Add a name before saving.',
301
+ 'builder.claudeError': 'Claude could not be reached. You can keep filling the resume manually.',
302
+ 'builder.claudeNoKey': 'No API key connected — add one in Settings to use Claude here.',
303
+ 'builder.fileError': 'Could not read that file.',
304
+ 'builder.fileTooBig': 'That file is too large — paste the text instead.',
305
+ 'builder.fileLoaded': 'File loaded — Parse with Claude or edit by hand.',
306
+ },
307
+ ru: {
308
+ 'nav.jobs': 'Вакансии', 'nav.leads': 'Лиды', 'nav.profiles': 'Профили', 'nav.settings': 'Настройки',
309
+ 'topbar.themeToggle': 'Переключить тему',
310
+ 'search.addPositionTitle': 'Добавить позицию', 'search.addPosition': '+ позиция',
311
+ 'posform.titlePlaceholder': 'Своя формулировка позиции', 'posform.keywordsPlaceholder': 'Ключевые слова',
312
+ 'engagement.opt.both': 'штат или контракт', 'engagement.opt.staff': 'только штат', 'engagement.opt.contract': 'только контракт',
313
+ 'common.add': 'Добавить', 'common.cancel': 'Отмена', 'common.save': 'Сохранить', 'common.delete': 'Удалить',
314
+ 'search.findJobs': '🔎 Найти вакансии',
315
+ 'search.freshnessTitle': 'Агент возьмёт только вакансии, опубликованные за этот срок',
316
+ 'search.freshness.1': 'не старше 1 дня', 'search.freshness.2': 'не старше 2 дней',
317
+ 'search.freshness.3': 'не старше 3 дней', 'search.freshness.5': 'не старше 5 дней',
318
+ 'search.textFilterPlaceholder': 'Фильтр: компания, должность, стек…',
319
+ 'filter.geo.all': 'География: все', 'filter.geo.global': '🌍 Global — весь мир', 'filter.geo.remote': '🏠 Remote — любой',
320
+ 'filter.geo.region': 'Remote — регион (EU/EMEA…)', 'filter.geo.country': 'Remote — одна страна', 'filter.geo.onsite': '🏢 Офис / гибрид',
321
+ 'filter.eng.all': 'Формат: все', 'filter.eng.staff': 'Штат', 'filter.eng.contract': 'Контракт / B2B',
322
+ 'filter.apply.all': 'Отклик: все', 'filter.apply.form': 'Форма на сайте',
323
+ 'filter.salary.all': 'Зарплата: все', 'filter.salary.yes': 'Только с вилкой',
324
+ 'filter.fresh.any': 'Свежесть: любая', 'filter.fresh.1': 'за 1 день', 'filter.fresh.2': 'за 2 дня',
325
+ 'filter.fresh.3': 'за 3 дня', 'filter.fresh.5': 'за 5 дней',
326
+ 'filter.reset': 'Сбросить',
327
+ 'filter.validateLinksTitle': 'Пройтись по базе и скрыть вакансии с мёртвыми ссылками (бесплатно, без токенов)',
328
+ 'filter.validateLinks': '🧹 Проверить ссылки',
329
+ 'leads.heading': 'Лиды — компании с сигналом найма', 'leads.findLeads': '🔎 Найти лиды (инвестраунды)',
330
+ 'leads.hint': 'Продуктовые компании, поднявшие раунд за последние ~60 дней — они нанимают (или скоро будут), даже без публичных вакансий. Агент находит контакт фаундера/CTO, а «Питч» пишет холодное письмо под профиль кандидата.',
331
+ 'profiles.heading': 'Профили и резюме', 'profiles.addCandidate': '+ Добавить кандидата',
332
+ 'profiles.hint': 'Достаточно имени и LinkedIn: резюме импортируется само, роли подберутся автоматически, поиск запустится сразу.',
333
+ 'onboard.title': 'Новый кандидат', 'onboard.namePlaceholder': 'Имя и фамилия *',
334
+ 'onboard.linkedinPlaceholder': 'LinkedIn URL — https://www.linkedin.com/in/…',
335
+ 'onboard.rolesLabel': 'Роли — можно не выбирать, подберутся из резюме автоматически:',
336
+ 'onboard.noLinkedin': 'Нет LinkedIn — вставить резюме текстом',
337
+ 'onboard.resumePlaceholder': 'Резюме в любом виде — агент сам приведёт в порядок',
338
+ 'onboard.submit': '🚀 Добавить и найти вакансии',
339
+ 'settings.plan': 'Тариф',
340
+ 'settings.plan.free': 'Free — 1 кандидат', 'settings.plan.start': 'Start',
341
+ 'settings.plan.pro': 'Pro — 10 кандидатов, автозаявки, лиды',
342
+ 'settings.plan.wallet': 'Кошелёк — оплата по факту', 'settings.plan.dev': 'Dev — без лимитов (свой инстанс)',
343
+ 'settings.license': 'Ключ лицензии', 'settings.licensePlaceholder': 'wf_… — вставьте ключ из письма о покупке',
344
+ 'settings.licenseApply': 'Применить', 'settings.buyPro': 'Купить Pro — $99/год',
345
+ 'settings.licenseNone': 'Бесплатный тариф — 1 кандидат. Pro ($99/год): кандидаты без лимита, автозаявки, лиды.',
346
+ 'settings.licenseChecking': 'Проверяем ключ…',
347
+ 'settings.licenseActive': 'Лицензия {plan} активна · до {until}',
348
+ 'settings.licenseInvalid': 'Ключ недействителен для Work Finder (или истёк).',
349
+ 'settings.licenseAppliedToast': '{plan} подключён ✓', 'settings.licenseInvalidToast': 'Ключ недействителен — проверьте и попробуйте снова.',
350
+ 'settings.licenseRemovedToast': 'Лицензия убрана — снова Free.',
351
+ 'topbar.planTitle': 'Тариф {planName}. Запросы идут на вашем ключе Anthropic — без лимитов на поиск.',
352
+ 'settings.wallet': 'Кошелёк', 'settings.walletTopup': '+ Пополнить', 'settings.markup': 'наценка ×',
353
+ 'settings.markupTitle': 'Множитель к себестоимости токенов',
354
+ 'settings.model': 'Модель агентов', 'settings.model.haiku': 'haiku — быстро и дёшево',
355
+ 'settings.model.sonnet': 'sonnet — баланс (рекомендуется)', 'settings.model.opus': 'opus — максимум качества',
356
+ 'settings.resultsPerSearch': 'Вакансий за один поиск',
357
+ 'settings.autoSubmit': 'Автоотправка заявок — агент отправляет форму сам, без вашей проверки',
358
+ 'settings.searchViaBrowser': 'Поиск через браузер — глубже (djinni/DOU под логином), но медленнее',
359
+ 'settings.excludeBoards': 'Без джоб-бордов (djinni, DOU, RemoteOK…) — только прямые вакансии с сайтов компаний и ATS',
360
+ 'settings.searchExtra': 'Дополнительные пожелания к поиску (зарплата, страны, исключения…)',
361
+ 'settings.searchExtraPlaceholder': 'напр.: только компании с зарплатой от $4000, не предлагать гемблинг',
362
+ 'widget.working': 'Агенты работают…',
363
+ 'status.new': 'новая', 'status.prepared': 'отклик готов', 'status.ready': 'ждёт отправки', 'status.sent': 'отправлено',
364
+ 'status.answered': 'ответили', 'status.interview': 'интервью', 'status.rejected': 'отказ', 'status.hidden': 'скрыто',
365
+ 'engagement.staff': 'штат', 'engagement.contract': 'контракт', 'engagement.both': 'штат/контракт',
366
+ 'statustab.active': 'Все', 'statustab.new': 'Новые', 'statustab.prepared': 'Отклик готов', 'statustab.ready': 'Ждёт отправки',
367
+ 'statustab.sent': 'Отправлено', 'statustab.response': 'Ответы', 'statustab.archive': 'Архив',
368
+ 'time.today': 'сегодня', 'time.yesterday': 'вчера',
369
+ 'time.daysAgo': { one: '{count} день назад', few: '{count} дня назад', many: '{count} дней назад' },
370
+ 'topbar.browserOn': '🌐 браузер подключен', 'topbar.browserOff': '🌐 браузер выключен',
371
+ 'topbar.balance': '💰 баланс: ${b}', 'topbar.walletTitle': 'Оплата по факту: себестоимость токенов × {markup}',
372
+ 'topbar.credits': '⚡ кредиты: {remaining}/{limit}',
373
+ 'topbar.creditsTitle': 'Тариф {planName}. Поиск = 1 кредит, лиды = 2, автозаявка = 1. Письма бесплатны.',
374
+ 'search.allPeople': '👥 Все', 'search.searching': 'Ищу… ({n}) — остановить',
375
+ 'search.findJobsN': { one: '🔎 Найти вакансии ({count} позиция)', few: '🔎 Найти вакансии ({count} позиции)', many: '🔎 Найти вакансии ({count} позиций)' },
376
+ 'history.now': '🕐 Сейчас — по лейблам',
377
+ 'card.scope.region': ' · регион', 'card.scope.country': ' · страна',
378
+ 'card.posted': 'опубл. {date}', 'card.found': 'найдено {ago}', 'card.foundVia': ' · найдено через {source}',
379
+ 'card.postedTag': '📅 опубл. {date}', 'card.linkDead': '✂ ссылка мертва: {reason}',
380
+ 'card.eligibility': 'География / право работать', 'card.whyFit': 'Почему подходит', 'card.openJob': 'Открыть вакансию ↗',
381
+ 'card.rewriteLetter': 'Письмо заново', 'card.prepareApply': 'Подготовить отклик', 'card.autoApply': '🤖 Автозаявка',
382
+ 'card.email': '📧 Написать', 'card.autoApplyResult': 'Результат автозаявки', 'card.coverLetter': 'Сопроводительное письмо',
383
+ 'card.saveLetter': '💾 Сохранить письмо', 'card.statusLabel': 'Статус:', 'card.expand': 'Развернуть', 'card.collapse': 'Свернуть',
384
+ 'list.emptyTitle': 'Здесь пока пусто.', 'list.emptyHint': 'Выберите позиции выше и нажмите «Найти вакансии».',
385
+ 'leadstatus.new': 'новый', 'leadstatus.ready': 'питч готов', 'leadstatus.sent': 'отправлено',
386
+ 'leadstatus.answered': 'ответили', 'leadstatus.archive': 'архив',
387
+ 'leads.searching': 'Ищу лиды…', 'leads.contact': '🔗 Контакт', 'leads.source': 'источник',
388
+ 'leads.alreadyHiring': '🟢 уже нанимают ↗', 'leads.rewritePitch': 'Питч заново', 'leads.writePitch': 'Написать питч',
389
+ 'leads.pitch': 'Питч', 'leads.empty': 'Пока пусто — нажмите «Найти лиды».',
390
+ 'profiles.namePlaceholder': 'Имя', 'profiles.locationPlaceholder': 'Локация, напр. Ukraine',
391
+ 'profiles.locationTitle': 'Агент проверяет право работать удалённо именно из этой страны',
392
+ 'profiles.resume': 'Резюме',
393
+ 'profiles.resumeChars': { one: '({count} символ)', few: '({count} символа)', many: '({count} символов)' },
394
+ 'profiles.resumeEmpty': '(пусто — импортируйте из LinkedIn)', 'profiles.importLinkedin': '⬇️ Импорт из LinkedIn',
395
+ 'onboard.noSeniority': 'уровень не указывать', 'posform.customRole': 'Своя роль…', 'posform.noLevel': 'без уровня',
396
+ 'settings.usageLine': 'Потрачено на вашем ключе Anthropic: ≈ ${cost}. Лимитов на поиск нет — темп задаёт ваш тариф Claude.',
397
+ 'settings.walletStats': 'Списано с клиента: ${spent} · себестоимость токенов: ${cost} · ваша маржа: ${margin}',
398
+ 'widget.workingN': 'Агенты работают: {n}', 'widget.finished': '✅ Агенты закончили', 'widget.cancelTitle': 'Отменить',
399
+ 'dialog.applyAuto': 'Агент заполнит форму И ОТПРАВИТ заявку. Продолжить?',
400
+ 'dialog.applyManual': 'Агент заполнит форму отклика в браузере и остановится перед отправкой. Продолжить?',
401
+ 'dialog.delVacancy': 'Удалить вакансию из базы?',
402
+ 'dialog.importLinkedin': 'URL профиля LinkedIn (нужен залогиненный браузер):',
403
+ 'dialog.delProfile': 'Удалить профиль?', 'dialog.enterRole': 'Впишите название своей роли',
404
+ 'dialog.topup': 'Сумма пополнения, USD (демо — без реальной оплаты):',
405
+ 'dialog.candidateAdded': 'Кандидат добавлен. Резюме импортируется, роли и поиск запустятся автоматически — следите за виджетом агентов.',
406
+ 'landing.badge': 'AI-агенты для поиска работы · любая IT-профессия',
407
+ 'landing.heroTitle': 'Поиск работы — на автопилоте.',
408
+ 'landing.heroSub': 'Work Finder запускает агентов Claude: они сканируют весь рынок, подбирают вакансии под ваш профиль и готовят персональные отклики — для любой профессии в IT: разработка, QA, проджект-менеджмент, дизайн, DevOps — пока вы занимаетесь своими делами.',
409
+ 'landing.heroCta': 'Подключить ключ и начать',
410
+ 'landing.heroNote': 'Ваш собственный ключ Anthropic · всё работает локально в браузере',
411
+ 'landing.pipeScan': 'Сканирую компании, которые нанимают',
412
+ 'landing.pipeMatch': 'Подбираю вакансии под профиль',
413
+ 'landing.pipeDraft': 'Найдено 12 вакансий · 4 письма готовы',
414
+ 'landing.howTitle': 'Как это работает',
415
+ 'landing.step1Title': 'Добавьте кандидата',
416
+ 'landing.step1Text': 'Достаточно имени и LinkedIn — резюме импортируется само, роли подбираются автоматически.',
417
+ 'landing.step2Title': 'Агенты сканируют рынок',
418
+ 'landing.step2Text': 'Агенты Claude проходят по карьерным страницам компаний, ATS-системам и джоб-бордам в поиске свежих подходящих вакансий.',
419
+ 'landing.step2Mock': 'Сканирую 1 240 источников…',
420
+ 'landing.step3Title': 'Смотрите подобранные вакансии',
421
+ 'landing.step3Text': 'Каждое совпадение приходит карточкой: почему подходит, зарплата, локация и свежесть — фильтруйте как удобно.',
422
+ 'landing.step4Title': 'Откликайтесь на автопилоте',
423
+ 'landing.step4Text': 'Агент пишет персональное сопроводительное письмо и может сам заполнить и отправить форму отклика.',
424
+ 'landing.roleBackend': 'Senior Backend-инженер',
425
+ 'landing.roleDevops': 'DevOps-инженер',
426
+ 'landing.roleDesigner': 'Продуктовый дизайнер',
427
+ 'landing.roleData': 'Data-инженер',
428
+ 'landing.searchTitle': 'Как устроен поиск',
429
+ 'landing.searchLead': 'Work Finder не ограничивается одним джоб-бордом. Агенты проходят по всему рынку и сводят всё в один список с фильтрами.',
430
+ 'landing.searchCol1Title': 'Все компании, которые нанимают',
431
+ 'landing.searchCol1Text': 'Карьерные страницы и ATS-системы (Greenhouse, Lever, Ashby и подобные) — прямые вакансии из первоисточника, включая те, что не попадают на крупные борды.',
432
+ 'landing.searchCol2Title': 'Независимые борды и агрегаторы',
433
+ 'landing.searchCol2Text': 'Публичные джоб-борды и агрегаторы сканируются параллельно и очищаются от дублей с прямыми вакансиями — одну и ту же роль вы не увидите дважды.',
434
+ 'landing.searchFiltersTitle': 'Фильтруйте всё по',
435
+ 'landing.filterCompany': 'Компании',
436
+ 'landing.filterGeo': 'Географии',
437
+ 'landing.filterFormat': 'Формату',
438
+ 'landing.filterFreshness': 'Свежести',
439
+ 'landing.benefitsTitle': 'Почему Work Finder',
440
+ 'landing.benefit1Title': 'Агенты на автопилоте',
441
+ 'landing.benefit1Text': 'Запустите поиск и занимайтесь своими делами — агенты продолжают работать, а результаты появляются по мере подтверждения.',
442
+ 'landing.benefit2Title': 'Персональные отклики',
443
+ 'landing.benefit2Text': 'Сопроводительные письма пишутся под каждую вакансию на основе профиля кандидата — без копипаста и шаблонов.',
444
+ 'landing.benefit3Title': 'Всё в одном месте',
445
+ 'landing.benefit3Text': 'Кандидаты, вакансии, письма и статусы откликов живут в единой воронке.',
446
+ 'landing.benefit4Title': 'Ваш ключ, ваши данные',
447
+ 'landing.benefit4Text': 'Работает на вашем ключе Anthropic и остаётся на вашей машине — мы ничего не получаем.',
448
+ 'landing.connectTitle': 'Подключите свой API-ключ Anthropic',
449
+ 'landing.connectLead': 'Work Finder работает на вашем ключе Anthropic — вы платите Anthropic напрямую ровно за то, что используют агенты.',
450
+ 'landing.connectStep1': 'Войдите в Anthropic Console.',
451
+ 'landing.connectStep2': 'Откройте раздел API Keys и нажмите Create key.',
452
+ 'landing.connectStep3': 'Скопируйте ключ (он начинается с sk-ant-) и вставьте ниже.',
453
+ 'landing.consoleLink': 'Открыть Anthropic Console ↗',
454
+ 'landing.keyPlaceholder': 'sk-ant-…',
455
+ 'landing.connectBtn': 'Подключить',
456
+ 'landing.keyNote': 'Ключ хранится только в этом браузере (localStorage) и никогда не отправляется на наши серверы.',
457
+ 'landing.keyInvalid': 'Это не похоже на ключ Anthropic — он должен начинаться с «sk-».',
458
+ 'landing.connected': 'Ключ подключён — Work Finder готов к работе.',
459
+ 'settings.apiKey': 'API-ключ Anthropic',
460
+ 'settings.keyPlaceholder': 'Вставьте новый ключ для замены (sk-ant-…)',
461
+ 'settings.disconnect': 'Отключить',
462
+ 'settings.keyConnected': 'Подключён — агенты работают на вашем ключе. Вставьте новый, чтобы заменить.',
463
+ 'settings.keyMissing': 'Ключ не подключён.',
464
+ 'settings.keySaved': 'Ключ обновлён.',
465
+ 'settings.keyRemoved': 'Ключ отключён.',
466
+ /* slice 2 — linear onboarding */
467
+ 'jobsonb.title': 'Создайте профиль, чтобы начать',
468
+ 'jobsonb.sub': 'Добавьте кандидата — агенты сразу начнут сканировать рынок. Один шаг, и появятся результаты.',
469
+ 'jobsonb.namePlaceholder': 'Имя и фамилия *',
470
+ 'jobsonb.linkedinPlaceholder': 'LinkedIn URL — https://www.linkedin.com/in/…',
471
+ 'jobsonb.submit': '🚀 Добавить и найти вакансии',
472
+ 'jobsonb.builderLink': '🧩 Собрать полное резюме',
473
+ 'jobsonb.needLinkedin': 'Укажите LinkedIn URL здесь или соберите резюме в конструкторе с нуля.',
474
+ 'jobsonb.badLinkedin': 'LinkedIn URL должен быть вида https://www.linkedin.com/in/…',
475
+ 'onboard.needSource': 'Укажите LinkedIn URL или вставьте резюме — агентам нужно с чего начать.',
476
+ 'common.createFailed': 'Не удалось добавить кандидата — проверьте данные и попробуйте снова.',
477
+ /* slice 2 — tiers / upsell */
478
+ 'plan.free': 'Free', 'plan.pro': 'Pro',
479
+ 'upsell.title': 'Ведите несколько кандидатов на тарифе Pro',
480
+ 'upsell.sub': 'Free рассчитан на одного кандидата — идеально для собственного поиска работы. Рекрутеры и хедхантеры ведут сразу несколько кандидатов, у каждого свои поиски и воронка.',
481
+ 'upsell.price': '$99/год', 'upsell.priceNote': 'оплата за год · безопасная оплата картой',
482
+ 'upsell.benefit1': 'Неограниченное число профилей кандидатов',
483
+ 'upsell.benefit2': 'Отдельная воронка поиска для каждого',
484
+ 'upsell.benefit3': 'Автозаявки и лиды по инвестраундам',
485
+ 'upsell.upgrade': 'Перейти на Pro', 'upsell.later': 'Может быть, позже',
486
+ 'upsell.upgraded': 'Вы на Pro — добавляйте сколько угодно кандидатов.',
487
+ /* slice 2 — resume builder */
488
+ 'builder.open': '🧩 Конструктор резюме', 'builder.openEdit': 'Собрать / изменить резюме',
489
+ 'builder.newTitle': 'Конструктор резюме', 'builder.editTitle': 'Изменить резюме',
490
+ 'builder.contact': 'Контакты', 'builder.headlineLabel': 'Заголовок и о себе',
491
+ 'builder.experience': 'Опыт', 'builder.skillsLabel': 'Навыки', 'builder.education': 'Образование',
492
+ 'builder.secSummary': 'О себе',
493
+ 'builder.import': 'Импорт готового резюме', 'builder.preview': 'Предпросмотр',
494
+ 'builder.previewEmpty': 'Здесь появится оформленное резюме по мере заполнения полей.',
495
+ 'builder.namePh': 'Имя и фамилия *', 'builder.locationPh': 'Локация, напр. Ukraine',
496
+ 'builder.emailPh': 'Email', 'builder.linkedinPh': 'LinkedIn URL',
497
+ 'builder.headlinePh': 'Заголовок, напр. Senior Backend-инженер', 'builder.summaryPh': 'Короткое профессиональное саммари',
498
+ 'builder.expRolePh': 'Должность', 'builder.expCompanyPh': 'Компания', 'builder.expDatesPh': 'Период, напр. 2021–2024',
499
+ 'builder.expBulletsPh': 'Ключевые достижения — по одному в строке',
500
+ 'builder.skillsPh': 'Навыки через запятую',
501
+ 'builder.eduDegreePh': 'Степень', 'builder.eduSchoolPh': 'Учебное заведение', 'builder.eduDatesPh': 'Годы',
502
+ 'builder.importPh': 'Вставьте готовое резюме сюда, затем «Разобрать через Claude» — или оставьте как есть.',
503
+ 'builder.addExp': '+ Добавить место работы', 'builder.addEdu': '+ Добавить образование', 'builder.removeRow': 'Удалить',
504
+ 'builder.genSummary': '✨ Сгенерировать саммари', 'builder.polishSummary': '✨ Отполировать',
505
+ 'builder.parseImport': '✨ Разобрать через Claude', 'builder.importFile': 'Загрузить .txt',
506
+ 'builder.save': '💾 Сохранить кандидата', 'builder.saveEdit': '💾 Сохранить резюме', 'builder.cancel': 'Отмена',
507
+ 'builder.working': 'Работаю…',
508
+ 'builder.saved': 'Резюме сохранено в профиль кандидата.',
509
+ 'builder.summaryDone': 'Саммари готово — при желании отредактируйте.',
510
+ 'builder.polishEmpty': 'Сначала напишите черновик саммари, потом отполируйте.',
511
+ 'builder.parseEmpty': 'Сначала вставьте резюме выше.',
512
+ 'builder.parsed': 'Резюме разобрано по полям — проверьте перед сохранением.',
513
+ 'builder.parseFailed': 'Не удалось разобрать автоматически — текст сохранён в «О себе», чтобы ничего не потерять.',
514
+ 'builder.needName': 'Перед сохранением укажите имя.',
515
+ 'builder.claudeError': 'Не удалось обратиться к Claude. Можно продолжать заполнять резюме вручную.',
516
+ 'builder.claudeNoKey': 'API-ключ не подключён — добавьте его в Настройках, чтобы пользоваться Claude здесь.',
517
+ 'builder.fileError': 'Не удалось прочитать файл.',
518
+ 'builder.fileTooBig': 'Файл слишком большой — вставьте текст вручную.',
519
+ 'builder.fileLoaded': 'Файл загружен — разберите через Claude или отредактируйте вручную.',
520
+ },
521
+ };
522
+
523
+ /* CLDR-ish plural category: EN one/other, RU one/few/many. */
524
+ function pluralForm(l, n) {
525
+ n = Math.abs(Number(n) || 0);
526
+ if (l === 'ru') {
527
+ const m10 = n % 10, m100 = n % 100;
528
+ if (m10 === 1 && m100 !== 11) return 'one';
529
+ if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return 'few';
530
+ return 'many';
531
+ }
532
+ return n === 1 ? 'one' : 'other';
533
+ }
534
+
535
+ /* t(key, params): looks up the current language (falls back to EN, then the key
536
+ itself), picks a plural form when the entry is an object (driver = params.count),
537
+ then fills {token} placeholders from params. */
538
+ function t(key, params = {}) {
539
+ let entry = (I18N[lang] && I18N[lang][key]);
540
+ if (entry == null) entry = I18N.en[key];
541
+ if (entry == null) return key;
542
+ if (typeof entry === 'object') {
543
+ const form = pluralForm(lang, params.count);
544
+ entry = entry[form] != null ? entry[form] : (entry.other != null ? entry.other : entry.many);
545
+ }
546
+ return String(entry).replace(/\{(\w+)\}/g, (_, k) => (params[k] != null ? params[k] : ''));
547
+ }
548
+
549
+ /* Fill static markup: textContent for [data-i18n], attributes for the -title/-ph
550
+ variants. Runs on load and on every language switch. */
551
+ function applyStaticI18n(root = document) {
552
+ root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
553
+ root.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(el.dataset.i18nTitle); });
554
+ root.querySelectorAll('[data-i18n-ph]').forEach(el => { el.placeholder = t(el.dataset.i18nPh); });
555
+ }
556
+
557
+ /* Label helpers over the fixed logic keys — the keys stay data/enum values,
558
+ only the returned display text is localized. Non-label keys return ''. */
559
+ const STATUS_KEYS = ['new', 'prepared', 'ready', 'sent', 'answered', 'interview', 'rejected', 'hidden'];
560
+ const statusLabel = k => t('status.' + k);
561
+ const engLabel = k => (['staff', 'contract', 'both'].includes(k) ? t('engagement.' + k) : '');
562
+ const statustabLabel = k => t('statustab.' + k);
563
+ const LEAD_STATUS_KEYS = ['new', 'ready', 'sent', 'answered', 'archive'];
564
+ const leadStatusLabel = k => (LEAD_STATUS_KEYS.includes(k) ? t('leadstatus.' + k) : '');
565
+ const STATUS_TABS = [
566
+ { key: 'active', match: v => !['hidden', 'rejected'].includes(v.status) },
567
+ { key: 'new', match: v => v.status === 'new' },
568
+ { key: 'prepared', match: v => v.status === 'prepared' },
569
+ { key: 'ready', match: v => v.status === 'ready' },
570
+ { key: 'sent', match: v => v.status === 'sent' },
571
+ { key: 'response', match: v => ['answered', 'interview'].includes(v.status) },
572
+ { key: 'archive', match: v => ['hidden', 'rejected'].includes(v.status) },
573
+ ];
574
+
575
+ async function api(path, opts = {}) {
576
+ const res = await fetch(path, {
577
+ headers: { 'Content-Type': 'application/json' },
578
+ ...opts,
579
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
580
+ });
581
+ const data = await res.json().catch(() => ({}));
582
+ if (!res.ok) { alert(data.error || res.statusText); throw new Error(data.error); }
583
+ return data;
584
+ }
585
+
586
+ /* Same-origin fetch that never alert()s — callers present their own localized
587
+ errors (used by the onboarding / builder flows so a server 402/400, whose
588
+ message is Russian-only, never leaks through the generic api() alert). */
589
+ async function apiQuiet(path, opts = {}) {
590
+ const res = await fetch(path, {
591
+ headers: { 'Content-Type': 'application/json' },
592
+ ...opts,
593
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
594
+ });
595
+ const data = await res.json().catch(() => ({}));
596
+ return { ok: res.ok, status: res.status, data };
597
+ }
598
+
599
+ /* Direct browser → Anthropic call for the resume builder, on the user's own key
600
+ (BYO-key model). Independent of the server's `claude -p` path. The key is read
601
+ fresh and NEVER logged. On a placeholder/absent key or CORS/network/HTTP error
602
+ this throws — callers catch and degrade to a toast + manual editing. */
603
+ async function claudeText(prompt, maxTokens = 800) {
604
+ const key = getKey();
605
+ if (!key) throw new Error('nokey');
606
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
607
+ method: 'POST',
608
+ headers: {
609
+ 'content-type': 'application/json',
610
+ 'x-api-key': key,
611
+ 'anthropic-version': '2023-06-01',
612
+ 'anthropic-dangerous-direct-browser-access': 'true', // required for the browser/CORS path
613
+ },
614
+ body: JSON.stringify({ model: BUILDER_MODEL, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }),
615
+ });
616
+ const data = await res.json().catch(() => ({}));
617
+ if (!res.ok || data.type === 'error') throw new Error((data.error && data.error.message) || 'request-failed');
618
+ const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();
619
+ if (!text) throw new Error('empty');
620
+ return text;
621
+ }
622
+
623
+ const claudeErrMsg = err => (err && err.message === 'nokey') ? t('builder.claudeNoKey') : t('builder.claudeError');
624
+
625
+ /* tolerant JSON extraction from a model reply (handles stray prose / code fences) */
626
+ function safeJson(text) {
627
+ const tryParse = s => { try { return JSON.parse(s); } catch { return null; } };
628
+ let o = tryParse(text);
629
+ if (!o) { const m = text.match(/\{[\s\S]*\}/); if (m) o = tryParse(m[0]); }
630
+ return o && typeof o === 'object' ? o : null;
631
+ }
632
+
633
+ /* ─────────── demo tiers: 1-profile cap + Pro upsell ─────────── */
634
+ function renderPlanBadge() {
635
+ const el = $('#plan-badge');
636
+ if (!el) return;
637
+ const p = planId();
638
+ el.textContent = p === 'pro' ? t('plan.pro') : p === 'start' ? 'Start' : t('plan.free');
639
+ el.className = 'plan-badge ' + (p === 'free' ? 'free' : 'pro');
640
+ }
641
+
642
+ /* Seats are the only limit: run `proceed` while under the plan's maxProfiles,
643
+ else stash it and show the upsell (which opens the real checkout). */
644
+ function requireProfileSlot(proceed) {
645
+ if (!state || state.profiles.length < maxProfiles()) { proceed(); return; }
646
+ pendingUpgradeAction = proceed;
647
+ $('#upsell-overlay').hidden = false;
648
+ }
649
+ function closeUpsell() { $('#upsell-overlay').hidden = true; pendingUpgradeAction = null; }
650
+ /* Upgrade is a real purchase: open the checkout. The plan flips once the buyer
651
+ pastes the emailed license key in Settings and the backend verifies it. */
652
+ function upgradeToPro() {
653
+ $('#upsell-overlay').hidden = true;
654
+ pendingUpgradeAction = null;
655
+ window.open(buyUrl('pro'), '_blank', 'noopener');
656
+ }
657
+
658
+ const userIsTyping = () => {
659
+ const el = document.activeElement;
660
+ return el && (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && el.type === 'text') || (el.tagName === 'INPUT' && !el.type));
661
+ };
662
+
663
+ /* company initial → stable pastel color */
664
+ function avatar(company, big = false) {
665
+ let h = 0;
666
+ for (const ch of company || '?') h = (h * 31 + ch.codePointAt(0)) % 360;
667
+ return `<div class="avatar" style="background:hsl(${h},58%,52%)">${esc((company || '?')[0].toUpperCase())}</div>`;
668
+ }
669
+
670
+ const daysAgo = ts => {
671
+ const d = Math.floor((Date.now() - ts) / 86_400_000);
672
+ return d === 0 ? t('time.today') : d === 1 ? t('time.yesterday') : t('time.daysAgo', { count: d });
673
+ };
674
+
675
+ /* ─────────── refresh / render ─────────── */
676
+ async function refresh(force = false) {
677
+ state = await api('/api/state');
678
+ state.lang = lang; // mirror the UI preference onto state; source of truth stays the module var
679
+ renderTopbar();
680
+ renderJobsWidget();
681
+ if (force || !userIsTyping()) {
682
+ renderSearchPanel();
683
+ renderStatusTabs();
684
+ renderHistory();
685
+ renderList();
686
+ renderDetail();
687
+ renderLeads();
688
+ renderProfiles();
689
+ renderOnboard();
690
+ renderSettings();
691
+ }
692
+ /* Re-assert the gate on every poll, OUTSIDE the userIsTyping guard: renderJobsWidget
693
+ above may have re-shown the widget, and the gate must not go stale while the user
694
+ is typing a key. Idempotent — safe to run each 2.5s tick. */
695
+ renderGate();
696
+ }
697
+
698
+ function renderTopbar() {
699
+ const b = $('#browser-status');
700
+ b.textContent = state.browserAlive ? t('topbar.browserOn') : t('topbar.browserOff');
701
+ b.className = 'pill ' + (state.browserAlive ? 'ok' : 'err');
702
+
703
+ // Plan pill — no credits/wallet; only the tier (usage is on the user's own key).
704
+ const c = $('#credits-pill');
705
+ const p = planId();
706
+ if (p === 'free' || p === 'dev') { c.hidden = true; return; }
707
+ c.hidden = false;
708
+ c.textContent = p === 'pro' ? 'Pro' : 'Start';
709
+ c.className = 'pill ok';
710
+ c.title = t('topbar.planTitle', { planName: billing().planName || '' });
711
+ }
712
+
713
+ /* ── search panel: profile + position chips ── */
714
+ const visiblePositions = () => state.positions.filter(p => !profileFilter || p.profileId === profileFilter);
715
+
716
+ function miniAvatar(name) {
717
+ let h = 0;
718
+ for (const ch of name || '?') h = (h * 31 + ch.codePointAt(0)) % 360;
719
+ return `<span class="mini-avatar" style="background:hsl(${h},58%,52%)">${esc((name || '?')[0].toUpperCase())}</span>`;
720
+ }
721
+
722
+ function renderSearchPanel() {
723
+ $('#profile-chips').innerHTML = [
724
+ `<button class="prof-chip all ${profileFilter === '' ? 'on' : ''}" data-prof-filter="">${t('search.allPeople')}</button>`,
725
+ ...state.profiles.map(p => `
726
+ <button class="prof-chip ${profileFilter === p.id ? 'on' : ''}" data-prof-filter="${p.id}">
727
+ ${miniAvatar(p.name)} ${esc(p.name)}
728
+ </button>`),
729
+ ].join('');
730
+
731
+ const profileName = id => state.profiles.find(p => p.id === id)?.name?.split(' ')[0] || '';
732
+ $('#position-chips').innerHTML = visiblePositions().map(p => `
733
+ <button class="pos-chip ${p.selected ? 'on' : ''}" data-toggle-pos="${p.id}"
734
+ title="${esc(p.keywords || '')} — ${engLabel(p.engagement)} — ${esc(profileName(p.profileId))}">
735
+ <span class="dot">${p.selected ? '✓' : '○'}</span>
736
+ ${esc(p.title)}${profileFilter ? '' : ` <span style="opacity:.55;font-weight:400">· ${esc(profileName(p.profileId))}</span>`}
737
+ <span class="rm" data-del-pos="${p.id}" title="${t('common.delete')}">✕</span>
738
+ </button>`).join('');
739
+
740
+ const sel = $('#position-profile');
741
+ sel.innerHTML = state.profiles.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
742
+ if (profileFilter) sel.value = profileFilter;
743
+
744
+ const n = visiblePositions().filter(p => p.selected).length;
745
+ const btn = $('#search-all');
746
+ const activeSearches = state.jobs.filter(j => j.type === 'search' && (j.status === 'running' || j.status === 'queued'));
747
+ if (activeSearches.length) {
748
+ btn.innerHTML = `<span class="spin">⏳</span> ${t('search.searching', { n: activeSearches.length })}`;
749
+ btn.disabled = false;
750
+ btn.dataset.mode = 'stop';
751
+ } else {
752
+ btn.textContent = n ? t('search.findJobsN', { count: n }) : t('search.findJobs');
753
+ btn.disabled = !n;
754
+ btn.dataset.mode = 'start';
755
+ }
756
+
757
+ $('#freshness').value = String(state.settings.freshnessDays || 3);
758
+ }
759
+
760
+ /* ── история поисков ── */
761
+ function fmtRunTime(ts) {
762
+ const d = new Date(ts);
763
+ const today = new Date().toDateString() === d.toDateString();
764
+ const hm = d.toLocaleTimeString(dateLocale(), { hour: '2-digit', minute: '2-digit' });
765
+ return today ? hm : `${d.toLocaleDateString(dateLocale(), { day: '2-digit', month: '2-digit' })} ${hm}`;
766
+ }
767
+
768
+ function renderHistory() {
769
+ const row = $('#history-row');
770
+ const runs = (state.searches || []).slice(0, 15);
771
+ row.hidden = !runs.length;
772
+ if (!runs.length) return;
773
+ const personName = id => state.profiles.find(p => p.id === id)?.name?.split(' ')[0] || '';
774
+ row.innerHTML = [
775
+ `<button class="hist-chip ${historyFilter === null ? 'on' : ''}" data-history-run="">${t('history.now')}</button>`,
776
+ ...runs.map(r => {
777
+ const titles = r.titles.slice(0, 2).map(t => esc(t)).join(' + ') + (r.titles.length > 2 ? ` +${r.titles.length - 2}` : '');
778
+ const who = r.profileIds.map(personName).filter(Boolean).join(', ');
779
+ return `<button class="hist-chip ${historyFilter === r.id ? 'on' : ''}" data-history-run="${r.id}" title="${esc(r.titles.join(', '))}">
780
+ ${fmtRunTime(r.at)} · ${titles}${who ? ` · ${esc(who)}` : ''} <span class="n">+${r.added}</span>
781
+ </button>`;
782
+ }),
783
+ ].join('');
784
+ }
785
+
786
+ /* ── status tabs with counts ── */
787
+ function renderStatusTabs() {
788
+ const pool = baseFiltered();
789
+ $('#status-tabs').innerHTML = STATUS_TABS.map(t => {
790
+ const n = pool.filter(t.match).length;
791
+ return `<button class="status-tab ${statusTab === t.key ? 'on' : ''}" data-status-tab="${t.key}">
792
+ ${statustabLabel(t.key)}<span class="n">${n}</span></button>`;
793
+ }).join('');
794
+ }
795
+
796
+ /* ── vacancy list ── */
797
+ function vacancyProfileId(v) {
798
+ return state.positions.find(p => p.id === v.positionId)?.profileId;
799
+ }
800
+ /* worldwide-remote: explicit remote_scope from newer searches, text heuristic for older records */
801
+ function isGlobal(v) {
802
+ if (v.remote_scope) return v.remote_scope === 'global';
803
+ return v.remote && /worldwide|anywhere|global|весь мир/i.test(`${v.location || ''} ${v.notes || ''}`);
804
+ }
805
+ function geoTag(v) {
806
+ if (isGlobal(v)) return '<span class="tag remote">🌍 worldwide</span>';
807
+ if (v.remote) {
808
+ const scope = v.remote_scope === 'region' ? t('card.scope.region') : v.remote_scope === 'country' ? t('card.scope.country') : '';
809
+ return `<span class="tag remote">remote${scope}</span>`;
810
+ }
811
+ return '';
812
+ }
813
+ /* Context slice: profile + history/labels only, WITHOUT the adjustable filter-bar/text
814
+ filters. This is the signal for progressive disclosure (are there results in this
815
+ context?), so applying a geo/salary/etc. filter that yields zero never hides the
816
+ filter bar the user needs to clear it. */
817
+ function contextVacancies() {
818
+ let list = [...state.vacancies];
819
+ if (profileFilter) list = list.filter(v => vacancyProfileId(v) === profileFilter);
820
+ if (historyFilter) {
821
+ const run = (state.searches || []).find(r => r.id === historyFilter);
822
+ if (run) list = list.filter(v => run.vacancyIds.includes(v.id));
823
+ } else {
824
+ // "now": the list follows the enabled labels
825
+ const sel = state.positions.filter(p => p.selected && (!profileFilter || p.profileId === profileFilter)).map(p => p.id);
826
+ if (sel.length) list = list.filter(v => sel.includes(v.positionId));
827
+ }
828
+ return list;
829
+ }
830
+
831
+ /* Full base selection: context + filter-bar + text (without the status tab). */
832
+ function baseFiltered() {
833
+ let list = contextVacancies();
834
+ if (filters.geo === 'global') list = list.filter(isGlobal);
835
+ if (filters.geo === 'remote') list = list.filter(v => v.remote);
836
+ if (filters.geo === 'region') list = list.filter(v => v.remote_scope === 'region');
837
+ if (filters.geo === 'country') list = list.filter(v => v.remote_scope === 'country');
838
+ if (filters.geo === 'onsite') list = list.filter(v => !v.remote);
839
+ if (filters.eng === 'staff') list = list.filter(v => ['staff', 'both'].includes(v.engagement));
840
+ if (filters.eng === 'contract') list = list.filter(v => ['contract', 'both'].includes(v.engagement));
841
+ if (filters.apply) list = list.filter(v => v.apply_method === filters.apply);
842
+ if (filters.salary === 'yes') list = list.filter(v => v.salary);
843
+ if (filters.fresh) {
844
+ const cutoff = Date.now() - Number(filters.fresh) * 86_400_000;
845
+ list = list.filter(v => (v.posted_date ? Date.parse(v.posted_date) : v.foundAt) >= cutoff);
846
+ }
847
+ if (textFilter.trim()) {
848
+ const q = textFilter.trim().toLowerCase();
849
+ list = list.filter(v => `${v.company} ${v.vacancy_title} ${v.notes} ${v.location}`.toLowerCase().includes(q));
850
+ }
851
+ return list.sort((a, b) => b.foundAt - a.foundAt);
852
+ }
853
+
854
+ function visibleVacancies() {
855
+ const tabDef = STATUS_TABS.find(t => t.key === statusTab) || STATUS_TABS[0];
856
+ return baseFiltered().filter(tabDef.match);
857
+ }
858
+
859
+ const animatedIds = new Set(); // анимация «залёта» — один раз на карточку
860
+ function compactCard(v) {
861
+ const fresh = Date.now() - v.foundAt < 30_000 && !animatedIds.has(v.id);
862
+ if (fresh) animatedIds.add(v.id);
863
+ return `
864
+ <div class="vcard ${fresh ? 'fresh' : ''}" data-vac="${v.id}">
865
+ ${avatar(v.company)}
866
+ <div class="vcard-body">
867
+ <div class="title">${esc(v.vacancy_title)}</div>
868
+ <div class="company">${esc(v.company)}${v.location ? ' · ' + esc(v.location) : ''}</div>
869
+ <div class="meta">
870
+ ${geoTag(v)}
871
+ ${engLabel(v.engagement) ? `<span class="tag eng">${engLabel(v.engagement)}</span>` : ''}
872
+ ${v.salary ? `<span class="tag salary" title="${esc(v.salary)}">${esc(v.salary)}</span>` : ''}
873
+ </div>
874
+ <div class="foot">
875
+ <span>${v.posted_date ? t('card.posted', { date: esc(v.posted_date) }) : t('card.found', { ago: daysAgo(v.foundAt) })}</span>
876
+ <span class="st st-${v.status}">${statusLabel(v.status)}</span>
877
+ </div>
878
+ </div>
879
+ <button class="zoom" title="${t('card.expand')}">⤢</button>
880
+ </div>`;
881
+ }
882
+
883
+ function expandedCard(v) {
884
+ const url = v.vacancy_url || v.careers_url || v.website;
885
+ const pr = state.profiles.find(p => p.id === vacancyProfileId(v));
886
+ return `
887
+ <div class="vcard expanded" data-expanded="${v.id}">
888
+ <button class="zoom" data-collapse-vac="1" title="${t('card.collapse')}">✕</button>
889
+ <div class="dhead">
890
+ ${avatar(v.company)}
891
+ <div style="flex:1;min-width:0">
892
+ <h3 style="margin:0;font-size:18px">${esc(v.vacancy_title)}</h3>
893
+ <div class="company">${esc(v.company)}${v.source ? t('card.foundVia', { source: esc(v.source) }) : ''}</div>
894
+ </div>
895
+ </div>
896
+ <div class="dmeta">
897
+ ${v.location ? `<span class="tag">📍 ${esc(v.location)}</span>` : ''}
898
+ ${geoTag(v)}
899
+ ${engLabel(v.engagement) ? `<span class="tag eng">${engLabel(v.engagement)}</span>` : ''}
900
+ ${v.salary ? `<span class="tag salary">💰 ${esc(v.salary)}</span>` : ''}
901
+ ${v.posted_date ? `<span class="tag">${t('card.postedTag', { date: esc(v.posted_date) })}</span>` : ''}
902
+ <span class="tag">${t('card.found', { ago: daysAgo(v.foundAt) })}</span>
903
+ ${pr ? `<span class="tag">👤 ${esc(pr.name)}</span>` : ''}
904
+ ${v.dead_reason ? `<span class="tag" style="color:var(--err)">${t('card.linkDead', { reason: esc(v.dead_reason) })}</span>` : ''}
905
+ </div>
906
+ ${v.eligibility_note ? `<div class="dsection"><div class="lbl">${t('card.eligibility')}</div><div class="dnotes">🌍 ${esc(v.eligibility_note)}</div></div>` : ''}
907
+ ${v.notes ? `<div class="dsection"><div class="lbl">${t('card.whyFit')}</div><div class="dnotes">${esc(v.notes)}</div></div>` : ''}
908
+ <div class="dactions">
909
+ <a class="btn ghost" href="${esc(url)}" target="_blank" rel="noopener">${t('card.openJob')}</a>
910
+ <button class="btn ghost" data-prepare="${v.id}">✍️ ${v.coverLetter ? t('card.rewriteLetter') : t('card.prepareApply')}</button>
911
+ <button class="btn primary" data-apply="${v.id}">${t('card.autoApply')}</button>
912
+ ${v.apply_email ? `<a class="btn ghost" href="mailto:${esc(v.apply_email)}?subject=${encodeURIComponent(v.vacancy_title)}&body=${encodeURIComponent(v.coverLetter || '')}">${t('card.email')}</a>` : ''}
913
+ </div>
914
+ ${v.applyResult ? `<div class="dsection"><div class="lbl">${t('card.autoApplyResult')}</div>
915
+ <div class="apply-box ${v.applyResult.status === 'submitted' ? 'done' : ''}">
916
+ <b>${esc(v.applyResult.status)}</b> — ${esc(v.applyResult.details || '')}
917
+ </div></div>` : ''}
918
+ ${v.coverLetter ? `<div class="dsection letter-area"><div class="lbl">${t('card.coverLetter')}</div>
919
+ <textarea rows="10" data-letter="${v.id}">${esc(v.coverLetter)}</textarea>
920
+ <div class="letter-actions"><button class="btn ghost sm" data-save-letter="${v.id}">${t('card.saveLetter')}</button></div>
921
+ </div>` : ''}
922
+ <div class="dfoot">
923
+ <label style="font-size:13px;color:var(--muted)">${t('card.statusLabel')}</label>
924
+ <select data-status="${v.id}">
925
+ ${STATUS_KEYS.map(k => `<option value="${k}" ${v.status === k ? 'selected' : ''}>${statusLabel(k)}</option>`).join('')}
926
+ </select>
927
+ <span class="spacer"></span>
928
+ <button class="btn danger-ghost sm" data-del-vac="${v.id}">${t('common.delete')}</button>
929
+ </div>
930
+ </div>`;
931
+ }
932
+
933
+ function renderList() {
934
+ const list = visibleVacancies();
935
+ if (!list.some(v => v.id === selectedVacancyId)) selectedVacancyId = null;
936
+ $('#vacancy-list').innerHTML = list.map(v => (v.id === selectedVacancyId ? expandedCard(v) : compactCard(v))).join('')
937
+ || `<div class="empty-list">${t('list.emptyTitle')}<br>${t('list.emptyHint')}</div>`;
938
+ renderJobsIntro();
939
+ }
940
+
941
+ /* Linear entry path on the Jobs view:
942
+ - zero profiles → lead with ONE focused step (the onboarding card), hide the
943
+ search console and results;
944
+ - a profile exists → show the search console (prominent Find-jobs button);
945
+ - filters/status-tabs/text-filter appear only once there are results in the
946
+ current context (progressive disclosure). Centralized here and called from
947
+ renderList(), which runs on every context change and every poll tick. */
948
+ function renderJobsIntro() {
949
+ if (!state) return;
950
+ const zero = state.profiles.length === 0;
951
+ const results = contextVacancies().length > 0;
952
+ $('#jobs-onboard').hidden = !zero;
953
+ $('#search-panel').hidden = zero;
954
+ $('#vacancy-list').hidden = zero;
955
+ $('#status-tabs').hidden = zero || !results;
956
+ $('#filter-bar').hidden = zero || !results;
957
+ $('#text-filter').hidden = zero || !results;
958
+ }
959
+
960
+ /* превью встроено в развёрнутую карточку — отдельной панели больше нет */
961
+ function renderDetail() {}
962
+
963
+ /* ── leads ── */
964
+ function renderLeads() {
965
+ const leads = [...(state.leads || [])].sort((a, b) => b.foundAt - a.foundAt);
966
+ $('#leads-count').textContent = leads.filter(l => l.status === 'new' || l.status === 'ready').length || '';
967
+
968
+ const btn = $('#search-leads');
969
+ const searching = state.jobs.some(j => j.type === 'leads' && (j.status === 'running' || j.status === 'queued'));
970
+ btn.disabled = searching;
971
+ btn.innerHTML = searching ? `<span class="spin">⏳</span> ${t('leads.searching')}` : t('leads.findLeads');
972
+
973
+ $('#leads-list').innerHTML = leads.filter(l => l.status !== 'archive').map(l => {
974
+ const profile = state.profiles.find(p => p.id === l.profileId);
975
+ const contactLink = l.contact_channel === 'email' && l.contact_value
976
+ ? `<a class="btn ghost sm" href="mailto:${esc(l.contact_value)}?subject=${encodeURIComponent((l.pitch || '').match(/Subject:\s*(.+)/)?.[1] || 'Intro — ' + (profile?.name || ''))}&body=${encodeURIComponent((l.pitch || '').replace(/^Subject:.*\n+/, ''))}">📧 ${esc(l.contact_value)}</a>`
977
+ : l.contact_value
978
+ ? `<a class="btn ghost sm" href="${esc(/^https?:/.test(l.contact_value) ? l.contact_value : 'https://' + l.contact_value)}" target="_blank" rel="noopener">${l.contact_channel === 'linkedin' ? '💼 LinkedIn' : t('leads.contact')} ↗</a>`
979
+ : '';
980
+ return `
981
+ <div class="lead-card">
982
+ <div class="dhead">
983
+ ${avatar(l.company)}
984
+ <div style="flex:1;min-width:0">
985
+ <h3 style="font-size:16.5px;margin:0"><a href="${esc(l.website || '#')}" target="_blank" rel="noopener">${esc(l.company)} ↗</a></h3>
986
+ <div class="company">${esc(l.signal || '')}${l.source_url ? ` · <a href="${esc(l.source_url)}" target="_blank" rel="noopener">${t('leads.source')}</a>` : ''}</div>
987
+ </div>
988
+ <span class="st st-${l.status === 'ready' ? 'prepared' : l.status === 'sent' ? 'sent' : l.status === 'answered' ? 'answered' : 'new'}">${leadStatusLabel(l.status) || l.status}</span>
989
+ </div>
990
+ <div class="dmeta">
991
+ ${profile ? `<span class="tag">👤 ${esc(profile.name)}</span>` : ''}
992
+ ${l.contact_name ? `<span class="tag">${esc(l.contact_name)}${l.contact_role ? ' · ' + esc(l.contact_role) : ''}</span>` : ''}
993
+ ${l.hiring_evidence ? `<a class="tag remote" href="${esc(l.hiring_evidence)}" target="_blank" rel="noopener">${t('leads.alreadyHiring')}</a>` : ''}
994
+ </div>
995
+ ${l.why_fit ? `<div class="dnotes">${esc(l.why_fit)}</div>` : ''}
996
+ <div class="dactions">
997
+ <button class="btn ghost sm" data-lead-pitch="${l.id}">✍️ ${l.pitch ? t('leads.rewritePitch') : t('leads.writePitch')}</button>
998
+ ${contactLink}
999
+ <select data-lead-status="${l.id}">
1000
+ ${LEAD_STATUS_KEYS.map(k => `<option value="${k}" ${l.status === k ? 'selected' : ''}>${leadStatusLabel(k)}</option>`).join('')}
1001
+ </select>
1002
+ <button class="btn danger-ghost sm" data-del-lead="${l.id}">${t('common.delete')}</button>
1003
+ </div>
1004
+ ${l.pitch ? `<details class="letter-area" style="margin-top:10px"><summary class="hint" style="margin:0">${t('leads.pitch')}</summary>
1005
+ <textarea rows="9" data-lead-pitch-text="${l.id}">${esc(l.pitch)}</textarea>
1006
+ <div class="letter-actions"><button class="btn ghost sm" data-save-pitch="${l.id}">💾 ${t('common.save')}</button></div>
1007
+ </details>` : ''}
1008
+ </div>`;
1009
+ }).join('') || `<p class="hint">${t('leads.empty')}</p>`;
1010
+ }
1011
+
1012
+ /* ── profiles ── */
1013
+ function renderProfiles() {
1014
+ $('#profiles-list').innerHTML = state.profiles.map(p => `
1015
+ <div class="profile-card" data-id="${p.id}">
1016
+ <div class="row">
1017
+ <input data-f="name" value="${esc(p.name)}" placeholder="${t('profiles.namePlaceholder')}">
1018
+ <input data-f="email" value="${esc(p.email)}" placeholder="Email">
1019
+ <input data-f="linkedin" value="${esc(p.linkedin)}" placeholder="LinkedIn URL">
1020
+ <input data-f="location" value="${esc(p.location || '')}" placeholder="${t('profiles.locationPlaceholder')}" title="${t('profiles.locationTitle')}">
1021
+ </div>
1022
+ <details>
1023
+ <summary>${t('profiles.resume')} ${p.resume ? t('profiles.resumeChars', { count: p.resume.length }) : t('profiles.resumeEmpty')}</summary>
1024
+ <textarea rows="14" data-f="resume">${esc(p.resume)}</textarea>
1025
+ </details>
1026
+ <div class="actions">
1027
+ <button class="btn primary sm" data-save-profile="${p.id}">💾 ${t('common.save')}</button>
1028
+ <button class="btn ghost sm" data-build-profile="${p.id}">🧩 ${t('builder.openEdit')}</button>
1029
+ <button class="btn ghost sm" data-import="${p.id}">${t('profiles.importLinkedin')}</button>
1030
+ <button class="btn danger-ghost sm" data-del-profile="${p.id}">${t('common.delete')}</button>
1031
+ </div>
1032
+ </div>`).join('');
1033
+ }
1034
+
1035
+ /* ── onboarding wizard ── */
1036
+ let onboardRendered = false;
1037
+ function renderOnboard() {
1038
+ if (onboardRendered || !state.catalog) return;
1039
+ onboardRendered = true;
1040
+ $('#onb-location').innerHTML = state.locations.map(l => `<option value="${l}" ${l === 'Ukraine' ? 'selected' : ''}>${l}</option>`).join('');
1041
+ $('#onb-seniority').innerHTML = state.seniority.map(s => `<option value="${s}" ${s === 'Senior' ? 'selected' : ''}>${s || t('onboard.noSeniority')}</option>`).join('');
1042
+ $('#onb-roles').innerHTML = state.catalog.map(r => `
1043
+ <label class="role-chip" data-role-chip title="${esc(r.keywords)}">
1044
+ <input type="checkbox" value="${r.id}" hidden>
1045
+ ${esc(r.title)} <span class="g">${esc(r.group)}</span>
1046
+ </label>`).join('');
1047
+
1048
+ /* форма «+ позиция» на вкладке вакансий: роль из каталога + уровень */
1049
+ $('#position-role').innerHTML =
1050
+ state.catalog.map(r => `<option value="${r.id}">${esc(r.title)}</option>`).join('')
1051
+ + `<option value="custom">${t('posform.customRole')}</option>`;
1052
+ $('#position-seniority').innerHTML = state.seniority.map(s => `<option value="${s}" ${s === 'Senior' ? 'selected' : ''}>${s || t('posform.noLevel')}</option>`).join('');
1053
+ }
1054
+
1055
+ /* ── settings ── */
1056
+ function renderSettings() {
1057
+ const f = $('#settings-form');
1058
+ f.plan.value = state.settings.plan || 'free';
1059
+ // Usage line — no credits; just what the user's own key has spent (informational).
1060
+ $('#usage-line').textContent = t('settings.usageLine', { cost: (billing().totalCostUsd ?? 0).toFixed(2) });
1061
+ renderLicenseStatus();
1062
+ f.model.value = state.settings.model;
1063
+ f.resultsPerSearch.value = state.settings.resultsPerSearch;
1064
+ f.autoSubmit.checked = !!state.settings.autoSubmit;
1065
+ f.searchViaBrowser.checked = !!state.settings.searchViaBrowser;
1066
+ f.excludeBoards.checked = !!state.settings.excludeBoards;
1067
+ f.searchExtra.value = state.settings.searchExtra || '';
1068
+
1069
+ /* key field: the secret lives client-side, so the input stays empty (paste a new
1070
+ key to replace) and only a status line reflects the connection. Never write the
1071
+ key into the DOM value; the 2.5s poll would otherwise clobber it while typing
1072
+ (a password input isn't caught by userIsTyping). */
1073
+ $('#key-status').textContent = hasKey() ? t('settings.keyConnected') : t('settings.keyMissing');
1074
+ }
1075
+
1076
+ function renderLicenseStatus() {
1077
+ const st = $('#license-status');
1078
+ if (!st) return;
1079
+ const lic = billing().license || {};
1080
+ const p = planId();
1081
+ if (lic.invalid) { st.textContent = t('settings.licenseInvalid'); st.className = 'hint err'; return; }
1082
+ if (lic.set && (p === 'start' || p === 'pro')) {
1083
+ const until = lic.paidUntil ? new Date(lic.paidUntil).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US') : '';
1084
+ st.textContent = t('settings.licenseActive', { plan: p.charAt(0).toUpperCase() + p.slice(1), until });
1085
+ st.className = 'hint ok';
1086
+ } else if (lic.set) {
1087
+ st.textContent = t('settings.licenseChecking'); st.className = 'hint';
1088
+ } else {
1089
+ st.textContent = t('settings.licenseNone'); st.className = 'hint';
1090
+ }
1091
+ }
1092
+
1093
+ async function applyLicense() {
1094
+ const inp = $('#settings-form') && $('#settings-form').licenseKey;
1095
+ const key = (inp && inp.value || '').trim();
1096
+ const st = $('#license-status');
1097
+ if (st) { st.textContent = t('settings.licenseChecking'); st.className = 'hint'; }
1098
+ try { await api('/api/license', { method: 'POST', body: { key } }); } catch (e) { /* surfaced via billing */ }
1099
+ if (inp) inp.value = '';
1100
+ await refresh(true);
1101
+ const lic = billing().license || {};
1102
+ const p = planId();
1103
+ if (key && lic.invalid) showToast(t('settings.licenseInvalidToast'), 'err');
1104
+ else if (key && (p === 'start' || p === 'pro')) showToast(t('settings.licenseAppliedToast', { plan: p.charAt(0).toUpperCase() + p.slice(1) }), 'ok');
1105
+ else if (!key) showToast(t('settings.licenseRemovedToast'), 'ok');
1106
+ }
1107
+
1108
+ /* ─────────── resume builder ───────────
1109
+ Structured form → live preview; saves the composed resume onto the profile.
1110
+ New candidates go through /api/onboard (auto positions + auto search); editing an
1111
+ existing profile PATCHes /api/profiles. Claude actions (generate/polish/parse) run
1112
+ in the browser on the user's key and degrade gracefully on any failure. */
1113
+ function emptyBuilder() {
1114
+ return {
1115
+ editingId: null, name: '', location: '', email: '', linkedin: '',
1116
+ headline: '', summary: '', skills: '',
1117
+ experience: [{ role: '', company: '', dates: '', bullets: '' }],
1118
+ education: [{ degree: '', school: '', dates: '' }],
1119
+ };
1120
+ }
1121
+
1122
+ function openBuilder(profile) {
1123
+ builderModel = emptyBuilder();
1124
+ if (profile) {
1125
+ builderModel.editingId = profile.id;
1126
+ builderModel.name = profile.name || '';
1127
+ builderModel.location = profile.location || '';
1128
+ builderModel.email = profile.email || '';
1129
+ builderModel.linkedin = profile.linkedin || '';
1130
+ // freeform resumes don't map cleanly back to sections — seed the import box so
1131
+ // the user can Parse-with-Claude or edit fresh; nothing is lost either way.
1132
+ $('#builder-import').value = profile.resume || '';
1133
+ } else {
1134
+ $('#builder-import').value = '';
1135
+ }
1136
+ builderOpen = true;
1137
+ $('#builder-overlay').hidden = false;
1138
+ renderBuilderTitle();
1139
+ syncBuilderFields();
1140
+ renderBuilderRows();
1141
+ renderBuilderPreview();
1142
+ }
1143
+ /* new-candidate entry goes through the tier gate; editing is always allowed */
1144
+ function openBuilderForNew() { requireProfileSlot(() => openBuilder(null)); }
1145
+ function closeBuilder() { builderOpen = false; builderModel = null; $('#builder-overlay').hidden = true; }
1146
+
1147
+ function renderBuilderTitle() {
1148
+ const editing = builderModel && builderModel.editingId;
1149
+ $('#builder-title').textContent = t(editing ? 'builder.editTitle' : 'builder.newTitle');
1150
+ $('#builder-save').textContent = t(editing ? 'builder.saveEdit' : 'builder.save');
1151
+ }
1152
+
1153
+ /* write model scalars → the static inputs. Only on open / after generate / parse —
1154
+ never per keystroke (would reset the caret). */
1155
+ function syncBuilderFields() {
1156
+ ['name', 'location', 'email', 'linkedin', 'headline', 'summary', 'skills'].forEach(f => {
1157
+ const el = $(`#builder-form [data-bf="${f}"]`);
1158
+ if (el) el.value = builderModel[f] || '';
1159
+ });
1160
+ }
1161
+
1162
+ function renderBuilderRows() {
1163
+ $('#builder-exp').innerHTML = builderModel.experience.map((e, i) => `
1164
+ <div class="bld-row">
1165
+ <div class="bld-row-grid">
1166
+ <input data-exp-idx="${i}" data-exp-field="role" value="${esc(e.role)}" placeholder="${t('builder.expRolePh')}">
1167
+ <input data-exp-idx="${i}" data-exp-field="company" value="${esc(e.company)}" placeholder="${t('builder.expCompanyPh')}">
1168
+ <input data-exp-idx="${i}" data-exp-field="dates" value="${esc(e.dates)}" placeholder="${t('builder.expDatesPh')}">
1169
+ </div>
1170
+ <textarea rows="3" data-exp-idx="${i}" data-exp-field="bullets" placeholder="${t('builder.expBulletsPh')}">${esc(e.bullets)}</textarea>
1171
+ <button type="button" class="btn danger-ghost sm" data-rm-exp="${i}">${t('builder.removeRow')}</button>
1172
+ </div>`).join('');
1173
+ $('#builder-edu').innerHTML = builderModel.education.map((e, i) => `
1174
+ <div class="bld-row">
1175
+ <div class="bld-row-grid">
1176
+ <input data-edu-idx="${i}" data-edu-field="degree" value="${esc(e.degree)}" placeholder="${t('builder.eduDegreePh')}">
1177
+ <input data-edu-idx="${i}" data-edu-field="school" value="${esc(e.school)}" placeholder="${t('builder.eduSchoolPh')}">
1178
+ <input data-edu-idx="${i}" data-edu-field="dates" value="${esc(e.dates)}" placeholder="${t('builder.eduDatesPh')}">
1179
+ </div>
1180
+ <button type="button" class="btn danger-ghost sm" data-rm-edu="${i}">${t('builder.removeRow')}</button>
1181
+ </div>`).join('');
1182
+ }
1183
+
1184
+ function renderBuilderPreview() {
1185
+ const m = builderModel;
1186
+ const p = [];
1187
+ if (m.name.trim()) p.push(`<div class="rp-name">${esc(m.name)}</div>`);
1188
+ const contact = [m.location, m.email, m.linkedin].map(s => s.trim()).filter(Boolean);
1189
+ if (contact.length) p.push(`<div class="rp-contact">${contact.map(esc).join(' · ')}</div>`);
1190
+ if (m.headline.trim()) p.push(`<div class="rp-headline">${esc(m.headline)}</div>`);
1191
+ if (m.summary.trim()) p.push(`<h4 class="rp-h">${esc(t('builder.secSummary'))}</h4><p class="rp-p">${esc(m.summary).replace(/\n/g, '<br>')}</p>`);
1192
+ const exp = m.experience.filter(e => (e.role + e.company + e.dates + e.bullets).trim());
1193
+ if (exp.length) {
1194
+ p.push(`<h4 class="rp-h">${esc(t('builder.experience'))}</h4>`);
1195
+ exp.forEach(e => {
1196
+ const head = [e.role.trim(), e.company.trim()].filter(Boolean).map(esc).join(' — ');
1197
+ p.push(`<div class="rp-item"><div class="rp-item-head">${head}${e.dates.trim() ? ` <span class="rp-dates">${esc(e.dates)}</span>` : ''}</div>`);
1198
+ const bl = e.bullets.split('\n').map(b => b.trim()).filter(Boolean);
1199
+ if (bl.length) p.push('<ul class="rp-ul">' + bl.map(b => `<li>${esc(b)}</li>`).join('') + '</ul>');
1200
+ p.push('</div>');
1201
+ });
1202
+ }
1203
+ if (m.skills.trim()) p.push(`<h4 class="rp-h">${esc(t('builder.skillsLabel'))}</h4><p class="rp-p">${esc(m.skills)}</p>`);
1204
+ const edu = m.education.filter(e => (e.degree + e.school + e.dates).trim());
1205
+ if (edu.length) {
1206
+ p.push(`<h4 class="rp-h">${esc(t('builder.education'))}</h4>`);
1207
+ edu.forEach(e => {
1208
+ const head = [e.degree.trim(), e.school.trim()].filter(Boolean).map(esc).join(', ');
1209
+ p.push(`<div class="rp-item"><div class="rp-item-head">${head}${e.dates.trim() ? ` <span class="rp-dates">${esc(e.dates)}</span>` : ''}</div></div>`);
1210
+ });
1211
+ }
1212
+ $('#builder-preview').innerHTML = p.length ? p.join('') : `<div class="rp-empty">${esc(t('builder.previewEmpty'))}</div>`;
1213
+ }
1214
+
1215
+ /* model → plain-markdown resume, stored on the profile */
1216
+ function composeResume(m) {
1217
+ const L = [];
1218
+ if (m.name.trim()) L.push('# ' + m.name.trim());
1219
+ const contact = [m.location, m.email, m.linkedin].map(s => s.trim()).filter(Boolean).join(' · ');
1220
+ if (contact) L.push(contact);
1221
+ if (m.headline.trim()) L.push('', '**' + m.headline.trim() + '**');
1222
+ if (m.summary.trim()) L.push('', '## Summary', m.summary.trim());
1223
+ const exp = m.experience.filter(e => (e.role + e.company + e.dates + e.bullets).trim());
1224
+ if (exp.length) {
1225
+ L.push('', '## Experience');
1226
+ exp.forEach(e => {
1227
+ const head = [e.role.trim(), e.company.trim()].filter(Boolean).join(' — ');
1228
+ const d = e.dates.trim() ? ' (' + e.dates.trim() + ')' : '';
1229
+ if (head || d) L.push('', '### ' + (head || '—') + d);
1230
+ e.bullets.split('\n').map(b => b.trim()).filter(Boolean).forEach(b => L.push('- ' + b));
1231
+ });
1232
+ }
1233
+ if (m.skills.trim()) L.push('', '## Skills', m.skills.trim());
1234
+ const edu = m.education.filter(e => (e.degree + e.school + e.dates).trim());
1235
+ if (edu.length) {
1236
+ L.push('', '## Education');
1237
+ edu.forEach(e => {
1238
+ const head = [e.degree.trim(), e.school.trim()].filter(Boolean).join(', ');
1239
+ const d = e.dates.trim() ? ' (' + e.dates.trim() + ')' : '';
1240
+ L.push('- ' + (head || '—') + d);
1241
+ });
1242
+ }
1243
+ return L.join('\n').trim();
1244
+ }
1245
+
1246
+ function applyParsed(d) {
1247
+ const s = v => (typeof v === 'string' ? v : v == null ? '' : String(v));
1248
+ builderModel.name = s(d.name) || builderModel.name;
1249
+ builderModel.location = s(d.location) || builderModel.location;
1250
+ builderModel.email = s(d.email);
1251
+ builderModel.linkedin = s(d.linkedin);
1252
+ builderModel.headline = s(d.headline);
1253
+ builderModel.summary = s(d.summary);
1254
+ builderModel.skills = Array.isArray(d.skills) ? d.skills.map(s).join(', ') : s(d.skills);
1255
+ const exp = Array.isArray(d.experience) ? d.experience : [];
1256
+ builderModel.experience = exp.length ? exp.map(e => ({
1257
+ role: s(e.role), company: s(e.company), dates: s(e.dates),
1258
+ bullets: Array.isArray(e.bullets) ? e.bullets.map(s).join('\n') : s(e.bullets),
1259
+ })) : [{ role: '', company: '', dates: '', bullets: '' }];
1260
+ const edu = Array.isArray(d.education) ? d.education : [];
1261
+ builderModel.education = edu.length
1262
+ ? edu.map(e => ({ degree: s(e.degree), school: s(e.school), dates: s(e.dates) }))
1263
+ : [{ degree: '', school: '', dates: '' }];
1264
+ syncBuilderFields();
1265
+ renderBuilderRows();
1266
+ renderBuilderPreview();
1267
+ }
1268
+
1269
+ /* loading state around a Claude / save action; one at a time */
1270
+ async function withBuilderBusy(btn, fn) {
1271
+ if (builderBusy) return;
1272
+ builderBusy = true;
1273
+ const orig = btn.innerHTML;
1274
+ btn.disabled = true;
1275
+ btn.innerHTML = `<span class="spin">⏳</span> ${t('builder.working')}`;
1276
+ try { await fn(); } finally { builderBusy = false; btn.disabled = false; btn.innerHTML = orig; }
1277
+ }
1278
+
1279
+ /* prompts for the browser-side Claude calls (English — provider-facing, not UI) */
1280
+ function summaryPrompt(m) {
1281
+ const facts = [
1282
+ m.name && `Name: ${m.name}`,
1283
+ m.headline && `Target role / headline: ${m.headline}`,
1284
+ m.skills && `Skills: ${m.skills}`,
1285
+ ...m.experience.filter(e => (e.role + e.company + e.bullets).trim())
1286
+ .map(e => `Experience: ${e.role} at ${e.company} (${e.dates}). ${e.bullets.replace(/\n/g, '; ')}`),
1287
+ ...m.education.filter(e => (e.degree + e.school).trim())
1288
+ .map(e => `Education: ${e.degree}, ${e.school} (${e.dates})`),
1289
+ ].filter(Boolean).join('\n');
1290
+ return `Write a concise, professional resume summary (2-4 sentences, no heading, no bullet points) for this candidate. Return only the summary text.\n\n${facts}`;
1291
+ }
1292
+ function polishPrompt(text) {
1293
+ return `Rewrite this resume summary to be clearer and more professional. Keep it to 2-4 sentences, no heading, no bullet points. Return only the rewritten summary.\n\n${text}`;
1294
+ }
1295
+ function parsePrompt(raw) {
1296
+ return `Convert the resume below into a JSON object with exactly these keys: "name", "location", "email", "linkedin", "headline", "summary", "experience" (array of objects with "role","company","dates","bullets" — bullets is one string with each achievement on its own line), "skills" (a single comma-separated string), "education" (array of objects with "degree","school","dates"). Use empty strings or empty arrays when unknown. Return ONLY the JSON, with no explanation or code fences.\n\nRESUME:\n${raw}`;
1297
+ }
1298
+
1299
+ /* ── floating agents widget ── */
1300
+ function renderJobsWidget() {
1301
+ const active = state.jobs.filter(j => j.status === 'running' || j.status === 'queued');
1302
+ const recent = state.jobs.filter(j => j.finishedAt && Date.now() - j.finishedAt < 3 * 60_000);
1303
+ const shown = [...active, ...recent];
1304
+ const w = $('#jobs-widget');
1305
+ w.hidden = !shown.length;
1306
+ if (!shown.length) return;
1307
+ $('#jobs-widget-title').innerHTML = active.length
1308
+ ? `<span class="spin">⏳</span> ${t('widget.workingN', { n: active.length })}`
1309
+ : t('widget.finished');
1310
+ $('#jobs-widget-arrow').textContent = widgetCollapsed ? '▸' : '▾';
1311
+ $('#jobs-widget-progress').hidden = !active.length;
1312
+ const body = $('#jobs-widget-body');
1313
+ body.hidden = widgetCollapsed;
1314
+ const icon = j => j.status === 'running' ? '<span class="spin">⏳</span>'
1315
+ : j.status === 'queued' ? '🕐'
1316
+ : j.status === 'done' ? '✅'
1317
+ : j.status === 'cancelled' ? '⏹' : '❌';
1318
+ body.innerHTML = shown.map(j => `
1319
+ <div class="job-row ${j.status}">
1320
+ <div class="label" style="display:flex;align-items:center;gap:6px">
1321
+ <span style="flex:1;min-width:0">${icon(j)} ${esc(j.label)}</span>
1322
+ ${(j.status === 'running' || j.status === 'queued') ? `<button class="btn ghost sm" data-cancel-job="${j.id}" title="${t('widget.cancelTitle')}" style="padding:1px 7px;font-size:11px">⏹</button>` : ''}
1323
+ </div>
1324
+ ${j.error ? `<div class="log">${esc(j.error)}</div>` : ''}
1325
+ ${j.status === 'running' && j.log.length ? `<div class="log">${esc(j.log.slice(-4).join('\n'))}</div>` : ''}
1326
+ </div>`).join('');
1327
+ }
1328
+
1329
+ /* ── key gate: landing vs app ──
1330
+ Idempotent + cheap: called on boot, at the END of every refresh() (outside the
1331
+ userIsTyping guard) so the 2.5s poll re-asserts it, and synchronously on
1332
+ connect/disconnect. body.gated (CSS) does the actual show/hide and is authoritative;
1333
+ we also set the hidden attributes so the un-gated per-tab logic keeps working. */
1334
+ function renderGate() {
1335
+ const gated = !hasKey();
1336
+ document.body.classList.toggle('gated', gated);
1337
+ const landing = $('#view-landing');
1338
+ if (!landing) return;
1339
+ if (gated) {
1340
+ document.querySelectorAll('main.view').forEach(v => { v.hidden = v !== landing; });
1341
+ landing.hidden = false;
1342
+ $('#jobs-widget').hidden = true; // CSS also force-hides it, but keep the attr honest
1343
+ } else {
1344
+ landing.hidden = true;
1345
+ document.querySelectorAll('main.view').forEach(v => { if (v !== landing) v.hidden = v.id !== `view-${tab}`; });
1346
+ }
1347
+ }
1348
+
1349
+ /* lightweight toast (created lazily, no markup in the HTML) */
1350
+ let toastTimer;
1351
+ function showToast(msg, kind = 'ok') {
1352
+ let el = $('#wf-toast');
1353
+ if (!el) { el = document.createElement('div'); el.id = 'wf-toast'; document.body.appendChild(el); }
1354
+ el.textContent = msg;
1355
+ el.className = 'wf-toast ' + kind + ' show';
1356
+ clearTimeout(toastTimer);
1357
+ toastTimer = setTimeout(() => el.classList.remove('show'), 3200);
1358
+ }
1359
+
1360
+ /* store a key (never logged) and reveal the app */
1361
+ function connectKey() {
1362
+ const inp = $('#landing-key');
1363
+ const val = (inp.value || '').trim();
1364
+ if (!val) { inp.focus(); return; }
1365
+ if (!/^sk-/.test(val)) { showToast(t('landing.keyInvalid'), 'err'); inp.focus(); return; }
1366
+ localStorage.setItem(KEY_STORE, val);
1367
+ inp.value = '';
1368
+ tab = 'jobs';
1369
+ document.querySelectorAll('.nav-tab').forEach(x => x.classList.toggle('active', x.dataset.tab === 'jobs'));
1370
+ renderGate();
1371
+ refresh(true); // populate the just-revealed app immediately (don't wait for the idle poll)
1372
+ postReady();
1373
+ showToast(t('landing.connected'), 'ok');
1374
+ }
1375
+
1376
+ /* ─────────── events ─────────── */
1377
+
1378
+ /* top nav — no-op while gated (tabs are hidden, but guard defends postMessage/keyboard) */
1379
+ document.querySelectorAll('.nav-tab').forEach(b => b.addEventListener('click', () => {
1380
+ if (!hasKey()) return;
1381
+ tab = b.dataset.tab;
1382
+ document.querySelectorAll('.nav-tab').forEach(x => x.classList.toggle('active', x === b));
1383
+ document.querySelectorAll('main.view').forEach(v => { v.hidden = v.id !== `view-${tab}`; });
1384
+ }));
1385
+
1386
+ /* landing: hero CTA scrolls to the connect block; Connect / Enter store the key */
1387
+ $('#landing-cta').addEventListener('click', () => {
1388
+ $('#lp-connect').scrollIntoView({ behavior: 'smooth', block: 'start' });
1389
+ setTimeout(() => $('#landing-key').focus(), 380);
1390
+ });
1391
+ $('#landing-connect').addEventListener('click', connectKey);
1392
+ $('#landing-key').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); connectKey(); } });
1393
+
1394
+ /* settings: disconnect clears the key and swaps straight back to the landing */
1395
+ $('#disconnect-key').addEventListener('click', () => {
1396
+ localStorage.removeItem(KEY_STORE);
1397
+ renderGate();
1398
+ postReady();
1399
+ showToast(t('settings.keyRemoved'), 'ok');
1400
+ });
1401
+
1402
+ /* delegated clicks */
1403
+ document.addEventListener('click', async e => {
1404
+ const el = e.target.closest('[data-history-run],[data-cancel-job],[data-prof-filter],[data-del-pos],[data-toggle-pos],[data-status-tab],[data-collapse-vac],[data-vac],[data-build-profile],[data-prepare],[data-apply],[data-del-vac],[data-save-letter],[data-save-profile],[data-import],[data-del-profile],[data-lead-pitch],[data-del-lead],[data-save-pitch]');
1405
+ if (!el) return;
1406
+ const d = el.dataset;
1407
+
1408
+ if (d.historyRun !== undefined) {
1409
+ historyFilter = d.historyRun || null;
1410
+ renderHistory();
1411
+ renderStatusTabs();
1412
+ renderList();
1413
+ return;
1414
+ }
1415
+ if (d.cancelJob) {
1416
+ await api(`/api/jobs/${d.cancelJob}/cancel`, { method: 'POST' });
1417
+ refresh(true);
1418
+ return;
1419
+ }
1420
+ if (d.profFilter !== undefined) {
1421
+ profileFilter = d.profFilter;
1422
+ renderSearchPanel(); renderStatusTabs(); renderList();
1423
+ return;
1424
+ }
1425
+ if (d.statusTab) { statusTab = d.statusTab; renderStatusTabs(); renderList(); return; }
1426
+ if (d.collapseVac) { selectedVacancyId = null; renderList(); return; }
1427
+ if (d.vac) {
1428
+ selectedVacancyId = d.vac;
1429
+ renderList();
1430
+ document.querySelector('.vcard.expanded')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1431
+ return;
1432
+ }
1433
+ if (d.buildProfile) {
1434
+ const p = state.profiles.find(x => x.id === d.buildProfile);
1435
+ if (p) openBuilder(p); // editing an existing profile is always allowed
1436
+ return;
1437
+ }
1438
+
1439
+ /* labels: optimistic instant update, then sync to server */
1440
+ if (d.delPos) {
1441
+ state.positions = state.positions.filter(x => x.id !== d.delPos);
1442
+ renderSearchPanel();
1443
+ if (!historyFilter) { renderStatusTabs(); renderList(); }
1444
+ api(`/api/positions/${d.delPos}`, { method: 'DELETE' });
1445
+ return;
1446
+ }
1447
+ if (d.togglePos) {
1448
+ const p = state.positions.find(x => x.id === d.togglePos);
1449
+ p.selected = !p.selected;
1450
+ renderSearchPanel();
1451
+ if (!historyFilter) { renderStatusTabs(); renderList(); } // список следует за лейблами
1452
+ api(`/api/positions/${d.togglePos}`, { method: 'PATCH', body: { selected: p.selected } });
1453
+ return;
1454
+ }
1455
+ if (d.prepare) {
1456
+ await api(`/api/vacancies/${d.prepare}/prepare`, { method: 'POST' });
1457
+ } else if (d.apply) {
1458
+ const auto = state.settings.autoSubmit;
1459
+ if (!confirm(auto ? t('dialog.applyAuto') : t('dialog.applyManual'))) return;
1460
+ await api(`/api/vacancies/${d.apply}/autoapply`, { method: 'POST' });
1461
+ } else if (d.delVac) {
1462
+ if (!confirm(t('dialog.delVacancy'))) return;
1463
+ await api(`/api/vacancies/${d.delVac}`, { method: 'DELETE' });
1464
+ } else if (d.saveLetter) {
1465
+ const ta = document.querySelector(`textarea[data-letter="${d.saveLetter}"]`);
1466
+ await api(`/api/vacancies/${d.saveLetter}`, { method: 'PATCH', body: { coverLetter: ta.value } });
1467
+ } else if (d.saveProfile) {
1468
+ const card = el.closest('.profile-card');
1469
+ const val = f => card.querySelector(`[data-f="${f}"]`).value;
1470
+ await api('/api/profiles', { method: 'POST', body: { id: d.saveProfile, name: val('name'), email: val('email'), linkedin: val('linkedin'), location: val('location'), resume: val('resume') } });
1471
+ } else if (d.import) {
1472
+ const p = state.profiles.find(x => x.id === d.import);
1473
+ const url = prompt(t('dialog.importLinkedin'), p.linkedin || 'https://www.linkedin.com/in/');
1474
+ if (!url) return;
1475
+ await api(`/api/profiles/${d.import}/import-linkedin`, { method: 'POST', body: { url } });
1476
+ } else if (d.delProfile) {
1477
+ if (!confirm(t('dialog.delProfile'))) return;
1478
+ await api(`/api/profiles/${d.delProfile}`, { method: 'DELETE' });
1479
+ } else if (d.leadPitch) {
1480
+ await api(`/api/leads/${d.leadPitch}/pitch`, { method: 'POST' });
1481
+ } else if (d.delLead) {
1482
+ await api(`/api/leads/${d.delLead}`, { method: 'DELETE' });
1483
+ } else if (d.savePitch) {
1484
+ const ta = document.querySelector(`textarea[data-lead-pitch-text="${d.savePitch}"]`);
1485
+ await api(`/api/leads/${d.savePitch}`, { method: 'PATCH', body: { pitch: ta.value } });
1486
+ }
1487
+ refresh(true);
1488
+ });
1489
+
1490
+ /* status selects (vacancy detail + lead cards) */
1491
+ document.addEventListener('change', async e => {
1492
+ if (e.target.dataset.status) {
1493
+ await api(`/api/vacancies/${e.target.dataset.status}`, { method: 'PATCH', body: { status: e.target.value } });
1494
+ refresh(true);
1495
+ }
1496
+ if (e.target.dataset.leadStatus) {
1497
+ await api(`/api/leads/${e.target.dataset.leadStatus}`, { method: 'PATCH', body: { status: e.target.value } });
1498
+ refresh(true);
1499
+ }
1500
+ });
1501
+
1502
+ /* leads search */
1503
+ $('#search-leads').addEventListener('click', async () => {
1504
+ await api('/api/leads-search', { method: 'POST' });
1505
+ refresh(true);
1506
+ });
1507
+
1508
+ /* text filter */
1509
+ $('#text-filter').addEventListener('input', e => {
1510
+ textFilter = e.target.value;
1511
+ renderStatusTabs();
1512
+ renderList();
1513
+ });
1514
+
1515
+ /* filter bar */
1516
+ $('#filter-bar').addEventListener('change', e => {
1517
+ const key = e.target.dataset.flt;
1518
+ if (!key) return;
1519
+ filters[key] = e.target.value;
1520
+ e.target.classList.toggle('active', !!e.target.value);
1521
+ renderStatusTabs();
1522
+ renderList();
1523
+ });
1524
+ $('#validate-links').addEventListener('click', async () => {
1525
+ await api('/api/validate-links', { method: 'POST' });
1526
+ refresh(true);
1527
+ });
1528
+ $('#reset-filters').addEventListener('click', () => {
1529
+ for (const k of Object.keys(filters)) filters[k] = '';
1530
+ document.querySelectorAll('#filter-bar select').forEach(s => { s.value = ''; s.classList.remove('active'); });
1531
+ renderList();
1532
+ renderDetail();
1533
+ });
1534
+
1535
+ /* freshness for the SEARCH agents (persisted in settings) */
1536
+ $('#freshness').addEventListener('change', async e => {
1537
+ await api('/api/settings', { method: 'POST', body: { freshnessDays: Number(e.target.value) } });
1538
+ state.settings.freshnessDays = Number(e.target.value);
1539
+ });
1540
+
1541
+ /* add-position form show/hide + submit */
1542
+ $('#show-add-position').addEventListener('click', () => { $('#position-form').hidden = false; $('#position-form').title.focus(); });
1543
+ $('#hide-add-position').addEventListener('click', () => { $('#position-form').hidden = true; });
1544
+ $('#position-role').addEventListener('change', e => {
1545
+ const custom = e.target.value === 'custom';
1546
+ const f = $('#position-form');
1547
+ f.title.hidden = !custom;
1548
+ f.keywords.hidden = !custom;
1549
+ });
1550
+ $('#position-form').addEventListener('submit', async e => {
1551
+ e.preventDefault();
1552
+ const f = e.target;
1553
+ let title, keywords;
1554
+ if (f.role.value === 'custom') {
1555
+ title = f.title.value.trim();
1556
+ keywords = f.keywords.value;
1557
+ if (!title) return alert(t('dialog.enterRole'));
1558
+ } else {
1559
+ const role = state.catalog.find(r => r.id === f.role.value);
1560
+ title = `${f.seniority.value} ${role.title}`.trim();
1561
+ keywords = role.keywords;
1562
+ }
1563
+ await api('/api/positions', { method: 'POST', body: { title, keywords, profileId: f.profileId.value, engagement: f.engagement.value } });
1564
+ f.reset();
1565
+ f.hidden = true;
1566
+ refresh(true);
1567
+ });
1568
+
1569
+ /* search */
1570
+ $('#search-all').addEventListener('click', async () => {
1571
+ const btn = $('#search-all');
1572
+ if (btn.dataset.mode === 'stop') {
1573
+ const active = state.jobs.filter(j => j.type === 'search' && (j.status === 'running' || j.status === 'queued'));
1574
+ await Promise.all(active.map(j => api(`/api/jobs/${j.id}/cancel`, { method: 'POST' })));
1575
+ refresh(true);
1576
+ return;
1577
+ }
1578
+ const ids = visiblePositions().filter(p => p.selected).map(p => p.id);
1579
+ if (!ids.length) return;
1580
+ await api('/api/search', { method: 'POST', body: { positionIds: ids } });
1581
+ refresh(true);
1582
+ });
1583
+
1584
+ /* settings + profiles */
1585
+ $('#settings-form').addEventListener('submit', async e => {
1586
+ e.preventDefault();
1587
+ const f = e.target;
1588
+ /* the Anthropic key is client-side only — handle it here, never in the /api/settings body */
1589
+ const nk = (f.anthropicKey.value || '').trim();
1590
+ if (nk) {
1591
+ if (!/^sk-/.test(nk)) { showToast(t('landing.keyInvalid'), 'err'); return; }
1592
+ localStorage.setItem(KEY_STORE, nk);
1593
+ f.anthropicKey.value = '';
1594
+ showToast(t('settings.keySaved'), 'ok');
1595
+ renderGate();
1596
+ }
1597
+ await api('/api/settings', { method: 'POST', body: {
1598
+ plan: f.plan.value,
1599
+ model: f.model.value,
1600
+ resultsPerSearch: Number(f.resultsPerSearch.value) || 8,
1601
+ autoSubmit: f.autoSubmit.checked,
1602
+ searchViaBrowser: f.searchViaBrowser.checked,
1603
+ excludeBoards: f.excludeBoards.checked,
1604
+ searchExtra: f.searchExtra.value,
1605
+ } });
1606
+ refresh(true);
1607
+ });
1608
+ /* license: apply a key / open checkout */
1609
+ $('#license-apply').addEventListener('click', applyLicense);
1610
+ $('#buy-pro').addEventListener('click', () => window.open(buyUrl('pro'), '_blank', 'noopener'));
1611
+
1612
+ /* onboarding wizard */
1613
+ $('#add-profile').addEventListener('click', () => {
1614
+ const f = $('#onboard-form');
1615
+ if (f.hidden) requireProfileSlot(() => { f.hidden = false; f.name.focus(); }); // gate opening a 2nd-candidate form
1616
+ else f.hidden = true;
1617
+ });
1618
+ $('#onboard-cancel').addEventListener('click', () => { $('#onboard-form').hidden = true; });
1619
+ /* open the resume builder for a brand-new candidate (tier-gated) */
1620
+ $('#open-builder').addEventListener('click', openBuilderForNew);
1621
+ document.addEventListener('change', e => {
1622
+ const chip = e.target.closest('[data-role-chip]');
1623
+ if (chip) chip.classList.toggle('on', chip.querySelector('input').checked);
1624
+ });
1625
+ $('#onboard-form').addEventListener('submit', e => {
1626
+ e.preventDefault();
1627
+ const f = e.target;
1628
+ const name = f.name.value.trim();
1629
+ const linkedin = f.linkedin.value.trim();
1630
+ const resumeText = f.resumeText.value.trim();
1631
+ if (!name) { f.name.focus(); return; }
1632
+ if (!linkedin && !resumeText) { showToast(t('onboard.needSource'), 'err'); return; }
1633
+ const linkedinUrl = linkedin ? normalizeLinkedin(linkedin) : '';
1634
+ if (linkedin && !linkedinUrl) { showToast(t('jobsonb.badLinkedin'), 'err'); return; }
1635
+ const roles = [...f.querySelectorAll('#onb-roles input:checked')].map(i => i.value);
1636
+ requireProfileSlot(async () => {
1637
+ const r = await apiQuiet('/api/onboard', { method: 'POST', body: {
1638
+ name, linkedin, resumeText, location: f.location.value, seniority: f.seniority.value, engagement: f.engagement.value, roles,
1639
+ } });
1640
+ if (!r.ok) { showToast(t('common.createFailed'), 'err'); return; }
1641
+ f.reset();
1642
+ f.querySelectorAll('[data-role-chip]').forEach(c => c.classList.remove('on'));
1643
+ f.hidden = true;
1644
+ showToast(t('dialog.candidateAdded'), 'ok');
1645
+ refresh(true);
1646
+ });
1647
+ });
1648
+
1649
+ /* jobs zero-state quick add (name + LinkedIn) — routes through /api/onboard so
1650
+ positions + a first search kick off automatically (linear onboarding) */
1651
+ $('#jobs-onboard-form').addEventListener('submit', e => {
1652
+ e.preventDefault();
1653
+ const f = e.target;
1654
+ const name = f.name.value.trim();
1655
+ const linkedin = f.linkedin.value.trim();
1656
+ if (!name) { f.name.focus(); return; }
1657
+ if (!linkedin) { showToast(t('jobsonb.needLinkedin'), 'err'); return; } // quick path needs a LinkedIn (server needs linkedin or resume)
1658
+ const linkedinUrl = normalizeLinkedin(linkedin);
1659
+ if (!linkedinUrl) { showToast(t('jobsonb.badLinkedin'), 'err'); return; }
1660
+ requireProfileSlot(async () => {
1661
+ const r = await apiQuiet('/api/onboard', { method: 'POST', body: { name, linkedin, engagement: 'both', roles: [] } });
1662
+ if (!r.ok) { showToast(t('common.createFailed'), 'err'); return; }
1663
+ f.reset();
1664
+ showToast(t('dialog.candidateAdded'), 'ok');
1665
+ refresh(true);
1666
+ });
1667
+ });
1668
+ $('#jobs-onboard-builder').addEventListener('click', openBuilderForNew);
1669
+
1670
+ /* ── pro upsell modal ── */
1671
+ $('#upsell-upgrade').addEventListener('click', upgradeToPro);
1672
+ $('#upsell-close').addEventListener('click', closeUpsell);
1673
+ $('#upsell-later').addEventListener('click', closeUpsell);
1674
+
1675
+ /* ── resume builder wiring ── */
1676
+ $('#builder-close').addEventListener('click', closeBuilder);
1677
+ $('#builder-cancel').addEventListener('click', closeBuilder);
1678
+ $('#builder-add-exp').addEventListener('click', () => {
1679
+ builderModel.experience.push({ role: '', company: '', dates: '', bullets: '' });
1680
+ renderBuilderRows(); renderBuilderPreview();
1681
+ });
1682
+ $('#builder-add-edu').addEventListener('click', () => {
1683
+ builderModel.education.push({ degree: '', school: '', dates: '' });
1684
+ renderBuilderRows(); renderBuilderPreview();
1685
+ });
1686
+ /* live-update model + preview on typing, without re-rendering the inputs (keeps caret) */
1687
+ $('#builder-form').addEventListener('input', e => {
1688
+ const el = e.target;
1689
+ if (!builderModel) return;
1690
+ if (el.dataset.bf) { builderModel[el.dataset.bf] = el.value; renderBuilderPreview(); return; }
1691
+ if (el.dataset.expField !== undefined) { builderModel.experience[Number(el.dataset.expIdx)][el.dataset.expField] = el.value; renderBuilderPreview(); return; }
1692
+ if (el.dataset.eduField !== undefined) { builderModel.education[Number(el.dataset.eduIdx)][el.dataset.eduField] = el.value; renderBuilderPreview(); }
1693
+ });
1694
+ $('#builder-form').addEventListener('click', e => {
1695
+ const rm = e.target.closest('[data-rm-exp],[data-rm-edu]');
1696
+ if (!rm || !builderModel) return;
1697
+ e.preventDefault();
1698
+ if (rm.dataset.rmExp !== undefined) builderModel.experience.splice(Number(rm.dataset.rmExp), 1);
1699
+ else builderModel.education.splice(Number(rm.dataset.rmEdu), 1);
1700
+ renderBuilderRows(); renderBuilderPreview();
1701
+ });
1702
+ $('#builder-file').addEventListener('change', async e => {
1703
+ const file = e.target.files && e.target.files[0];
1704
+ e.target.value = '';
1705
+ if (!file) return;
1706
+ if (file.size > 512 * 1024) { showToast(t('builder.fileTooBig'), 'err'); return; }
1707
+ try { $('#builder-import').value = await file.text(); showToast(t('builder.fileLoaded'), 'ok'); }
1708
+ catch { showToast(t('builder.fileError'), 'err'); }
1709
+ });
1710
+ $('#builder-parse').addEventListener('click', () => withBuilderBusy($('#builder-parse'), async () => {
1711
+ const raw = $('#builder-import').value.trim();
1712
+ if (!raw) { showToast(t('builder.parseEmpty'), 'err'); return; }
1713
+ let text;
1714
+ try { text = await claudeText(parsePrompt(raw), 1500); }
1715
+ catch (err) { showToast(claudeErrMsg(err), 'err'); return; }
1716
+ const parsed = safeJson(text);
1717
+ if (!parsed) { builderModel.summary = raw; syncBuilderFields(); renderBuilderPreview(); showToast(t('builder.parseFailed'), 'err'); return; }
1718
+ applyParsed(parsed);
1719
+ showToast(t('builder.parsed'), 'ok');
1720
+ }));
1721
+ $('#builder-gen-summary').addEventListener('click', () => withBuilderBusy($('#builder-gen-summary'), async () => {
1722
+ try { builderModel.summary = await claudeText(summaryPrompt(builderModel), 400); syncBuilderFields(); renderBuilderPreview(); showToast(t('builder.summaryDone'), 'ok'); }
1723
+ catch (err) { showToast(claudeErrMsg(err), 'err'); }
1724
+ }));
1725
+ $('#builder-polish').addEventListener('click', () => withBuilderBusy($('#builder-polish'), async () => {
1726
+ if (!builderModel.summary.trim()) { showToast(t('builder.polishEmpty'), 'err'); return; }
1727
+ try { builderModel.summary = await claudeText(polishPrompt(builderModel.summary), 400); syncBuilderFields(); renderBuilderPreview(); showToast(t('builder.summaryDone'), 'ok'); }
1728
+ catch (err) { showToast(claudeErrMsg(err), 'err'); }
1729
+ }));
1730
+ $('#builder-save').addEventListener('click', () => {
1731
+ const m = builderModel;
1732
+ if (!m || !m.name.trim()) { showToast(t('builder.needName'), 'err'); return; }
1733
+ const resume = composeResume(m);
1734
+ if (m.editingId) {
1735
+ withBuilderBusy($('#builder-save'), async () => {
1736
+ const r = await apiQuiet('/api/profiles', { method: 'POST', body: { id: m.editingId, name: m.name.trim(), email: m.email.trim(), linkedin: m.linkedin.trim(), location: m.location.trim(), resume } });
1737
+ if (!r.ok) { showToast(t('common.createFailed'), 'err'); return; }
1738
+ closeBuilder(); showToast(t('builder.saved'), 'ok'); refresh(true);
1739
+ });
1740
+ } else {
1741
+ // new candidate: tier-gated, then routed through onboard for auto positions + search
1742
+ requireProfileSlot(() => withBuilderBusy($('#builder-save'), async () => {
1743
+ const li = m.linkedin.trim();
1744
+ const body = { name: m.name.trim(), resumeText: resume, location: m.location.trim() || 'Ukraine', engagement: 'both', roles: [] };
1745
+ if (/^https:\/\/(www\.)?linkedin\.com\/in\//.test(li)) body.linkedin = li; // only send a valid LinkedIn; resumeText satisfies onboard otherwise
1746
+ const r = await apiQuiet('/api/onboard', { method: 'POST', body });
1747
+ if (!r.ok) { showToast(t('common.createFailed'), 'err'); return; }
1748
+ closeBuilder(); showToast(t('dialog.candidateAdded'), 'ok'); refresh(true);
1749
+ }));
1750
+ }
1751
+ });
1752
+
1753
+ $('#jobs-widget-toggle').addEventListener('click', () => { widgetCollapsed = !widgetCollapsed; renderJobsWidget(); });
1754
+
1755
+ /* theme toggle */
1756
+ function syncThemeBtn() {
1757
+ $('#theme-toggle').textContent = document.documentElement.dataset.theme === 'dark' ? '☀️' : '🌙';
1758
+ }
1759
+ $('#theme-toggle').addEventListener('click', () => {
1760
+ const dark = document.documentElement.dataset.theme === 'dark';
1761
+ if (dark) delete document.documentElement.dataset.theme;
1762
+ else document.documentElement.dataset.theme = 'dark';
1763
+ localStorage.setItem(THEME_KEY, dark ? 'light' : 'dark');
1764
+ syncThemeBtn();
1765
+ });
1766
+ syncThemeBtn();
1767
+
1768
+ /* language toggle: no reload, no state loss. applyStaticI18n() repaints the fixed
1769
+ markup; refresh(true) bypasses the userIsTyping() guard and re-runs every render
1770
+ fn from the same server `state` + the same module vars (active tab, filters,
1771
+ selected card…), so only the visible strings change. renderOnboard is one-time
1772
+ (onboardRendered) — reset it so its fallback option labels re-translate. */
1773
+ function syncLangBtns() {
1774
+ document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === lang));
1775
+ }
1776
+ function setLang(l) {
1777
+ l = l === 'ru' ? 'ru' : 'en';
1778
+ if (l === lang) return;
1779
+ lang = l;
1780
+ localStorage.setItem(LANG_KEY, lang);
1781
+ document.documentElement.lang = lang;
1782
+ if (state) state.lang = lang;
1783
+ syncLangBtns();
1784
+ applyStaticI18n();
1785
+ onboardRendered = false;
1786
+ postReady(); // keep the embedding hub's sidebar labels in sync (no-op when standalone)
1787
+ refresh(true);
1788
+ }
1789
+ document.querySelectorAll('.lang-btn').forEach(b => b.addEventListener('click', () => setLang(b.dataset.lang)));
1790
+ syncLangBtns();
1791
+ applyStaticI18n();
1792
+ renderGate(); // set landing/app DOM state before the first /api/state resolves
1793
+
1794
+ /* ─────────── polling ─────────── */
1795
+ setInterval(async () => {
1796
+ try {
1797
+ const jobs = await api('/api/jobs');
1798
+ const fp = jobs.map(j => j.id + j.status).join('|');
1799
+ if (fp !== lastJobsFingerprint) {
1800
+ lastJobsFingerprint = fp;
1801
+ await refresh();
1802
+ } else if (jobs.some(j => j.status === 'running')) {
1803
+ // во время работы агентов тянем состояние целиком:
1804
+ // партиальные находки «залетают» в список по мере подтверждения
1805
+ await refresh();
1806
+ }
1807
+ } catch { /* server restarting */ }
1808
+ }, 2500);
1809
+
1810
+ refresh(true);
1811
+
1812
+ /* ─────────── embed bridge (Qiksy Studio hub ⇄ mini-app) ───────────
1813
+ In embed mode the hub's unified bar drives navigation via postMessage.
1814
+ Tab ids are fixed by the shared contract; map them to this app's views. */
1815
+ const EMBED_TAB_TO_VIEW = { vacancies: 'jobs', leads: 'leads', profiles: 'profiles', settings: 'settings' };
1816
+ const VIEW_TO_EMBED_TAB = { jobs: 'vacancies', leads: 'leads', profiles: 'profiles', settings: 'settings' };
1817
+
1818
+ function postTab(view) {
1819
+ if (!EMBED) return;
1820
+ try { window.parent.postMessage({ qiksy: 'tab', id: VIEW_TO_EMBED_TAB[view] || view }, '*'); } catch { /* not embedded / cross-origin */ }
1821
+ }
1822
+
1823
+ /* embed-only view switch: same DOM operation as the top-nav click handler,
1824
+ without reloading. Called when the hub sends a nav message. */
1825
+ function embedNavTo(embedId) {
1826
+ if (!hasKey()) return; // gated: the hub can't jump into the app without a key
1827
+ const view = EMBED_TAB_TO_VIEW[embedId];
1828
+ if (!view) return;
1829
+ tab = view;
1830
+ document.querySelectorAll('.nav-tab').forEach(x => x.classList.toggle('active', x.dataset.tab === view));
1831
+ document.querySelectorAll('main.view').forEach(v => { v.hidden = v.id !== `view-${view}`; });
1832
+ postTab(view);
1833
+ }
1834
+
1835
+ /* Announce (or re-announce) this app's sub-tabs to the hub sidebar with localized
1836
+ labels. The `id` fields are the fixed shared contract — never translated. Called
1837
+ once on load and again on language change so the hub relabels in place. No-op
1838
+ when standalone. */
1839
+ function postReady() {
1840
+ if (!EMBED) return;
1841
+ try {
1842
+ window.parent.postMessage({
1843
+ qiksy: 'ready',
1844
+ tabs: [
1845
+ { id: 'vacancies', label: t('nav.jobs') },
1846
+ { id: 'leads', label: t('nav.leads') },
1847
+ { id: 'profiles', label: t('nav.profiles') },
1848
+ { id: 'settings', label: t('nav.settings') },
1849
+ ],
1850
+ active: VIEW_TO_EMBED_TAB[tab] || 'vacancies',
1851
+ }, '*');
1852
+ } catch { /* not embedded / cross-origin */ }
1853
+ }
1854
+
1855
+ if (EMBED) {
1856
+ window.addEventListener('message', e => {
1857
+ if (!e.data) return;
1858
+ if (e.data.qiksy === 'nav') embedNavTo(e.data.to);
1859
+ else if (e.data.qiksy === 'mode') applyEmbedTheme(e.data.mode);
1860
+ else if (e.data.qiksy === 'lang') setLang(e.data.lang); // hub-driven language (embedded topbar is hidden)
1861
+ });
1862
+ postReady();
1863
+ }