PageSourceSearch

https://afyti.co/landing/script.js

js afyti.co collected 2026-10-02 01:20:17 UTC 13,331 bytes, 381 lines download raw bytes

1// AFYTI landing — vanilla JS
2// - i18n: apply translations from window.LANDING_I18N, respect cookie `lang`
3// - language switcher (globe dropdown in navbar)
4// - mobile nav toggle
5// - FAQ accordion (single-open)
6// - smooth scroll for in-page anchors
7// - footer year
8
9(function () {
10  'use strict';
11
12  // ----- i18n -----------------------------------------------------------
13  var SUPPORTED = ['ar', 'en', 'he'];
14  var RTL = { ar: true, he: true };
15  var DEFAULT_LANG = 'ar';
16
17  function readCookie(name) {
18    var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
19    return m ? decodeURIComponent(m[1]) : null;
20  }
21  function writeCookie(name, value) {
22    document.cookie = name + '=' + encodeURIComponent(value) +
23      '; path=/; max-age=31536000; SameSite=Lax';
24  }
25
26  function getInitialLang() {
27    var c = readCookie('lang');
28    if (c && SUPPORTED.indexOf(c) !== -1) return c;
29    var ls = null;
30    try { ls = localStorage.getItem('lang'); } catch (e) {}
31    if (ls && SUPPORTED.indexOf(ls) !== -1) return ls;
32    return DEFAULT_LANG;
33  }
34
35  function lookup(dict, key) {
36    var parts = key.split('.');
37    var val = dict;
38    for (var i = 0; i < parts.length; i++) {
39      if (val && typeof val === 'object' && parts[i] in val) val = val[parts[i]];
40      else return undefined;
41    }
42    return typeof val === 'string' ? val : undefined;
43  }
44
45  function translate(key, lang) {
46    var dicts = window.LANDING_I18N || {};
47    var v = lookup(dicts[lang] || {}, key);
48    if (v === undefined && lang !== 'en') v = lookup(dicts.en || {}, key);
49    return v;
50  }
51
52  function interpolate(str, vars) {
53    if (!vars) return str;
54    return str.replace(/\{(\w+)\}/g, function (m, k) {
55      return vars[k] != null ? String(vars[k]) : m;
56    });
57  }
58
59  function applyTranslations(lang) {
60    var year = new Date().getFullYear();
61    var vars = { year: year };
62
63    // Text nodes via data-i18n
64    document.querySelectorAll('[data-i18n]').forEach(function (el) {
65      var key = el.getAttribute('data-i18n');
66      var v = translate(key, lang);
67      if (v != null) el.textContent = interpolate(v, vars);
68    });
69
70    // Attributes via data-i18n-attr="attr:key[,attr:key]"
71    document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
72      var spec = el.getAttribute('data-i18n-attr');
73      spec.split(',').forEach(function (pair) {
74        var bits = pair.trim().split(':');
75        if (bits.length !== 2) return;
76        var attr = bits[0].trim();
77        var key = bits[1].trim();
78        var v = translate(key, lang);
79        if (v != null) el.setAttribute(attr, interpolate(v, vars));
80      });
81    });
82
83    // Title fallback (some browsers ignore textContent on <title>).
84    var t = translate('meta.title', lang);
85    if (t) document.title = t;
86
87    // Current-language indicator in the switcher.
88    var code = document.querySelector('[data-lang-code]');
89    if (code) code.textContent = lang.toUpperCase();
90
91    // Mark active item in dropdown.
92    document.querySelectorAll('[data-lang]').forEach(function (b) {
93      var isActive = b.getAttribute('data-lang') === lang;
94      b.setAttribute('aria-checked', String(isActive));
95      b.classList.toggle('is-active', isActive);
96    });
97  }
98
99  function applyDir(lang) {
100    var isRtl = !!RTL[lang];
101    document.documentElement.lang = lang;
102    document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
103    document.body.classList.toggle('rtl', isRtl);
104  }
105
106  function setLanguage(lang, opts) {
107    if (SUPPORTED.indexOf(lang) === -1) return;
108    writeCookie('lang', lang);
109    try { localStorage.setItem('lang', lang); } catch (e) {}
110    applyDir(lang);
111    applyTranslations(lang);
112    if (opts && opts.reload) window.location.reload();
113  }
114
115  // Map the current URL to its equivalent in the target language so the
116  // switcher navigates to the proper per-locale URL (and the static HTML
117  // variant served by middleware for /en or /he).
118  function urlForLang(lang) {
119    var path = window.location.pathname || '/';
120    // Strip leading /en or /he if present.
121    var stripped = path.replace(/^\/(en|he)(?=\/|$)/, '');
122    if (stripped === '') stripped = '/';
123    var target;
124    if (lang === 'ar') {
125      target = stripped;
126    } else {
127      target = stripped === '/' ? '/' + lang : '/' + lang + stripped;
128    }
129    return target + (window.location.search || '') + (window.location.hash || '');
130  }
131
132  // Boot i18n as early as possible.
133  var initial = getInitialLang();
134  applyDir(initial);
135  applyTranslations(initial);
136
137  // ----- language switcher dropdown ------------------------------------
138  var langRoot = document.querySelector('[data-lang-switch]');
139  if (langRoot) {
140    var langBtn = langRoot.querySelector('.nav-lang__btn');
141    var langMenu = langRoot.querySelector('.nav-lang__menu');
142
143    var closeMenu = function () {
144      langMenu.hidden = true;
145      langBtn.setAttribute('aria-expanded', 'false');
146    };
147    var openMenu = function () {
148      langMenu.hidden = false;
149      langBtn.setAttribute('aria-expanded', 'true');
150    };
151
152    langBtn.addEventListener('click', function (e) {
153      e.stopPropagation();
154      if (langMenu.hidden) openMenu(); else closeMenu();
155    });
156
157    langMenu.addEventListener('click', function (e) {
158      var b = e.target.closest('[data-lang]');
159      if (!b) return;
160      var lang = b.getAttribute('data-lang');
161      closeMenu();
162      // Persist the choice and navigate to the language-specific URL so the
163      // browser lands on the SSR-correct static HTML variant (for /, /en,
164      // /he) or the locale-prefixed Next.js route (inner pages).
165      writeCookie('lang', lang);
166      try { localStorage.setItem('lang', lang); } catch (err) {}
167      window.location.assign(urlForLang(lang));
168    });
169
170    document.addEventListener('click', function (e) {
171      if (!langRoot.contains(e.target)) closeMenu();
172    });
173    document.addEventListener('keydown', function (e) {
174      if (e.key === 'Escape') closeMenu();
175    });
176  }
177
178  // ----- mobile nav toggle ---------------------------------------------
179  var nav = document.querySelector('.nav');
180  var toggle = document.querySelector('.nav-toggle');
181  if (nav && toggle) {
182    toggle.addEventListener('click', function () {
183      var open = nav.classList.toggle('is-open');
184      toggle.setAttribute('aria-expanded', String(open));
185      var key = open ? 'nav.closeMenu' : 'nav.openMenu';
186      var v = translate(key, document.documentElement.lang || initial);
187      if (v) toggle.setAttribute('aria-label', v);
188    });
189  }
190
191  // ----- FAQ accordion: only one open at a time ------------------------
192  var faqs = document.querySelectorAll('.faq');
193  faqs.forEach(function (item) {
194    var btn = item.querySelector('.faq-summary');
195    if (!btn) return;
196    btn.addEventListener('click', function () {
197      var willOpen = !item.classList.contains('is-open');
198      faqs.forEach(function (other) {
199        other.classList.remove('is-open');
200        var b = other.querySelector('.faq-summary');
201        if (b) b.setAttribute('aria-expanded', 'false');
202      });
203      if (willOpen) {
204        item.classList.add('is-open');
205        btn.setAttribute('aria-expanded', 'true');
206      }
207    });
208  });
209
210  // ----- smooth-scroll for # links + close mobile nav after click ------
211  document.addEventListener('click', function (e) {
212    var a = e.target.closest('a[href^="#"]');
213    if (!a) return;
214    var href = a.getAttribute('href');
215    if (!href || href === '#') return;
216    var t = document.querySelector(href);
217    if (!t) return;
218    e.preventDefault();
219    t.scrollIntoView({ behavior: 'smooth', block: 'start' });
220    if (nav && nav.classList.contains('is-open')) {
221      nav.classList.remove('is-open');
222      toggle.setAttribute('aria-expanded', 'false');
223    }
224  });
225
226  // Legacy span — kept harmless. footer.copy now ships {year} interpolation.
227  var year = document.getElementById('year');
228  if (year) year.textContent = String(new Date().getFullYear());
229
230  // ----- scroll-reveal -------------------------------------------------
231  // CSS pre-hides these selectors (via `html.js …`) so there's no flash.
232  // IntersectionObserver toggles .is-visible when each enters the viewport.
233  var revealSelector = [
234    '.section-head',
235    '.mission-copy',
236    '.mission-visual',
237    '.benefit-card',
238    '.feature-card',
239    '.features-phone',
240    '.automation-card',
241    '.step',
242    '.faq',
243    '.cta-card'
244  ].join(',');
245
246  var revealNodes = document.querySelectorAll(revealSelector);
247
248  // Stagger siblings inside the same parent so grids cascade in (wrap at 5).
249  var staggerCounts = new WeakMap();
250  revealNodes.forEach(function (el) {
251    var parent = el.parentNode;
252    var n = (staggerCounts.get(parent) || 0) + 1;
253    staggerCounts.set(parent, n);
254    el.setAttribute('data-reveal-stagger', String(((n - 1) % 5) + 1));
255  });
256
257  var prefersReducedMotion =
258    window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
259
260  if (prefersReducedMotion || !('IntersectionObserver' in window)) {
261    revealNodes.forEach(function (el) { el.classList.add('is-visible'); });
262  } else {
263    var io = new IntersectionObserver(function (entries) {
264      entries.forEach(function (e) {
265        if (e.isIntersecting) {
266          e.target.classList.add('is-visible');
267          io.unobserve(e.target);
268        }
269      });
270    }, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' });
271
272    revealNodes.forEach(function (el) { io.observe(el); });
273  }
274})();
275
276// ---------------------------------------------------------------------------
277// Latest articles
278//
279// The landing page is a static export with no bundler and no env vars, so it
280// reads the blog over a same-origin proxy (next.config rewrites /api/blog/* to
281// the backend). The <section id="articles"> ships with `hidden` and is only
282// revealed once at least one card has actually rendered — an empty blog, a
283// failed request or an unreachable API all leave the page exactly as it was.
284// ---------------------------------------------------------------------------
285(function () {
286  'use strict';
287
288  var section = document.getElementById('articles');
289  var grid = document.getElementById('article-grid');
290  if (!section || !grid) return;
291
292  var SUPPORTED = ['ar', 'en', 'he'];
293  var LIMIT = 3;
294
295  // The per-locale static variants carry the right <html lang> already; the
296  // cookie is the fallback for the Arabic default file.
297  function currentLang() {
298    var l = (document.documentElement.getAttribute('lang') || '').slice(0, 2);
299    if (SUPPORTED.indexOf(l) !== -1) return l;
300    var m = document.cookie.match(/(?:^|; )lang=([^;]*)/);
301    var c = m ? decodeURIComponent(m[1]) : '';
302    return SUPPORTED.indexOf(c) !== -1 ? c : 'ar';
303  }
304
305  function blogPath(lang, sub) {
306    return (lang === 'ar' ? '/blog' : '/' + lang + '/blog') + (sub || '');
307  }
308
309  function t(key, fallback) {
310    try {
311      var dict = window.LANDING_I18N && window.LANDING_I18N[currentLang()];
312      var parts = key.split('.');
313      var v = dict;
314      for (var i = 0; i < parts.length; i++) {
315        if (v && typeof v === 'object' && parts[i] in v) v = v[parts[i]];
316        else return fallback;
317      }
318      return typeof v === 'string' ? v : fallback;
319    } catch (e) {
320      return fallback;
321    }
322  }
323
324  function el(tag, cls, text) {
325    var n = document.createElement(tag);
326    if (cls) n.className = cls;
327    // textContent throughout — article fields are rendered as text here, never
328    // parsed as HTML.
329    if (text != null) n.textContent = text;
330    return n;
331  }
332
333  function card(a, lang) {
334    var link = el('a', 'article-card');
335    link.href = blogPath(lang, '/' + a.slug);
336
337    var media = el('div', 'article-card__media');
338    if (a.coverImage) {
339      var img = el('img');
340      img.src = '/api/blog/media/' + a.coverImage;
341      img.alt = a.title || '';
342      img.loading = 'lazy';
343      img.decoding = 'async';
344      media.appendChild(img);
345    }
346    link.appendChild(media);
347
348    var body = el('div', 'article-card__body');
349    if (a.category && a.category.name) body.appendChild(el('span', 'article-card__cat', a.category.name));
350    body.appendChild(el('h3', 'article-card__title', a.title || ''));
351    if (a.excerpt) body.appendChild(el('p', 'article-card__excerpt', a.excerpt));
352
353    if (a.wordCount) {
354      var mins = Math.max(1, Math.round(a.wordCount / 200));
355      body.appendChild(
356        el('div', 'article-card__meta', t('articles.readingTime', '{count} min read').replace('{count}', mins))
357      );
358    }
359
360    link.appendChild(body);
361    return link;
362  }
363
364  var lang = currentLang();
365  fetch('/api/blog/articles?locale=' + encodeURIComponent(lang) + '&limit=' + LIMIT, {
366    headers: { accept: 'application/json' },
367  })
368    .then(function (r) { return r.ok ? r.json() : null; })
369    .then(function (json) {
370      var items = (json && json.data && json.data.items) || [];
371      if (!items.length) return; // nothing published — leave the section hidden
372      var frag = document.createDocumentFragment();
373      items.slice(0, LIMIT).forEach(function (a) {
374        if (a && a.slug) frag.appendChild(card(a, lang));
375      });
376      if (!frag.childNodes.length) return;
377      grid.appendChild(frag);
378      section.hidden = false;
379    })
380    .catch(function () { /* API unreachable — section stays hidden */ });
381})();

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.