1// KAGN Analytics - GA4 loader with EU/UK/EEA geo-gate. 2// Runs alongside telemetry.js. This file owns commerce/conversion events; 3// telemetry.js owns product behavior and errors. 4(function () { 5 'use strict'; 6 7 var MEASUREMENT_ID = 'G-FP7G5794M0'; 8 var GEO_KEY = 'kagn-geo'; 9 var GEO_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 10 var GEO_ENDPOINT = '/api/geo'; 11 var GTAG_SRC = 'https://www.googletagmanager.com/gtag/js?id=' + MEASUREMENT_ID; 12 13 var loaded = false; 14 15 // ââ First-touch ad attribution ââââââââââââââââââââââââââââââââââââ 16 // Captured unconditionally, BEFORE the GA geo-gate: click IDs and UTMs 17 // are first-party data we need to join paid campaigns to signups/trials 18 // in our own telemetry, independent of whether gtag ever loads. 19 var ATTR_KEY = 'kagn-attribution'; 20 var ATTR_PARAMS = [ 21 'gclid', 'gbraid', 'wbraid', // Google Ads 22 'fbclid', // Meta 23 'ttclid', // TikTok 24 'msclkid', // Microsoft 25 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 26 'ref', 'source', 'share', 'trip_share' 27 ]; 28 // Keys stamped onto every GA4 event from the stored first touch: the URL 29 // params above plus the referrer fields captured below. Kept separate 30 // from ATTR_PARAMS so ?referrer_class=ai in a URL can never forge a 31 // channel â those two are only ever set from document.referrer. 32 var STAMP_KEYS = ATTR_PARAMS.concat(['referrer_host', 'referrer_class']); 33 34 function readAttribution() { 35 try { 36 var raw = localStorage.getItem(ATTR_KEY); 37 return raw ? JSON.parse(raw) : null; 38 } catch (e) { return null; } 39 } 40 41 // Where a first visit came from, when the URL carries no click ID or UTM. 42 // Answer engines (ChatGPT, Perplexity, Copilot, Gemini, Claude) send plain 43 // referrers, so without this the whole "AI search" channel reads as 44 // direct traffic. Only the hostname is kept â never the referrer path. 45 var REFERRER_CLASSES = [ 46 ['ai', /(^|\.)(chatgpt\.com|openai\.com|perplexity\.ai|claude\.ai|anthropic\.com|gemini\.google\.com|copilot\.microsoft\.com|you\.com|phind\.com|poe\.com|meta\.ai|mistral\.ai|deepseek\.com|kagi\.com)$/], 47 ['search', /(^|\.)(google\.[a-z.]+|bing\.com|duckduckgo\.com|yahoo\.com|yandex\.[a-z]+|baidu\.com|ecosia\.org|brave\.com|startpage\.com|qwant\.com|search\.[a-z.]+)$/], 48 ['social', /(^|\.)(facebook\.com|fb\.com|instagram\.com|t\.co|twitter\.com|x\.com|reddit\.com|tiktok\.com|youtube\.com|youtu\.be|linkedin\.com|pinterest\.[a-z.]+|whatsapp\.com|threads\.net|mastodon\.[a-z]+|bsky\.app)$/], 49 ]; 50 function classifyReferrer(host) { 51 if (!host) return ''; 52 host = String(host).toLowerCase(); 53 for (var i = 0; i < REFERRER_CLASSES.length; i++) { 54 if (REFERRER_CLASSES[i][1].test(host)) return REFERRER_CLASSES[i][0]; 55 } 56 return 'referral'; 57 } 58 function externalReferrerHost() { 59 try { 60 var ref = (typeof document !== 'undefined' && document.referrer) || ''; 61 if (!ref) return ''; 62 var host = new URL(ref).hostname.toLowerCase(); 63 if (!host || host === 'kagn.app' || /(^|\.)kagn\.app$/.test(host) || host === 'localhost') return ''; 64 return host.slice(0, 120); 65 } catch (e) { return ''; } 66 } 67 68 function captureAttribution() { 69 var search = (window.location && window.location.search) || ''; 70 var refHost = externalReferrerHost(); 71 if (!search && !refHost) return; 72 var query = {}; 73 var pairs = search.replace(/^\?/, '').split('&'); 74 for (var i = 0; i < pairs.length; i++) { 75 var eq = pairs[i].indexOf('='); 76 if (eq <= 0) continue; 77 try { 78 query[decodeURIComponent(pairs[i].slice(0, eq))] = 79 decodeURIComponent(pairs[i].slice(eq + 1).replace(/\+/g, ' ')); 80 } catch (e) { /* malformed escape â skip pair */ } 81 } 82 var attr = {}; 83 var found = false;
84 for (var j = 0; j < ATTR_PARAMS.length; j++) { 85 var v = query[ATTR_PARAMS[j]]; 86 if (v) { attr[ATTR_PARAMS[j]] = String(v).slice(0, 200); found = true; } 87 } 88 if (refHost) { 89 attr.referrer_host = refHost; 90 attr.referrer_class = classifyReferrer(refHost); 91 found = true; 92 } 93 if (!found) return; 94 if (readAttribution()) return; // first touch wins 95 attr.landing_page = (window.location && window.location.pathname) || ''; 96 attr.ts = Date.now(); 97 try { 98 localStorage.setItem(ATTR_KEY, JSON.stringify(attr)); 99 } catch (e) { return; /* quota â don't emit an event we can't dedup */ } 100 // One-time telemetry event: lets D1 join acquisition source to the 101 // funnel (telemetry.js loads before analytics.js on every page). 102 if (window.KagnTelemetry && typeof window.KagnTelemetry.track === 'function') { 103 window.KagnTelemetry.track('ad_attribution', attr); 104 } 105 } 106 107 // Calls often happen before /api/geo resolves. Queue them instead of losing 108 // first_open/onboarding events during that async gap; discard the queue if 109 // the geo privacy gate blocks analytics. 110 var pendingCalls = []; 111 function queued(method) { 112 return function (props) { pendingCalls.push([method, props]); }; 113 } 114 var stubApi = { 115 appOpen: queued('appOpen'), 116 onboardingCompleted: queued('onboardingCompleted'), 117 firstIdCompleted: queued('firstIdCompleted'), 118 signInCompleted: queued('signInCompleted'), 119 paywallViewed: queued('paywallViewed'), 120 trialStarted: queued('trialStarted'), 121 purchase: queued('purchase'), 122 upgradeComplete: queued('upgradeComplete') 123 }; 124 // Expose the stub immediately so call sites never crash if gtag never loads. 125 window.kagnAnalytics = stubApi; 126 127 function loadGtag() { 128 if (loaded) return; 129 loaded = true; 130 131 // In the Capacitor iOS build, use the native Firebase SDK. That gives 132 // Google Ads an app data stream, first_open attribution and SKAN-aware 133 // post-install events. The remote PWA continues to use the web tag. 134 var nativeAnalytics = getNativeAnalytics(); 135 if (nativeAnalytics) { 136 installEventApi(); 137 return; 138 } 139 window.dataLayer = window.dataLayer || []; 140 if (typeof window.gtag !== 'function') { 141 window.gtag = function () { window.dataLayer.push(arguments); }; 142 } 143 window.gtag('js', new Date()); 144 window.gtag('config', MEASUREMENT_ID, { send_page_view: true }); 145 146 var s = document.createElement('script'); 147 s.async = true; 148 s.src = GTAG_SRC; 149 // Inject only after the window load event. A script inserted before 150 // `load` joins the load chain (gtag.js, then everything it pulls 151 // in), and inside the Android TWA Chrome keeps its progress bar up 152 // until `load` fires (2026-09-04). Nothing is lost: gtag() calls 153 // queue in dataLayer and replay when the script executes. 154 if (document.readyState === 'complete') { 155 document.head.appendChild(s); 156 } else { 157 window.addEventListener('load', function () { document.head.appendChild(s); }, { once: true }); 158 } 159 160 installEventApi(); 161 } 162 163 function getNativeAnalytics() { 164 try { 165 var cap = window.Capacitor; 166 if (!cap || typeof cap.isNativePlatform !== 'function' || !cap.isNativePlatform()) return null; 167 return cap.Plugins && cap.Plugins.FirebaseAnalytics || null; 168 } catch (e) { return null; } 169 } 170 171 function getContext() { 172 var ua = navigator.userAgent || ''; 173 var platform = /android/i.test(ua) ? 'android' 174 : /iphone|ipad|ipod/i.test(ua) ? 'ios' 175 : 'web'; 176 var pack = (window.PACK && window.PACK.id) || ''; 177 var tier = (window.KagnFreemium && typeof window.KagnFreemium.getTier === 'function') 178 ? window.KagnFreemium.getTier() : 'free'; 179 var isPwa = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ? 1 : 0; 180 return { pack_id: pack, tier: tier, platform: platform, is_pwa: isPwa }; 181 } 182 183 function fire(name, props) { 184 var nativeAnalytics = getNativeAnalytics(); 185 if (nativeAnalytics && typeof nativeAnalytics.logEvent === 'function') { 186 nativeAnalytics.logEvent({ name: name, params: props || {} }).catch(function () {}); 187 return; 188 } 189 if (typeof window.gtag !== 'function') return; 190 var ctx = getContext(); 191 var payload = Object.assign({}, ctx, props || {}); 192 // Stamp stored first-touch attribution onto every event so 193 // conversions fired sessions later still carry their source. 194 var attr = readAttribution(); 195 if (attr) { 196 for (var i = 0; i < STAMP_KEYS.length; i++) { 197 var k = STAMP_KEYS[i]; 198 if (attr[k] && payload[k] === undefined) payload[k] = attr[k]; 199 } 200 } 201 window.gtag('event', name, payload); 202 } 203 204 function onceLocal(key, fn) { 205 try { 206 if (localStorage.getItem(key) === '1') return; 207 localStorage.setItem(key, '1'); 208 } catch (e) { /* quota â just fire */ } 209 fn(); 210 } 211 212 function onceSession(key, fn) { 213 try { 214 if (sessionStorage.getItem(key) === '1') return; 215 sessionStorage.setItem(key, '1'); 216 } catch (e) { /* quota â just fire */ } 217 fn(); 218 } 219 220 function installEventApi() { 221 // Mutate the existing stub in-place so callers that captured
222 // window.kagnAnalytics before geo resolution automatically see 223 // the real methods once gtag loads. 224 stubApi.appOpen = function () { 225 onceSession('kagn-ga-app-open', function () { fire('app_open'); }); 226 }; 227 stubApi.onboardingCompleted = function (props) { 228 onceLocal('kagn-ga-onboarding', function () { 229 fire('onboarding_completed', { 230 selected_packs: (props && props.selectedPacks || []).join(',') 231 }); 232 }); 233 }; 234 stubApi.firstIdCompleted = function (props) { 235 onceLocal('kagn-ga-first-id', function () { 236 fire('first_id_completed', { 237 mode: props && props.mode || 'unknown', 238 result: props && props.result || 'unknown' 239 }); 240 }); 241 }; 242 stubApi.signInCompleted = function (props) { 243 fire('sign_in_completed', { is_new_user: !!(props && props.isNewUser) }); 244 }; 245 stubApi.paywallViewed = function (props) { 246 fire('paywall_viewed', { trigger: (props && props.trigger) || 'manual' }); 247 }; 248 stubApi.trialStarted = function (props) { 249 fire('trial_started', { 250 sku: props && props.sku || '', 251 tier: props && props.tier || '' 252 }); 253 }; 254 stubApi.upgradeComplete = function (props) { 255 // GA4 key event + Google Ads Primary conversion. Callers dedupe 256 // (fires once per new store token) â never call on every boot. 257 fire('upgrade_complete', { 258 source: props && props.source || '', 259 tier: props && props.tier || '' 260 }); 261 }; 262 stubApi.purchase = function (props) { 263 var sku = props && props.sku || ''; 264 var tier = props && props.tier || ''; 265 var value = (props && typeof props.value === 'number') ? props.value : 0; 266 var currency = props && props.currency || 'USD'; 267 var tid = props && props.transactionId || ''; 268 fire('purchase', { 269 transaction_id: tid, 270 value: value, 271 currency: currency, 272 items: [{ 273 item_id: sku, 274 item_name: tier === 'pro' ? 'Kagn Pro' : 'Kagn Plus', 275 price: value, 276 quantity: 1 277 }] 278 }); 279 }; 280 281 var replay = pendingCalls.splice(0); 282 for (var i = 0; i < replay.length; i++) { 283 var method = replay[i][0]; 284 if (typeof stubApi[method] === 'function') stubApi[method](replay[i][1]); 285 } 286 } 287 288 function tryLoad() { 289 var cached = null; 290 try { 291 var raw = localStorage.getItem(GEO_KEY); 292 if (raw) cached = JSON.parse(raw); 293 } catch (e) { /* corrupt cache â refetch */ } 294 295 var now = Date.now(); 296 if (cached && typeof cached.ts === 'number' && (now - cached.ts) < GEO_TTL_MS) { 297 if (!cached.blocked) loadGtag(); 298 else pendingCalls.length = 0; 299 return; 300 } 301 302 fetch(GEO_ENDPOINT).then(function (r) { 303 return r.json(); 304 }).then(function (g) { 305 if (typeof g.country !== 'string') return; 306 try { 307 localStorage.setItem(GEO_KEY, JSON.stringify({ 308 country: g.country, blocked: !!g.blocked, ts: now 309 })); 310 } catch (e) { /* quota */ } 311 if (!g.blocked) loadGtag(); 312 else pendingCalls.length = 0; 313 }).catch(function () { 314 // Fail closed: no gtag on fetch failure. 315 }); 316 } 317 318 captureAttribution(); 319 tryLoad(); 320})();
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.