PageSourceSearch

https://writecv.ai/js/guest-modal.js

js writecv.ai collected 2026-10-05 13:17:45 UTC 52,447 bytes, 1,081 lines download raw bytes

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 &middot; 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 ? ' &middot; ' + 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.