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.