1import { getResumes, getResume, saveResume, deleteResume, getProfile } from '../db.js'; 2import { formatDate, escapeHtml, svgIcon, uid } from '../utils.js'; 3import { canScoreResume, markResumeUsed, savedResumesLimit, isPro } from '../plan.js'; 4import { showUpgradeModal } from '../components/modal.js'; 5import { showToast } from '../components/toast.js'; 6import { getAccessToken } from '../auth.js'; 7import { maybeStartPipelineOnboarding } from '../components/kemba-pipeline-onboarding.js'; 8import { markPhase1Completed } from '../phases.js'; 9import { t, hasKey } from '../i18n.js'; 10 11// ââ Module state âââââââââââââââââââââââââââââââââââââââââââââââââ 12let resumes = []; 13let currentResume = null; 14let experiences = []; 15let educations = []; 16let _rbProfile = null; 17 18// Reveal-flow state (Part 9): the resume that was scored + the AI rewrite result. 19let _revealBase = null; // resumeData passed to the score screen 20let _revealRewrite = null; // { rewrittenResume, newScore, changes } 21let _revealOldScore = null; // the pre-rewrite score (0-100) 22let _lastJobDesc = ''; // job description from the last scoring run (for rewrite context) 23 24const CIRC = 2 * Math.PI * 54; 25 26// Scores are stored 0-100 internally but displayed on a /10 scale (Part 9). 27function fmt10(n) { return (Math.round((Number(n) || 0) * 10) / 100).toFixed(1); } 28 29function scoreColor(n) { 30 if (n >= 80) return '#22c55e'; 31 if (n >= 60) return '#f59e0b'; 32 return '#ef4444'; 33} 34 35function scoreLabel(n) { 36 if (n >= 80) return t('rb.score_excellent'); 37 if (n >= 60) return t('rb.score_good'); 38 if (n >= 40) return t('rb.score_needs_work'); 39 return t('rb.score_poor'); 40} 41 42// ââ Entry point ââââââââââââââââââââââââââââââââââââââââââââââââââ 43 44export async function renderResumeBuilder(container) { 45 container.innerHTML = `<div class="fade-in" id="rb-root"></div>`; 46 [resumes, _rbProfile] = await Promise.all([getResumes(), getProfile().catch(() => null)]); 47 currentResume = null; 48 experiences = []; 49 educations = []; 50 const root = document.getElementById('rb-root'); 51 renderRoot(root); 52 53 // Part 8 hand-off: onboarding's "Tailor an existing Resume" path uploads the 54 // PDF inline, then routes here with the resume + targeting already captured. 55 // Load it straight into the editor and run the (free) scoring automatically so 56 // the user lands on the score screen with no second manual upload. 57 const pre = window.__rbResume; 58 if (pre && (pre.raw_text || '').trim()) { 59 window.__rbResume = null; 60 openEditor(root, null); 61 root.querySelector('#rb-name').value = pre.name || 'My Resume'; 62 root.querySelector('#rb-target-role').value = pre.target_role || ''; 63 root.querySelector('#rb-raw-text').value = pre.raw_text || ''; 64 if (pre.location) root.querySelector('#rb-c-location').value = pre.location; 65 // Auto-run scoring (free) â lands on the score screen, ready to "Generate My New Resume". 66 handleSave(root, true); 67 return; 68 } 69 70 // Deep-link entry from the resume-first onboarding: open straight into the 71 // editor in the chosen mode ('upload' = tailor existing, 'scratch' = build new). 72 const intent = window.__rbIntent; 73 if (intent) { 74 window.__rbIntent = null; 75 openEditor(root, null); 76 if (intent === 'upload') { 77 // Pop the PDF picker so they can drop in their existing CV right away. 78 setTimeout(() => root.querySelector('#rb-pdf-upload')?.click(), 350); 79 } 80 } 81} 82 83function renderRoot(root) { 84 root.innerHTML = htmlScreenA() + htmlScreenB() 85 + `<div id="rb-screen-c" class="hidden"></div>` 86 + `<div id="rb-screen-d" class="hidden"></div>`; 87 bindScreenA(root); 88 bindScreenB(root); 89 showScreen('a', root); 90} 91 92// ââ Screen A: Resume list ââââââââââââââââââââââââââââââââââââââââ 93 94function htmlScreenA() { 95 return ` 96 <div id="rb-screen-a" class="hidden"> 97 <div class="dash-header"> 98 <div> 99 <div class="dash-title">${t('rb.title')}</div> 100 <div class="dash-subtitle">${t('rb.subtitle')}</div> 101 </div> 102 <button class="btn btn-primary" id="rb-new-btn"> 103 ${svgIcon('plus', '#000', 16)} ${t('rb.new')} 104 </button> 105 </div> 106 <div id="rb-list">${htmlResumeList()}</div> 107 </div> 108 `; 109} 110 111function htmlResumeList() { 112 if (!resumes.length) { 113 return ` 114 <div class="empty-state"> 115 <div class="empty-icon">${svgIcon('file-text', 'currentColor', 32)}</div> 116 <div class="empty-title">${t('rb.empty_title')}</div> 117 <div class="empty-desc">${t('rb.empty_desc')}</div> 118 </div> 119 `; 120 } 121 return `<div class="rb-resume-grid">${resumes.map(htmlResumeCard).join('')}</div>`; 122} 123 124function htmlResumeCard(r) { 125 const s = r.ai_score; 126 const badge = s != null 127 ? `<span class="rb-score-badge" style="background:${scoreColor(s)}22;color:${scoreColor(s)};border-color:${scoreColor(s)}55">${fmt10(s)}</span>` 128 : `<span class="rb-score-badge rb-score-none">â</span>`;
129 return ` 130 <div class="app-card rb-resume-card" data-id="${r.id}" style="cursor:pointer"> 131 <div class="card-header"> 132 <div> 133 <div class="card-company">${escapeHtml(r.name || t('rb.untitled'))}</div> 134 <div class="card-title">${escapeHtml(r.target_role || t('rb.no_target'))}</div> 135 </div> 136 <div class="card-badges">${badge}</div> 137 </div> 138 <div class="card-meta"> 139 <span class="card-meta-item">${svgIcon('calendar', 'currentColor', 12)} ${t('common.updated', { date: formatDate(r.updated_at) })}</span> 140 </div> 141 <div class="card-footer" style="justify-content:flex-end"> 142 <button class="btn btn-sm btn-secondary" data-edit="${r.id}"> 143 ${svgIcon('edit', 'currentColor', 12)} ${t('btn.edit')} 144 </button> 145 <button class="btn btn-sm btn-danger rb-delete-btn" data-id="${r.id}"> 146 ${svgIcon('trash', 'currentColor', 12)} ${t('btn.delete')} 147 </button> 148 </div> 149 </div> 150 `; 151} 152 153function bindScreenA(root) { 154 root.querySelector('#rb-new-btn')?.addEventListener('click', () => { 155 const limit = savedResumesLimit(); 156 if (resumes.length >= limit) { 157 showUpgradeModal('saved_resumes_limit'); 158 return; 159 } 160 openEditor(root, null); 161 }); 162 163 root.addEventListener('click', e => { 164 const delBtn = e.target.closest('.rb-delete-btn'); 165 const editBtn = e.target.closest('[data-edit]'); 166 const card = e.target.closest('.rb-resume-card'); 167 168 if (delBtn) { e.stopPropagation(); confirmDelete(root, delBtn.dataset.id); return; } 169 if (editBtn) { e.stopPropagation(); loadAndEditResume(root, editBtn.dataset.edit); return; } 170 if (card) { loadAndEditResume(root, card.dataset.id); } 171 }); 172} 173 174// ââ Screen B: Editor âââââââââââââââââââââââââââââââââââââââââââââ 175 176function htmlScreenB() { 177 return ` 178 <div id="rb-screen-b" class="hidden"> 179 <div class="view-header"> 180 <button class="back-btn" id="rb-back-list" title="Back to resumes"> 181 ${svgIcon('arrow-left', 'currentColor', 16)} 182 </button> 183 <h2 class="view-header-title" id="rb-editor-title">${t('rb.new')}</h2> 184 </div> 185 186 <div class="rb-editor-wrap"> 187 <div class="form-card" style="max-width:100%"> 188 189 <div class="form-section"> 190 <div class="form-section-title">${t('rb.details')}</div> 191 <div class="form-row"> 192 <div class="form-group"> 193 <label class="form-label" for="rb-name">${t('rb.name')}</label> 194 <input type="text" id="rb-name" class="form-input" placeholder="e.g. My SWE Resume"> 195 </div> 196 <div class="form-group"> 197 <label class="form-label" for="rb-target-role">${t('rb.target_role')}</label> 198 <input type="text" id="rb-target-role" class="form-input" placeholder="e.g. Software Engineer"> 199 </div> 200 </div> 201 </div> 202 203 <div class="form-section"> 204 <div class="form-section-title"> 205 ${t('rb.jd')} 206 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-muted);font-size:12px"> ${t('rb.jd_hint')}</span> 207 </div> 208 <div class="form-row single"> 209 <div class="form-group"> 210 <textarea id="rb-job-desc" class="form-textarea" rows="4" 211 placeholder="${t('rb.jd_ph')}"></textarea> 212 </div> 213 </div> 214 </div> 215 216 <div class="form-section"> 217 <div class="form-section-title">${t('rb.full_text')}</div> 218 <div class="rb-upload-row"> 219 <label class="btn btn-secondary btn-sm rb-upload-label" for="rb-pdf-upload"> 220 ${svgIcon('file-text', 'currentColor', 13)} ${t('rb.upload_pdf')} 221 </label> 222 <input type="file" id="rb-pdf-upload" accept=".pdf" style="display:none"> 223 <span id="rb-upload-status" class="rb-upload-status"></span> 224 </div> 225 <div class="form-row single"> 226 <div class="form-group"> 227 <textarea id="rb-raw-text" class="form-textarea" rows="10" 228 placeholder="${t('rb.raw_ph')}"></textarea> 229 </div> 230 </div> 231 </div> 232 233 <div class="form-section" style="margin-bottom:0"> 234 <button class="rb-section-toggle" id="rb-toggle-structured" type="button">
235 <span class="rb-toggle-icon">${svgIcon('plus', 'var(--accent)', 14)}</span> 236 <span>${t('rb.build_sections')}</span> 237 <span class="rb-toggle-chevron">${svgIcon('sort-desc', 'currentColor', 13)}</span> 238 </button> 239 <div id="rb-structured" class="hidden"> 240 ${htmlStructured()} 241 </div> 242 </div> 243 244 <div id="rb-error" class="rb-error hidden"></div> 245 246 <div class="rb-action-bar"> 247 <button class="btn btn-secondary" id="rb-save-draft">${t('rb.save_draft')}</button> 248 <button class="btn btn-primary rb-score-btn" id="rb-save-score"> 249 ${t('rb.score_btn')} 250 </button> 251 </div> 252 253 </div> 254 </div> 255 </div> 256 `; 257} 258 259function htmlStructured() { 260 return ` 261 <div class="rb-structured-inner"> 262 263 <div class="form-section"> 264 <div class="form-section-title">${t('rb.contact')}</div> 265 <div class="form-row"> 266 <div class="form-group"> 267 <label class="form-label">${t('label.full_name')}</label> 268 <input type="text" id="rb-c-name" class="form-input" placeholder="Jane Doe"> 269 </div> 270 <div class="form-group"> 271 <label class="form-label">${t('label.email')}</label> 272 <input type="text" id="rb-c-email" class="form-input" placeholder="[email protected]"> 273 </div> 274 </div> 275 <div class="form-row triple"> 276 <div class="form-group"> 277 <label class="form-label">${t('label.phone')}</label> 278 <input type="text" id="rb-c-phone" class="form-input" placeholder="+1 (555) 000-0000"> 279 </div> 280 <div class="form-group"> 281 <label class="form-label">${t('label.linkedin')}</label> 282 <input type="text" id="rb-c-linkedin" class="form-input" placeholder="linkedin.com/in/â¦"> 283 </div> 284 <div class="form-group"> 285 <label class="form-label">${t('label.location')}</label> 286 <input type="text" id="rb-c-location" class="form-input" placeholder="New York, NY"> 287 </div> 288 </div> 289 </div> 290 291 <div class="form-section"> 292 <div class="form-section-title">${t('rb.summary')}</div> 293 <div class="form-row single"> 294 <div class="form-group"> 295 <textarea id="rb-summary" class="form-textarea" rows="4" 296 placeholder="${t('rb.summary_ph')}"></textarea> 297 </div> 298 </div> 299 </div> 300 301 <div class="form-section"> 302 <div class="form-section-title">${t('rb.experience')}</div> 303 <div id="rb-exp-list"></div> 304 <button class="btn btn-ghost btn-sm rb-add-row-btn" id="rb-add-exp" type="button"> 305 ${svgIcon('plus', 'currentColor', 12)} ${t('rb.add_position')} 306 </button> 307 </div> 308 309 <div class="form-section"> 310 <div class="form-section-title">${t('rb.education')}</div> 311 <div id="rb-edu-list"></div> 312 <button class="btn btn-ghost btn-sm rb-add-row-btn" id="rb-add-edu" type="button"> 313 ${svgIcon('plus', 'currentColor', 12)} ${t('rb.add_education')} 314 </button> 315 </div> 316 317 <div class="form-section" style="margin-bottom:0"> 318 <div class="form-section-title">${t('rb.skills_certs')}</div> 319 <div class="form-row"> 320 <div class="form-group"> 321 <label class="form-label">${t('rb.skills')} <small style="color:var(--text-muted);font-weight:400">${t('rb.comma_sep')}</small></label> 322 <input type="text" id="rb-skills" class="form-input" placeholder="JavaScript, React, Node.js, SQLâ¦"> 323 </div> 324 <div class="form-group"> 325 <label class="form-label">${t('rb.certs')} <small style="color:var(--text-muted);font-weight:400">${t('rb.comma_sep')}</small></label> 326 <input type="text" id="rb-certs" class="form-input" placeholder="AWS Solutions Architect, PMPâ¦"> 327 </div> 328 </div> 329 </div> 330 331 </div> 332 `; 333} 334 335function htmlExpRow(i, exp = {}) { 336 return ` 337 <div class="rb-repeat-group" data-exp="${i}"> 338 <div class="rb-repeat-header">
339 <span class="rb-repeat-label">${t('rb.position_n', { n: i + 1 })}</span> 340 <button class="btn-icon rb-rm-exp" data-idx="${i}" type="button" title="Remove"> 341 ${svgIcon('x', 'currentColor', 12)} 342 </button> 343 </div> 344 <div class="form-row"> 345 <div class="form-group"> 346 <label class="form-label">${t('label.company')}</label> 347 <input class="form-input rb-exp-company" placeholder="Acme Corp" value="${escapeHtml(exp.company || '')}"> 348 </div> 349 <div class="form-group"> 350 <label class="form-label">${t('label.position')}</label> 351 <input class="form-input rb-exp-title" placeholder="Senior Engineer" value="${escapeHtml(exp.title || '')}"> 352 </div> 353 </div> 354 <div class="form-row single"> 355 <div class="form-group"> 356 <label class="form-label">${t('rb.dates')}</label> 357 <input class="form-input rb-exp-dates" placeholder="Jan 2021 â Present" value="${escapeHtml(exp.dates || '')}"> 358 </div> 359 </div> 360 <div class="form-row single"> 361 <div class="form-group"> 362 <label class="form-label">${t('rb.bullets')}</label> 363 <textarea class="form-textarea rb-exp-bullets" rows="3" 364 placeholder="⢠Built a scalable microservices platformâ¦">${escapeHtml(exp.bullets || '')}</textarea> 365 </div> 366 </div> 367 </div> 368 `; 369} 370 371function htmlEduRow(i, edu = {}) { 372 return ` 373 <div class="rb-repeat-group" data-edu="${i}"> 374 <div class="rb-repeat-header"> 375 <span class="rb-repeat-label">${t('rb.education_n', { n: i + 1 })}</span> 376 <button class="btn-icon rb-rm-edu" data-idx="${i}" type="button" title="Remove"> 377 ${svgIcon('x', 'currentColor', 12)} 378 </button> 379 </div> 380 <div class="form-row triple"> 381 <div class="form-group"> 382 <label class="form-label">${t('rb.school')}</label> 383 <input class="form-input rb-edu-school" placeholder="MIT" value="${escapeHtml(edu.school || '')}"> 384 </div> 385 <div class="form-group"> 386 <label class="form-label">${t('rb.degree')}</label> 387 <input class="form-input rb-edu-degree" placeholder="B.S. Computer Science" value="${escapeHtml(edu.degree || '')}"> 388 </div> 389 <div class="form-group"> 390 <label class="form-label">${t('rb.dates')}</label> 391 <input class="form-input rb-edu-dates" placeholder="2017 â 2021" value="${escapeHtml(edu.dates || '')}"> 392 </div> 393 </div> 394 </div> 395 `; 396} 397 398function bindScreenB(root) { 399 root.querySelector('#rb-back-list')?.addEventListener('click', () => showScreen('a', root)); 400 401 root.querySelector('#rb-toggle-structured')?.addEventListener('click', () => { 402 root.querySelector('#rb-structured')?.classList.toggle('hidden'); 403 root.querySelector('#rb-toggle-structured')?.classList.toggle('open'); 404 }); 405 406 root.querySelector('#rb-add-exp')?.addEventListener('click', () => { 407 syncExpState(root); 408 experiences.push({}); 409 refreshExpList(root); 410 }); 411 412 root.querySelector('#rb-add-edu')?.addEventListener('click', () => { 413 syncEduState(root); 414 educations.push({}); 415 refreshEduList(root); 416 }); 417 418 root.querySelector('#rb-exp-list')?.addEventListener('click', e => { 419 const rm = e.target.closest('.rb-rm-exp'); 420 if (!rm) return; 421 syncExpState(root); 422 experiences.splice(parseInt(rm.dataset.idx), 1); 423 refreshExpList(root); 424 }); 425 426 root.querySelector('#rb-edu-list')?.addEventListener('click', e => { 427 const rm = e.target.closest('.rb-rm-edu'); 428 if (!rm) return; 429 syncEduState(root); 430 educations.splice(parseInt(rm.dataset.idx), 1); 431 refreshEduList(root); 432 }); 433 434 root.querySelector('#rb-save-draft')?.addEventListener('click', () => handleSave(root, false)); 435 root.querySelector('#rb-save-score')?.addEventListener('click', () => handleSave(root, true)); 436 437 // PDF upload â extract text into the textarea 438 root.querySelector('#rb-pdf-upload')?.addEventListener('change', async e => { 439 const file = e.target.files?.[0]; 440 const statusEl = root.querySelector('#rb-upload-status'); 441 if (!file) return; 442 if (statusEl) statusEl.textContent = 'Extractingâ¦'; 443 try { 444 const text = await extractPDFText(file); 445 root.querySelector('#rb-raw-text').value = text; 446 if (statusEl) { statusEl.textContent = 'â Resume extracted'; statusEl.style.color = 'var(--s-offer)'; } 447 } catch (err) { 448 if (statusEl) { statusEl.textContent = 'â ' + err.message; statusEl.style.color = 'var(--s-rejected)'; } 449 } 450 }); 451} 452 453function refreshExpList(root) { 454 const list = root.querySelector('#rb-exp-list'); 455 if (list) list.innerHTML = experiences.map((e, i) => htmlExpRow(i, e)).join(''); 456} 457 458function refreshEduList(root) { 459 const list = root.querySelector('#rb-edu-list'); 460 if (list) list.innerHTML = educations.map((e, i) => htmlEduRow(i, e)).join(''); 461} 462 463function syncExpState(root) { 464 root.querySelectorAll('[data-exp]').forEach((g, i) => { 465 if (!experiences[i]) experiences[i] = {}; 466 experiences[i].company = g.querySelector('.rb-exp-company')?.value || ''; 467 experiences[i].title = g.querySelector('.rb-exp-title')?.value || ''; 468 experiences[i].dates = g.querySelector('.rb-exp-dates')?.value || ''; 469 experiences[i].bullets = g.querySelector('.rb-exp-bullets')?.value || ''; 470 }); 471} 472 473function syncEduState(root) { 474 root.querySelectorAll('[data-edu]').forEach((g, i) => { 475 if (!educations[i]) educations[i] = {}; 476 educations[i].school = g.querySelector('.rb-edu-school')?.value || ''; 477 educations[i].degree = g.querySelector('.rb-edu-degree')?.value || ''; 478 educations[i].dates = g.querySelector('.rb-edu-dates')?.value || ''; 479 }); 480} 481 482// ââ Editor helpers ââââââââââââââââââââââââââââââââââââââââââââââââ 483 484async function loadAndEditResume(root, id) { 485 const resume = await getResume(id); 486 if (resume) openEditor(root, resume); 487} 488 489function openEditor(root, resume) { 490 currentResume = resume ? { ...resume } : { id: uid() }; 491 experiences = (resume?.content?.experience || []).map(e => ({ ...e })); 492 educations = (resume?.content?.education || []).map(e => ({ ...e })); 493 494 root.querySelector('#rb-editor-title').textContent = resume 495 ? (resume.name || 'Edit Resume') : 'New Resume'; 496 497 root.querySelector('#rb-name').value = resume?.name || ''; 498 root.querySelector('#rb-target-role').value = resume?.target_role || (!resume && _rbProfile?.target_role) || ''; 499 root.querySelector('#rb-job-desc').value = ''; 500 root.querySelector('#rb-raw-text').value = resume?.raw_text || ''; 501 502 const c = resume?.content || {}; 503 root.querySelector('#rb-c-name').value = c.contact?.name || ''; 504 root.querySelector('#rb-c-email').value = c.contact?.email || ''; 505 root.querySelector('#rb-c-phone').value = c.contact?.phone || ''; 506 root.querySelector('#rb-c-linkedin').value = c.contact?.linkedin || ''; 507 root.querySelector('#rb-c-location').value = c.contact?.location || ''; 508 root.querySelector('#rb-summary').value = c.summary || ''; 509 root.querySelector('#rb-skills').value = (c.skills || []).join(', '); 510 root.querySelector('#rb-certs').value = (c.certifications || []).join(', '); 511 512 refreshExpList(root); 513 refreshEduList(root); 514 515 root.querySelector('#rb-error').classList.add('hidden'); 516 root.querySelector('#rb-structured').classList.add('hidden'); 517 root.querySelector('#rb-toggle-structured')?.classList.remove('open'); 518 519 showScreen('b', root); 520} 521 522function collectData(root) { 523 syncExpState(root); 524 syncEduState(root); 525 return { 526 id: currentResume.id, 527 name: root.querySelector('#rb-name')?.value?.trim() || 'Untitled Resume', 528 target_role: root.querySelector('#rb-target-role')?.value?.trim() || '', 529 raw_text: root.querySelector('#rb-raw-text')?.value?.trim() || '', 530 content: { 531 contact: { 532 name: root.querySelector('#rb-c-name')?.value?.trim() || '', 533 email: root.querySelector('#rb-c-email')?.value?.trim() || '', 534 phone: root.querySelector('#rb-c-phone')?.value?.trim() || '', 535 linkedin: root.querySelector('#rb-c-linkedin')?.value?.trim() || '', 536 location: root.querySelector('#rb-c-location')?.value?.trim() || '', 537 }, 538 summary: root.querySelector('#rb-summary')?.value?.trim() || '', 539 experience: experiences, 540 education: educations, 541 skills: splitComma(root.querySelector('#rb-skills')?.value), 542 certifications: splitComma(root.querySelector('#rb-certs')?.value), 543 }, 544 }; 545} 546 547function buildText(root) { 548 const raw = root.querySelector('#rb-raw-text')?.value?.trim(); 549 if (raw) return raw; 550 551 const parts = []; 552 const name = root.querySelector('#rb-c-name')?.value?.trim(); 553 if (name) parts.push(name); 554 555 const contact = [ 556 root.querySelector('#rb-c-email')?.value?.trim(), 557 root.querySelector('#rb-c-phone')?.value?.trim(), 558 root.querySelector('#rb-c-location')?.value?.trim(), 559 root.querySelector('#rb-c-linkedin')?.value?.trim(),
560 ].filter(Boolean); 561 if (contact.length) parts.push(contact.join(' | ')); 562 563 const summary = root.querySelector('#rb-summary')?.value?.trim(); 564 if (summary) { parts.push('\nSUMMARY'); parts.push(summary); } 565 566 syncExpState(root); 567 if (experiences.length) { 568 parts.push('\nEXPERIENCE'); 569 experiences.forEach(e => { 570 const hdr = [e.title, e.company].filter(Boolean).join(' at ') + (e.dates ? ` (${e.dates})` : ''); 571 if (hdr.trim()) parts.push(hdr); 572 if (e.bullets) parts.push(e.bullets); 573 }); 574 } 575 576 syncEduState(root); 577 if (educations.length) { 578 parts.push('\nEDUCATION'); 579 educations.forEach(e => { 580 const line = [e.degree, e.school].filter(Boolean).join(', ') + (e.dates ? ` (${e.dates})` : ''); 581 if (line.trim()) parts.push(line); 582 }); 583 } 584 585 const skills = root.querySelector('#rb-skills')?.value?.trim(); 586 if (skills) { parts.push('\nSKILLS'); parts.push(skills); } 587 588 const certs = root.querySelector('#rb-certs')?.value?.trim(); 589 if (certs) { parts.push('\nCERTIFICATIONS'); parts.push(certs); } 590 591 return parts.join('\n'); 592} 593 594function splitComma(str) { 595 return (str || '').split(',').map(s => s.trim()).filter(Boolean); 596} 597 598// ââ Save & Score âââââââââââââââââââââââââââââââââââââââââââââââââ 599 600async function handleSave(root, andScore) { 601 const errorEl = root.querySelector('#rb-error'); 602 const scoreBtn = root.querySelector('#rb-save-score'); 603 const draftBtn = root.querySelector('#rb-save-draft'); 604 605 errorEl.classList.add('hidden'); 606 607 const resumeText = buildText(root); 608 const targetRole = root.querySelector('#rb-target-role')?.value?.trim(); 609 const jobDesc = root.querySelector('#rb-job-desc')?.value?.trim(); 610 611 if (andScore) { 612 if (!resumeText) { showError(errorEl, 'Please add resume content before scoring.'); return; } 613 if (!targetRole) { showError(errorEl, 'Please enter a target role before scoring.'); return; } 614 scoreBtn.disabled = true; 615 scoreBtn.innerHTML = t('rb.analyzing'); 616 } 617 618 try { 619 const data = collectData(root); 620 // Persist the full assembled text (so structured-only resumes still carry 621 // content) and remember the JD so the rewrite step has the same context. 622 if (andScore) { data.raw_text = resumeText; _lastJobDesc = jobDesc || ''; } 623 await saveResume(data); 624 resumes = await getResumes(); 625 markPhase1Completed(); // honest flag: a resume actually exists now 626 627 if (!andScore) { 628 const orig = draftBtn.textContent; 629 draftBtn.textContent = t('msg.saved'); 630 setTimeout(() => { draftBtn.textContent = orig; }, 1500); 631 // A saved draft IS a created resume â Phase 2 must not depend on the 632 // user also scoring it (4 of the first 10 signups saved without ever 633 // scoring and never got the flow). Self-gating, so safe to call here. 634 maybeStartPipelineOnboarding(); 635 return; 636 } 637 638 // Scoring is FREE for everyone (Part 9) â no quota check here. The metered 639 // step is "Generate My New Resume" on the score screen. 640 const token = await getAccessToken(); 641 const res = await fetch('/api/resume-score', { 642 method: 'POST', 643 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 644 body: JSON.stringify({ 645 resumeText, 646 targetRole, 647 ...(jobDesc ? { jobDescription: jobDesc } : {}), 648 }), 649 }); 650 651 if (!res.ok) { 652 const body = await res.json().catch(() => ({})); 653 throw new Error(body.error || `Server error ${res.status}`); 654 } 655 656 const results = await res.json(); 657 658 await saveResume({ ...data, ai_score: results.overallScore, ai_feedback: results }); 659 resumes = await getResumes(); 660 661 renderScoreScreen(root, data, results); 662 showScreen('c', root); 663 664 // Phase-2 onboarding: first resume scored â Kemba offers to create the 665 // user's first tracked pipeline item. One-time; self-gating. 666 maybeStartPipelineOnboarding(); 667 668 } catch (err) { 669 showError(errorEl, err.message || 'Something went wrong. Please try again.'); 670 } finally { 671 if (scoreBtn) { 672 scoreBtn.disabled = false;
673 scoreBtn.innerHTML = t('rb.score_btn'); 674 } 675 } 676} 677 678function showError(el, msg) { 679 el.textContent = msg; 680 el.classList.remove('hidden'); 681} 682 683// ââ Screen C: AI results âââââââââââââââââââââââââââââââââââââââââ 684 685function renderScoreScreen(root, resumeData, results) { 686 const score = results.overallScore; 687 const color = scoreColor(score); 688 const offset = CIRC - (score / 100) * CIRC; 689 const secs = results.sections || {}; 690 691 root.querySelector('#rb-screen-c').innerHTML = ` 692 <div class="view-header"> 693 <button class="back-btn" id="rb-back-editor" title="Back to editor"> 694 ${svgIcon('arrow-left', 'currentColor', 16)} 695 </button> 696 <h2 class="view-header-title">${escapeHtml(resumeData.name || t('rb.score_results'))}</h2> 697 </div> 698 699 <div class="rb-score-layout"> 700 701 <div class="rb-gauge-card"> 702 <svg viewBox="0 0 120 120" width="160" height="160" aria-label="ATS score ${score}"> 703 <circle cx="60" cy="60" r="54" fill="none" stroke="var(--bg-raised)" stroke-width="10"/> 704 <circle class="rb-gauge-arc" cx="60" cy="60" r="54" fill="none" 705 stroke="${color}" stroke-width="10" stroke-linecap="round" 706 stroke-dasharray="${CIRC.toFixed(2)}" 707 stroke-dashoffset="${CIRC.toFixed(2)}" 708 transform="rotate(-90 60 60)"/> 709 <text x="60" y="62" text-anchor="middle" font-size="24" font-weight="700" 710 fill="${color}" font-family="system-ui,-apple-system,sans-serif">${fmt10(score)}</text> 711 <text x="60" y="80" text-anchor="middle" font-size="11" 712 fill="var(--text-muted)" font-family="system-ui,-apple-system,sans-serif">${t('rb.score_of10')}</text> 713 </svg> 714 <div class="rb-gauge-label" style="color:${color}">${scoreLabel(score)}</div> 715 </div> 716 717 <div class="rb-sections-list"> 718 ${Object.entries(secs).map(([key, s]) => htmlSectionBar(key, s)).join('')} 719 </div> 720 721 </div> 722 723 <div class="rb-result-block"> 724 <div class="form-section-title">${t('rb.top3')}</div> 725 <div class="rb-improvements"> 726 ${(results.topImprovements || []).slice(0, 3).map((txt, i) => ` 727 <div class="rb-improve-card"> 728 <div class="rb-improve-num">${i + 1}</div> 729 <div class="rb-improve-text">${escapeHtml(txt)}</div> 730 </div> 731 `).join('')} 732 </div> 733 </div> 734 735 <div class="rb-result-block"> 736 <div class="form-section-title">${t('rb.ats_keywords')}</div> 737 <div class="rb-kw-grid"> 738 <div> 739 <div class="rb-kw-heading" style="color:#22c55e">${t('rb.kw_found')}</div> 740 <div class="rb-chips"> 741 ${(results.atsKeywords?.found || []).map(k => `<span class="rb-chip rb-chip-found">${escapeHtml(k)}</span>`).join('')} 742 </div> 743 </div> 744 <div> 745 <div class="rb-kw-heading" style="color:#ef4444">${t('rb.kw_missing')}</div> 746 <div class="rb-chips"> 747 ${(results.atsKeywords?.missing || []).map(k => `<span class="rb-chip rb-chip-missing">${escapeHtml(k)}</span>`).join('')} 748 </div> 749 </div> 750 <div> 751 <div class="rb-kw-heading" style="color:var(--accent)">${t('rb.kw_recommended')}</div> 752 <div class="rb-chips"> 753 ${(results.atsKeywords?.recommended || []).map(k => `<span class="rb-chip rb-chip-rec">${escapeHtml(k)}</span>`).join('')} 754 </div> 755 </div> 756 </div> 757 </div> 758 759 <div class="rb-result-block"> 760 <div class="form-section-title">${t('rb.rewritten_summary')}</div> 761 <div class="rb-rewrite-card"> 762 <p>${escapeHtml(results.rewrittenSummary || '')}</p> 763 <button class="btn btn-sm btn-secondary" id="rb-copy-btn" style="margin-top:12px"> 764 ${svgIcon('clipboard', 'currentColor', 12)} ${t('rb.copy')} 765 </button> 766 </div> 767 </div> 768 769 <div class="rb-action-bar" style="margin-top:8px;margin-bottom:40px"> 770 <button class="btn btn-secondary btn-sm" id="rb-dl-pdf"> 771 ${svgIcon('download', 'currentColor', 13)} PDF 772 </button> 773 <button class="btn btn-secondary btn-sm" id="rb-dl-docx"> 774 ${svgIcon('download', 'currentColor', 13)} Word 775 </button> 776 <button class="btn btn-primary" id="rb-generate-new"> 777 ${t('rb.generate')} 778 </button> 779 </div> 780 `; 781 782 // Trigger gauge animation after two frames so the browser has painted the initial state 783 requestAnimationFrame(() => requestAnimationFrame(() => { 784 const arc = root.querySelector('.rb-gauge-arc'); 785 if (arc) arc.style.strokeDashoffset = offset.toFixed(2); 786 })); 787 788 root.querySelector('#rb-back-editor')?.addEventListener('click', () => showScreen('b', root)); 789 root.querySelector('#rb-generate-new')?.addEventListener('click', () => generateNewResume(root, resumeData, results)); 790 791 root.querySelector('#rb-dl-pdf')?.addEventListener('click', () => downloadResumePDF(resumeData)); 792 root.querySelector('#rb-dl-docx')?.addEventListener('click', () => downloadResumeDocx(resumeData)); 793 794 root.querySelector('#rb-copy-btn')?.addEventListener('click', async () => { 795 const btn = root.querySelector('#rb-copy-btn'); 796 try { 797 await navigator.clipboard.writeText(results.rewrittenSummary || ''); 798 btn.innerHTML = `${svgIcon('check', 'currentColor', 12)} ${t('rb.copied')}`; 799 setTimeout(() => { btn.innerHTML = `${svgIcon('clipboard', 'currentColor', 12)} ${t('rb.copy')}`; }, 2000); 800 } catch { 801 btn.textContent = 'Copy failed'; 802 } 803 }); 804} 805 806function htmlSectionBar(key, s) { 807 if (!s) return ''; 808 const c = scoreColor(s.score || 0); 809 // Section names come from the scoring API â translate known ones, fall back 810 // to a capitalized key for anything new the model invents. 811 const nameKey = 'rb.sec_' + String(key).toLowerCase(); 812 const name = hasKey(nameKey) ? t(nameKey) : key.charAt(0).toUpperCase() + key.slice(1); 813 return ` 814 <div class="rb-section-bar"> 815 <div class="rb-section-bar-head">
816 <span class="rb-section-bar-name">${name}</span> 817 <span class="rb-section-bar-score" style="color:${c}">${fmt10(s.score)}/10</span> 818 </div> 819 <div class="rb-progress-track"> 820 <div class="rb-progress-fill" style="width:${s.score}%;background:${c}"></div> 821 </div> 822 <div class="rb-section-feedback">${escapeHtml(s.feedback || '')}</div> 823 </div> 824 `; 825} 826 827// ââ Screen D: Generate new resume (loading â reveal) ââââââââââââââ 828 829function revealLoadingHTML() { 830 return ` 831 <div class="rb-reveal-loading"> 832 <div class="rb-reveal-spinner"></div> 833 <div class="rb-reveal-loading-title">${t('rb.finalizing')}</div> 834 <div class="rb-reveal-loading-sub">${t('rb.finalizing_sub')}</div> 835 </div>`; 836} 837 838// "Generate My New Resume" â the metered step (consumes one AI-resume use). 839async function generateNewResume(root, resumeData, results) { 840 // Free 2/mo · Global 5 · Pro 20 · Pro Max+ unlimited. canScoreResume() now 841 // gates the rewrite (scoring itself is free). 842 if (!canScoreResume()) { 843 showUpgradeModal(isPro() ? 'resume_pro_limit' : 'resume_limit'); 844 return; 845 } 846 847 ensureRevealStyles(); 848 const screenD = root.querySelector('#rb-screen-d'); 849 screenD.innerHTML = revealLoadingHTML(); 850 showScreen('d', root); 851 852 const missing = [ 853 ...(results.atsKeywords?.missing || []), 854 ...(results.atsKeywords?.recommended || []), 855 ]; 856 857 try { 858 const token = await getAccessToken(); 859 const res = await fetch('/api/ai?action=resume-rewrite', { 860 method: 'POST', 861 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 862 body: JSON.stringify({ 863 resumeText: resumeData.raw_text || '', 864 targetRole: resumeData.target_role || '', 865 ...(_lastJobDesc ? { jobDescription: _lastJobDesc } : {}), 866 missingKeywords: missing, 867 }), 868 }); 869 870 if (!res.ok) { 871 const body = await res.json().catch(() => ({})); 872 if (res.status === 402 || body.error === 'upgrade_required') { 873 showUpgradeModal(body.reason || (isPro() ? 'resume_pro_limit' : 'resume_limit')); 874 showScreen('c', root); 875 return; 876 } 877 throw new Error(body.error || `Server error ${res.status}`); 878 } 879 880 const rewrite = await res.json(); 881 markResumeUsed(); 882 883 _revealBase = resumeData; 884 _revealRewrite = rewrite; 885 _revealOldScore = results.overallScore; 886 renderRevealScreen(root); 887 } catch (err) { 888 showScreen('c', root); 889 showToast(err.message || 'Could not generate your new resume. Please try again.', 'error'); 890 } 891} 892 893function renderRevealScreen(root) { 894 ensureRevealStyles(); 895 const base = _revealBase || {}; 896 const rw = _revealRewrite || {}; 897 const oldS = _revealOldScore ?? 0; 898 const newS = rw.newScore ?? oldS; 899 const color = scoreColor(newS); 900 const offset = CIRC - (newS / 100) * CIRC; 901 const changes = Array.isArray(rw.changes) ? rw.changes : []; 902 903 const screenD = root.querySelector('#rb-screen-d'); 904 screenD.innerHTML = ` 905 <div class="view-header"> 906 <button class="back-btn" id="rb-reveal-back" title="Back to score"> 907 ${svgIcon('arrow-left', 'currentColor', 16)} 908 </button> 909 <h2 class="view-header-title">${t('rb.your_new_resume')}</h2> 910 </div> 911 912 <div class="rb-reveal-scorebanner"> 913 <svg viewBox="0 0 120 120" width="92" height="92" aria-label="New score ${fmt10(newS)}"> 914 <circle cx="60" cy="60" r="54" fill="none" stroke="var(--bg-raised)" stroke-width="10"/> 915 <circle class="rb-reveal-arc" cx="60" cy="60" r="54" fill="none" 916 stroke="${color}" stroke-width="10" stroke-linecap="round" 917 stroke-dasharray="${CIRC.toFixed(2)}" stroke-dashoffset="${CIRC.toFixed(2)}" 918 transform="rotate(-90 60 60)"/> 919 <text x="60" y="64" text-anchor="middle" font-size="26" font-weight="700" 920 fill="${color}" font-family="system-ui,-apple-system,sans-serif">${fmt10(newS)}</text> 921 <text x="60" y="80" text-anchor="middle" font-size="11" 922 fill="var(--text-muted)" font-family="system-ui,-apple-system,sans-serif">${t('rb.score_of10')}</text> 923 </svg> 924 <div class="rb-reveal-scoretext"> 925 <div class="rb-reveal-jump">${t('rb.score_jumped', { from: fmt10(oldS), to: `<strong style="color:${color}">${fmt10(newS)}</strong>` })}</div> 926 <div class="rb-reveal-jump-sub">${t('rb.reveal_sub')}</div> 927 </div> 928 </div> 929 930 <div class="rb-reveal-layout"> 931 <div class="rb-reveal-main"> 932 <div class="rb-reveal-main-head">${t('rb.rewritten_resume')}</div> 933 <pre class="rb-reveal-resume" id="rb-reveal-text">${escapeHtml(rw.rewrittenResume || '')}</pre> 934 </div> 935 936 <aside class="rb-reveal-side"> 937 <div class="rb-kemba-head"> 938 <div class="rb-kemba-avatar">K<span class="rb-kemba-dot"></span></div> 939 <div> 940 <div class="rb-kemba-name">Kemba</div> 941 <div class="rb-kemba-role">${t('rb.coach_role')}</div> 942 </div> 943 </div> 944 <div class="rb-kemba-changes-title">${t('rb.what_changed')}</div> 945 <ul class="rb-kemba-changes"> 946 ${changes.length 947 ? changes.map(c => `<li>${escapeHtml(c)}</li>`).join('') 948 : `<li>${t('rb.default_change')}</li>`} 949 </ul> 950 951 <div class="rb-kemba-chips-title">${t('rb.quick_tweaks')}</div> 952 <div class="rb-kemba-chips"> 953 <button class="rb-kemba-chip" data-tweak="Use stronger, more varied action verbs throughout the experience bullets.">${t('rb.tweak_verbs')}</button> 954 <button class="rb-kemba-chip" data-tweak="Shorten the professional summary to 2â3 tight sentences.">${t('rb.tweak_shorten')}</button> 955 <button class="rb-kemba-chip" data-tweak="Remove skills and lines that aren't relevant to the target role.">${t('rb.tweak_remove')}</button> 956 </div> 957 958 <div class="rb-kemba-ask"> 959 <textarea id="rb-kemba-input" class="rb-kemba-textarea" rows="3" 960 placeholder="${t('rb.tweak_ph')}"></textarea> 961 <button class="btn btn-primary btn-sm" id="rb-kemba-edit" style="width:100%">${t('rb.edit_kemba')}</button> 962 </div> 963 </aside> 964 </div> 965 966 <div class="rb-action-bar rb-reveal-actions"> 967 <button class="btn btn-secondary btn-sm" id="rb-reveal-pdf">${svgIcon('download', 'currentColor', 13)} ${t('rb.download_pdf')}</button> 968 <button class="btn btn-secondary btn-sm" id="rb-reveal-docx">${svgIcon('download', 'currentColor', 13)}
968 ${t('rb.download_word')}</button> 969 <button class="btn btn-primary" id="rb-reveal-save">${svgIcon('check', '#000', 14)} ${t('rb.save_trackply')}</button> 970 </div> 971 `; 972 973 requestAnimationFrame(() => requestAnimationFrame(() => { 974 const arc = screenD.querySelector('.rb-reveal-arc'); 975 if (arc) arc.style.strokeDashoffset = offset.toFixed(2); 976 })); 977 978 showScreen('d', root); 979 980 screenD.querySelector('#rb-reveal-back')?.addEventListener('click', () => showScreen('c', root)); 981 screenD.querySelectorAll('.rb-kemba-chip').forEach(chip => 982 chip.addEventListener('click', () => refineResume(root, chip.dataset.tweak))); 983 screenD.querySelector('#rb-kemba-edit')?.addEventListener('click', () => { 984 const v = screenD.querySelector('#rb-kemba-input')?.value?.trim(); 985 if (!v) { showToast(t('rb.tell_kemba_first'), 'info'); return; } 986 refineResume(root, v); 987 }); 988 989 const dlData = () => ({ name: (base.name || 'Resume') + ' ' + t('rb.tailored_suffix'), raw_text: rw.rewrittenResume || '' }); 990 screenD.querySelector('#rb-reveal-pdf')?.addEventListener('click', () => downloadResumePDF(dlData())); 991 screenD.querySelector('#rb-reveal-docx')?.addEventListener('click', () => downloadResumeDocx(dlData())); 992 screenD.querySelector('#rb-reveal-save')?.addEventListener('click', () => saveRewrittenResume(root)); 993 994 // Phase-2 onboarding: "Your New Resume" is the other completion point. 995 maybeStartPipelineOnboarding(); 996} 997 998// Targeted tweak via Kemba (unmetered refine) â chips + the free-form box. 999async function refineResume(root, instruction) { 1000 const screenD = root.querySelector('#rb-screen-d'); 1001 const editBtn = screenD.querySelector('#rb-kemba-edit'); 1002 const chips = screenD.querySelectorAll('.rb-kemba-chip'); 1003 if (editBtn) { editBtn.disabled = true; editBtn.textContent = t('rb.editing'); } 1004 chips.forEach(c => c.disabled = true); 1005 1006 try { 1007 const token = await getAccessToken(); 1008 const res = await fetch('/api/ai?action=resume-refine', { 1009 method: 'POST', 1010 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 1011 body: JSON.stringify({ 1012 resumeText: _revealRewrite?.rewrittenResume || '', 1013 targetRole: _revealBase?.target_role || '', 1014 ...(_lastJobDesc ? { jobDescription: _lastJobDesc } : {}), 1015 instruction, 1016 }), 1017 }); 1018 if (!res.ok) { 1019 const body = await res.json().catch(() => ({})); 1020 throw new Error(body.error || `Server error ${res.status}`); 1021 } 1022 const rewrite = await res.json(); 1023 // The score is relative to the previous rewrite; keep the "from" anchor as the 1024 // original score so the jump still reads against where they started. 1025 _revealRewrite = rewrite; 1026 renderRevealScreen(root); 1027 showToast(t('rb.updated_toast'), 'success'); 1028 } catch (err) { 1029 if (editBtn) { editBtn.disabled = false; editBtn.textContent = t('rb.edit_kemba'); } 1030 chips.forEach(c => c.disabled = false); 1031 showToast(err.message || 'Could not apply that tweak. Please try again.', 'error'); 1032 } 1033} 1034 1035// Save the rewritten resume as a NEW resume record in Trackply. 1036async function saveRewrittenResume(root) { 1037 const base = _revealBase || {}; 1038 const rw = _revealRewrite || {}; 1039 const btn = root.querySelector('#rb-reveal-save'); 1040 if (btn) { btn.disabled = true; btn.textContent = t('msg.saving'); } 1041 try { 1042 const newId = uid(); 1043 await saveResume({ 1044 id: newId, 1045 name: (base.name || 'Resume') + ' ' + t('rb.tailored_suffix'), 1046 target_role: base.target_role || '', 1047 raw_text: rw.rewrittenResume || '', 1048 ai_score: rw.newScore ?? null, 1049 content: {}, 1050 }); 1051 resumes = await getResumes(); 1052 markPhase1Completed(); 1053 if (btn) { btn.disabled = false; btn.innerHTML = `${svgIcon('check', '#000', 14)} ${t('rb.saved_check')}`; } 1054 showToast('Saved to your resumes.', 'success'); 1055 } catch (err) { 1056 if (btn) { btn.disabled = false; btn.innerHTML = `${svgIcon('check', '#000', 14)} Save to Trackply`; } 1057 showToast(err.message || 'Could not save. Please try again.', 'error'); 1058 } 1059} 1060 1061function ensureRevealStyles() { 1062 if (document.getElementById('rb-reveal-styles')) return; 1063 const s = document.createElement('style'); 1064 s.id = 'rb-reveal-styles'; 1065 s.textContent = ` 1066 .rb-reveal-loading{display:flex;flex-direction:column;align-items:center;justify-content:center; 1067 gap:14px;text-align:center;min-height:50vh;padding:30px;} 1068 .rb-reveal-spinner{width:46px;height:46px;border-radius:50%;border:3px solid var(--border,rgba(255,255,255,.12)); 1069 border-top-color:var(--accent,#00d4ff);animation:rbSpin .8s linear infinite;}
1070 @keyframes rbSpin{to{transform:rotate(360deg)}} 1071 .rb-reveal-loading-title{font-size:18px;font-weight:800;color:var(--text-primary,#eef1f8);} 1072 .rb-reveal-loading-sub{font-size:13px;color:var(--text-muted,#8a93a8);max-width:380px;line-height:1.5;} 1073 1074 .rb-reveal-scorebanner{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:6px 0 18px; 1075 padding:16px 18px;border-radius:16px;border:1px solid rgba(34,197,94,.3); 1076 background:linear-gradient(135deg,rgba(34,197,94,.10),rgba(0,212,255,.06));} 1077 .rb-reveal-jump{font-size:18px;font-weight:800;color:var(--text-primary,#eef1f8);} 1078 .rb-reveal-jump strong{font-weight:800;} 1079 .rb-reveal-jump-sub{font-size:13px;color:var(--text-muted,#8a93a8);margin-top:3px;} 1080 1081 .rb-reveal-layout{display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start;} 1082 @media(max-width:860px){.rb-reveal-layout{grid-template-columns:1fr;}} 1083 .rb-reveal-main{border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;overflow:hidden;background:var(--surface-2,rgba(255,255,255,.02));} 1084 .rb-reveal-main-head{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.05em; 1085 color:var(--text-muted,#8a93a8);padding:12px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,.1));} 1086 .rb-reveal-resume{margin:0;padding:18px;white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,'SF Mono',Menlo,monospace; 1087 font-size:12.5px;line-height:1.6;color:var(--text-primary,#eef1f8);max-height:70vh;overflow-y:auto;} 1088 1089 .rb-reveal-side{border:1px solid rgba(124,92,255,.3);border-radius:14px;padding:16px; 1090 background:linear-gradient(180deg,rgba(124,92,255,.08),rgba(124,92,255,.02));position:sticky;top:12px;} 1091 .rb-kemba-head{display:flex;align-items:center;gap:10px;margin-bottom:14px;} 1092 .rb-kemba-avatar{position:relative;width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center; 1093 font-weight:800;color:#06080f;background:linear-gradient(135deg,#7c5cff,#28c8ff);} 1094 .rb-kemba-dot{position:absolute;right:1px;bottom:1px;width:10px;height:10px;border-radius:50%;background:#22e07a;border:2px solid #11121d;} 1095 .rb-kemba-name{font-size:14px;font-weight:800;color:var(--text-primary,#eef1f8);} 1096 .rb-kemba-role{font-size:11px;color:var(--text-muted,#8a93a8);} 1097 .rb-kemba-changes-title,.rb-kemba-chips-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em; 1098 color:var(--text-muted,#8a93a8);margin:6px 0 8px;} 1099 .rb-kemba-changes{margin:0 0 14px;padding-left:18px;font-size:12.5px;line-height:1.6;color:var(--text-secondary,#aeb6cb);} 1100 .rb-kemba-changes li{margin-bottom:5px;} 1101 .rb-kemba-chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:14px;} 1102 .rb-kemba-chip{font-size:11.5px;font-weight:600;padding:6px 11px;border-radius:999px;cursor:pointer; 1103 color:#cfc6ff;background:rgba(124,92,255,.16);border:1px solid rgba(124,92,255,.4);} 1104 [data-theme="light"] .rb-kemba-chip{color:#5b3bd6;background:rgba(124,92,255,.10);} 1105 .rb-kemba-chip:hover{filter:brightness(1.12);} 1106 .rb-kemba-chip:disabled{opacity:.55;cursor:default;} 1107 .rb-kemba-textarea{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;font-size:13px;font-family:inherit; 1108 color:var(--text-primary,#eef1f8);background:var(--bg-surface);border:1px solid var(--border-mid);margin-bottom:8px;resize:vertical;} 1109 .rb-kemba-textarea:focus{outline:none;border-color:rgba(124,92,255,.7);} 1110 .rb-reveal-actions{margin-top:18px;margin-bottom:40px;} 1111 `; 1112 document.head.appendChild(s); 1113} 1114 1115// ââ Shared helpers ââââââââââââââââââââââââââââââââââââââââââââââââ 1116 1117function showScreen(s, root) { 1118 root.querySelector('#rb-screen-a').classList.toggle('hidden', s !== 'a'); 1119 root.querySelector('#rb-screen-b').classList.toggle('hidden', s !== 'b'); 1120 root.querySelector('#rb-screen-c').classList.toggle('hidden', s !== 'c'); 1121 root.querySelector('#rb-screen-d')?.classList.toggle('hidden', s !== 'd'); 1122} 1123 1124async function confirmDelete(root, id) { 1125 if (!confirm('Delete this resume? This cannot be undone.')) return; 1126 try { 1127 await deleteResume(id); 1128 resumes = await getResumes(); 1129 root.querySelector('#rb-list').innerHTML = htmlResumeList(); 1130 } catch (err) { 1131 alert('Failed to delete: ' + err.message); 1132 } 1133} 1134 1135// ââ PDF text extraction (PDF.js loaded on demand) âââââââââââââââââ 1136 1137export async function extractPDFText(file) { 1138 if (!window.pdfjsLib) { 1139 await new Promise((resolve, reject) => { 1140 const s = document.createElement('script'); 1141 s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; 1142 s.onload = resolve; 1143 s.onerror = () => reject(new Error('Could not load PDF.js')); 1144 document.head.appendChild(s); 1145 }); 1146 window.pdfjsLib.GlobalWorkerOptions.workerSrc = 1147 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; 1148 } 1149 1150 const buf = await file.arrayBuffer(); 1151 const pdf = await window.pdfjsLib.getDocument({ data: buf }).promise; 1152 const parts = []; 1153 for (let i = 1; i <= pdf.numPages; i++) { 1154 const page = await pdf.getPage(i); 1155 const content = await page.getTextContent(); 1156 parts.push(content.items.map(item => item.str).join(' ')); 1157 } 1158 return parts.join('\n\n').trim(); 1159} 1160 1161// ââ Download as PDF (print dialog) âââââââââââââââââââââââââââââââ 1162 1163function downloadResumePDF(resumeData) { 1164 const text = resumeData.raw_text || ''; 1165 const name = escapeHtml(resumeData.name || 'Resume'); 1166 const body = text.split(/\n{2,}/) 1167 .map(p =>
1167 p.trim()).filter(Boolean) 1168 .map(p => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`) 1169 .join('\n'); 1170 1171 const win = window.open('', '_blank'); 1172 if (!win) return; 1173 win.document.write(`<!DOCTYPE html><html lang="en"><head> 1174<meta charset="UTF-8"><title>${name}</title> 1175<style>body{font-family:Georgia,'Times New Roman',serif;max-width:680px;margin:72px auto;line-height:1.75;color:#111;font-size:14px} 1176p{margin:0 0 12px}h1{font-family:Arial,sans-serif;font-size:14px;color:#555;margin-bottom:28px;font-weight:400} 1177@media print{body{margin:0}}</style></head><body> 1178<h1>${name}</h1>${body}</body></html>`); 1179 win.document.close(); 1180 win.focus(); 1181 setTimeout(() => win.print(), 250); 1182} 1183 1184// ââ Download as Word (.docx via docx.js CDN) âââââââââââââââââââââ 1185 1186async function downloadResumeDocx(resumeData) { 1187 if (!window.docx) { 1188 await new Promise((resolve, reject) => { 1189 const s = document.createElement('script'); 1190 s.src = 'https://cdnjs.cloudflare.com/ajax/libs/docx/7.8.2/docx.js'; 1191 s.onload = resolve; 1192 s.onerror = () => reject(new Error('Could not load docx.js')); 1193 document.head.appendChild(s); 1194 }); 1195 } 1196 1197 const { Document, Paragraph, TextRun, HeadingLevel, Packer } = window.docx; 1198 const text = resumeData.raw_text || ''; 1199 const lines = text.split('\n').filter(l => l.trim()); 1200 1201 const children = lines.map(line => { 1202 const t = line.trim(); 1203 // Treat short ALL-CAPS lines as headings 1204 const isHeading = t.length < 50 && t === t.toUpperCase() && /[A-Z]/.test(t); 1205 return isHeading 1206 ? new Paragraph({ text: t, heading: HeadingLevel.HEADING_2, spacing: { before: 240, after: 80 } }) 1207 : new Paragraph({ children: [new TextRun({ text: t })], spacing: { after: 100 } }); 1208 }); 1209 1210 const doc = new Document({ 1211 sections: [{ properties: { page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } }, children }], 1212 }); 1213 const blob = await Packer.toBlob(doc); 1214 const url = URL.createObjectURL(blob); 1215 const a = document.createElement('a'); 1216 a.href = url; 1217 a.download = `${(resumeData.name || 'Resume').replace(/\s+/g, '_')}.docx`; 1218 a.click(); 1219 URL.revokeObjectURL(url); 1220}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.