PageSourceSearch

https://rachanaranade.com/js/app-helpers.js

js rachanaranade.com collected 2026-10-01 16:36:22 UTC 14,123 bytes, 298 lines download raw bytes

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.