PageSourceSearch

https://differer.ru/vpn/js/site.js?v=26

js differer.ru collected 2026-09-27 15:06:06 UTC 40,394 bytes, 1,008 lines download raw bytes

1/*
2 * site.js — All site JavaScript in one file.
3 * Replaces: webflow.js, jQuery, lottie, menu.js, carousel.js, anchor-scroll.js, packages-loader.js
4 */
5(function () {
6  'use strict';
7
8  // ── Navbar ──────────────────────────────────────────────────────────
9  function initNavbar() {
10    var nav = document.querySelector('.w-nav');
11    if (!nav) return;
12    var btn = nav.querySelector('.w-nav-button');
13    var menu = nav.querySelector('.w-nav-menu');
14    var overlay = nav.querySelector('.w-nav-overlay');
15    if (!btn || !menu) return;
16
17    var isOpen = false;
18    var collapse = nav.getAttribute('data-collapse') || 'medium';
19    var bp = collapse === 'medium' ? 991 : collapse === 'small' ? 767 : 479;
20
21    function open() {
22      isOpen = true;
23      btn.classList.add('w--open');
24      btn.setAttribute('aria-expanded', 'true');
25      menu.setAttribute('data-nav-menu-open', '');
26      if (overlay) {
27        overlay.style.display = 'block';
28        overlay.style.height = '0';
29        overlay.appendChild(menu);
30        var h = menu.scrollHeight;
31        overlay.style.transition = 'height 350ms ease';
32        overlay.offsetHeight;
33        overlay.style.height = h + 'px';
34      }
35      document.body.style.overflow = 'hidden';
36    }
37
38    function close() {
39      isOpen = false;
40      btn.classList.remove('w--open');
41      btn.setAttribute('aria-expanded', 'false');
42      menu.removeAttribute('data-nav-menu-open');
43      document.body.style.overflow = '';
44      if (overlay) {
45        overlay.style.height = '0';
46        setTimeout(function () {
47          overlay.style.display = 'none';
48          var headerWrapper = nav.querySelector('.header-wrapper');
49          if (headerWrapper) {
50            headerWrapper.insertBefore(menu, overlay);
51          }
52        }, 350);
53      }
54    }
55
56    btn.addEventListener('click', function () {
57      isOpen ? close() : open();
58    });
59
60    menu.addEventListener('click', function (e) {
61      if (e.target.closest('a') && isOpen) {
62        setTimeout(close, 150);
63      }
64    });
65
66    window.addEventListener('resize', function () {
67      if (window.innerWidth > bp && isOpen) close();
68    });
69  }
70
71  // ── Hamburger (CSS-based, replaces lottie) ─────────────────────────
72  function initHamburger() {
73    var icon = document.querySelector('.hamburger-menu-icon');
74    if (!icon) return;
75    icon.innerHTML = '<span></span><span></span><span></span>';
76
77    var style = document.createElement('style');
78    style.textContent =
79      '.hamburger-menu-icon{width:22px;height:18px;position:relative;display:flex;flex-direction:column;justify-content:space-between;cursor:pointer}' +
80      '.hamburger-menu-icon span{display:block;height:2px;width:100%;background:#fff;border-radius:2px;transition:transform .3s ease,opacity .3s ease}' +
81      '.w-nav-button.w--open .hamburger-menu-icon span:nth-child(1){transform:translateY(8px) rotate(45deg)}' +
82      '.w-nav-button.w--open .hamburger-menu-icon span:nth-child(2){opacity:0}' +
83      '.w-nav-button.w--open .hamburger-menu-icon span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}';
84    document.head.appendChild(style);
85  }
86
87  // ── Dropdowns ──────────────────────────────────────────────────────
88  function initDropdowns() {
89    document.querySelectorAll('.w-dropdown').forEach(function (dd) {
90      var toggle = dd.querySelector('.w-dropdown-toggle');
91      var list = dd.querySelector('.w-dropdown-list');
92      if (!toggle || !list) return;
93
94      var isFaq = list.classList.contains('faq-dropdown-list');
95
96      toggle.addEventListener('click', function (e) {
97        e.preventDefault();
98        e.stopPropagation();
99        var wasOpen = list.classList.contains('w--open');
100        closeAllDropdowns();
101        if (!wasOpen) {
102          list.classList.add('w--open');
103          if (isFaq) list.style.height = list.scrollHeight + 'px';
104        }
105      });
106    });
107
108    document.addEventListener('click', function (e) {
109      if (!e.target.closest('.w-dropdown')) closeAllDropdowns();
110    });
111
112    document.addEventListener('keydown', function (e) {
113      if (e.key === 'Escape') closeAllDropdowns();
114    });
115  }
116
117  function closeAllDropdowns() {
118    document.querySelectorAll('.w-dropdown-list.w--open').forEach(function (l) {
119      l.classList.remove('w--open');
120      if (l.classList.contains('faq-dropdown-list')) l.style.height = '0';
121    });
122  }
123
124  // ── Slider ─────────────────────────────────────────────────────────
125  function initSliders() {
126    document.querySelectorAll('.w-slider').forEach(function (slider) {
127      var mask = slider.querySelector('.w-slider-mask');
128      var slides = mask ? mask.querySelectorAll('.w-slide') : [];
129      var leftArrow = slider.querySelector('.w-slider-arrow-left');
130      var rightArrow = slider.querySelector('.w-slider-arrow-right');
131      var nav = slider.querySelector('.w-slider-nav');
132      if (!mask || !slides.length) return;
133
134      var current = 0;
135      var total = slides.length;
136      var infinite = slider.getAttribute('data-infinite') !== 'false';
137      var autoDelay = parseInt(slider.getAttribute('data-delay')) || 2000;
138      var autoplay = slider.getAttribute('data-autoplay') === 'true';
139      var autoTimer = null;
140
141      function styleSlide(slide, active) {
142        var card = slide.querySelector('.slider-single');
143        var btn = slide.querySelector('.slider-cart-network-button');
144        if (card) {
145          card.style.transition = 'background-color 500ms ease';
146          card.style.backgroundColor = active ? 'rgba(1, 17, 64, 0.85)' : 'rgba(255, 127, 17, 0.2)';
147        }
148        if (btn) {
149          btn.style.transition = 'opacity 500ms ease';
150          btn.style.opacity = active ? '1' : '0';
151        }
152      }
153
154      function goTo(idx) {
155        if (infinite) {
156          current = ((idx % total) + total) % total;
157        } else {
158          current = Math.max(0, Math.min(idx, total - 1));
159        }
160        mask.style.transition = 'transform 500ms ease';
161        mask.style.transform = 'translateX(' + (-slides[current].offsetLeft) + 'px)';
162        slides.forEach(function (s, i) { styleSlide(s, i === current); });
163        updateDots();
164      }
165
166      function updateDots() {
167        if (!nav) return;
168        nav.querySelectorAll('.w-slider-dot').forEach(function (d, i) {
169          d.classList.toggle('w-active', i === current);
170        });
171      }
172
173      styleSlide(slides[0], true);
174      for (var s = 1; s < total; s++) styleSlide(slides[s], false);
175
176      if (nav) {
177        var isNum = nav.classList.contains('w-num');
178        for (var i = 0; i < total; i++) {
179          var dot = document.createElement('div');
180          dot.className = 'w-slider-dot' + (i === 0 ? ' w-active' : '');
181          if (isNum) dot.textContent = i + 1;
182          dot.setAttribute('data-index', i);
183          dot.addEventListener('click', function () {
184            goTo(parseInt(this.getAttribute('data-index')));
185            restartAuto();
186          });
187          nav.appendChild(dot);
188        }
189      }
190
191      if (leftArrow) leftArrow.addEventListener('click', function () { goTo(current - 1); restartAuto(); });
192      if (rightArrow) rightArrow.addEventListener('click', function () { goTo(current + 1); restartAuto(); });
193
194      var touchStartX = 0;
195      var touchDelta = 0;
196      mask.addEventListener('touchstart', function (e) {
197        touchStartX = e.touches[0].clientX;
198        touchDelta = 0;
199        mask.style.transition = 'none';
200      }, { passive: true });
201      mask.addEventListener('touchmove', function (e) {
202        touchDelta = e.touches[0].clientX - touchStartX;
203        mask.style.transform = 'translateX(' + (-slides[current].offsetLeft + touchDelta) + 'px)';
204      }, { passive: true });
205      mask.addEventListener('touchend', function () {
206        if (Math.abs(touchDelta) > 50) {
207          goTo(current + (touchDelta < 0 ? 1 : -1));
208        } else {
209          goTo(current);
210        }
211        restartAuto();
212      });
213
214      function startAuto() {
215        if (!autoplay || autoDelay <= 0) return;
216        autoTimer = setInterval(function () {
217          var menuBtn = document.querySelector('.w-nav-button');
218          if (menuBtn && menuBtn.classList.contains('w--open')) return;
219          goTo(current + 1);
220        }, autoDelay);
221      }
222
223      function restartAuto() {
224        clearInterval(autoTimer);
225        startAuto();
226      }
227
228      window.addEventListener('resize', function () { goTo(current); });
229
230      slider.querySelectorAll('.slider-single-cart').forEach(function (card) {
231        card.style.cursor = 'pointer';
232        card.style.position = 'relative';
233        card.style.zIndex = '20';
234      });
235      document.addEventListener('click', function (e) {
236        if (e.target.closest('.slider-single-cart')) {
237          e.preventDefault();
238          e.stopPropagation();
239          var dl = document.getElementById('download');
240          if (dl) dl.scrollIntoView({ behavior: 'smooth' });
241        }
242      }, true);
243
244      startAuto();
245    });
246  }
247
248  // ── Scroll Animations (replaces IX2) ───────────────────────────────
249  function initScrollAnimations() {
250    var targets = document.querySelectorAll('[data-w-id]');
251    var animatable = [];
252
253    targets.forEach(function (el) {
254      var op = el.style.opacity;
255      var tr = el.style.transform;
256      if (op === '0' || (tr && tr.indexOf('translate3d') !== -1 && tr.indexOf('rotateX') !== -1)) {
257        animatable.push(el);
258      }
259    });
260
261    if (!animatable.length) return;
262
263    if (!('IntersectionObserver' in window)) {
264      animatable.forEach(function (el) {
265        el.style.opacity = '1';
266        el.style.transform = 'none';
267      });
268      return;
269    }
270
271    var observer = new IntersectionObserver(function (entries) {
272      entries.forEach(function (entry) {
273        if (!entry.isIntersecting) return;
274        var el = entry.target;
275        el.style.transition = 'opacity 0.8s ease, transform 0.8s ease';
276        el.style.opacity = '1';
277        if (el.style.transform && el.style.transform.indexOf('rotateX') !== -1) {
278          el.style.transform = 'translate3d(0, 0, 0) scale3d(1, 1, 1) rotateX(0) rotateY(0) rotateZ(0) skew(0, 0)';
279        }
280        observer.unobserve(el);
281      });
282    }, { threshold: 0.15, rootMargin: '0px 0px -50px 0px' });
283
284    animatable.forEach(function (el) { observer.observe(el); });
285  }
286
287  // Shared by hash-anchor clicks, referral landing, and the APK bonus banner.
288  // Rechecks after layout/images settle because content above #download
289  // (lazy images, testimonials) often shifts the target. A new call cancels
290  // the previous correction loop so two scrolls cannot fight.
291  var cancelScrollToElement = function () {};
292
293  function scrollToElement(el, opts) {
294    opts = opts || {};
295    var behavior = opts.instant ? 'auto' : 'smooth';
296    var correctAfter = opts.instant ? 0 : 600;
297    cancelScrollToElement();
298
299    function jump(mode) {
300      var offset = opts.offset || 0;
301      var top = el.getBoundingClientRect().top + window.pageYOffset - offset;
302      window.scrollTo({ top: Math.max(0, top), behavior: mode || behavior });
303    }
304    jump();
305
306    var startTime = Date.now();
307    var lastCheck = 0;
308    var cancelled = false;
309    var offset = opts.offset || 0;
310
311    function cancel() {
312      cancelled = true;
313      window.removeEventListener('wheel', cancel);
314      window.removeEventListener('touchmove', cancel);
315      window.removeEventListener('resize', onResize);
316    }
317    function onResize() {
318      if (!cancelled) jump('auto');
319    }
320    cancelScrollToElement = cancel;
321    window.addEventListener('wheel', cancel, { passive: true, once: true });
322    window.addEventListener('touchmove', cancel, { passive: true, once: true });
323    window.addEventListener('resize', onResize);
324
325    function correct() {
326      if (cancelled) return;
327      var elapsed = Date.now() - startTime;
328      if (elapsed > 4000) { cancel(); return; }
329      if (elapsed > correctAfter && elapsed - lastCheck > 200) {
330        lastCheck = elapsed;
331        var off = el.getBoundingClientRect().top - offset;
332        if (Math.abs(off) > 20) {
333          window.scrollTo({ top: window.pageYOffset + off, behavior: 'auto' });
334        }
335      }
336      requestAnimationFrame(correct);
337    }
338    requestAnimationFrame(correct);
339  }
340
341  // ── Anchor Scroll ──────────────────────────────────────────────────
342  function initAnchorScroll() {
343
344    function isSamePage(href) {
345      var a = document.createElement('a');
346      a.href = href;
347      return a.pathname.replace(/\/$/, '') === window.location.pathname.replace(/\/$/, '');
348    }
349
350    document.addEventListener('click', function (e) {
351      var link = e.target.closest('a[href*="#"]');
352      if (!link) return;
353      var href = link.getAttribute('href');
354      var hashIdx = href.indexOf('#');
355      if (hashIdx === -1) return;
356      var hash = href.substring(hashIdx);
357      if (!hash || hash === '#') return;
358      var path = href.substring(0, hashIdx);
359      if (path && !isSamePage(href)) return;
360      var target = document.querySelector(hash);
361      if (!target) return;
362      e.preventDefault();
363      history.pushState(null, null, hash);
364      scrollToElement(target);
365    });
366
367    if (window.location.hash) {
368      window.addEventListener('load', function () {
369        var target = document.querySelector(window.location.hash);
370        if (target) {
371          setTimeout(function () { scrollToElement(target); }, 100);
372        }
373      });
374    }
375  }
376
377  // ── Testimonial horizontal scroll ──────────────────────────────────
378  function initTestimonialScroll() {
379    var section = document.querySelector('.section.testimonial');
380    var grid = section && section.querySelector('.testimonial-grid');
381    if (!section || !grid) return;
382
383    function update() {
384      var vw = window.innerWidth;
385      // On tablet/mobile the CSS switches the grid to a normal block layout,
386      // so we only need the effect on wider screens.
387      if (vw <= 991) {
388        grid.style.transform = '';
389        return;
390      }
391
392      var rect = section.getBoundingClientRect();
393      var sectionH = section.offsetHeight;   // 200vh
394      var viewH   = window.innerHeight;
395
396      // progress: 0 when top of section hits viewport bottom → 1 when bottom of section hits viewport top
397      // We clamp to 0–1 so nothing happens outside the section.
398      var scrolled = -rect.top;              // how many px the section has scrolled upward
399      var totalTravel = sectionH - viewH;    // total scroll distance while section is "active"
400      var progress = Math.min(1, Math.max(0, scrolled / totalTravel));
401
402      // How far we need to translate: grid width minus the visible container width
403      var containerW = section.querySelector('.testimonial-content-wrapper')
404                       ? section.querySelector('.testimonial-content-wrapper').offsetWidth
405                       : vw - 80; // fallback: viewport minus rough gutters
406      var maxShift = grid.scrollWidth - containerW;
407      if (maxShift <= 0) return;
408
409      grid.style.transform = 'translateX(' + (-progress * maxShift) + 'px)';
410      grid.style.willChange = 'transform';
411    }
412
413    window.addEventListener('scroll', update, { passive: true });
414    window.addEventListener('resize', update, { passive: true });
415    update();
416  }
417
418  // ── Packages / Pricing Loader ──────────────────────────────────────
419  function initPackagesLoader() {
420    if (!document.querySelector('.grid-pricing-plan')) return;
421
422    var path = window.location.pathname;
423    var lang = 'en';
424    if (path.startsWith('/vpn/ru/') || path === '/ru') lang = 'ru';
425    else if (path.startsWith('/vpn/zh/') || path === '/zh') lang = 'zh';
426    else if (path.startsWith('/vpn/fa/') || path === '/fa') lang = 'fa';
427
428    function formatPrice(pkg) {
429      if (lang === 'ru') return pkg.price_rub + ' RUB';
430      if (lang === 'zh') return '\u00a5' + pkg.price_cny;
431      return '$' + pkg.price_usd.toFixed(2);
432    }
433
434    fetch('../packages.json')
435      .then(function (r) { return r.json(); })
436      .then(function (data) {
437        var packages = data.packages;
438        var planItems = document.querySelectorAll('.grid-pricing-plan .inner-tab-link');
439        planItems.forEach(function (item, i) {
440          var pkg = packages[i];
441          if (!pkg) return;
442          var priceEl = item.querySelector('.price-2');
443          if (priceEl) priceEl.textContent = formatPrice(pkg);
444        });
445      })
446      .catch(function () {});
447  }
448
449  // ── Referral handling + GA event tracking ──────────────────────────
450  // Ports the legacy referral flow into the new design and adds GA4 events
451  // for every download/CTA button. Safe to run on any page – it no-ops if
452  // the relevant DOM nodes don't exist.
453  function initReferralAndTracking() {
454    var langCfg = window.GFVPN_LANG || {};
455    var messages = langCfg.messages || {};
456    var currentLang = langCfg.lang || (function () {
457      var m = window.location.pathname.match(/^\/(en|ru|zh|fa)/);
458      return m ? m[1] : 'en';
459    })();
460
461    var urlParams = new URLSearchParams(window.location.search);
462    var referralCode = urlParams.get('code');
463    var userAgent = navigator.userAgent || navigator.vendor || window.opera || '';
464
465    function track(eventName, params) {
466      if (typeof window.gtag === 'function') {
467        window.gtag('event', eventName, params || {});
468      }
469    }
470
471    var CONFIG = {
472      PLAY_STORE_URL: 'https://play.google.com/store/apps/details?id=org.greatfire.gfvpn',
473      APP_STORE_URL: 'https://apps.apple.com/app/greatfirevpn/id6761056824'
474    };
475
476    var downloadSection = document.getElementById('download');
477    // Prefer stable IDs; fall back to href-substring selectors so the script
478    // keeps working even if the IDs are accidentally removed.
479    var googlePlayLink = document.getElementById('google-play-link') ||
480      (downloadSection && downloadSection.querySelector('a[href*="play.google.com"]'));
481    var appStoreLink = document.getElementById('app-store-button') ||
482      (downloadSection && downloadSection.querySelector('a[href*="apps.apple.com"]'));
483    var apkLink = document.getElementById('apk-download-link') ||
484      (downloadSection && downloadSection.querySelector('a[href*=".apk"]'));
485    var windowsLink = document.getElementById('windows-download-link') ||
486      (downloadSection && downloadSection.querySelector('a[href*="GreatFireVPN-Setup.exe"]'));
487
488    var bonusLink = document.getElementById('activate-bonus-link');
489    var copyIosLink = document.getElementById('copy-referral-ios');
490    var copyWindowsLink = document.getElementById('copy-referral-windows');
491
492    var downloadLinks = [googlePlayLink, appStoreLink, apkLink, windowsLink];
493
494    // Propagate URL query (e.g. ?code=…) onto internal nav links so the
495    // referral code survives navigation to /payment, footer links, etc.
496    var search = window.location.search;
497    if (search) {
498      var searchBody = search.substring(1);
499      document.querySelectorAll('a[href]').forEach(function (link) {
500        var href = link.getAttribute('href');
501        if (!href) return;
502        if (/^(https?:|javascript:|mailto:|tel:|#)/i.test(href)) return;
503        if (link.id === 'activate-bonus-link') return;
504        if (downloadLinks.indexOf(link) !== -1) return;
505        if (!href.startsWith('/vpn/')) return;
506        var hashIdx = href.indexOf('#');
507        if (hashIdx !== -1) {
508          var beforeHash = href.substring(0, hashIdx);
509          var hash = href.substring(hashIdx);
510          var sep1 = beforeHash.indexOf('?') !== -1 ? '&' : '?';
511          link.setAttribute('href', beforeHash + sep1 + searchBody + hash);
512        } else {
513          var sep2 = href.indexOf('?') !== -1 ? '&' : '?';
514          link.setAttribute('href', href + sep2 + searchBody);
515        }
516      });
517    }
518
519    // Referral landing: mark the download section so the 1→2 stepper CSS
520    // reveals Activate Bonus / Copy code, then scroll and track.
521    if (referralCode) {
522      if (downloadSection) {
523        downloadSection.classList.add('is-referral');
524        var scrollToDownload = function () {
525          setTimeout(function () { scrollToElement(downloadSection); }, 100);
526        };
527        if (document.readyState === 'complete') scrollToDownload();
528        else window.addEventListener('load', scrollToDownload);
529      }
530      track('referral_landing', {
531        event_category: 'engagement',
532        referral_code: referralCode,
533        language: currentLang
534      });
535    }
536
537    // Google Play: append &referrer=code%3D… so Play's Install Referrer API
538    // can hand the code to the app on first launch (auto-redemption).
539    if (googlePlayLink) {
540      if (referralCode) {
541        var base = googlePlayLink.getAttribute('href') || CONFIG.PLAY_STORE_URL;
542        var sep = base.indexOf('?') !== -1 ? '&' : '?';
543        googlePlayLink.setAttribute('href', base + sep + 'referrer=code%3D' + encodeURIComponent(referralCode));
544      }
545      googlePlayLink.addEventListener('click', function () {
546        track('app_download_android', {
547          event_category: 'download',
548          event_label: 'google_play_store',
549          download_type: 'google_play',
550          referral_code: referralCode || 'none'
551        });
552      });
553    }
554
555    // App Store: show an availability notice first (the app is region-removed
556    // in many of our users' countries). On "continue" we open the store and
557    // copy the referral code to the clipboard (the App Store strips referrers,
558    // so iOS users paste it into the app after installing).
559    if (appStoreLink) {
560      var appStoreGuideUrl = '/vpn/' + currentLang + '/install-deleted-appstore-app';
561      var isRtlLang = currentLang === 'fa';
562      var appStoreModal = null;
563
564      var onAppStoreModalKey = function (e) {
565        if (e.key === 'Escape') closeAppStoreModal();
566      };
567
568      var closeAppStoreModal = function () {
569        if (appStoreModal) appStoreModal.style.display = 'none';
570        document.removeEventListener('keydown', onAppStoreModalKey);
571      };
572
573      var proceedToAppStore = function () {
574        // Open inside the click gesture so popup blockers don't intervene.
575        window.open(CONFIG.APP_STORE_URL, '_blank', 'noopener');
576        if (referralCode && navigator.clipboard && messages.clipboardSuccess) {
577          navigator.clipboard.writeText(referralCode).then(function () {
578            try { alert(messages.clipboardSuccess.replace('{code}', referralCode)); } catch (_) {}
579          }).catch(function () {});
580        }
581      };
582
583      var buildAppStoreModal = function () {
584        if (appStoreModal) return appStoreModal;
585        var title = messages.appStoreNoticeTitle || "Can't find the app? Read this first";
586        var body = messages.appStoreNoticeBody || "GreatFireVPN may not appear in the App Store in y
586our region. If you can't find it, you'll most likely need a free second Apple ID — about 10 minutes, no card needed.";
587        var contLabel = messages.appStoreNoticeContinue || 'Continue to App Store';
588        var guideLabel = messages.appStoreNoticeGuide || 'How to install';
589
590        var overlay = document.createElement('div');
591        overlay.setAttribute('role', 'dialog');
592        overlay.setAttribute('aria-modal', 'true');
593        overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;background:rgba(1,17,64,.55);padding:20px;';
594
595        var card = document.createElement('div');
596        card.setAttribute('dir', isRtlLang ? 'rtl' : 'ltr');
597        card.style.cssText = 'background:#fff;border-radius:20px;max-width:430px;width:100%;padding:30px 26px;box-shadow:0 24px 60px rgba(1,17,64,.4);text-align:' + (isRtlLang ? 'right' : 'left') + ';';
598
599        card.innerHTML =
600          '<div style="display:flex;gap:12px;align-items:flex-start;margin-bottom:14px;">' +
601            '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#e25b00" stroke-width="2" style="flex:0 0 auto;margin-top:1px;"><path d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg>' +
602            '<h3 style="margin:0;font-family:\'IBM Plex Sans\',sans-serif;font-weight:600;font-size:20px;line-height:1.25;color:#011140;">' + title + '</h3>' +
603          '</div>' +
604          '<p style="margin:0 0 22px;font-family:\'Open Sans\',sans-serif;font-size:15px;line-height:1.55;color:#4a4a4a;">' + body + '</p>' +
605          '<div style="display:flex;flex-direction:column;gap:10px;">' +
606            '<a href="' + appStoreGuideUrl + '" data-ag-guide style="display:block;text-align:center;text-decoration:none;border:1px solid #eff0f6;border-radius:12px;padding:12px 20px;font-family:\'Open Sans\',sans-serif;font-size:15px;font-weight:600;color:#011140;background:#fff;">' + guideLabel + '</a>' +
607            '<button type="button" data-ag-continue style="cursor:pointer;border:0;border-radius:12px;padding:13px 20px;font-family:\'IBM Plex Sans\',sans-serif;font-size:16px;font-weight:600;color:#fff;background-image:linear-gradient(90deg,#e70e02,#ff7f11);">' + contLabel + '</button>' +
608          '</div>';
609
610        overlay.appendChild(card);
611        overlay.addEventListener('click', function (e) {
612          if (e.target === overlay) closeAppStoreModal();
613        });
614        card.querySelector('[data-ag-continue]').addEventListener('click', function () {
615          track('appstore_notice_continue', { event_category: 'engagement', language: currentLang });
616          closeAppStoreModal();
617          proceedToAppStore();
618        });
619        card.querySelector('[data-ag-guide]').addEventListener('click', function () {
620          track('appstore_notice_guide', { event_category: 'engagement', language: currentLang });
621        });
622
623        document.body.appendChild(overlay);
624        appStoreModal = overlay;
625        return overlay;
626      };
627
628      appStoreLink.addEventListener('click', function (e) {
629        e.preventDefault();
630        track('app_download_ios', {
631          event_category: 'download',
632          event_label: 'app_store',
633          download_type: 'app_store',
634          referral_code: referralCode || 'none'
635        });
636        var modal = buildAppStoreModal();
637        modal.style.display = 'flex';
638        document.addEventListener('keydown', onAppStoreModalKey);
639      });
640    }
641
642    // Trigger a same-origin file download via a synthetic <a download> click.
643    // This avoids the "stray about:blank tab" issue caused by the link's
644    // target="_blank" attribute (Chromium opens the new tab synchronously
645    // before our click handler can preventDefault, especially after a
646    // confirm() dialog interrupts the click).
647    function triggerDownload(href, suggestedName) {
648      var dl = document.createElement('a');
649      dl.href = href;
650      dl.setAttribute('download', suggestedName || '');
651      dl.style.display = 'none';
652      document.body.appendChild(dl);
653      dl.click();
654      document.body.removeChild(dl);
655    }
656
657    // APK: confirm popup explaining the bonus flow when there's a referral
658    // code, then track and trigger the download. Activate Bonus stays visible
659    // under the APK button so the user can tap it after installing.
660    if (apkLink) {
661      apkLink.addEventListener('click', function (e) {
662        e.preventDefault();
663        if (referralCode && messages.apkDownloadPopup) {
664          var ok = confirm(messages.apkDownloadPopup.replace('{code}', referralCode));
665          if (!ok) return;
666        }
667        track('app_download_apk', {
668          event_category: 'download',
669          event_label: 'apk_download',
670          download_type: 'apk',
671          referral_code: referralCode || 'none'
672        });
673        triggerDownload(apkLink.getAttribute('href'), 'greatfirevpn.apk');
674      });
675    }
676
677    // Windows: same paste-in-Settings flow as iOS. Copy the code, explain
678    // in a confirm dialog, then start the installer download.
679    if (windowsLink) {
680      windowsLink.addEventListener('click', function (e) {
681        e.preventDefault();
682        if (referralCode && messages.windowsDownloadPopup) {
683          if (navigator.clipboard && navigator.clipboard.writeText) {
684            navigator.clipboard.writeText(referralCode).catch(function () {});
685          }
686          var ok = confirm(messages.windowsDownloadPopup.replace('{code}', referralCode));
687          if (!ok) return;
688        }
689        track('app_download_windows', {
690          event_category: 'download',
691          event_label: 'windows_installer',
692          download_type: 'windows',
693          referral_code: referralCode || 'none'
694        });
695        triggerDownload(windowsLink.getAttribute('href'), 'GreatFireVPN-Setup.exe');
696      });
697    }
698
699    function copyReferralCode(source) {
700      if (!referralCode) return;
701      track('referral_code_copy', {
702        event_category: 'engagement',
703        referral_code: referralCode,
704        copy_source: source
705      });
706      var msg = (messages.clipboardSuccess || 'Referral code {code} copied! Paste it in the app after installing.').replace('{code}', referralCode);
707      var show = function () {
708        try { alert(msg); } catch (_) {}
709      };
710      if (navigator.clipboard && navigator.clipboard.writeText) {
711        navigator.clipboard.writeText(referralCode).then(show).catch(show);
712      } else {
713        show();
714      }
715    }
716
717    if (copyIosLink) {
718      copyIosLink.addEventListener('click', function (e) {
719        e.preventDefault();
720        copyReferralCode('ios');
721      });
722    }
723    if (copyWindowsLink) {
724      copyWindowsLink.addEventListener('click', function (e) {
725        e.preventDefault();
726        copyReferralCode('windows');
727      });
728    }
729
730    // Activate Bonus: try to open the installed app, then fall back to the
731    // greatfirevpn.com App Link if the page is still visible after 2.5s
732    // (in-app browsers that ignore intent:// / gfvpn://).
733    // Chrome: intent:// unwraps to gfvpn:// for package org.greatfire.gfvpn.
734    // Firefox: raw gfvpn:// (it does not unwrap intent://).
735    if (bonusLink) {
736      bonusLink.addEventListener('click', function (e) {
737        e.preventDefault();
738        e.stopPropagation();
739        track('referral_bonus_click', {
740          event_category: 'conversion',
741          referral_code: referralCode || 'unknown'
742        });
743        if (!referralCode) return;
744        var encodedCode = encodeURIComponent(referralCode);
745        var isAndroid = /android/i.test(userAgent);
746        if (isAndroid) {
747          var isFirefox = /firefox/i.test(userAgent);
748          var openLink = isFirefox
749            ? 'gfvpn://referral?code=' + encodedCode
750            : 'intent://referral?code=' + encodedCode +
751              '#Intent;scheme=gfvpn;package=org.greatfire.gfvpn;end';
752          var fallbackLink = '/vpn/referral?code=' + encodedCode;
753          var appOpened = false;
754          var onVis = function () { if (document.hidden) appOpened = true; };
755          var onBlur = function () { appOpened = true; };
756          document.addEventListener('visibilitychange', onVis);
757          window.addEventListener('blur', onBlur);
758          window.location.href = openLink;
759          setTimeout(function () {
760            document.removeEventListener('visibilitychange', onVis);
761            window.removeEventListener('blur', onBlur);
762            if (!appOpened && !document.hidden) {
763              window.location.href = fallbackLink;
764            }
765          }, 2500);
766        } else {
767          alert(messages.androidOnly || 'This feature is only available on Android. Please download the APK and activate your bonus from an Android device.');
768        }
769      });
770    }
771
772    // Top-up CTA tracking (nav + footer "Top up" links).
773    document.querySelectorAll('a[href*="/payment"]').forEach(function (link) {
774      link.addEventListener('click', function () {
775        track('top_up_click', {
776          event_category: 'conversion',
777          event_label: 'top_up_balance'
778        });
779      });
780    });
781
782    // Language change tracking (nav dropdown).
783    document.querySelectorAll('.language-dropdown-wrapper a.dropdown-link').forEach(function (link) {
784      link.addEventListener('click', function () {
785        var href = link.getAttribute('href') || '';
786        var m = href.match(/^\/(en|ru|zh|fa)\b/);
787        track('language_change', {
788          event_category: 'engagement',
789          language_from: currentLang,
790          language_to: m ? m[1] : 'unknown'
791        });
792      });
793    });
794
795    // App Store install help link tracking.
796    document.querySelectorAll('a[href*="install-deleted-appstore-app"]').forEach(function (link) {
797      link.addEventListener('click', function () {
798        track('appstore_help_click', {
799          event_category: 'engagement',
800          event_label: 'app_store_install_help'
801        });
802      });
803    });
804  }
805
806  // Cloudflare Turnstile site key from the Brevo form (verified by Brevo).
807  var TURNSTILE_SITE_KEY = '0x4AAAAAAEyFDGhpq9bmQMJE';
808
809  window.handleCaptchaResponse = function () {
810    window.grecaptcha = window.turnstile;
811  };
812
813  function loadTurnstile(done) {
814    if (window.turnstile) {
815      done(null);
816      return;
817    }
818    var settled = false;
819    var finish = function (err) {
820      if (settled) return;
821      settled = true;
822      done(err || null);
823    };
824    var timer = setTimeout(function () { finish(new Error('timeout')); }, 8000);
825    var s = document.createElement('script');
826    s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
827    s.async = true;
828    s.onload = function () {
829      clearTimeout(timer);
830      finish(window.turnstile ? null : new Error('missing'));
831    };
832    s.onerror = function () {
833      clearTimeout(timer);
834      finish(new Error('error'));
835    };
836    document.head.appendChild(s);
837  }
838
839  var NL_STORE_KEY = 'gfvpn_nl_subscribed';
840  var NL_STORE_MAX_AGE = 365 * 24 * 60 * 60;
841
842  function hasNewsletterSubscribed() {
843    try {
844      if (window.localStorage && localStorage.getItem(NL_STORE_KEY) === '1') return true;
845    } catch (e) { /* ignore */ }
846    try {
847      var parts = document.cookie.split(';');
848      for (var i = 0; i < parts.length; i++) {
849        if (parts[i].trim() === NL_STORE_KEY + '=1') return true;
850      }
851    } catch (e) { /* ignore */ }
852    return false;
853  }
854
855  function markNewsletterSubscribed() {
856    try { localStorage.setItem(NL_STORE_KEY, '1'); } catch (e) { /* ignore */ }
857    try {
858      var secure = window.location.protocol === 'https:' ? '; Secure' : '';
859      document.cookie = NL_STORE_KEY + '=1; Path=/; Max-Age=' + NL_STORE_MAX_AGE + '; SameSite=Lax' + secure;
860    } catch (e) { /* ignore */ }
861  }
862
863  function showNewsletterThanks(form, successMsg, scroll) {
864    if (successMsg) successMsg.style.display = 'block';
865    if (form) form.style.display = 'none';
866    if (scroll && successMsg && successMsg.scrollIntoView) {
867      successMsg.scrollIntoView({ behavior: 'smooth', block: 'center' });
868    }
869  }
870
871  function initNewsletterForm() {
872    var form = document.getElementById('sib-form');
873    if (!form) return;
874
875    var email = form.querySelector('#EMAIL');
876    var honeypot = form.querySelector('input[name="email_address_check"]');
877    var btn = form.querySelector('.footer-subscribe-btn');
878    var btnText = form.querySelector('.footer-subscribe-btn-text');
879    var errLabel = form.querySelector('.footer-subscribe-error');
880    var successMsg = document.getElementById('success-message');
881    var errorMsg = document.getElementById('error-message');
882    var captchaHost = form.querySelector('.footer-subscribe-captcha');
883    var loadingText = form.getAttribute('data-msg-loading') || 'Subscribing…';
884    var invalidText = form.getAttribute('data-msg-invalid') || 'Please enter a valid email address.';
885    var captchaText = form.getAttribute('data-msg-captcha') || 'Please complete the verification.';
886    var defaultText = btnText ? btnText.textContent : '';
887    var emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
888    var widgetId = null;
889    var captchaActive = false;
890    var captchaRequired = !!TURNSTILE_SITE_KEY;
891
892    if (hasNewsletterSubscribed()) {
893      showNewsletterThanks(form, successMsg, false);
894      return;
895    }
896
897    function resetMsgs() {
898      if (successMsg) successMsg.style.display = 'none';
899      if (errorMsg) errorMsg.style.display = 'none';
900      if (errLabel) errLabel.textContent = '';
901    }
902
903    function loading(on) {
904      if (!btn) return;
905      btn.disabled = on;
906      if (on) {
907        btn.classList.add('is-loading');
908        if (btnText) btnText.textContent = loadingText;
909      } else {
910        btn.classList.remove('is-loading');
911        if (btnText) btnText.textContent = defaultText;
912      }
913    }
914
915    function resetCaptcha() {
916      if (captchaActive && window.turnstile && widgetId !== null) {
917        try { window.turnstile.reset(widgetId); } catch (e) { /* ignore */ }
918      }
919    }
920
921    if (captchaRequired && captchaHost) {
922      loadTurnstile(function (err) {
923        if (err || !window.turnstile) return;
924        widgetId = window.turnstile.render(captchaHost, {
925          sitekey: TURNSTILE_SITE_KEY,
926          theme: 'light',
927          size: 'flexible',
928          appearance: 'always',
929          language: document.documentElement.lang || 'auto',
930          callback: function () {
931            if (typeof window.handleCaptchaResponse === 'function') {
932              window.handleCaptchaResponse();
933            }
934          }
935        });
936        captchaActive = true;
937      });
938    }
939
940    form.addEventListener('submit', function (e) {
941      e.preventDefault();
942      resetMsgs();
943      if (honeypot && honeypot.value) return;
944      var value = email ? email.value.trim() : '';
945      if (!emailRe.test(value)) {
946        if (errLabel) errLabel.textContent = invalidText;
947        if (email) email.focus();
948        return;
949      }
950      if (captchaRequired) {
951        var token = '';
952        if (captchaActive && window.turnstile) {
953          try { token = window.turnstile.getResponse(widgetId) || ''; } catch (err) { token = ''; }
954        }
955        if (!token) {
956          var hidden = form.querySelector('[name="cf-turnstile-response"]');
957          token = hidden && hidden.value ? hidden.value : '';
958        }
959        if (!token) {
960          if (errLabel) errLabel.textContent = captchaText;
961          return;
962        }
963      }
964      loading(true);
965      var body = new URLSearchParams(new FormData(form)).toString();
966      fetch(form.action, {
967        method: 'POST',
968        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
969        body: body
970      }).then(function (r) {
971        return r.json().catch(function () { return { success: false }; });
972      }).then(function (res) {
973        if (res && res.success) {
974          markNewsletterSubscribed();
975          showNewsletterThanks(form, successMsg, true);
976        } else {
977          if (errorMsg) errorMsg.style.display = 'block';
978          loading(false);
979          resetCaptcha();
980        }
981      }).catch(function () {
982        if (errorMsg) errorMsg.style.display = 'block';
983        loading(false);
984        resetCaptcha();
985      });
986    });
987  }
988
989  // ── Init ────────────────────────────────────────────────────────────
990  if (document.readyState === 'loading') {
991    document.addEventListener('DOMContentLoaded', init);
992  } else {
993    init();
994  }
995
996  function init() {
997    initHamburger();
998    initNavbar();
999    initDropdowns();
1000    initSliders();
1001    initScrollAnimations();
1002    initAnchorScroll();
1003    initTestimonialScroll();
1004    initPackagesLoader();
1005    initReferralAndTracking();
1006    initNewsletterForm();
1007  }
1008})();

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.