1// app-helpers.js â Application helper functions 2// Moved from inline <script> blocks in index.html for CSP compliance. 3// This file MUST be loaded synchronously (no async/defer) because: 4// ⢠onRecaptchaApiLoad must be defined before reCAPTCHA's onload fires 5// ⢠downloadFile/geoHelper must be available before Blazor components call them 6 7// ââ reCAPTCHA ready signal âââââââââââââââââââââââââââââââââââââââââ 8// Referenced by: <script src="...recaptcha/api.js?...&onload=onRecaptchaApiLoad"> 9function onRecaptchaApiLoad() { 10 window._recaptchaReady = true; 11} 12 13// ââ File download helper âââââââââââââââââââââââââââââââââââââââââââ 14window.downloadFile = function (filename, contentType, base64Data) { 15 var link = document.createElement('a'); 16 link.href = 'data:' + contentType + ';base64,' + base64Data; 17 link.download = filename; 18 document.body.appendChild(link); 19 link.click(); 20 document.body.removeChild(link); 21}; 22 23// ââ Invoice print helper âââââââââââââââââââââââââââââââââââââââââââ 24// Opens invoice HTML in a new tab (bypasses auth header requirement) 25window.openInvoiceInNewTab = function (html) { 26 var win = window.open('', '_blank'); 27 if (win) { 28 win.document.open(); 29 win.document.write(html); 30 win.document.close(); 31 } else { 32 alert('Popup blocked. Please allow popups for this site to print invoices.'); 33 } 34}; 35 36// ââ Currency-context cookie ââââââââââââââââââââââââââââââââââââââââ 37// Caches the resolved country + currency + FX rate in a first-party cookie 38// so repeat visits skip the slow browser-geo lookup (up to 2.5 s) AND the 39// /api/currency/resolve call. TTL matches the SERVER-side rate cache 40// (30 min) so a customer never sees a rate more than half an hour stale, 41// and the cookie is versioned so a schema change invalidates old copies. 42// 43// Format (single line, pipe-delimited): 44// rr_geo=v1|<countryCode>|<currencyCode>|<currencySymbol>|<rate>|<isConverted> 45// Path=/, Max-Age=1800 (30 min), SameSite=Lax, Secure (when https). 46// 47// SAFETY: this cookie is display-only. Server-side pricing, cart, and 48// payment gateways always recompute from the request context â the cookie 49// is never trusted for anything that would land on a customer's invoice. 50window.currencyCookie = { 51 _NAME: 'rr_geo', 52 _VERSION: 'v1', 53 _MAX_AGE: 1800, // 30 minutes â matches server IMemoryCache TTL 54 55 // Read the cookie and return { countryCode, currencyCode, currencySymbol, 56 // rate, isConverted } or null when missing / malformed / wrong version. 57 // Wrapped in try/catch so a locked-down browser (Safari ITP, private mode 58 // with cookies blocked, etc.) simply falls back to the slow path. 59 get: function () { 60 try { 61 var raw = document.cookie 62 .split(';') 63 .map(function (c) { return c.trim(); }) 64 .find(function (c) { return c.indexOf(window.currencyCookie._NAME + '=') === 0; }); 65 if (!raw) return null; 66 var val = decodeURIComponent(raw.substring(window.currencyCookie._NAME.length + 1)); 67 var parts = val.split('|'); 68 if (parts.length < 6 || parts[0] !== window.currencyCookie._VERSION) return null; 69 var rate = parseFloat(parts[4]); 70 if (!isFinite(rate) || rate <= 0) return null; 71 return { 72 countryCode: parts[1], 73 currencyCode: parts[2], 74 currencySymbol: parts[3], 75 rate: rate, 76 isConverted: parts[5] === '1' 77 }; 78 } catch (e) { return null; } 79 }, 80 81 // Persist a currency context. Called AFTER the server has authoritatively 82 // resolved country â currency â rate, so we're caching a real response, 83 // not a client-side guess. 84 set: function (countryCode, currencyCode, currencySymbol, rate, isConverted) { 85 try { 86 var v = window.currencyCookie._VERSION + '|' 87 + (countryCode || '') + '|' 88 + (currencyCode || '') + '|' 89 + (currencySymbol || '') + '|' 90 + (rate || 1) + '|' 91 + (isConverted ? '1' : '0'); 92 var secure = window.location.protocol === 'https:' ? '; Secure' : ''; 93 document.cookie = window.currencyCookie._NAME + '=' + encodeURIComponent(v) 94 + '; Path=/; Max-Age=' + window.currencyCookie._MAX_AGE 95 + '; SameSite=Lax' + secure; 96 } catch (e) { /* cookies blocked â repeat visits just skip the fast path */ } 97 }, 98 99 // Called on logout so the next login re-resolves from scratch. 100 clear: function () { 101 try { 102 document.cookie = window.currencyCookie._NAME + '=; Path=/; Max-Age=0; SameSite=Lax'; 103 } catch (e) { } 104 } 105}; 106 107// ââ Geo detection ââââââââââââââââââââââââââââââââââââââââââââââââââ 108// Called by CurrencyService.InitializeAsync() to get the user's real country. 109// Runs in the BROWSER so it gets the actual public IP, not the server's IP. 110// Uses 4 independent sources with individual timeouts. 111window.geoHelper = { 112 _cached: null, 113 114 _fetchWithTimeout: async function(url, timeoutMs) { 115 var ctrl = new AbortController(); 116 var timer = setTimeout(function() { ctrl.abort(); }, timeoutMs); 117 try { 118 var r = await fetch(url, { cache: 'no-store', signal: ctrl.signal }); 119 return r; 120 } finally { 121 clearTimeout(timer); 122 } 123 }, 124 125 getCountryCode: async function () { 126 if (window.geoHelper._cached) return window.geoHelper._cached; 127 128 // Source 1: Cloudflare CDN trace 129 try { 130 var r = await window.geoHelper._fetchWithTimeout( 131 'https://www.cloudflare.com/cdn-cgi/trace', 2000); 132 var text = await r.text(); 133 var loc = text.split('\n').find(function(l) { return l.startsWith('loc='); }); 134 if (loc) { 135 var code = loc.split('=')[1].trim().toUpperCase(); 136 if (code && code.length === 2 && code !== 'XX' && code !== 'T1') { 137 window.geoHelper._cached = code; 138 console.log('[GeoHelper] Source 1 (Cloudflare trace):', code); 139 return code; 140 } 141 } 142 } catch(e) { console.warn('[GeoHelper] Source 1 failed:', e.message); } 143 144 // Source 2: ip-api.com 145 try { 146 var r2 = await window.geoHelper._fetchWithTimeout( 147 'http://ip-api.com/json/?fields=countryCode,status', 2500); 148 var j2 = await r2.json(); 149 if (j2 && j2.status === 'success' && j2.countryCode && j2.countryCode.length === 2) { 150 var code2 = j2.countryCode.toUpperCase(); 151 window.geoHelper._cached = code2; 152 console.log('[GeoHelper] Source 2 (ip-api.com):', code2); 153 return code2; 154 } 155 } catch(e) { console.warn('[GeoHelper] Source 2 failed:', e.message); } 156 157 // Source 3: ipapi.co 158 try { 159 var r3 = await window.geoHelper._fetchWithTimeout( 160 'https://ipapi.co/country/', 2500); 161 var code3 = (await r3.text()).trim().toUpperCase(); 162 if (code3 && code3.length === 2 && code3.indexOf('<') === -1) { 163 window.geoHelper._cached = code3; 164 console.log('[GeoHelper] Source 3 (ipapi.co):', code3); 165 return code3; 166 } 167 } catch(e) { console.warn('[GeoHelper] Source 3 failed:', e.message); } 168 169 // Source 4: freeipapi.com 170 try { 171 var r4 = await window.geoHelper._fetchWithTimeout( 172 'https://freeipapi.com/api/json', 2500); 173 var j4 = await r4.json(); 174 if (j4 && j4.countryCode && j4.countryCode.length === 2) { 175 var code4 = j4.countryCode.toUpperCase(); 176 window.geoHelper._cached = code4; 177 console.log('[GeoHelper] Source 4 (freeipapi.com):', code4); 178 return code4; 179 } 180 } catch(e) { console.warn('[GeoHelper] Source 4 failed:', e.message); } 181 182 console.warn('[GeoHelper] All sources failed â server will detect from IP headers'); 183 return null; 184 } 185}; 186 187// ââ Home page animations âââââââââââââââââââââââââââââââââââââââââââ 188// Idempotent, safe to call multiple times as async data loads 189window.homeAnimations = function () { 190 191 // Typing animation â init once only 192 if (!window._hpTypingStarted) { 193 var typingEl = document.getElementById('hp-typing-text'); 194 if (typingEl) { 195 window._hpTypingStarted = true; 196 var words = ['Simple', 'Practical', 'Fun']; 197 var wordIdx = 0, charIdx = 0, isDeleting = false;
198 function type() { 199 var word = words[wordIdx]; 200 typingEl.textContent = isDeleting 201 ? word.substring(0, charIdx - 1) 202 : word.substring(0, charIdx + 1); 203 isDeleting ? charIdx-- : charIdx++; 204 var speed = isDeleting ? 80 : 150; 205 if (!isDeleting && charIdx === word.length) { speed = 1800; isDeleting = true; } 206 else if (isDeleting && charIdx === 0) { isDeleting = false; wordIdx = (wordIdx + 1) % words.length; speed = 600; } 207 setTimeout(type, speed); 208 } 209 setTimeout(type, 1200); 210 } 211 } 212 213 // Credential counters + 3D tilt 214 (function initCredAnimations() { 215 var firstCard = document.querySelector('.hp-cred .card'); 216 if (!firstCard) return; 217 218 ['hp-num-20', 'hp-num-17', 'hp-num-mil'].forEach(function(id) { 219 var el = document.getElementById(id); 220 if (el) delete el.dataset.counted; 221 }); 222 223 var n20 = document.getElementById('hp-num-20'); 224 if (n20) n20.textContent = '0+'; 225 var n17 = document.getElementById('hp-num-17'); 226 if (n17) n17.textContent = '0+'; 227 var nm = document.getElementById('hp-num-mil'); 228 if (nm) nm.textContent = '1M+'; 229 230 if (window._hpCredObs) { try { window._hpCredObs.disconnect(); } catch (e) {} } 231 232 var credObs = new IntersectionObserver(function(entries) { 233 entries.forEach(function(e) { 234 if (!e.isIntersecting) return; 235 credObs.unobserve(e.target); 236 function countUp(el, target, suffix, duration) { 237 if (!el || el.dataset.counted) return; 238 el.dataset.counted = '1'; 239 var current = 0, steps = 40, iv = duration / steps; 240 var t = setInterval(function() { 241 current = Math.min(current + target / steps, target); 242 el.textContent = Math.floor(current) + suffix; 243 if (current >= target) clearInterval(t); 244 }, iv); 245 } 246 setTimeout(function() { countUp(document.getElementById('hp-num-20'), 20, '+', 1500); }, 150); 247 setTimeout(function() { countUp(document.getElementById('hp-num-17'), 17, '+', 1500); }, 350); 248 setTimeout(function() { 249 var m = document.getElementById('hp-num-mil'); 250 if (m && !m.dataset.counted) { 251 m.dataset.counted = '1'; 252 var s = ['1M+','3M+','5M+','10M+','Millions'], i = 0; 253 var t = setInterval(function() { m.textContent = s[i++]; if (i >= s.length) clearInterval(t); }, 280); 254 } 255 }, 550); 256 }); 257 }, { threshold: 0.25 }); 258 window._hpCredObs = credObs; 259 credObs.observe(firstCard); 260 261 if (!window._hpCredTiltInit) { 262 window._hpCredTiltInit = true; 263 document.addEventListener('mousemove', function(e) { 264 var card = e.target.closest('.hp-cred .card'); 265 if (!card) return; 266 var r = card.getBoundingClientRect(); 267 var dx = (e.clientX - r.left) / r.width - 0.5; 268 var dy = (e.clientY - r.top) / r.height - 0.5; 269 card.style.transform = 'translateY(-10px) rotateX(' + (-dy*8) + 'deg) rotateY(' + (dx*8) + 'deg)'; 270 }); 271 document.addEventListener('mouseleave', function(e) { 272 var card = e.target && e.target.closest && e.target.closest('.hp-cred .card'); 273 if (card) card.style.transform = ''; 274 }, true); 275 } 276 })(); 277 278 // FAQ slide-in observer 279 if (window._hpFaqObs) window._hpFaqObs.disconnect(); 280 window._hpFaqObs = new IntersectionObserver(function(entries) { 281 entries.forEach(function(e) { if (e.isIntersecting) e.target.classList.add('hp-visible'); }); 282 }, { threshold: 0.05, rootMargin: '0px 0px -20px 0px' }); 283 document.querySelectorAll('.hp-faq-item').forEach(function(el) { window._hpFaqObs.observe(el); }); 284 285 // Flip cards mobile tap-to-flip 286 if (!window._hpFlipInit) { 287 window._hpFlipInit = true; 288 document.querySelectorAll('.flip-card-wrapper').forEach(function(wrapper) { 289 wrapper.addEventListener('click', function() { 290 if (window.innerWidth <= 1024) 291 wrapper.querySelector('.flip-card').classList.toggle('flipped'); 292 }); 293 }); 294 document.querySelectorAll('.start-path-btn').forEach(function(btn
294) { 295 btn.addEventListener('click', function(e) { e.stopPropagation(); }); 296 }); 297 } 298};
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.