1/** 2 * Guest ATS Analysis Modal â shared across all marketing pages. 3 * Opens a modal with two flows: Resume Score and JD Match. 4 * Uses vanilla JS + Tailwind CDN classes. No React, no bundler. 5 */ 6(function () { 7 'use strict'; 8 9// var API_BASE = 'https://localhost:7290'; 10 var API_BASE = 'https://api.writecv.ai'; 11 var pdfJsLoaded = false; 12 var modalInjected = false; 13 var currentView = null; 14 var currentFlow = null; // 'resume-score' | 'jd-match' 15 var extractedResumeText = ''; 16 var uploadedFileName = ''; 17 18 // ââ Guest Data Persistence (localStorage) ââ 19 20 var GUEST_STORAGE_KEY = 'writecv_guest_data'; 21 var GUEST_DATA_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 22 var MAX_PDF_SIZE_FOR_STORAGE = 2 * 1024 * 1024; // 2MB 23 24 function storeGuestPdf(file) { 25 if (file.size > MAX_PDF_SIZE_FOR_STORAGE) return; 26 var reader = new FileReader(); 27 reader.onload = function () { 28 try { 29 var data = { 30 version: 1, 31 pdfBase64: reader.result.split(',')[1], 32 fileName: file.name, 33 timestamp: Date.now(), 34 }; 35 localStorage.setItem(GUEST_STORAGE_KEY, JSON.stringify(data)); 36 } catch (_) { /* QuotaExceededError or other â skip silently */ } 37 }; 38 reader.readAsDataURL(file); 39 } 40 41 function storeGuestAnalysis(type, data, jobDescription) { 42 try { 43 var raw = localStorage.getItem(GUEST_STORAGE_KEY); 44 if (!raw) return; 45 var existing = JSON.parse(raw); 46 if (!existing.pdfBase64) return; 47 if (type === 'resume-score') { 48 existing.resumeScore = { 49 overallScore: data.overallScore, 50 keywords: data.keywords, 51 content: data.content, 52 impact: data.impact, 53 narrative: data.narrative, 54 gaps: data.gaps, 55 targetRole: data.targetRole, 56 seniority: data.seniority, 57 }; 58 } else if (type === 'jd-match') { 59 existing.jdMatch = { 60 overallScore: data.overallScore, 61 keywords: data.keywords, 62 content: data.content, 63 impact: data.impact, 64 narrative: data.narrative, 65 gaps: data.gaps, 66 matchedSkills: data.matchedSkills, 67 missingSkills: data.missingSkills, 68 }; 69 if (jobDescription) existing.jobDescription = jobDescription; 70 } 71 localStorage.setItem(GUEST_STORAGE_KEY, JSON.stringify(existing)); 72 } catch (_) { /* silently fail */ } 73 } 74 75 // ââ SVG Icons ââ 76 77 var ICON_CLOSE = '<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 18L18 6M6 6l12 12"/></svg>'; 78 var ICON_BACK = '<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>'; 79 var ICON_CHEVRON_RIGHT = '<svg class="w-5 h-5 text-slate-300 group-hover:text-blue-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>'; 80 var ICON_ARROW = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'; 81 var ICON_UPLOAD = '<svg class="w-10 h-10 text-blue-400 mx-auto mb-3" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>'; 82 var ICON_CIRCLE_CHECK = '<svg class="w-[22px] h-[22px] text-blue-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'; 83 var ICON_CLIPBOARD = '<svg class="w-[22px] h-[22px] text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="15" y2="16"/></svg>'; 84 85 // ââ Analytics ââ 86 87 function guestTrack(eventName, params) { 88 try { 89 if (typeof gtag === 'function') { 90 gtag('event', eventName, params || {}); 91 } 92 } catch (_) { /* analytics should never break the modal */ } 93 } 94 95 // ââ Helpers ââ 96 97 function escapeHtml(str) { 98 var div = document.createElement('div'); 99 div.textContent = str; 100 return div.innerHTML; 101 } 102 103 function loadPdfJs() { 104 return new Promise(function (resolve, reject) { 105 if (pdfJsLoaded) { resolve(); return; } 106 var s = document.createElement('script'); 107 s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; 108 s.onload = function () { 109 window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; 110 pdfJsLoaded = true; 111 resolve(); 112 }; 113 s.onerror = function () { reject(new Error('Failed to load PDF parser')); }; 114 document.head.appendChild(s); 115 }); 116 } 117 118 async function extractTextFromPdf(file) { 119 await loadPdfJs(); 120 var arrayBuffer = await file.arrayBuffer(); 121 var pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; 122 var textParts = []; 123 for (var i = 1; i <= pdf.numPages; i++) { 124 var page = await pdf.getPage(i); 125 var content = await page.getTextContent(); 126 var pageText = content.items.map(function (item) { return item.str; }).join(' '); 127 textParts.push(pageText); 128 } 129 return textParts.join('\n'); 130 } 131 132 // ââ Modal Shell ââ 133
134 function injectModal() { 135 if (modalInjected) return; 136 var overlay = document.createElement('div'); 137 overlay.id = 'guest-modal-overlay'; 138 overlay.className = 'fixed inset-0 z-[9999] flex items-center justify-center p-4'; 139 overlay.style.cssText = 'background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);display:none;'; 140 overlay.innerHTML = 141 '<div id="guest-modal-container" class="relative w-full max-w-[520px] bg-white rounded-[20px] overflow-hidden shadow-2xl" style="max-height:90vh;display:flex;flex-direction:column;">' + 142 '<div id="guest-modal-content" style="overflow-y:auto;flex:1;"></div>' + 143 '</div>'; 144 document.body.appendChild(overlay); 145 modalInjected = true; 146 147 // Close on overlay click 148 overlay.addEventListener('click', function (e) { 149 if (e.target === overlay) closeModal(); 150 }); 151 152 // Close on Escape 153 document.addEventListener('keydown', function (e) { 154 if (e.key === 'Escape' && overlay.style.display !== 'none') closeModal(); 155 }); 156 } 157 158 function openModal() { 159 injectModal(); 160 var overlay = document.getElementById('guest-modal-overlay'); 161 overlay.style.display = 'flex'; 162 document.body.style.overflow = 'hidden'; 163 } 164 165 function closeModal() { 166 var overlay = document.getElementById('guest-modal-overlay'); 167 if (overlay) overlay.style.display = 'none'; 168 document.body.style.overflow = ''; 169 } 170 171 function getContent() { 172 return document.getElementById('guest-modal-content'); 173 } 174 175 // ââ Close & Back Buttons ââ 176 177 function closeBtn(dark) { 178 var cls = dark 179 ? 'w-8 h-8 rounded-lg flex items-center justify-center text-white/70 hover:text-white bg-white/[0.15] hover:bg-white/[0.25] border border-white/20 backdrop-blur-sm transition-colors cursor-pointer' 180 : 'w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer'; 181 return '<button onclick="window.__guestModalClose()" class="' + cls + '">' + ICON_CLOSE + '</button>'; 182 } 183 184 function backBtn(target) { 185 return '<button onclick="window.__guestModalNav(\'' + target + '\')" class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer">' + ICON_BACK + '</button>'; 186 } 187 188 // ââ Score Ring SVG ââ 189 190 function scoreRingSvg(score) { 191 var radius = 42, circ = 2 * Math.PI * radius; 192 var offset = circ - (score / 100) * circ; 193 return ( 194 '<div class="relative w-24 h-24 flex-shrink-0">' + 195 '<svg width="96" height="96" viewBox="0 0 96 96" class="-rotate-90">' + 196 '<circle cx="48" cy="48" r="' + radius + '" stroke="rgba(255,255,255,0.12)" stroke-width="6" fill="none"/>' + 197 '<circle cx="48" cy="48" r="' + radius + '" stroke="url(#gRingM)" stroke-width="6" fill="none" stroke-linecap="round" stroke-dasharray="' + circ.toFixed(2) + '" stroke-dashoffset="' + offset.toFixed(2) + '" style="transition:stroke-dashoffset 1s ease-out"/>' + 198 '<defs><linearGradient id="gRingM" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#a78bfa"/></linearGradient></defs>' + 199 '</svg>' + 200 '<div class="absolute inset-0 flex flex-col items-center justify-center">' + 201 '<span class="text-[28px] font-black text-white leading-none">' + score + '</span>' + 202 '<span class="text-[10px] text-white/50 font-medium">/100</span>' + 203 '</div>' + 204 '</div>' 205 ); 206 } 207 208 // ââ Category Card ââ 209 210 function catCard(label, value) { 211 var c = value >= 70 ? 'text-emerald-600' : 'text-amber-600'; 212 return '<div class="text-center p-3 rounded-xl bg-slate-50 border border-slate-100"><p class="text-[20px] font-extrabold ' + c + '">' + value + '</p><p class="text-[11px] text-slate-400 font-semibold mt-0.5">' + label + '</p></div>'; 213 } 214 215 // ââ Fake Suggestion Card ââ 216 217 function fakeSuggestion(title, reason, before, after) { 218 return ( 219 '<div class="p-3 rounded-xl border border-slate-100 bg-white">' + 220 '<p class="text-[13px] font-semibold text-slate-800">' + title + '</p>' + 221 '<p class="text-[12px] text-slate-400 mt-0.5">' + reason + '</p>' + 222 '<div class="mt-2 space-y-1">' + 223 '<p class="text-[12px] text-red-400 line-through">' + before + '</p>' + 224 '<p class="text-[12px] text-emerald-600">' + after + '</p>' + 225 '</div>' + 226 '</div>' 227 ); 228 } 229 230 // ââ Blurred Suggestions + CTA ââ 231 232 function blurredSuggestionsCta() { 233 return ( 234 '<div class="px-6 py-5 relative">' + 235 '<p class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-3">AI Suggestions</p>' + 236 '<div class="space-y-2.5 filter blur-[3px] select-none pointer-events-none" aria-hidden="true">' + 237 fakeSuggestion('Strengthen impact verb', 'Replace passive voice with a specific action verb to convey ownership', 'Managed multiple cross-functional projects for the engineering team', 'Spearheaded [N] cross-functional initiatives, delivering features ahead of schedule') + 238 fakeSuggestion('Add quantified outcome', 'Include a metric so you can fill in the real number', 'Improved application performance through code optimization', 'Optimized application performance, reducing load time by [X]%') + 239 fakeSuggestion('Remove filler language', 'Cut vague adverbs that weaken the bullet', 'Successfully implemented a comprehensive testing strategy for the team', 'Implemented an end-to-e
239nd testing strategy covering [N] critical user flows') + 240 '</div>' + 241 '<div class="absolute inset-0 flex items-center justify-center bg-gradient-to-t from-white via-white/90 to-white/60 rounded-b-2xl">' + 242 '<div class="text-center">' + 243 '<p class="text-lg font-bold text-[#0f172a] mb-2">Get AI-powered suggestions</p>' + 244 '<p class="text-sm text-slate-500 mb-4 max-w-[340px]">Sign up free to unlock personalized bullet rewrites that improve your score</p>' + 245 '<a href="/login" onclick="window.__guestModalSignupClick()" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold text-sm transition-all hover:bg-blue-700 no-underline">Sign Up Free - Improve Your Score ' + ICON_ARROW + '</a>' + 246 '</div>' + 247 '</div>' + 248 '</div>' 249 ); 250 } 251 252 // ââ Gaps HTML ââ 253 254 function gapsHtml(gaps) { 255 if (!gaps || gaps.length === 0) return ''; 256 var items = gaps.map(function (g) { 257 var dot = g.severity === 'high' ? 'bg-red-500' : g.severity === 'medium' ? 'bg-amber-500' : 'bg-blue-500'; 258 return ( 259 '<div class="flex items-start gap-2.5">' + 260 '<div class="w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ' + dot + '"></div>' + 261 '<div><span class="text-[13px] font-semibold text-slate-700 capitalize">' + escapeHtml(g.section) + '</span>' + 262 '<p class="text-[13px] text-slate-500 leading-relaxed">' + escapeHtml(g.description) + '</p></div>' + 263 '</div>' 264 ); 265 }).join(''); 266 return ( 267 '<div class="px-6 py-4 border-b border-slate-100">' + 268 '<p class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-3">Gaps Identified</p>' + 269 '<div class="space-y-3">' + items + '</div>' + 270 '</div>' 271 ); 272 } 273 274 // ââ Skill Chips ââ 275 276 function skillChipsHtml(matched, missing) { 277 if ((!matched || matched.length === 0) && (!missing || missing.length === 0)) return ''; 278 279 var matchedChips = (matched || []).map(function (s) { 280 return '<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[12px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200">' + 281 '<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' + 282 escapeHtml(s.skill) + '</span>'; 283 }).join(''); 284 285 var missingChips = (missing || []).map(function (s) { 286 var isHard = s.classification === 'hard'; 287 var cls = isHard ? 'bg-red-50 text-red-600 border-red-200' : 'bg-amber-50 text-amber-600 border-amber-200'; 288 return '<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[12px] font-medium ' + cls + ' border">' + 289 '<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' + 290 escapeHtml(s.skill) + '</span>'; 291 }).join(''); 292 293 return ( 294 '<div class="px-6 py-4 border-b border-slate-100">' + 295 '<p class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-3">Skill Match</p>' + 296 (matchedChips ? '<div class="mb-2"><p class="text-[11px] text-slate-400 font-medium mb-1.5">Matched</p><div class="flex flex-wrap gap-1.5">' + matchedChips + '</div></div>' : '') + 297 (missingChips ? '<div><p class="text-[11px] text-slate-400 font-medium mb-1.5">Missing</p><div class="flex flex-wrap gap-1.5">' + missingChips + '</div></div>' : '') + 298 '</div>' 299 ); 300 } 301 302 // âââââââââââââââââââââââââââââââââââââââââââââââ 303 // VIEW RENDERERS 304 // âââââââââââââââââââââââââââââââââââââââââââââââ 305 306 // ââ Landing View ââ 307 308 function renderLanding() { 309 currentView = 'landing'; 310 getContent().innerHTML = 311 // Gradient header 312 '<div class="relative overflow-hidden px-7 pt-5 pb-6" style="background:linear-gradient(to bottom right,#1e3a8a,#2563eb,#6366f1)">' + 313 '<div class="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/10 blur-sm pointer-events-none"></div>' + 314 '<div class="absolute -bottom-16 left-[30%] w-52 h-52 rounded-full bg-white/[0.05] blur-sm pointer-events-none"></div>' + 315 '<div class="relative z-10 flex items-center justify-between">' + 316 '<div>' + 317 '<h2 class="text-[22px] font-bold text-white mt-4">ATS Analysis</h2>' + 318 '<p class="text-sm text-white/70 mt-1">Optimize your resume for Applicant Tracking Systems</p>' + 319 '</div>' + 320 closeBtn(true) + 321 '</div>' + 322 '</div>' + 323 // Action cards 324 '<div class="px-7 py-6 flex flex-col gap-3">
324' + 325 '<button onclick="window.__guestModalFlow(\'resume-score\')" class="group w-full text-left flex items-center gap-4 p-[18px] rounded-[14px] border-[1.5px] border-slate-200 bg-white hover:border-blue-500 hover:shadow-[0_4px_24px_rgba(59,130,246,0.12)] hover:-translate-y-px transition-all duration-200 cursor-pointer">' + 326 '<div class="w-12 h-12 rounded-[14px] bg-gradient-to-br from-blue-100 to-blue-200 flex items-center justify-center flex-shrink-0">' + ICON_CIRCLE_CHECK + '</div>' + 327 '<div class="flex-1">' + 328 '<h3 class="text-[15px] font-semibold text-slate-800">Resume Score</h3>' + 329 '<p class="text-[13px] text-slate-500 mt-0.5 leading-relaxed">Get an overall quality score with a detailed breakdown of keywords, impact, and content.</p>' + 330 '</div>' + 331 ICON_CHEVRON_RIGHT + 332 '</button>' + 333 '<button onclick="window.__guestModalFlow(\'jd-match\')" class="group w-full text-left flex items-center gap-4 p-[18px] rounded-[14px] border-[1.5px] border-slate-200 bg-white hover:border-blue-500 hover:shadow-[0_4px_24px_rgba(59,130,246,0.12)] hover:-translate-y-px transition-all duration-200 cursor-pointer">' + 334 '<div class="w-12 h-12 rounded-[14px] bg-gradient-to-br from-amber-100 to-amber-200 flex items-center justify-center flex-shrink-0">' + ICON_CLIPBOARD + '</div>' + 335 '<div class="flex-1">' + 336 '<h3 class="text-[15px] font-semibold text-slate-800">Match Against Job</h3>' + 337 '<p class="text-[13px] text-slate-500 mt-0.5 leading-relaxed">Paste a job description and see how well your resume matches its requirements.</p>' + 338 '</div>' + 339 ICON_CHEVRON_RIGHT + 340 '</button>' + 341 '</div>'; 342 } 343 344 // ââ Upload View ââ 345 346 function renderUpload() { 347 currentView = 'upload'; 348 var showRole = false; 349 var fileLabel = uploadedFileName 350 ? '<p class="text-sm font-medium text-emerald-600 mb-1">' + escapeHtml(uploadedFileName) + '</p><p class="text-[12px] text-slate-400">Resume uploaded. Drop a new file to replace.</p>' 351 : '<p class="text-sm font-medium text-slate-600 mb-1">Drop your resume PDF here</p><p class="text-[12px] text-slate-400">or click to browse · PDF only, max 5MB</p>'; 352 353 getContent().innerHTML = 354 '<div class="px-5 pt-5 pb-3 flex items-center gap-3">
354' + 355 backBtn('landing') + 356 '<h2 class="text-[15px] font-semibold text-slate-800">Upload Resume</h2>' + 357 '<div class="flex-1"></div>' + 358 closeBtn(false) + 359 '</div>' + 360 '<div class="px-5 pb-5 flex flex-col gap-4">' + 361 '<div id="gm-drop-area" class="p-10 rounded-2xl border-2 border-dashed border-blue-500/25 bg-blue-500/[0.02] text-center cursor-pointer transition-all duration-300 hover:border-blue-500 hover:bg-blue-500/[0.04]">' + 362 ICON_UPLOAD + 363 fileLabel + 364 '<input type="file" id="gm-file-input" accept=".pdf" class="hidden">' + 365 '</div>' + 366 '<div id="gm-upload-error" class="hidden text-sm text-red-500 text-center"></div>' + 367 (showRole 368 ? '<div class="flex flex-col gap-1.5">' + 369 '<label class="text-[12px] font-semibold text-slate-500 uppercase tracking-wider">Intended Role (optional)</label>' + 370 '<input id="gm-intended-role" type="text" placeholder="e.g. Senior Software Engineer" class="h-[40px] px-3 rounded-xl border border-slate-200 bg-slate-50/50 text-[13px] text-slate-700 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400/30 transition-colors">' + 371 '</div>' 372 : '') + 373 (extractedResumeText 374 ? '<button onclick="window.__guestModalContinueAfterUpload()" class="w-full h-[40px] rounded-xl bg-blue-600 text-white font-semibold text-[14px] hover:bg-blue-700 transition-colors cursor-pointer">' + 375 (currentFlow === 'jd-match' ? 'Continue' : 'Analyze Resume') + 376 '</button>' 377 : '') + 378 '</div>'; 379 380 // Bind events 381 var dropArea = document.getElementById('gm-drop-area'); 382 var fileInput = document.getElementById('gm-file-input'); 383 384 dropArea.addEventListener('click', function () { fileInput.click(); }); 385 fileInput.addEventListener('change', function (e) { 386 if (e.target.files.length > 0) handleFileUpload(e.target.files[0]); 387 }); 388 dropArea.addEventListener('dragover', function (e) { 389 e.preventDefault(); 390 dropArea.style.borderColor = '#3b82f6'; 391 dropArea.style.backgroundColor = 'rgba(59,130,246,0.04)'; 392 }); 393 dropArea.addEventListener('dragleave', function () { 394 dropArea.style.borderColor = ''; 395 dropArea.style.backgroundColor = ''; 396 }); 397 dropArea.addEventListener('drop', function (e) { 398 e.preventDefault(); 399 dropArea.style.borderColor = ''; 400 dropArea.style.backgroundColor = ''; 401 if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]); 402 }); 403 } 404 405 // ââ JD Input View ââ 406 407 function renderJDInput() { 408 currentView = 'jd-input'; 409 getContent().innerHTML = 410 '<div class="px-5 pt-5 pb-3 flex items-center gap-3">
410' + 411 backBtn('upload') + 412 '<h2 class="text-[15px] font-semibold text-slate-800">Paste Job Description</h2>' + 413 '<div class="flex-1"></div>' + 414 closeBtn(false) + 415 '</div>' + 416 '<div class="px-5 pb-5 flex flex-col gap-3" style="height:420px">' + 417 '<p class="text-[13px] text-slate-500">We\'ll analyze how well your resume matches this job\'s requirements.</p>' + 418 '<textarea id="gm-jd-text" class="flex-1 rounded-xl border border-slate-200 bg-slate-50/50 p-4 text-[13px] leading-[1.6] resize-none outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400/30 transition-colors" placeholder="Paste the full job description here..."></textarea>' + 419 '<button id="gm-jd-analyze-btn" onclick="window.__guestModalAnalyzeJD()" class="w-full h-[40px] rounded-xl bg-blue-600 text-white font-semibold text-[14px] hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" disabled>Analyze Match</button>' + 420 '</div>'; 421 422 // Enable button when text is entered 423 var textarea = document.getElementById('gm-jd-text'); 424 var btn = document.getElementById('gm-jd-analyze-btn'); 425 textarea.addEventListener('input', function () { 426 btn.disabled = textarea.value.trim().length < 50; 427 }); 428 textarea.focus(); 429 } 430 431 // ââ Loading View ââ 432 433 function renderLoading(message) { 434 currentView = 'loading'; 435 getContent().innerHTML = 436 '<div class="flex flex-col items-center justify-center py-20 px-6" style="min-height:350px">' + 437 '<svg class="animate-spin w-8 h-8 text-blue-500 mb-4" viewBox="0 0 24 24" fill="none">' + 438 '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/>' + 439 '<path d="M12 2a10 10 0 0110 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>' + 440 '</svg>' + 441 '<p class="text-lg font-semibold text-slate-800">' + (message || 'Analyzing your resume...') + '</p>' + 442 '<p class="text-sm text-slate-400 mt-1">This takes about 10 seconds</p>' + 443 '</div>'; 444 } 445 446 // ââ Error View ââ 447 448 function renderError(message) { 449 currentView = 'error'; 450 getContent().innerHTML = 451 '<div class="px-5 pt-5 pb-3 flex items-center justify-end">' + 452 closeBtn(false) + 453 '</div>' + 454 '<div class="flex flex-col items-center justify-center py-12 px-6">' + 455 '<div class="w-14 h-14 rounded-full bg-red-50 flex items-center justify-center mb-4">' + 456 '<svg class="w-7 h-7 text-red-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>' + 457 '</div>' + 458 '<p class="text-base font-semibold text-slate-800 mb-2 text-center">Something went wrong</p>' + 459 '<p class="text-sm text-slate-500 text-center max-w-[360px] mb-6">' + escapeHtml(message) + '</p>' + 460 '<button onclick="window.__guestModalNav(\'upload\')" class="px-6 h-[40px] rounded-xl bg-blue-600 text-white font-semibold text-[14px] hover:bg-blue-700 transition-colors cursor-pointer">Try Again</button>' + 461 '</div>'; 462 } 463 464 // ââ Resume Score Results View ââ 465 466 function renderResumeResults(data) { 467 currentView = 'resume-results'; 468 var scoreLabel = data.overallScore >= 85 ? 'Excellent' : data.overallScore >= 80 ? 'Good' : 'Needs Work'; 469 var labelBg = data.overallScore >= 85 ? 'bg-emerald-100 text-emerald-700' : data.overallScore >= 80 ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'; 470 471 getContent().innerHTML = 472 '<div class="rounded-b-none">' + 473 // Gradient hero 474 '<div class="relative overflow-hidden px-6 pt-4 pb-6" style="background:linear-gradient(135deg,#1e3a8a,#3b82f6,#6366f1)">' + 475 '<div class="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/10 blur-sm pointer-events-none"></div>' + 476 '<div class="flex items-center justify-between mb-4">' + 477 backBtn('landing').replace('text-slate-400 hover:text-slate-600 hover:bg-slate-100', 'text-white/70 hover:text-white bg-white/[0.15] hover:bg-white/[0.25] border border-white/20') + 478 closeBtn(true) + 479 '</div>' + 480 '<div class="flex items-center gap-5">' + 481 scoreRingSvg(data.overallScore) + 482 '<div class="flex-1">' + 483 '<p class="text-base font-bold text-white">Resume Score</p>' + 484 '<span class="inline-flex items-center mt-1 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide ' + labelBg + '">' + scoreLabel + '</span>' + 485 (data.targetRole ? '<p class="text-[11px] text-white/40 mt-1.5">' + escapeHtml(data.targetRole) + (data.seniority ? ' · ' + escapeHtml(data.seniority) : '') + '</p>' : '') + 486 '</div>' + 487 '</div>' + 488 '</div>' + 489 // Category cards 490 '<div class="grid grid-cols-3 gap-2 px-6 py-4 border-b border-slate-100">' + 491 catCard('Keywords', data.keywords) + catCard('Content', data.content) + catCard('Impact', data.impact) + 492 '</div>' + 493 // Narrative 494 '<div class="px-6 py-4 border-b border-slate-100">' + 495 '<p class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">Analysis</p>' + 496 '<p class="text-[14px] text-slate-600 leading-relaxed">' + escapeHtml(data.narrative) + '</p>' + 497 '</div>' + 498 // Gaps 499 gapsHtml(data.gaps) + 500 // Blurred suggestions 501 blurredSuggestionsCta() + 502 // Reset button 503 '<div class="text-center py-4 border-t border-slate-100">' +
504 '<button onclick="window.__guestModalReset()" class="text-sm font-medium text-blue-600 hover:underline cursor-pointer">Score another resume</button>' + 505 '</div>' + 506 '</div>'; 507 } 508 509 // ââ JD Match Results View ââ 510 511 function renderJDResults(data) { 512 currentView = 'jd-results'; 513 var scoreLabel = data.overallScore >= 85 ? 'Excellent' : data.overallScore >= 80 ? 'Good' : 'Needs Work'; 514 var labelBg = data.overallScore >= 85 ? 'bg-emerald-100 text-emerald-700' : data.overallScore >= 80 ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'; 515 516 getContent().innerHTML = 517 '<div class="rounded-b-none">' + 518 // Gradient hero 519 '<div class="relative overflow-hidden px-6 pt-4 pb-6" style="background:linear-gradient(135deg,#1e3a8a,#3b82f6,#6366f1)">' + 520 '<div class="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/10 blur-sm pointer-events-none"></div>' + 521 '<div class="flex items-center justify-between mb-4">' + 522 backBtn('landing').replace('text-slate-400 hover:text-slate-600 hover:bg-slate-100', 'text-white/70 hover:text-white bg-white/[0.15] hover:bg-white/[0.25] border border-white/20') + 523 closeBtn(true) + 524 '</div>' + 525 '<div class="flex items-center gap-5">' + 526 scoreRingSvg(data.overallScore) + 527 '<div class="flex-1">' + 528 '<p class="text-base font-bold text-white">JD Match Score</p>' + 529 '<span class="inline-flex items-center mt-1 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide ' + labelBg + '">' + scoreLabel + '</span>' + 530 '</div>' + 531 '</div>' + 532 '</div>' + 533 // Category cards 534 '<div class="grid grid-cols-3 gap-2 px-6 py-4 border-b border-slate-100">' + 535 catCard('Keywords', data.keywords) + catCard('Content', data.content) + catCard('Impact', data.impact) + 536 '</div>' + 537 // Narrative 538 '<div class="px-6 py-4 border-b border-slate-100">' + 539 '<p class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">Analysis</p>' + 540 '<p class="text-[14px] text-slate-600 leading-relaxed">' + escapeHtml(data.narrative) + '</p>' + 541 '</div>' + 542 // Skill chips 543 skillChipsHtml(data.matchedSkills, data.missingSkills) + 544 // Gaps 545 gapsHtml(data.gaps) + 546 // Blurred suggestions 547 blurredSuggestionsCta() + 548 // Reset button 549 '<div class="text-center py-4 border-t border-slate-100">' + 550 '<button onclick="window.__guestModalReset()" class="text-sm font-medium text-blue-600 hover:underline cursor-pointer">Try another job description</button>' + 551 '</div>' + 552 '</div>'; 553 } 554 555 // âââââââââââââââââââââââââââââââââââââââââââââââ 556 // FILE HANDLING & API CALLS 557 // âââââââââââââââââââââââââââââââââââââââââââââââ 558 559 async function handleFileUpload(file) { 560 var errorEl = document.getElementById('gm-upload-error'); 561 if (errorEl) errorEl.classList.add('hidden'); 562 563 if (file.type !== 'application/pdf') { 564 showUploadError('Please upload a PDF file.'); 565 return; 566 } 567 if (file.size > 5 * 1024 * 1024) { 568 showUploadError('File is too large. Maximum 5MB.'); 569 return; 570 } 571 572 // Show loading in the upload area 573 renderLoading('Extracting text from PDF...'); 574 575 try { 576 var text = await extractTextFromPdf(file); 577 if (text.trim().length < 100) { 578 throw new Error('Could not extract text from this PDF. It may be a scanned image. Please use a text-based PDF.'); 579 } 580 581 extractedResumeText = text; 582 uploadedFileName = file.name; 583 storeGuestPdf(file); 584 guestTrack('guest_resume_uploaded', { flow: currentFlow }); 585 586 if (currentFlow === 'jd-match') { 587 renderJDInput(); 588 } else { 589 // Resume score â analyze immediately 590 await analyzeResume(); 591 } 592 } catch (err) { 593 renderUpload(); 594 showUploadError(err.message || 'Failed to process PDF. Please try again.'); 595 } 596 } 597 598 function showUploadError(msg) { 599 var errorEl = document.getElementById('gm-upload-error'); 600 if (errorEl) { 601 errorEl.textContent = msg; 602 errorEl.classList.remove('hidden'); 603 } 604 } 605 606 async function analyzeResume() { 607 renderLoading('Analyzing your resume...'); 608 try { 609 var body = { resumeText: extractedResumeText }; 610 var roleInput = document.getElementById('gm-intended-role'); 611 if (roleInput && roleInput.value.trim()) { 612 body.intendedRole = roleInput.value.trim(); 613 } 614 615 var response = await fetch(API_BASE + '/api/ats/analyze-guest', { 616 method: 'POST', 617 headers: { 'Content-Type': 'application/json' }, 618 body: JSON.stringify(body), 619 }); 620 621 if (response.status === 429) { 622 throw new Error("You've reached the limit of 3 free checks per day. Sign up for unlimited analysis."); 623 } 624 if (!response.ok) { 625 var errBody = await response.text(); 626 throw new Error(errBody || 'Analysis failed. Please try again.'); 627 } 628 629 var data = await response.json(); 630 storeGuestAnalysis('resume-score', data); 631 guestTrack('guest_score_completed', { score: data.overallScore }); 632 renderResumeResults(data); 633 } catch (err) { 634 guestTrack('guest_score_error', { error: err.message || 'unknown' }); 635 renderError(err.message || 'Something went wrong. Please try again.'); 636 } 637 } 638 639 async function analyzeJDMatch() { 640 var jdText = document.getElementById('gm-jd-text'); 641 if (!jdText || jdText.value.trim().length < 50) return; 642 var jobDescriptionText = jdText.value.trim(); 643 644 renderLoading('Analyzing job match...'); 645 try { 646 var response = await fetch(API_BASE + '/api/ats/analyze-guest-jd', { 647 method: 'POST', 648 headers: { 'Content-Type': 'application/json' }, 649 body: JSON.stringify({ 650 resumeText: extractedResumeText, 651 jobDescription: jobDescriptionText, 652 }), 653 }); 654 655 if (response.status === 429) { 656 throw new Error("You've reached the limit of 3 free checks per day. Sign up for unlimited analysis."); 657 } 658 if (!response.ok) { 659 var errBody = await response.text(); 660 throw new Error(errBody || 'Analysis failed. Please try again.'); 661 } 662 663 var data = await response.json(); 664 storeGuestAnalysis('jd-match', data, jobDescriptionText); 665 guestTrack('guest_jd_match_completed', { score: data.overallScore }); 666 renderJDResults(data); 667 } catch (err) { 668 guestTrack('guest_jd_match_error', { error: err.message || 'unknown' }); 669 renderError(err.message || 'Something went wrong. Please try again.'); 670 } 671 } 672 673 // âââââââââââââââââââââââââââââââââââââââââââââââ 674 // GLOBAL API (called from onclick handlers) 675 // âââââââââââââââââââââââââââââââââââââââââââââââ 676
677 window.__guestModalClose = function () { 678 closeModal(); 679 }; 680 681 window.__guestModalNav = function (target) { 682 if (target === 'landing') renderLanding(); 683 else if (target === 'upload') renderUpload(); 684 else if (target === 'jd-input') renderJDInput(); 685 }; 686 687 window.__guestModalFlow = function (flow) { 688 currentFlow = flow; 689 guestTrack('guest_flow_selected', { flow: flow }); 690 renderUpload(); 691 }; 692 693 window.__guestModalContinueAfterUpload = function () { 694 if (currentFlow === 'jd-match') { 695 renderJDInput(); 696 } else { 697 analyzeResume(); 698 } 699 }; 700 701 window.__guestModalAnalyzeJD = function () { 702 analyzeJDMatch(); 703 }; 704 705 window.__guestModalReset = function () { 706 extractedResumeText = ''; 707 uploadedFileName = ''; 708 try { localStorage.removeItem(GUEST_STORAGE_KEY); } catch (_) {} 709 renderLanding(); 710 }; 711 712 window.__guestModalSignupClick = function () { 713 guestTrack('guest_signup_clicked', { flow: currentFlow }); 714 }; 715 716 // ââ Public Entry Point ââ 717 718 window.openGuestModal = function (defaultFlow) { 719 guestTrack('guest_modal_opened', { default_flow: defaultFlow || 'none' }); 720 openModal(); 721 if (defaultFlow === 'resume-score' || defaultFlow === 'jd-match') { 722 currentFlow = defaultFlow; 723 renderUpload(); 724 } else { 725 renderLanding(); 726 } 727 }; 728})(); 729 730/** 731 * Shared dark-theme support + scroll sign-in prompt for the marketing pages. 732 * 733 * 1,614 of the 1,622 static pages ship a "Dark theme overrides" style block that 734 * remaps `.bg-white` onto a navy palette but leaves `text-slate-*` alone â which 735 * would put dark text on a dark card. Rather than editing every page, we detect 736 * the page theme at runtime from the body background and inject scoped overrides 737 * for the ATS analysis modal; the sign-in prompt below picks its inline colours 738 * from the same palette. 739 */ 740(function () { 741 'use strict'; 742 743 // ââ Theme detection ââ 744 745 var DARK = { 746 surface: '#0f1729', 747 surfaceAlt: 'rgba(255,255,255,0.05)', 748 border: 'rgba(255,255,255,0.10)', 749 textStrong: '#e9eef7', 750 textBody: 'rgba(232,238,248,0.74)', 751 textMuted: 'rgba(232,238,248,0.48)', 752 accent: '#93b4ff', 753 shadow: '0 25px 50px -12px rgba(0,0,0,0.7)', 754 }; 755 756 var LIGHT = { 757 surface: '#ffffff', 758 surfaceAlt: '#f8fafc', 759 border: '#e2e8f0', 760 textStrong: '#1e293b', 761 textBody: '#475569', 762 textMuted: '#94a3b8', 763 accent: '#2563eb', 764 shadow: '0 25px 50px -12px rgba(0,0,0,0.45)', 765 }; 766 767 function isDarkPage() { 768 try { 769 var bg = getComputedStyle(document.body).backgroundColor; 770 var p = bg.match(/[\d.]+/g); 771 if (!p || p.length < 3) return false; 772 if (p.length > 3 && parseFloat(p[3]) === 0) return false; // transparent body 773 var lum = 0.299 * +p[0] + 0.587 * +p[1] + 0.114 * +p[2]; 774 return lum < 128; 775 } catch (_) { return false; } 776 } 777 778 var dark = isDarkPage(); 779 var C = dark ? DARK : LIGHT; 780 781 // ââ ATS analysis modal: remap its light-mode utilities onto the navy palette ââ 782 // Scoped by id so specificity beats the pages' own `.bg-white` / `button.bg-white` 783 // rules without touching anything else on the page. 784
785 function injectGuestModalDarkCss() { 786 if (!dark || document.getElementById('wcv-modal-dark')) return; 787 var s = document.createElement('style'); 788 s.id = 'wcv-modal-dark'; 789 s.textContent = [ 790 '#guest-modal-overlay .bg-white,#guest-modal-overlay button.bg-white{background-color:#0f1729;color:#e9eef7}', 791 '#guest-modal-overlay button.bg-white:hover{background-color:rgba(255,255,255,0.04)}', 792 '#guest-modal-overlay .bg-slate-50,#guest-modal-overlay .bg-slate-100{background-color:rgba(255,255,255,0.05)}', 793 '#guest-modal-overlay .hover\\:bg-slate-100:hover{background-color:rgba(255,255,255,0.09)}', 794 '#guest-modal-overlay .text-slate-800,#guest-modal-overlay .text-slate-700{color:#e9eef7}', 795 '#guest-modal-overlay .text-slate-600,#guest-modal-overlay .text-slate-500{color:rgba(232,238,248,0.74)}', 796 '#guest-modal-overlay .text-slate-400,#guest-modal-overlay .text-slate-300{color:rgba(232,238,248,0.48)}', 797 '#guest-modal-overlay .hover\\:text-slate-600:hover{color:#e9eef7}', 798 '#guest-modal-overlay .border-slate-100{border-color:rgba(255,255,255,0.08)}', 799 '#guest-modal-overlay .border-slate-200{border-color:rgba(255,255,255,0.14)}', 800 // Accent tints â the pastel -50/-100 fills disappear on navy. 801 '#guest-modal-overlay .bg-blue-100,#guest-modal-overlay .from-blue-100{background-image:none;background-color:rgba(59,130,246,0.20)}', 802 '#guest-modal-overlay .from-amber-100{background-image:none;background-color:rgba(245,158,11,0.20)}', 803 '#guest-modal-overlay .bg-emerald-50,#guest-modal-overlay .bg-emerald-100{background-color:rgba(16,185,129,0.18)}', 804 '#guest-modal-overlay .bg-amber-50{background-color:rgba(245,158,11,0.16)}', 805 '#guest-modal-overlay .bg-red-50,#guest-modal-overlay .bg-red-100{background-color:rgba(239,68,68,0.16)}', 806 '#guest-modal-overlay .text-blue-600,#guest-modal-overlay .text-blue-700{color:#93b4ff}', 807 '#guest-modal-overlay .text-emerald-600,#guest-modal-overlay .text-emerald-700{color:#4ade80}', 808 '#guest-modal-overlay .text-amber-600{color:#fbbf24}', 809 '#guest-modal-overlay .text-red-500,#guest-modal-overlay .text-red-600,#guest-modal-overlay .text-red-700{color:#fca5a5}', 810 '#guest-modal-overlay .border-emerald-200{border-color:rgba(16,185,129,0.35)}', 811 '#guest-modal-overlay .border-amber-200{border-color:rgba(245,158,11,0.35)}', 812 '#guest-modal-overlay .border-red-200{border-color:rgba(239,68,68,0.35)}', 813 ].join(''); 814 document.head.appendChild(s); 815 } 816
817 injectGuestModalDarkCss(); 818 819 // ââ Scroll sign-in prompt ââ 820 // 821 // Fires once when a signed-out visitor scrolls past 50% of the page, and signs 822 // them in via Google Identity Services without leaving the page. Signed-in state 823 // is read from `_wcv_uid` (set on login in useAuthInternal.ts, cleared on logout 824 // in auth.utils.ts) â a soft signal, fine for a marketing prompt. 825 826 // Match how the app resolves its API: in dev it goes through the vite proxy at 827 // /api (vite.config.ts) so the `_auth` cookie lands on the same origin the app 828 // reads it from. Hitting prod from localhost signs you into prod and leaves the 829 // local app unauthenticated. 830 var API_BASE = /^(localhost|127\.0\.0\.1)$/.test(location.hostname) 831 ? '/api' 832 : 'https://api.writecv.ai/api'; 833 var GOOGLE_CLIENT_ID = '714338205547-rmm2hlmas40v9j41gnlnkq9ae121ur75.apps.googleusercontent.com'; 834 var GSI_SRC = 'https://accounts.google.com/gsi/client'; 835 var SCROLL_THRESHOLD = 0.5; 836 837 // Skip crawlers and headless browsers â they never sign in and can trip the 838 // scroll handler during prerendering. 839 var ua = navigator.userAgent; 840 if (/bot|crawl|spider|slurp|facebookexternalhit|Twitterbot|LinkedInBot|Applebot|HeadlessChrome|PhantomJS/i.test(ua)) return; 841 try { if (navigator.webdriver === true) return; } catch (_) {} 842 843 function ls(key) { try { return localStorage.getItem(key); } catch (_) { return null; } } 844 function lsSet(key, val) { try { localStorage.setItem(key, val); } catch (_) {} } 845 846 function isSignedIn() { return !!ls('_wcv_uid'); } 847 848 function track(eventName, params) { 849 try { if (typeof gtag === 'function') gtag('event', eventName, params || {}); } catch (_) {} 850 } 851 852 // No dismissal cooldown: any signed-out visitor gets the prompt once per page view. 853 if (isSignedIn()) return; 854 855 var overlay = null; 856 var gsiLoading = false; 857 858 // ââ Google sign-in ââ 859 860 function loadGsi(cb) { 861 if (window.google && window.google.accounts && window.google.accounts.id) return cb(true); 862 if (gsiLoading) return; 863 gsiLoading = true; 864 var s = document.createElement('script'); 865 s.src = GSI_SRC; 866 s.async = true; 867 s.defer = true; 868 s.onload = function () { cb(!!(window.google && window.google.accounts && window.google.accounts.id)); }; 869 s.onerror = function () { cb(false); }; 870 document.head.appendChild(s); 871 } 872 873 // Read the email out of the Google ID token so `_wcv_uid` matches what the app 874 // writes after its own sign-in. 875 function emailFromCredential(jwt) { 876 try { 877 var part = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); 878 var json = decodeURIComponent(atob(part).split('').map(function (c) { 879 return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 880 }).join('')); 881 return JSON.parse(json).email || ''; 882 } catch (_) { return ''; } 883 } 884 885 function onCredential(response) { 886 var credential = (response && response.credential) || ''; 887 if (!credential) return; 888 setBusy(true); 889 var tz = ''; 890 try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (_) {} 891 892 fetch(API_BASE + '/auth/sign-in', { 893 method: 'POST', 894 credentials: 'include', 895 headers: { 'Content-Type': 'application/json', 'X-Timezone': tz }, 896 body: JSON.stringify({ 897 token: credential, 898 utmSource: ls('_wcv_utm_source') || undefined, 899 utmMedium: ls('_wcv_utm_medium') || undefined, 900 utmCampaign: ls('_wcv_utm_campaign') || undefined, 901 visitorId: ls('_wcv_vid') || undefined, 902 landingPage: ls('_wcv_landing_page') || undefined, 903 referrer: ls('_wcv_referrer') || undefined, 904 }), 905 }) 906 .then(function (res) { 907 if (res.ok) return res.json(); 908 return res.text().then(function (body) { 909 throw new Error('sign-in failed: HTTP ' + res.status + ' ' + (body || '').slice(0, 200)); 910 }); 911 }) 912 .then(function (data) { 913 // Mirror what the app stores after its own sign-in, so it doesn't bounce 914 // the user straight back to /login on arrival. 915 if (data && data.expiresAt) lsSet('TOKEN_EXPIRY_TIME', String(data.expiresAt)); 916 var email = emailFromCredential(credential); 917 if (email) lsSet('_wcv_uid', email); 918 if (data && data.isNewUser) { 919 try { sessionStorage.setItem('writecv_is_new_user', '1'); } catch (_) {} 920 } 921 track('signin_prompt_success', { page: location.pathname, is_new_user: !!(data && data.isNewUser) }); 922 window.location.href = '/resumes'; 923 }) 924 .catch(function (err) { 925 var msg = (err && err.message) || 'Network error'; 926 console.error('[writecv] sign-in prompt:', err); 927 setBusy(false);
928 showFallback(msg); 929 track('signin_prompt_error', { page: location.pathname, message: msg.slice(0, 100) }); 930 }); 931 } 932 933 function renderGoogleButton() { 934 var slot = overlay && overlay.querySelector('[data-wcv-gbtn]'); 935 if (!slot) return; 936 loadGsi(function (ok) { 937 if (!ok || !overlay) { 938 console.error('[writecv] sign-in prompt: Google Identity Services failed to load'); 939 return showFallback('Google sign-in could not load.'); 940 } 941 try { 942 window.google.accounts.id.initialize({ 943 client_id: GOOGLE_CLIENT_ID, 944 callback: onCredential, 945 auto_select: false, 946 cancel_on_tap_outside: true, 947 }); 948 // GIS only accepts 200â400px, and the modal is narrower on small screens. 949 var width = Math.max(200, Math.min(384, slot.clientWidth || 384)); 950 window.google.accounts.id.renderButton(slot, { 951 type: 'standard', 952 theme: 'filled_blue', 953 size: 'large', 954 text: 'continue_with', 955 shape: 'rectangular', 956 logo_alignment: 'left', 957 width: width, 958 }); 959 } catch (err) { 960 console.error('[writecv] sign-in prompt: renderButton failed', err); 961 showFallback('Google sign-in could not load.'); 962 } 963 }); 964 } 965 966 // Only surfaced when Google fails â the happy path keeps a single CTA. 967 function showFallback(msg) { 968 var el = overlay && overlay.querySelector('[data-wcv-fallback]'); 969 if (!el) return; 970 el.innerHTML = '<span style="color:#f87171">' + msg + '</span> ' + 971 '<a href="/login" style="color:' + C.accent + ';text-decoration:underline">Use the sign-in page</a>'; 972 el.style.display = 'block'; 973 } 974 975 function setBusy(busy) { 976 var el = overlay && overlay.querySelector('[data-wcv-busy]'); 977 if (el) el.style.display = busy ? 'block' : 'none'; 978 var slot = overlay && overlay.querySelector('[data-wcv-gbtn]'); 979 if (slot) slot.style.display = busy ? 'none' : 'flex'; 980 } 981 982 // ââ Modal ââ 983 984 function close(reason) { 985 if (!overlay) return; 986 overlay.remove(); 987 overlay = null; 988 document.body.style.overflow = ''; 989 document.removeEventListener('keydown', onKeydown); 990 track('signin_prompt_dismissed', { reason: reason || 'close', page: location.pathname }); 991 } 992 993 function onKeydown(e) { 994 if (e.key === 'Escape') close('escape'); 995 } 996 997 function bullet(text) { 998 return '<li style="display:flex;align-items:flex-start;gap:10px;font-size:14px;line-height:1.5;color:' + C.textBody + '">' + 999 '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="' + C.accent + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:2px"><polyline points="20 6 9 17 4 12"/></svg>' + 1000 '<span>' + text + '</span>' + 1001 '</li>'; 1002 } 1003 1004 function show() { 1005 // Never stack on top of the ATS analysis modal. 1006 var guest = document.getElementById('guest-modal-overlay'); 1007 if (guest && guest.style.display !== 'none') return false; 1008 if (overlay) return false; 1009 1010 overlay = document.createElement('div'); 1011 overlay.id = 'signin-prompt-overlay'; 1012 overlay.style.cssText = 'position:fixed;inset:0;z-index:9998;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);'; 1013 overlay.innerHTML = 1014 '<div role="dialog" aria-modal="true" aria-labelledby="wcv-signin-title" style="position:relative;width:100%;max-width:440px;max-height:90vh;overflow-y:auto;background:' + C.surface + ';border:1px solid ' + C.border + ';border-radius:20px;box-shadow:' + C.shadow + ';font-family:Inter,-apple-system,BlinkMacSystemFont,system-ui,sans-serif">' + 1015 '<div style="position:relative;overflow:hidden;padding:20px 28px 24px;background:linear-gradient(to bottom right,#1e3a8a,#2563eb,#6366f1)">' + 1016 '<div style="position:absolute;top:-40px;right:-40px;width:160px;height:160px;border-radius:9999px;background:rgba(255,255,255,0.1);filter:blur(4px);pointer-events:none"></div>' + 1017 '<div style="position:relative;z-index:1;display:flex;align-items:flex-start;justify-content:space-between;gap:16px">' + 1018 '<div>' + 1019 '<h2 id="wcv-signin-title" style="margin:16px 0 0;font-size:22px;font-weight:700;color:#ffffff;line-height:1.25">Land more interviews</h2>' + 1020 '<p style="margin:4px 0 0;font-size:14px;line-height:1.5;color:rgba(255,255,255,0.72)">Create a free account for an ATS-ready resume, an instant score, and job-match tailoring.</p>' + 1021 '</div>' + 1022 '<button type="button" data-wcv-close aria-label="Close" style="flex-shrink:0;margin-top:16px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.15);color:rgba(255,255,255,0.75);cursor:pointer">' + 1023 '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 18L18 6M6 6l12 12"/></svg>
1023' + 1024 '</button>' + 1025 '</div>' + 1026 '</div>' + 1027 '<div style="padding:24px 28px">' + 1028 '<ul style="list-style:none;padding:0;margin:0 0 24px;display:flex;flex-direction:column;gap:12px">' + 1029 bullet('ATS-ready templates recruiters can actually parse') + 1030 bullet('Instant resume score with a keyword breakdown') + 1031 bullet('Tailor your resume to any job description') + 1032 '</ul>' + 1033 '<div data-wcv-gbtn style="display:flex;justify-content:center;min-height:44px"></div>' + 1034 '<div data-wcv-busy style="display:none;text-align:center;font-size:14px;color:' + C.textBody + ';padding:12px 0">Signing you inâ¦</div>' + 1035 '<p data-wcv-fallback style="display:none;margin:12px 0 0;font-size:13px;line-height:1.5;text-align:center"></p>' + 1036 '<button type="button" data-wcv-close style="width:100%;margin-top:16px;padding:0;font-size:13px;color:' + C.textMuted + ';background:transparent;border:0;cursor:pointer">Maybe later</button>' + 1037 '</div>' + 1038 '</div>'; 1039 1040 document.body.appendChild(overlay); 1041 document.body.style.overflow = 'hidden'; 1042 1043 overlay.addEventListener('click', function (e) { 1044 if (e.target === overlay) close('overlay'); 1045 }); 1046 Array.prototype.forEach.call(overlay.querySelectorAll('[data-wcv-close]'), function (btn) { 1047 btn.addEventListener('click', function () { close('button'); }); 1048 }); 1049 document.addEventListener('keydown', onKeydown); 1050 1051 renderGoogleButton(); 1052 track('signin_prompt_shown', { page: location.pathname, theme: dark ? 'dark' : 'light' }); 1053 return true; 1054 } 1055 1056 // ââ Scroll trigger ââ 1057 1058 var ticking = false; 1059 1060 function onScroll() { 1061 if (ticking) return; 1062 ticking = true; 1063 requestAnimationFrame(function () { 1064 ticking = false; 1065 var scrolled = window.scrollY || document.documentElement.scrollTop || 0; 1066 // Require real scrolling, so short pages already showing 50% don't fire on load. 1067 if (scrolled <= 0) return; 1068 var docHeight = Math.max( 1069 document.body.scrollHeight, document.documentElement.scrollHeight, 1070 document.body.offsetHeight, document.documentElement.offsetHeight 1071 ); 1072 if (docHeight <= 0) return; 1073 if ((scrolled + window.innerHeight) / docHeight < SCROLL_THRESHOLD) return; 1074 // Re-check: the visitor may have signed in in another tab since page load. 1075 if (isSignedIn()) { window.removeEventListener('scroll', onScroll); return; } 1076 if (show()) window.removeEventListener('scroll', onScroll); 1077 }); 1078 } 1079 1080 window.addEventListener('scroll', onScroll, { passive: true }); 1081})();
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.