PageSourceSearch

https://resumegeni.com/static/js/tracking.js?v=ba3b6a04d5

js resumegeni.com collected 2026-10-01 16:32:19 UTC 24,428 bytes, 578 lines download raw bytes

1/**
2 * Funnel Event Tracking — Unified visitor identity + event pipeline
3 *
4 * Architecture:
5 * 1. Single source of truth for visitor ID (replaces 3 duplicate implementations)
6 * 2. Session ID for grouping events within a visit
7 * 3. Source attribution (UTM, referrer, direct)
8 * 4. sendBeacon delivery for zero INP impact + survives navigation
9 * 5. Fire-and-forget: never blocks UI, never throws
10 *
11 * Events tracked:
12 * - cta_click: User clicks any CTA (blog inline, homepage hero, exit intent)
13 * - file_picker_shown: File picker dialog opened
14 * - file_selected: User chose a file (captures file type + size)
15 * - upload_start: Upload/analysis begins
16 * - upload_complete: Analysis finished (captures score)
17 * - results_viewed: User sees partial results
18 * - signup_cta_click: User clicks "Get Full Analysis" or similar auth CTA
19 * - signup_start: User initiates sign-up/sign-in intent
20 * - signup_complete: User successfully completes authentication
21 * - activation_checkpoint: User reaches post-signup product milestones
22 * - auth_page_view: /auth/login page loaded
23 * - auth_method_click: User clicks a specific auth method
24 * - auth_success: Authentication completed
25 */
26
27(function () {
28    'use strict';
29
30    var TEST_MODE_KEY = 'rg_funnel_test';
31    var TEST_MODE_REASON_KEY = 'rg_funnel_test_reason';
32
33    // ---- Visitor ID (single source of truth) ----
34    // Priority: localStorage > cookie > generate
35    // Syncs both directions so all scripts share the same ID
36    //
37    // We also mirror this id into rg_funnel_vid so server-side emits
38    // (e.g. /analyze/upload preauth_created) can attribute events to the
39    // same anonymous visitor instead of falling back to a token prefix.
40    function getOrCreateVisitorId() {
41        var id = localStorage.getItem('ab_vid');
42        if (id) {
43            mirrorToFunnelCookie(id);
44            return id;
45        }
46
47        var match = document.cookie.match(/ab_vid=([^;]+)/);
48        if (match) {
49            id = match[1];
50            localStorage.setItem('ab_vid', id);
51            mirrorToFunnelCookie(id);
52            return id;
53        }
54
55        // crypto.randomUUID where available, fallback to manual UUID v4
56        if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
57            id = crypto.randomUUID();
58        } else {
59            id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
60                var r = Math.random() * 16 | 0;
61                var v = c === 'x' ? r : (r & 0x3 | 0x8);
62                return v.toString(16);
63            });
64        }
65
66        localStorage.setItem('ab_vid', id);
67        mirrorToFunnelCookie(id);
68        return id;
69    }
70
71    function mirrorToFunnelCookie(id) {
72        // 30 days, matches auth.py rg_funnel_vid lifetime so post-auth code
73        // sees a consistent identity from anon visit through signup.
74        var existing = getCookie('rg_funnel_vid');
75        if (existing === id) return;
76        setCookie('rg_funnel_vid', id, 30 * 24 * 60 * 60);
77    }
78
79    // ---- Session ID (groups events within a single visit) ----
80    function mirrorSessionCookie(sid) {
81        // Session-scoped cookie (no max-age) so SERVER-emitted funnel
82        // events (preauth_created etc.) can share the browser session
83        // id instead of inventing a phantom one — session-grain joins
84        // were broken because server events used visitor_id as
85        // session_id (2026-06-09). sessionStorage stays the source of
86        // truth; the cookie is a best-effort mirror (multi-tab races
87        // resolve to the most recent tab, which is the one uploading).
88        try {
89            var cookie = 'rg_funnel_sid=' + encodeURIComponent(sid) + '; path=/; SameSite=Lax';
90            if (window.location.protocol === 'https:') cookie += '; Secure';
91            document.cookie = cookie;
92        } catch (_e) { /* cookie write best-effort */ }
93    }
94
95    function getOrCreateSessionId() {
96        var sid = sessionStorage.getItem('funnel_sid');
97        if (sid) {
98            mirrorSessionCookie(sid);
99            return sid;
100        }
101
102        if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
103            sid = crypto.randomUUID();
104        } else {
105            sid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
106                var r = Math.random() * 16 | 0;
107                var v = c === 'x' ? r : (r & 0x3 | 0x8);
108                return v.toString(16);
109            });
110        }
111
112        sessionStorage.setItem('funnel_sid', sid);
113        mirrorSessionCookie(sid);
114        return sid;
115    }
116
117    function getCookie(name) {
118        var pattern = new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)');
119        var match = document.cookie.match(pattern);
120        return match ? decodeURIComponent(match[1]) : '';
121    }
122
123    function setCookie(name, value, maxAgeSeconds) {
124        var cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';
125        if (window.location.protocol === 'https:') cookie += '; Secure';
126        if (maxAgeSeconds) cookie += '; max-age=' + String(maxAgeSeconds);
127        document.cookie = cookie;
128    }
129
130    function deleteCookie(name) {
131        document.cookie = name + '=; path=/; max-age=0; SameSite=Lax';
132    }
133
134    function normalizeTestReason(value) {
135        var raw = String(value || '').toLowerCase().trim();
136        if (!raw) return 'manual';
137        var sanitized = raw.replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
138        return sanitized || 'manual';
139    }
140
141    function persistTestMode(enabled, reason) {
142        if (enabled) {
143            var normalizedReason = normalizeTestReason(reason);
144            localStorage.setItem(TEST_MODE_KEY, '1');
145            localStorage.setItem(TEST_MODE_REASON_KEY, normalizedReason);
146            setCookie(TEST_MODE_KEY, '1', 60 * 60 * 24 * 30);
147            setCookie(TEST_MODE_REASON_KEY, normalizedReason, 60 * 60 * 24 * 30);
148            return;
149        }
150        localStorage.removeItem(TEST_MODE_KEY);
151        localStorage.removeItem(TEST_MODE_REASON_KEY);
152        deleteCookie(TEST_MODE_KEY);
153        deleteCookie(TEST_MODE_REASON_KEY);
154    }
155
156    function processTestModeOverrides() {
157        var params = new URLSearchParams(window.location.search);
158        if (params.has('rg_test')) {
159            var raw = String(params.get('rg_test') || '').toLowerCase();
160            var enabled = !(raw === '0' || raw === 'false' || raw === 'off');
161            persistTestMode(enabled, params.get('rg_test_reason') || params.get('test_reason') || 'manual');
162        }
163
164        var html = document.documentElement;
165        if (html && html.dataset && html.dataset.funnelTest === 'true') {
166            persistTestMode(true, html.dataset.funnelTestReason || 'admin');
167        }
168    }
169
170    function getTestContext() {
171        var html = document.documentElement;
172        var serverMarked = !!(html && html.dataset && html.dataset.funnelTest === 'true');
173        var persisted = localStorage.getItem(TEST_MODE_KEY) === '1' || getCookie(TEST_MODE_KEY) === '1';
174        var isTest = serverMarked || persisted;
175        if (!isTest) {
176            return { is_test: false, test_reason: null };
177        }
178        var reason = (html && html.dataset && html.dataset.funnelTestReason) ||
179            localStorage.getItem(TEST_MODE_REASON_KEY) ||
180            getCookie(TEST_MODE_REASON_KEY) ||
181            'manual';
182        return {
183            is_test: true,
184            test_reason: normalizeTestReason(reason)
185        };
186    }
187
188    function normalizeAiSource(sourceRaw) {
189        if (!sourceRaw) return null;
190        var source = String(sourceRaw).toLowerCase().trim();
191        if (!source) return null;
192        if (source === 'chatgpt.com' || source === 'chatgpt' || source === 'openai') return 'chatgpt';
193        if (source.indexOf('perplexity') !== -1 || source.indexOf('pplx') !== -1) return 'perplexity';
194        if (source.indexOf('gemini') !== -1 || source === 'bard') return 'gemini';
195        if (source.indexOf('copilot') !== -1 || source.indexOf('microsoft copilot') !== -1) return 'copilot';
196        if (source.indexOf('claude') !== -1 || source.indexOf('anthropic') !== -1) return 'claude';
197        if (source.indexOf('deepseek') !== -1) return 'deepseek';
198        if (source === 'ai') return 'ai';
199        return null;
200    }
201
202    function parseUrlSafely(rawUrl) {
203        try {
204            return new URL(rawUrl);
205        } catch (e) {
206            return null;
207        }
208    }
209
210    function detectSourceFromReferrer(ref) {
211        var refUrl = parseUrlSafely(ref);
212        if (!refUrl) return 'unknown';
213        var refHost = refUrl.hostname.toLowerCase();
214        var refPath = refUrl.pathname.toLowerCase();
215
216        if (refHost === window.location.hostname) return 'internal';
217        if (refHost.indexOf('chatgpt.com') !== -1 || refHost.indexOf('chat.openai.com') !== -1 || refHost.indexOf('openai.com') !== -1) return 'chatgpt';
218        if (refHost.indexOf('perplexity.ai') !== -1 || refHost.indexOf('pplx.ai') !== -1) return 'perplexity';
219        if (refHost.indexOf('gemini.google.com') !== -1 || refHost.indexOf('bard.google.com') !== -1) return 'gemini';
220        if (refHost.indexOf('copilot.microsoft.com') !== -1 || refHost.indexOf('edgeservices.bing.com') !== -1) return 'copilot';
221        if (refHost.indexOf('bing.com') !== -1 && (refPath.indexOf('/chat') !== -1 || refPath.indexOf('/copilot') !== -1)) return 'copilot';
222        if (refHost.indexOf('claude.ai') !== -1 || refHost.indexOf('anthropic.com') !== -1) return 'claude';
223        if (refHost.indexOf('deepseek.com') !== -1 || refHost.indexOf('chat.deepseek.com') !== -1) return 'deepseek';
224        if (refHost.indexOf('google') !== -1) return 'google';
225        if (refHost.indexOf('bing') !== -1) return 'bing';
226        if (refHost.indexOf('linkedin') !== -1) return 'linkedin';
227        if (refHost.indexOf('twitter') !== -1 || refHost.indexOf('x.com') !== -1) return 'twitter';
228        if (refHost.indexOf('facebook') !== -1) return 'facebook';
229        return refHost;
230    }
231
232    // ---- Source Attribution ----
233    function getEntrySource() {
234        var params = new URLSearchParams(window.location.search);
235
236        // Explicit source hints from query params.
237        var aiSource = normalizeAiSource(params.get('ai_source') || params.get('source'));
238        if (aiSource) return aiSource;
239
240        // UTM source takes priority.
241        var utm = params.get('utm_source');
242        if (utm) {
243            var normalizedUtm = normalizeAiSource(utm);
244            if (normalizedUtm) return normalizedUtm;
245            return utm.toLowerCase();
246        }
247
248        // UTM medium fallback for AI-tagged links that omit utm_source.
249        var utmMedium = params.get('utm_medium');
250        if (utmMedium && utmMedium.toLowerCase().indexOf('ai') !== -1) {
251            return 'ai';
252        }
253
254        // ?from= parameter (used by blog CTA flow)
255        var from = params.get('from');
256        if (from) {
257            var normalizedFrom = normalizeAiSource(from);
258            if (normalizedFrom) return normalizedFrom;
259            return from;
260        }
261
262        // Referrer-based attribution
263        // PRD-SEC-62: data-server-referrer was removed because it baked one
264        // visitor's Referer into the CDN-cached HTML and served it to
265        // subsequent visitors. document.referrer is per-browser and safe.
266        var ref = document.referrer || '';
267        if (!ref) return 'direct';
268        return detectSourceFromReferrer(ref);
269    }
270
271    // ---- Device Detection ----
272    function getDeviceType() {
273        var isMobile = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.innerWidth < 768;
274        return isMobile ? 'mobile' : 'desktop';
275    }
276
277    function getCampaignContext() {
278        var params = new URLSearchParams(window.location.search);
279        return {
280            utm_source: params.get('utm_source') || null,
281            utm_medium: params.get('utm_medium') || null,
282            utm_campaign: params.get('utm_campaign') || null,
283            utm_content: params.get('utm_content') || null,
284            utm_term: params.get('utm_term') || null,
285            from: params.get('from') || null,
286            referrer_host: (function () {
287                try {
288                    // PRD-SEC-62: see note above — document.referrer only.
289                    var r = document.referrer || '';
290                    return r ? new URL(r).hostname : null;
291                } catch (e) {
292                    return null;
293                }
294            })(),
295            page_query: window.location.search || null
296        };
297    }
298
299    function isAttributionNoise(source) {
300        return source === 'internal' || source === 'unknown' || source === 'direct' || !source;
301    }
302
303    function hasExplicitAttributionHint() {
304        var params = new URLSearchParams(window.location.search);
305        if (params.get('ai_source') || params.get('source') || params.get('utm_source') || params.get('from')) return true;
306        var utmMedium = params.get('utm_medium');
307        return !!(utmMedium && utmMedium.toLowerCase().indexOf('ai') !== -1);
308    }
309
310    function shouldRotateSessionForAttribution(currentSource) {
311        var sessionSource = sessionStorage.getItem('funnel_entry_source');
312        if (!sessionSource) return false;
313        if (!hasExplicitAttributionHint()) return false;
314        if (isAttributionNoise(currentSource)) return false;
315        return sessionSource !== currentSource;
316    }
317
318    function rotateSessionForAttribution() {
319        sessionStorage.removeItem('funnel_sid');
320        sessionStorage.removeItem('funnel_session_started');
321        sessionStorage.removeItem('funnel_campaign_context');
322    }
323
324    function resolveEntrySource(currentSource) {
325        var sessionSource = sessionStorage.getItem('funnel_entry_source');
326        var explicitHint = hasExplicitAttributionHint();
327        if (sessionSource && !(explicitHint && !isAttributionNoise(currentSource))) return sessionSource;
328
329        var firstTouchSource = localStorage.getItem('funnel_first_touch_source');
330        var resolved = currentSource;
331
332        // Preserve external first-touch source across internal redirects/navigation.
333        if (currentSource === 'internal' && firstTouchSource) {
334            resolved = firstTouchSource;
335        }
336
337        if (!firstTouchSource && !isAttributionNoise(currentSource)) {
338            localStorage.setItem('funnel_first_touch_source', currentSource);
339            localStorage.setItem('funnel_first_touch_at', new Date().toISOString());
340            firstTouchSource = currentSource;
341        }
342
343        sessionStorage.setItem('funnel_entry_source', resolved);
344        if (!sessionStorage.getItem('funnel_first_touch_source') && firstTouchSource) {
345            sessionStorage.setItem('funnel_first_touch_source', firstTouchSource);
346        }
347        return resolved;
348    }
349
350    function resolveCampaignContext(currentContext, resolvedSource) {
351        var sessionContextRaw = sessionStorage.getItem('funnel_campaign_context');
352        if (sessionContextRaw) {
353            try {
354                return JSON.parse(sessionContextRaw);
355            } catch (e) {
356                // continue with fresh context
357            }
358        }
359
360        var firstTouchContext = null;
361        var firstTouchRaw = localStorage.getItem('funnel_first_touch_campaign_context');
362        if (firstTouchRaw) {
363            try {
364                firstTouchContext = JSON.parse(firstTouchRaw);
365            } catch (e) {
366                firstTouchContext = null;
367            }
368        }
369
370        var shouldCaptureFirstTouch = (
371            currentContext.utm_source ||
372            currentContext.utm_campaign ||
373            currentContext.from ||
374            (!isAttributionNoise(resolvedSource) && resolvedSource !== 'direct')
375        );
376        if (!firstTouchContext && shouldCaptureFirstTouch) {
377            firstTouchContext = currentContext;
378            localStorage.setItem('funnel_first_touch_campaign_context', JSON.stringify(currentContext));
379        }
380
381        var merged = Object.assign({}, currentContext);
382        if ((resolvedSource === 'internal' || resolvedSource === 'direct') && firstTouchContext) {
383            merged = Object.assign({}, firstTouchContext, currentContext);
384        }
385
386        merged.first_touch_source = localStorage.getItem('funnel_first_touch_source') || resolvedSource;
387        merged.session_entry_source = resolvedSource;
388        sessionStorage.setItem('funnel_campaign_context', JSON.stringify(merged));
389        return merged;
390    }
391
392    // ---- Initialize ----
393    processTestModeOverrides();
394    var initialSource = getEntrySource();
395    if (shouldRotateSessionForAttribution(initialSource)) {
396        rotateSessionForAttribution();
397    }
398    var visitorId = getOrCreateVisitorId();
399    var sessionId = getOrCreateSessionId();
400    var entrySource = resolveEntrySource(initialSource);
401    var deviceType = getDeviceType();
402    var campaignContext = resolveCampaignContext(getCampaignContext(), entrySource);
403
404    // ---- Core: Track Funnel Event ----
405    function trackFunnel(eventName, metadata) {
406        var locale = document.documentElement.dataset.locale || 'en';
407        // Identity thread (cave-plan-retention-observability, 2026-06-05):
408        // visitor_id is ALWAYS the stable anonymous ab_vid and session_id
409        // is ALWAYS the real browser session. The old behavior swapped
410        // both to the user id at auth, splitting every journey at the auth
411        // boundary and collapsing all authed visits into one eternal
412        // "session". The authed user_id is SERVER-stamped onto rows from
413        // the session cookie at /api/funnel/event (sendBeacon carries
414        // same-origin cookies) — the client neither sends nor can spoof it.
415        var payload = {
416            event_name: eventName,
417            visitor_id: visitorId,
418            session_id: sessionId,
419            device_type: deviceType,
420            page_path: window.location.pathname,
421            entry_source: entrySource,
422            locale: locale
423        };
424
425        payload.metadata = Object.assign({}, campaignContext, metadata || {});
426        var testContext = getTestContext();
427        if (testContext.is_test) {
428            payload.metadata.is_test = true;
429            payload.metadata.test_reason = testContext.test_reason;
430        }
431
432        // sendBeacon: zero INP impact, survives page navigation.
433        // sendBeacon returns false when the browser's beacon queue
434        // rejects the payload — typical during the pre-navigation burst
435        // (upload_complete + builder_draft_created + redirect in one
436        // tick). Fall back to keepalive fetch instead of dropping the
437        // event silently (2026-06-09).
438        var body = JSON.stringify(payload);
439        var queued = false;
440        if (navigator.sendBeacon) {
441            try {
442                queued = navigator.sendBeacon(
443                    '/api/funnel/event',
444                    new Blob([body], { type: 'application/json' })
445                );
446            } catch (_e) {
447                queued = false;
448            }
449        }
450        if (!queued) {
451            fetch('/api/funnel/event', {
452                method: 'POST',
453                headers: { 'Content-Type': 'application/json' },
454                body: body,
455                keepalive: true
456            }).catch(function () {
457                // Silent failure — analytics never breaks UX
458            });
459        }
460
461        if (window.location.hostname === 'localhost') {
462            console.log('[Funnel]', eventName, metadata || '');
463        }
464    }
465
466    // ---- Expose API ----
467    window.RGTracking = {
468        trackFunnel: trackFunnel,
469        setTestMode: function (enabled, reason) { persistTestMode(!!enabled, reason || 'manual'); },
470        getTestContext: getTestContext,
471        getVisitorId: function () { return visitorId; },
472        getSessionId: function () { return sessionId; },
473        getEntrySource: function () { return entrySource; },
474        getDeviceType: function () { return deviceType; }
475    };
476
477    // Track one session-start event per browser tab session.
478    // This gives us baseline AI session volume even when users do not click CTAs.
479    if (!sessionStorage.getItem('funnel_session_started')) {
480        trackFunnel('session_start', { source: 'tracking_bootstrap' });
481        sessionStorage.setItem('funnel_session_started', '1');
482    }
483
484    if (window.location.hostname === 'localhost') {
485        console.log('[Funnel] Initialized', {
486            visitorId: visitorId.substring(0, 8) + '...',
487            sessionId: sessionId.substring(0, 8) + '...',
488            entrySource: entrySource,
489            deviceType: deviceType
490        });
491    }
492
493    // ---- S6: Page Exit with Engagement Metrics ----
494    // Only fire for authenticated users (server-rendered data attribute)
495    if (document.documentElement.dataset.authenticated === 'true') {
496        var _pageVisibleStart = Date.now();
497        var _engagedMs = 0;
498        var _maxScrollPct = 0;
499        var _exitFired = false;
500
501        // Accumulate engaged time across visibility changes.
502        // A tab switch pauses the timer; returning resumes it.
503        // The event only fires on pagehide (true navigation away).
504        document.addEventListener('visibilitychange', function () {
505            try {
506                if (document.visibilityState === 'hidden') {
507                    _engagedMs += Date.now() - _pageVisibleStart;
508                } else {
509                    _pageVisibleStart = Date.now();
510                }
511            } catch (e) { /* never throw */ }
512        });
513
514        // Track max scroll depth
515        var _scrollTicking = false;
516        window.addEventListener('scroll', function () {
517            if (_scrollTicking) return;
518            _scrollTicking = true;
519            requestAnimationFrame(function () {
520                try {
521                    var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
522                    var docHeight = Math.max(
523                        document.documentElement.scrollHeight,
524                        document.body.scrollHeight
525                    ) - window.innerHeight;
526                    if (docHeight > 0) {
527                        var pct = Math.round((scrollTop / docHeight) * 100);
528                        if (pct > _maxScrollPct) _maxScrollPct = pct;
529                    }
530                } catch (e) { /* never throw */ }
531                _scrollTicking = false;
532            });
533        }, { passive: true });
534
535        // Fire page_exit on actual navigation away (pagehide).
536        // visibilitychange only accumulates time — it does NOT fire the event.
537        // This prevents truncated engaged_ms from tab switches.
538        window.addEventListener('pagehide', function () {
539            try {
540                // Add final visible interval if page was visible when user left
541                if (document.visibilityState !== 'hidden') {
542                    _engagedMs += Date.now() - _pageVisibleStart;
543                }
544                _firePageExit();
545            } catch (e) { /* never throw */ }
546        });
547
548        function _firePageExit() {
549            if (_exitFired) return;
550            _exitFired = true;
551
552            // Use authenticated user_id if available so page_exit joins
553            // with server-side post-auth events (same identity model).
554            var authUserId = document.documentElement.dataset.userId || '';
555            var exitVisitorId = authUserId || visitorId;
556            var exitSessionId = authUserId || sessionId;
557
558            var payload = {
559                event_name: 'page_exit',
560                visitor_id: exitVisitorId,
561                session_id: exitSessionId,
562                device_type: deviceType,
563                page_path: window.location.pathname,
564                entry_source: entrySource,
565                locale: document.documentElement.dataset.locale || 'en',
566                metadata: {
567                    engaged_ms: _engagedMs,
568                    max_scroll_pct: Math.min(_maxScrollPct, 100)
569                }
570            };
571
572            if (navigator.sendBeacon) {
573                var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
574                navigator.sendBeacon('/api/funnel/event', blob);
575            }
576        }
577    }
578})();

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.