PageSourceSearch

https://stratoplan-school.com/team/js/app.js?v=0.0.2.4

js stratoplan-school.com collected 2026-09-26 02:24:18 UTC 44,101 bytes, 1,425 lines download raw bytes

1/* =================================== */
2/* =================================== */
3/* =================================== */
4/* Danylo Oliinyk - https://iamdev.lol/ */
5/* =====October 2025 For Statoplan =====*/
6/* =================================== */
7/* =================================== */
8/* =================================== */
9/* 01. Script for the speakers slider */
10/* 02. Script for the video slider */
11/* 03. Script for displaying news */
12/* 04. Script for the popup */
13/* 05. Script for smooth scroll and navigation */
14/* 06. Script for the burger menu */
15/* 07. Script for the accordeon */
16/* =================================== */
17/* =================================== */
18/* =================================== */
19
20
21/* 01. Script for the speakers slider start*/
22document.addEventListener('DOMContentLoaded', () => {
23  const root  = document.querySelector('#speaker .speaker__content');
24  if (!root) return;
25
26  const track = root.querySelector('.speaker__track');
27  const prev  = root.querySelector('.speaker__nav-left');
28  const next  = root.querySelector('.speaker__nav-right');
29
30  if (!track || !prev || !next) return;
31
32  const state = {
33    index: 0,
34    max:   0,
35    gap:   0,
36    slideW: 0
37  };
38
39  function measure() {
40    const first = track.querySelector('.speaker__item');
41    if (!first) return;
42
43    const cs = getComputedStyle(track);
44    state.gap = parseFloat(cs.columnGap || cs.gap || 0) || 0;
45    state.slideW = first.getBoundingClientRect().width;
46
47    const viewportW = track.parentElement.getBoundingClientRect().width;
48
49    // сколько ПОЛНЫХ карточек реально влезает
50    const perView = Math.max(
51      1,
52      Math.floor((viewportW + state.gap) / (state.slideW + state.gap))
53    );
54
55    state.max = Math.max(0, track.children.length - perView);
56    state.index = Math.min(state.index, state.max);
57
58    update();
59    toggle();
60  }
61
62  function update() {
63    const offset = -(state.slideW + state.gap) * state.index;
64    track.style.transform = `translateX(${offset}px)`;
65  }
66
67  function toggle() {
68    prev.classList.toggle('is-disabled', state.index === 0);
69    next.classList.toggle('is-disabled', state.index === state.max);
70  }
71
72  prev.addEventListener('click', (e) => {
73    e.preventDefault();
74    if (state.index > 0) {
75      state.index--;
76      update();
77      toggle();
78    }
79  });
80
81  next.addEventListener('click', (e) => {
82    e.preventDefault();
83    if (state.index < state.max) {
84      state.index++;
85      update();
86      toggle();
87    }
88  });
89
90  window.addEventListener('resize', measure, { passive: true });
91
92  measure();
93});
94
95/* 01. Script for the speakers slider  end */
96
97
98/* 02. Script for the video slider start */
99document.addEventListener('DOMContentLoaded', () => {
100  const root = document.querySelector('#video');
101  if (!root) return;
102
103  /* slider */
104  const track = root.querySelector('.video__track');
105  const items = Array.from(track.children);
106  const prev  = root.querySelector('.video__nav-left');
107  const next  = root.querySelector('.video__nav-right');
108
109  const GAP = 30;
110  let perView = 2;
111
112  const mqMobile = window.matchMedia('(max-width:640px)');
113  function recalcPerView(){ perView = mqMobile.matches ? 1 : 2; }
114  recalcPerView();
115  mqMobile.addEventListener('change', recalcPerView);
116
117  let index = 0;
118
119  function clampIndex(){
120    const max = Math.max(0, items.length - perView);
121    if (index < 0) index = 0;
122    if (index > max) index = max;
123    prev.classList.toggle('is-disabled', index === 0);
124    next.classList.toggle('is-disabled', index === max);
125  }
126
127  function slide(){
128    const itemW = items[0]?.getBoundingClientRect().width || 0;
129    const offset = index * (itemW + GAP);
130    track.style.transform = `translateX(${-offset}px)`;
131  }
132
133  prev?.addEventListener('click', e => { e.preventDefault(); index--; clampIndex(); slide(); });
134  next?.addEventListener('click', e => { e.preventDefault(); index++; clampIndex(); slide(); });
135  window.addEventListener('resize', () => { clampIndex(); slide(); });
136
137  clampIndex(); slide();
138
139  /* players */
140  root.querySelectorAll('.video__player').forEach(setupPlayer);
141
142  function setupPlayer(box){
143    const poster = box.dataset.poster || '';
144    if (poster) box.style.backgroundImage = `url("${poster}")`;
145
146    function play(){
147      const src = box.dataset.src || '';
148      if (!src) return;
149
150      // MP4/WEBM
151      if (/\.(mp4|m4v|webm)(\?|$)/i.test(src)) {
152        const v = document.createElement('video');
153        v.src = src;
154        v.controls = true;
155        v.autoplay = true;
156        v.playsInline = true;
157        v.poster = poster || '';
158        v.style.width = '100%';
159        v.style.height = '100%';
160        v.style.display = 'block';
161        box.replaceChildren(v);
162        return;
163      }
164
165      // Iframe embeds (YouTube/Vimeo/др.)
166      const iframe = document.createElement('iframe');
167      iframe.src = src.includes('autoplay=1') ? src : (src + (src.includes('?') ? '&' : '?') + 'autoplay=1');
168      iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
169      iframe.allowFullscreen = true;
170      iframe.title = 'Видео';
171      iframe.style.width = '100%';
172      iframe.style.height = '100%';
173      iframe.frameBorder = '0';
174      box.replaceChildren(iframe);
175    }
176
177    box.addEventListener('click', play);
178    box.addEventListener('keydown', e => {
179      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
180    });
181  }
182});
183
184/* 02. Script for the video slider end */
185
186/* 03. Script for displaying news start */
187/* document.addEventListener('DOMContentLoaded', () => {
188  const root = document.querySelector('#news');
189  if (!root) return;
190
191  const rows = [...root.querySelectorAll('.news__wrap .news__row')];
192  const btn  = root.querySelector('.news__btn-group .btn');
193
194  rows.slice(1).forEach(r => r.classList.remove('active'));
195
196  btn?.addEventListener('click', e => {
197    e.preventDefault();
198    const hidden = rows.slice(1).filter(r => !r.classList.contains('active'));
199    if (hidden.length) {
200      hidden.forEach(r => r.classList.add('active'));
201      btn.textContent = 'Свернуть';
202    } else {
203      rows.slice(1).forEach(r => r.classList.remove('active'));
204      btn.textContent = 'Больше новостей';
205      root.scrollIntoView({behavior:'smooth', block:'start'});
206    }
207  });
208}); */
209/* 03. Script for displaying news end */
210
211
212
213/* Script for the Pop-Up start*/
214(function(){
215  const OPEN_IDS = new Set(['#tospeak', '#sponsored']);
216
217  function openModal(id){
218    const m = document.querySelector(id);
219    if (!m) return;
220    m.classList.add('is-open');
221    m.setAttribute('aria-hidden', 'false');
222    document.body.classList.add('no-scroll');
223  }
224  function closeModal(m){
225    if (!m) return;
226    m.classList.remove('is-open');
227    m.setAttribute('aria-hidden', 'true');
228    document.body.classList.remove('no-scroll');
229    if (location.hash && (location.hash === '#tospeak' || location.hash === '#sponsored')) {
230      history.replaceState('', document.title, location.pathname + location.search);
231    }
232  }
233
234  document.addEventListener('click', (e) => {
235    const a = e.target.closest('a[href^="#"]');
236    if (a && OPEN_IDS.has(a.getAttribute('href'))) {
237      e.preventDefault();
238      openModal(a.getAttribute('href'));
239      return;
240    }
241    if (e.target.matches('[data-close]')) {
242      closeModal(e.target.closest('.modal'));
243      return;
244    }
245    const modal = e.target.classList.contains('modal') ? e.target : null;
246    if (modal) closeModal(modal);
247  });
248
249  document.addEventListener('keydown', (e) => {
250    if (e.key === 'Escape') {
251      document.querySelectorAll('.modal.is-open').forEach(closeModal);
252    }
253  });
254
255  if (OPEN_IDS.has(location.hash)) openModal(location.hash);
256  window.addEventListener('hashchange', () => {
257    if (OPEN_IDS.has(location.hash)) openModal(location.hash);
258  });
259})();
260
261/* Script for the Pop-Up end*/
262
263
264/* 05. Script for smooth scroll and navigation start */
265/* (function(){
266  const EASE = 0.10;           // инерция
267  const STEP = 0.50;             // базовый шаг для колёсика
268  let target = window.scrollY;
269  let y = target;
270  let rafId = null;
271
272  const header = document.querySelector('header');
273  const headerH = () => (header ? header.offsetHeight : 0);
274
275  function isScrollable(el){
276    while (el && el !== document.body){
277      const s = getComputedStyle(el);
278      const canScroll = /(auto|scroll)/.test(s.overflow + s.overflowY + s.overflowX);
279      if (canScroll && el.scrollHeight > el.clientHeight) return true;
280      el = el.parentElement;
281    }
282    return false;
283  }
284
285  function animate(){
286    const diff = target - y;
287    if (Math.abs(diff) < 0.1){
288      y = target;
289      window.scrollTo(0, y);
290      rafId = null;
291      return;
292    }
293    y += diff * EASE;
294    window.scrollTo(0, y);
295    rafId = requestAnimationFrame(animate);
296  }
297
298  function clamp(v){
299    const max = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
300    return Math.min(Math.max(0, v), max);
301  }
302
303  function toY(pos){
304    target = clamp(pos);
305    if (!rafId) rafId = requestAnimationFrame(animate);
306  }
307
308  window.addEventListener('wheel', (e) => {
309    if (e.defaultPrevented) return;
310    if (isScrollable(e.target)) return;
311    e.preventDefault();
312    const delta = (Math.abs(e.deltaY) > 1 ? e.deltaY : Math.sign(e.deltaY)) * STEP;
313    toY((target = clamp(target + delta)));
314  }, { passive: false });
315
316  window.addEventListener('scroll', () => {
317    if (!rafId) { target = window.scrollY; y = window.scrollY; }
318  }, { passive: true });
319
320  window.addEventListener('resize', () => { target = clamp(target); y = clamp(y); });
321
322
323  document.addEventListener('click', (e) => {
324    const a = e.target.closest('a[href^="#"]');
325    if (!a) return;
326    const href = a.getAttribute('href');
327    if (!href || href === '#') return;
328
329    const id = href.slice(1);
330    const el = document.getElementById(id);
331    if (!el) return;
332
333    e.preventDefault();
334    const rectTop = el.getBoundingClientRect().top;
335    const offset = headerH();
336    const dest = (window.scrollY || 0) + rectTop - offset;
337    toY(dest);
338
339    if (history.replaceState) {
340      history.replaceState('', document.title, location.pathname + location.search);
341    }
342  }, { passive: false });
343
344  window.addEventListener('load', () => {
345    if (location.hash && location.hash.length > 1){
346      const el = document.getElementById(location.hash.slice(1));
347      if (el){
348        const rectTop = el.getBoundingClientRect().top;
349        toY((window.scrollY || 0) + rectTop - headerH());
350        history.replaceState('', document.title, location.pathname + location.search);
351      }
352    }
353  });
354})(); */
355/* 05. Script for smooth scroll and navigation end */
356
357/* 06. Script for the accordeon  Start*/
358// Accordeon Start
359(function () {
360  const root = document.querySelector('.program__acc-wrap');
361  if (!root) return;
362
363  const items = Array.from(root.querySelectorAll('.program__acc'));
364  if (!items.length) return;
365
366  const PLUS = '<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 19 19" fill="none"><path d="M9.1667 1V17.3334M1 9.16668H17.3333" stroke="var(--color-blue-strong)" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/></svg>';
367  const MINUS = '<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 19 19" fill="none"><path d="M1 9.16668H17.3333" stroke="var(--color-blue-strong)" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/></svg>';
368
369  // Инициализация: все закрыты, везде плюс
370  items.forEach(item => {
371    const text = item.querySelector('.program__acc-text');
372    const icon = item.querySelector('.program__acc-col-right');
373    if (text) text.classList.remove('acc-text-active');
374    if (icon) icon.innerHTML = PLUS;
375  });
376
377  function toggleAcc(target) {
378    const targetText = target.querySelector('.program__acc-text');
379    const targetIcon = target.querySelector('.program__acc-col-right');
380    if (!targetText || !targetIcon) return;
381
382    const willOpen = !targetText.classList.contains('acc-text-active');
383
384    // позиция блока ДО изменений
385    const beforeTop = target.getBoundingClientRect().top;
386
387    // закрываем всех и ставим плюсики
388    items.forEach(item => {
389      const text = item.querySelector('.program__acc-text');
390      const icon = item.querySelector('.program__acc-col-right');
391      if (!text || !icon) return;
392      text.classList.remove('acc-text-active');
393      icon.innerHTML = PLUS;
394    });
395
396    // открываем только целевой (если нужно)
397    if (willOpen) {
398      targetText.classList.add('acc-text-active');
399      targetIcon.innerHTML = MINUS;
400    }
401
402    // компенсация скролла — оставляем заголовок на месте
403    requestAnimationFrame(() => {
404      const afterTop = target.getBoundingClientRect().top;
405      const diff = afterTop - beforeTop;
406      if (Math.abs(diff) > 1) {
407        window.scrollBy(0, diff);
408      }
409    });
410  }
411
412  root.addEventListener('click', (e) => {
413    const acc = e.target.closest('.program__acc');
414    if (!acc || !root.contains(acc)) return;
415    e.preventDefault();
416    toggleAcc(acc);
417  });
418})();
419
420/* Script for the accordeon  End*/
421(() => {
422  const burger = document.getElementById('burger-icon');
423  const nav    = document.getElementById('nav-links');
424  if (!burger || !nav) return;
425
426  // a11y
427  burger.setAttribute('role','button');
428  burger.setAttribute('aria-controls','nav-links');
429  burger.setAttribute('aria-expanded','false');
430  burger.tabIndex = 0;
431
432  const mq = window.matchMedia('(max-width:1024px)');
433
434  const open = () => {
435    nav.classList.add('active');
436    document.documentElement.style.overflow = 'hidden';
437    burger.setAttribute('aria-expanded','true');
438  };
439
440  const close = () => {
441    nav.classList.remove('active');
442    document.documentElement.style.overflow = '';
443    burger.setAttribute('aria-expanded','false');
444  };
445
446  const toggle = () => (nav.classList.contains('active') ? close() : open());
447
448  burger.addEventListener('click', toggle);
449  burger.addEventListener('keydown', e => {
450    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
451  });
452
453  // клик по ссылке — закрыть
454  nav.addEventListener('click', e => {
455    if (e.target.closest('.header__link')) close();
456  });
457
458  // клик вне меню — закрыть
459  document.addEventListener('click', e => {
460    if (!mq.matches) return;
461    const t = e.target;
462    if (nav.classList.contains('active') && !nav.contains(t) && !burger.contains(t)) close();
463  });
464
465  // Esc
466  document.addEventListener('keydown', e => {
467    if (e.key === 'Escape') close();
468  });
469
470  // ресет при изменении брейкпоинта
471  window.addEventListener('resize', () => {
472    if (!mq.matches) close();
473  });
474})();
475
476
477document.querySelectorAll('a[href^="#"]').forEach(anchor => {
478  anchor.addEventListener('click', function (e) {
479    const targetId = this.getAttribute('href').substring(1); // убираем #
480    if (!targetId) return;
481
482    const target = document.getElementById(targetId);
483    if (!target) return;
484
485    e.preventDefault(); // отключаем стандартный переход с якорем в URL
486
487    const headerOffset = 80; // подгони под высоту своего фиксированного хедера
488    const elementPosition = target.getBoundingClientRect().top + window.pageYOffset;
489    const offsetPosition = elementPosition - headerOffset;
490
491    window.scrollTo({
492      top: offsetPosition,
493      behavior: 'smooth'
494    });
495  });
496});
497
498/* =================================== */
499
500/* ===== Modal: Регистрация ===== */
501(() => {
502  const modals = Array.from(
503    document.querySelectorAll('#reg-modal, #reg-modal-2, #reg-modal-3')
504  );
505  if (!modals.length) return;
506
507  const html = document.documentElement;
508  const body = document.body;
509
510  // Маппинг: на какую кнопку какой модал
511  // Подставь свои селекторы кнопок (или href), если отличаются.
512  const triggersMap = [
513    { selectors: 'a.js-reg-alt, a[href="#reg-alt"]', modalId: 'reg-modal' },
514    { selectors: 'a.js-reg-alt-2, a[href="#reg-alt-2"]', modalId: 'reg-modal-2' },
515    { selectors: 'a.js-reg-alt-3, a[href="#reg-alt-3"]', modalId: 'reg-modal-3' },
516  ];
517
518  const modalById = new Map(modals.map(m => [m.id, m]));
519
520  let activeModal = null;
521  let prevFocus = null;
522
523  // Делегирование по кнопкам открытия
524  document.addEventListener('click', (e) => {
525    const hit = triggersMap.find(x => e.target.closest(x.selectors));
526    if (!hit) return;
527
528    const modal = modalById.get(hit.modalId);
529    if (!modal) return;
530
531    e.preventDefault();
532    open(modal);
533  });
534
535  // Закрытие + бэкдроп для всех модалок
536  modals.forEach((modal) => {
537    modal.addEventListener('click', (e) => {
538      if (
539        e.target.closest('[data-close="modal"], .modal__close') ||
540        e.target.classList.contains('modal__backdrop')
541      ) {
542        close(modal);
543      }
544    });
545  });
546
547  // Esc + trap focus
548  document.addEventListener('keydown', (e) => {
549    if (!activeModal || !isOpen(activeModal)) return;
550
551    if (e.key === 'Escape') close(activeModal);
552    if (e.key === 'Tab') trapFocus(e, activeModal);
553  });
554
555  function isOpen(modal) {
556    return modal.classList.contains('is-open');
557  }
558
559  function open(modal) {
560    if (activeModal && activeModal !== modal) close(activeModal);
561    if (isOpen(modal)) return;
562
563    prevFocus = document.activeElement;
564    activeModal = modal;
565
566    modal.classList.add('is-open');
567    html.classList.add('modal-lock');
568    body.classList.add('modal-lock');
569
570    focusFirst(modal);
571  }
572
573  function close(modal) {
574    if (!isOpen(modal)) return;
575
576    modal.classList.remove('is-open');
577
578    // снимаем lock только если больше нет открытых модалок
579    if (!modals.some(isOpen)) {
580      html.classList.remove('modal-lock');
581      body.classList.remove('modal-lock');
582      activeModal = null;
583
584      if (prevFocus && typeof prevFocus.focus === 'function') {
585        prevFocus.focus({ preventScroll: true });
586      }
587    } else {
588      activeModal = modals.find(isOpen) || null;
589    }
590  }
591
592  function focusables(modal) {
593    return modal.querySelectorAll(
594      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
595    );
596  }
597
598  function focusFirst(modal) {
599    const dialog = modal.querySelector('.modal__dialog');
600    const f = focusables(modal);
601    (f[0] || dialog || modal).focus?.({ preventScroll: true });
602  }
603
604  function trapFocus(e, modal) {
605    const f = Array.from(focusables(modal));
606    if (!f.length) return;
607
608    const i = f.indexOf(document.activeElement);
609    if (e.shiftKey && i <= 0) {
610      e.preventDefault();
611      f[f.length - 1].focus();
612    } else if (!e.shiftKey && i === f.length - 1) {
613      e.preventDefault();
614      f[0].focus();
615    }
616  }
617})();
618
619/* =================================== */
620document.addEventListener('DOMContentLoaded', function () {
621    const tabs = document.querySelectorAll('.program__tabs-btn');
622    const panes = document.querySelectorAll('.program-tabs__pane');
623
624    if (!tabs.length || !panes.length) return;
625
626    const activateTab = (target) => {
627        tabs.forEach((tab) => {
628            tab.classList.toggle('is-active', tab.dataset.target === target);
629        });
630
631        panes.forEach((pane) => {
632            pane.classList.toggle('is-active', pane.dataset.tab === target);
633        });
634    };
635
636    tabs.forEach((tab) => {
637        tab.addEventListener('click', function (e) {
638            e.preventDefault();
639            const target = this.dataset.target;
640            if (!target) return;
641            activateTab(target);
642        });
643    });
644});
645
646/* =================================== */
647document.addEventListener('DOMContentLoaded', () => {
648  const mq = window.matchMedia('(max-width: 1024px)'); // mobile/tablet
649  const isMobile = () => mq.matches;
650
651  const left = document.querySelector('.topics__col-left');
652  const wrap = document.querySelector('.topics__wrap');
653  if (!left || !wrap) return;
654
655  const TOP = 110;
656  let raf = 0;
657
658  function reset() {
659    left.classList.remove('is-fixed');
660    left.style.left = '';
661    left.style.width = '';
662    left.style.transform = '';
663  }
664
665  function update() {
666    raf = 0;
667
668    // mobile/tablet — ВЫКЛ
669    if (isMobile()) {
670      reset();
671      return;
672    }
673
674    const wrapRect = wrap.getBoundingClientRect();
675    const leftRect = left.getBoundingClientRect();
676
677    // пока секция не дошла до TOP — обычное поведение
678    if (wrapRect.top > TOP) {
679      reset();
680      return;
681    }
682
683    left.classList.add('is-fixed');
684    left.style.left = wrapRect.left + 'px';
685    left.style.width = leftRect.width + 'px';
686
687    // стоп внизу секции
688    const maxShift = wrapRect.height - leftRect.height;
689    const currentShift = Math.min(maxShift, Math.max(0, TOP - wrapRect.top));
690    left.style.transform = `translateY(${currentShift}px)`;
691  }
692
693  function tick() {
694    if (!raf) raf = requestAnimationFrame(update);
695  }
696
697  window.addEventListener('scroll', tick, { passive: true });
698  window.addEventListener('resize', tick);
699  mq.addEventListener?.('change', tick);
700
701  update();
702});
703/* =================================== */
704/* Script for the Pop-Up start*/
705(function(){
706  const OPEN_IDS = new Set(['#variant-1ru', '#variant-2ru']);
707
708  function openModal(id){
709    const m = document.querySelector(id);
710    if (!m) return;
711    m.classList.add('is-open');
712    m.setAttribute('aria-hidden', 'false');
713    document.body.classList.add('no-scroll');
714  }
715  function closeModal(m){
716    if (!m) return;
717    m.classList.remove('is-open');
718    m.setAttribute('aria-hidden', 'true');
719    document.body.classList.remove('no-scroll');
720    if (location.hash && (location.hash === '#variant-1ru' || location.hash === '#variant-2ru')) {
721      history.replaceState('', document.title, location.pathname + location.search);
722    }
723  }
724
725  document.addEventListener('click', (e) => {
726    const a = e.target.closest('a[href^="#"]');
727    if (a && OPEN_IDS.has(a.getAttribute('href'))) {
728      e.preventDefault();
729      openModal(a.getAttribute('href'));
730      return;
731    }
732    if (e.target.matches('[data-close]')) {
733      closeModal(e.target.closest('.modal'));
734      return;
735    }
736    const modal = e.target.classList.contains('modal') ? e.target : null;
737    if (modal) closeModal(modal);
738  });
739
740  document.addEventListener('keydown', (e) => {
741    if (e.key === 'Escape') {
742      document.querySelectorAll('.modal.is-open').forEach(closeModal);
743    }
744  });
745
746  if (OPEN_IDS.has(location.hash)) openModal(location.hash);
747  window.addEventListener('hashchange', () => {
748    if (OPEN_IDS.has(location.hash)) openModal(location.hash);
749  });
750})();
751
752/* Script for the Pop-Up start*/
753(function(){
754  const OPEN_IDS = new Set(['#variant-eu', '#variant-2eu']);
755
756  function closeModal(m){
757    if (!m) return;
758    m.classList.remove('is-open');
759    m.setAttribute('aria-hidden', 'true');
760    document.body.classList.remove('no-scroll');
761
762    // чистим hash только для eu-вариантов
763    if (location.hash && (location.hash === '#variant-eu' || location.hash === '#variant-2eu')) {
764      history.replaceState('', document.title, location.pathname + location.search);
765    }
766  }
767
768  // Новый хелпер — закрыть все открытые модалки (включая те, что ру-скрипт открыл)
769  function closeAllModals() {
770    document.querySelectorAll('.modal.is-open').forEach(closeModal);
771  }
772
773  function openModal(id){
774    // при активации eu-попапа сначала гасим всё
775    closeAllModals();
776
777    const m = document.querySelector(id);
778    if (!m) return;
779    m.classList.add('is-open');
780    m.setAttribute('aria-hidden', 'false');
781    document.body.classList.add('no-scroll');
782  }
783
784  document.addEventListener('click', (e) => {
785    const a = e.target.closest('a[href^="#"]');
786    if (a && OPEN_IDS.has(a.getAttribute('href'))) {
787      e.preventDefault();
788      openModal(a.getAttribute('href'));
789      return;
790    }
791    if (e.target.matches('[data-close]')) {
792      closeModal(e.target.closest('.modal'));
793      return;
794    }
795    const modal = e.target.classList.contains('modal') ? e.target : null;
796    if (modal) closeModal(modal);
797  });
798
799  document.addEventListener('keydown', (e) => {
800    if (e.key === 'Escape') {
801      document.querySelectorAll('.modal.is-open').forEach(closeModal);
802    }
803  });
804
805  if (OPEN_IDS.has(location.hash)) openModal(location.hash);
806  window.addEventListener('hashchange', () => {
807    if (OPEN_IDS.has(location.hash)) openModal(location.hash);
808  });
809})();
810/* =================================== */
811
812
813
814
815/* PAYMODAL — ONLY RADIOS (paymode/currency/months) + FD SYNC + STEP2 OVERLAY + MOBILE STEP2 FIX + SCROLL HINTS */
816(function () {
817  const qs = (sel, root = document) => root.querySelector(sel);
818  const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
819
820  const OPEN_MODAL_SEL = '.paymodal.is-open';
821  const FD_IFRAME_SEL = 'iframe[src*="formdesigner"], iframe[src*="formdesigner."]';
822
823  // Modal id -> package key in data/info.json. Prices/deadline live only in
824  // info.json now (support edits that file, everything below reads from it).
825  const MODAL_PACKAGE = {
826    payModalBasic: 'standard',
827    payModalTrainer: 'advanced',
828    payModalTransBasic: 'standard_trans',
829    payModalTransTrainer: 'advanced_trans'
830  };
831
832  const getSiteInfo = () => window.__SITE_INFO__ || null;
833  const getCurrencySign = () => getSiteInfo()?.currency || '€';
834  const getPackageData = (key) => getSiteInfo()?.packages?.[key] || null;
835  const getModalPackageKey = (modalOrId) => {
836    const id = typeof modalOrId === 'string' ? modalOrId : modalOrId?.id;
837    return MODAL_PACKAGE[id] || null;
838  };
839
840  // window.__PAYMODAL_DEBUG = true;
841  const isDebug = () => window.__PAYMODAL_DEBUG === true;
842  const log = (...a) => { if (isDebug()) console.log('[PAYMODAL]', ...a); };
843
844  const lockScroll = (on) => {
845    document.documentElement.classList.toggle('no-scroll', on);
846    document.body.classList.toggle('no-scroll', on);
847  };
848
849  const getOpenModal = () => qs(OPEN_MODAL_SEL);
850  const getFdIframe = (modal) => modal?.querySelector(FD_IFRAME_SEL) || null;
851
852  const normalizeNumber = (v) => {
853    if (typeof v === 'number') return v;
854    if (typeof v === 'string') return Number(v.replace(',', '.').trim());
855    return Number(v);
856  };
857
858  const formatPrice = (v) => {
859    const n = normalizeNumber(v);
860    if (!Number.isFinite(n)) return '0';
861    return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
862  };
863
864  const renderModalPrices = (modal) => {
865    if (!modal) return;
866
867    const pkgKey = getModalPackageKey(modal);
868    const pkg = pkgKey && getPackageData(pkgKey);
869    if (!pkg) return;
870
871    const fullPrice = normalizeNumber(pkg.actual);
872    if (!Number.isFinite(fullPrice) || !fullPrice) return;
873
874    const fullNode = qs('.js-price-full', modal);
875    if (fullNode) {
876      fullNode.textContent = formatPrice(fullPrice);
877    }
878
879    const oldFullNode = qs('.js-price-old del, .js-price-old', modal);
880    if (oldFullNode && Number.isFinite(normalizeNumber(pkg.old))) {
881      oldFullNode.textContent = formatPrice(pkg.old);
882    }
883
884    const payType = getPayType(modal);
885    const months = Number(getMonthsSelected(modal) || 0);
886
887    const rassrNode = qs('.js-price-rassr', modal);
888    if (rassrNode) {
889      if (payType === 'installment' && months > 0) {
890        const installmentTotal = fullPrice * 1;
891        rassrNode.textContent = formatPrice(Math.ceil(installmentTotal / months));
892      } else {
893        rassrNode.textContent = formatPrice(fullPrice);
894      }
895    }
896  };
897
898  const rerenderAllModalPrices = () => {
899    qsa('.paymodal').forEach(renderModalPrices);
900  };
901
902  // ---------- card prices (register section) ----------
903  const renderCardPrice = (card) => {
904    if (!card) return;
905    const openBtn = qs('.paypack-open[data-modal]', card);
906    const pkgKey = openBtn && getModalPackageKey(openBtn.dataset.modal);
907    const pkg = pkgKey && getPackageData(pkgKey);
908    if (!pkg) return;
909
910    const currency = getCurrencySign();
911    const oldEl = qs('.register__option-prise-del', card);
912    const actualEl = qs('.register__option-prise-actual', card);
913    const instEl = qs('.js-installment-from', card);
914
915    if (oldEl && Number.isFinite(normalizeNumber(pkg.old))) {
916      oldEl.textContent = `${formatPrice(pkg.old)} ${currency}`;
917    }
918    if (actualEl && Number.isFinite(normalizeNumber(pkg.actual))) {
919      actualEl.textContent = `${formatPrice(pkg.actual)} ${currency}`;
920    }
921    if (instEl && Number.isFinite(normalizeNumber(pkg.actual))) {
922      const months = 9;
923      instEl.textContent = `от ${formatPrice(Math.ceil(pkg.actual / months))} ${currency}/мес`;
924    }
925  };
926
927  const rerenderAllCardPrices = () => {
928    qsa('#register .register__card').forEach(renderCardPrice);
929  };
930
931  // ---------- standard/advanced switcher (3rd card in #register and #format) ----------
932  const initPriceSwitchers = () => {
933    qsa('[data-price-switch]').forEach((card) => {
934      const btns = qsa('[data-switch]', card);
935      const openBtn = qs('.paypack-open[data-modal]', card);
936      if (!btns.length) return;
937
938      const apply = (mode) => {
939        btns.forEach((b) => b.classList.toggle('is-active', b.dataset.switch === mode));
940        if (openBtn) {
941          const modalId = mode === 'advanced' ? card.dataset.advancedModal : card.dataset.standardModal;
942          if (modalId) openBtn.dataset.modal = modalId;
943        }
944        renderCardPrice(card);
945      };
946
947      btns.forEach((b) => {
948        b.addEventListener('click', () => apply(b.dataset.switch));
949      });
950
951      apply('standard');
952    });
953  };
954
955  // ---------- injected css: preloader + step2 overlay (no layout shift) ----------
956  const ensureCss = () => {
957    if (qs('#paymodal-onlyradios-css')) return;
958    const s = document.createElement('style');
959    s.id = 'paymodal-onlyradios-css';
960    s.textContent = `
961.paymodal__dialog{ position:relative; }
962
963.paymodal__submitting{
964  position:absolute; inset:0; z-index:50;
965  display:none; align-items:center; justify-content:center;
966  background: rgb(var(--color-white-rgb) / .72);
967  backdrop-filter: blur(3px);
968}
969.paymodal.is-submitting .paymodal__submitting{ display:flex; }
970.paymodal__spinner{
971  width:34px; height:34px; border-radius:999px;
972  border:3px solid rgb(var(--color-black-rgb) / .18);
973  border-top-color: rgb(var(--color-black-rgb) / .55);
974  animation: paymodalSpin .8s linear infinite;
975}
976@keyframes paymodalSpin{ to{ transform:rotate(360deg);} }
977
978.paymodal__step--2{ display:none; }
979
980/* STEP2 OVERLAY (центр) */
981.paymodal.is-step2-soft .paymodal__step--1{
982  visibility:hidden;
983  pointer-events:none;
984}
985
986.paymodal.is-step2-soft .paymodal__step--2.is-active{
987  display:flex !important;
988  position:absolute !important;
989  inset:0 !important;
990  z-index:10 !important;
991  gap: 50px !important;
992
993  align-items:start !important;
994  justify-content:center !important;
995
996  padding:40px;
997  box-sizing:border-box;
998}
999
1000/* ограничиваем ширину контента, чтобы не разъезжалось */
1001.paymodal.is-step2-soft .paymodal__thx{
1002  width:100%;
1003}
1004
1005@media (max-width: 767px) {
1006  .paymodal.is-step2-soft .paymodal__step--2.is-active {
1007    gap: 0px !important;
1008  }
1009}
1010
1011/* ===== MOBILE SCROLL FIX + SCROLL HINTS ===== */
1012@media (max-width: 767px){
1013
1014  /* делаем модалку нормальным скролл-контейнером */
1015  .paymodal__dialog{
1016    max-height: 100dvh;
1017    overflow-y: auto;
1018    -webkit-overflow-scrolling: touch;
1019    overscroll-behavior: contain;
1020  }
1021
1022  /* шаг2 на мобиле фиксируем к viewport, чтобы не зависеть от проскролла шага1 */
1023  .paymodal.is-step2-soft .paymodal__step--2.is-active{
1024    position: fixed !important;
1025    inset: 0 !important;
1026    height: 100dvh !important;
1027    overflow-y: auto !important;
1028    -webkit-overflow-scrolling: touch;
1029    overscroll-behavior: contain;
1030    /* padding: 20px !important; */
1031  }
1032
1033  /* подсказки скролла (верх/низ) */
1034  .paymodal__scrollhint{
1035    position: relative;
1036  }
1037  .paymodal__scrollhint::before,
1038  .paymodal__scrollhint::after{
1039    content:"";
1040    position: sticky;
1041    left: 0;
1042    right: 0;
1043    height: 22px;
1044    pointer-events: none;
1045    z-index: 60;
1046    display: none;
1047  }
1048  .paymodal__scrollhint::before{
1049    top: 0;
1050    background: linear-gradient(to bottom, rgb(var(--color-white-rgb) / .92), rgb(var(--color-white-rgb) / 0));
1051  }
1052  .paymodal__scrollhint::after{
1053    bottom: 0;
1054    background: linear-gradient(to top, rgb(var(--color-white-rgb) / .92), rgb(var(--color-white-rgb) / 0));
1055  }
1056
1057  .paymodal__scrollhint.has-topfade::before{ display:block; }
1058  .paymodal__scrollhint.has-botfade::after{ display:block; }
1059}
1060
1061
1062`;
1063    document.head.appendChild(s);
1064  };
1065
1066  const ensureSubmittingOverlay = (modal) => {
1067    ensureCss();
1068    const dialog = qs('.paymodal__dialog', modal) || modal;
1069    if (qs('.paymodal__submitting', dialog)) return;
1070    const wrap = document.createElement('div');
1071    wrap.className = 'paymodal__submitting';
1072    wrap.innerHTML = `<div class="paymodal__spinner" aria-label="Обработка"></div>`;
1073    dialog.appendChild(wrap);
1074  };
1075
1076  const setSubmitting = (modal, on) => {
1077    if (!modal) return;
1078    ensureSubmittingOverlay(modal);
1079    modal.classList.toggle('is-submitting', !!on);
1080    modal.setAttribute('aria-busy', on ? 'true' : 'false');
1081  };
1082
1083  // ---------- helpers: mobile + scroll hints ----------
1084  const isMobile = () => window.matchMedia('(max-width: 767px)').matches;
1085
1086  const getStep2 = (modal) => qs('.paymodal__step--2', modal);
1087  const getStep1 = (modal) => qs('.paymodal__step--1', modal);
1088
1089  const pickScrollEl = (modal, stepEl) => {
1090    if (!stepEl) return null;
1091    if (stepEl === getStep2(modal) && isMobile()) return stepEl;
1092    return qs('.paymodal__dialog', modal) || stepEl;
1093  };
1094
1095  const updateScrollHint = (scrollEl) => {
1096    if (!scrollEl) return;
1097
1098    const max = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
1099    const y = scrollEl.scrollTop;
1100
1101    scrollEl.classList.add('paymodal__scrollhint');
1102
1103    if (max <= 2) {
1104      scrollEl.classList.remove('has-topfade', 'has-botfade');
1105      return;
1106    }
1107
1108    scrollEl.classList.toggle('has-topfade', y > 2);
1109    scrollEl.classList.toggle('has-botfade', y < max - 2);
1110  };
1111
1112  const bindScrollHint = (scrollEl) => {
1113    if (!scrollEl || scrollEl.__pmHintBound) return;
1114    scrollEl.__pmHintBound = true;
1115
1116    const onScroll = () => updateScrollHint(scrollEl);
1117    scrollEl.addEventListener('scroll', onScroll, { passive: true });
1118
1119    requestAnimationFrame(onScroll);
1120    setTimeout(onScroll, 50);
1121  };
1122
1123  const showStep2Soft = (modal, on = true) => {
1124    modal.classList.toggle('is-step2-soft', !!on);
1125    qs('.paymodal__step--2', modal)?.classList.toggle('is-active', !!on);
1126    qs('.paymodal__step--1', modal)?.classList.toggle('is-active', !on);
1127
1128    if (on) {
1129      const step2 = getStep2(modal);
1130      const scrollEl2 = pickScrollEl(modal, step2);
1131
1132      if (scrollEl2) scrollEl2.scrollTop = 0;
1133
1134      bindScrollHint(scrollEl2);
1135      updateScrollHint(scrollEl2);
1136    } else {
1137      const step1 = getStep1(modal);
1138      const scrollEl1 = pickScrollEl(modal, step1);
1139
1140      bindScrollHint(scrollEl1);
1141      updateScrollHint(scrollEl1);
1142    }
1143  };
1144
1145  // ---------- months block + title ----------
1146  const getMonthsWrap = (modal) => {
1147    const node = qs('[data-months]', modal);
1148    if (!node) return null;
1149    return node.closest('.paymodal__box, .paymodal__group, .paymodal__radrow, .paymodal__section, .paymodal__card') || node;
1150  };
1151
1152  const toggleInstallmentTitle = (modal, on) => {
1153    qsa('*', modal).forEach((el) => {
1154      if (!el || el.children.length) return;
1155      const t = (el.textContent || '').trim().toLowerCase();
1156      if (t === 'срок рассрочки') el.style.display = on ? '' : 'none';
1157    });
1158  };
1159
1160  const setMonthsBlockVisible = (modal, on) => {
1161    const wrap = getMonthsWrap(modal);
1162    if (wrap) {
1163      wrap.style.display = on ? '' : 'none';
1164      wrap.setAttribute('aria-hidden', on ? 'false' : 'true');
1165    }
1166    toggleInstallmentTitle(modal, on);
1167  };
1168
1169  const clearMonthsSelection = (modal) => {
1170    qsa('[data-months] input[type="radio"]', modal).forEach(r => { r.checked = false; });
1171  };
1172
1173  // ---------- collect only radios ----------
1174  const getCurrency = (modal) => {
1175    const checked = modal.querySelector('.paymodal__radrow input[name^="cur_"]:checked');
1176    if (!checked) return '';
1177    const t = (checked.closest('label')?.innerText || '').toLowerCase();
1178    if (t.includes('евро')) return 'eur';
1179    if (t.includes('руб')) return 'rub';
1180    if (t.includes('крип')) return 'crypto';
1181    return '';
1182  };
1183
1184  const getPayType = (modal) => {
1185    return modal.querySelector('.paymodal__seg-btn.is-active[data-paymode]')?.dataset?.paymode || 'installment';
1186  };
1187
1188  const getMonthsSelected = (modal) => {
1189    return modal.querySelector('[data-months] input[type="radio"]:checked')?.dataset?.month || '';
1190  };
1191
1192  const collectRadiosOnly = (modal) => {
1193    const pay_type = getPayType(modal);
1194    const months = pay_type === 'installment' ? getMonthsSelected(modal) : '';
1195    const currency = getCurrency(modal);
1196    return { pay_type, months, currency };
1197  };
1198
1199  const sendToFdRadiosOnly = (modal) => {
1200    const iframe = getFdIframe(modal);
1201    if (!iframe?.contentWindow) return false;
1202
1203    const payload = collectRadiosOnly(modal);
1204    iframe.contentWindow.postMessage({ type: 'paymodal-data', payload }, '*');
1205    log('-> FD paymodal-data (RADIOS ONLY)', payload);
1206    return true;
1207  };
1208
1209  const scheduleSync = (modal) => {
1210    let tries = 0;
1211    const maxTries = 40;
1212    const t = setInterval(() => {
1213      tries += 1;
1214      const ok = sendToFdRadiosOnly(modal);
1215      if (ok || tries >= maxTries) clearInterval(t);
1216    }, 250);
1217  };
1218
1219  // ---------- pay ui (ONLY) ----------
1220  const setMonths = (month) => {
1221    if (typeof window.setRassrMonths === 'function') {
1222      const n = parseInt(month, 10);
1223      if (!Number.isNaN(n)) window.setRassrMonths(n);
1224    }
1225  };
1226
1227  const setPayMode = (root, mode) => {
1228    qsa('[data-paymode]', root).forEach(btn => {
1229      btn.classList.toggle('is-active', btn.dataset.paymode === mode);
1230    });
1231
1232    qsa('.payment-block', root).forEach(b => {
1233      b.classList.toggle('active', b.dataset.name === mode);
1234    });
1235
1236    const modal = root.closest('.paymodal');
1237    if (!modal) return;
1238
1239    if (mode === 'full') {
1240      setMonthsBlockVisible(modal, false);
1241      clearMonthsSelection(modal);
1242      if (typeof window.setRassrMonths === 'function') window.setRassrMonths('');
1243    } else {
1244      setMonthsBlockVisible(modal, true);
1245      const checked = qs('[data-months] input[type="radio"]:checked', modal);
1246      if (!checked) {
1247        const r5 = qs('[data-months] input[type="radio"][data-month="5"]', modal);
1248        if (r5) r5.checked = true;
1249        setMonths(5);
1250      } else {
1251        setMonths(checked.dataset.month);
1252      }
1253    }
1254
1255    renderModalPrices(modal);
1256    sendToFdRadiosOnly(modal);
1257  };
1258
1259  // ---------- modal lifecycle ----------
1260  const resetModal = (modal) => {
1261    showStep2Soft(modal, false);
1262    setSubmitting(modal, false);
1263
1264    const root = qs('[data-pay-root]', modal);
1265    if (root) {
1266      setPayMode(root, 'installment');
1267      const r5 = qs('[data-months] input[type="radio"][data-month="5"]', root);
1268      if (r5) r5.checked = true;
1269      setMonths(5);
1270      setMonthsBlockVisible(modal, true);
1271    }
1272
1273    renderModalPrices(modal);
1274
1275    const s1 = pickScrollEl(modal, getStep1(modal));
1276    bindScrollHint(s1);
1277    updateScrollHint(s1);
1278  };
1279
1280  const openModal = (id) => {
1281    const modal = document.getElementById(id);
1282    if (!modal) return;
1283
1284    resetModal(modal);
1285
1286    modal.classList.add('is-open');
1287    modal.setAttribute('aria-hidden', 'false');
1288    lockScroll(true);
1289
1290    scheduleSync(modal);
1291    renderModalPrices(modal);
1292
1293    const s1 = pickScrollEl(modal, getStep1(modal));
1294    bindScrollHint(s1);
1295    updateScrollHint(s1);
1296  };
1297
1298  const closeModal = (modal) => {
1299    showStep2Soft(modal, false);
1300    modal.classList.remove('is-open');
1301    modal.setAttribute('aria-hidden', 'true');
1302    lockScroll(false);
1303  };
1304
1305  // ---------- open/close ----------
1306  document.addEventListener('click', (e) => {
1307    const openBtn = e.target.closest('.paypack-open');
1308    if (openBtn) {
1309      openModal(openBtn.dataset.modal);
1310      return;
1311    }
1312
1313    const closeBtn = e.target.closest('[data-close]');
1314    if (closeBtn) {
1315      const modal = closeBtn.closest('.paymodal');
1316      if (modal) closeModal(modal);
1317      return;
1318    }
1319  });
1320
1321  document.addEventListener('keydown', (e) => {
1322    if (e.key !== 'Escape') return;
1323    qsa('.paymodal.is-open').forEach(closeModal);
1324  });
1325
1326  // ---------- inside modal: ONLY radio controls ----------
1327  qsa('.paymodal [data-pay-root]').forEach((root) => {
1328    root.addEventListener('click', (e) => {
1329      const b = e.target.closest('[data-paymode]');
1330      if (!b) return;
1331
1332      e.preventDefault();
1333      setPayMode(root, b.dataset.paymode);
1334    });
1335
1336    root.addEventListener('change', (e) => {
1337      const modal = root.closest('.paymodal');
1338      if (!modal) return;
1339
1340      const r = e.target.closest('[data-months] input[type="radio"][data-month]');
1341      if (r) {
1342        setMonths(r.dataset.month);
1343        renderModalPrices(modal);
1344        sendToFdRadiosOnly(modal);
1345        return;
1346      }
1347
1348      if (e.target.closest('.paymodal__radrow')) {
1349        renderModalPrices(modal);
1350        sendToFdRadiosOnly(modal);
1351      }
1352    });
1353  });
1354
1355  // ---------- FD events: invalid/submit/success (tokenized) ----------
1356  let pmTimer = null;
1357  let pmToken = 0;
1358
1359  window.addEventListener('message', (e) => {
1360    const d = e.data;
1361    if (!d || !d.type) return;
1362
1363    const origin = String(e.origin || '');
1364    const isFromFD = origin.includes('formdesigner.');
1365    if (!isFromFD && !(isDebug() && origin === window.location.origin)) return;
1366
1367    const modal = getOpenModal();
1368    if (!modal) return;
1369
1370    const token = Number(d.payload?.token || d.token || 0);
1371
1372    if (d.type === 'fd-form-invalid') {
1373      clearTimeout(pmTimer);
1374      setSubmitting(modal, false);
1375      showStep2Soft(modal, false);
1376      return;
1377    }
1378
1379    if (d.type === 'fd-form-submit') {
1380      // Если предыдущий сабмит так и не получил ответа (invalid/success),
1381      // оверлей "Обработка..." завис — сразу снимаем его вместо повторного
1382      // 20-секундного ожидания, чтобы под ним стала видна ошибка валидации.
1383      if (modal.classList.contains('is-submitting')) {
1384        clearTimeout(pmTimer);
1385        setSubmitting(modal, false);
1386        showStep2Soft(modal, false);
1387        return;
1388      }
1389
1390      pmToken = token || (pmToken + 1);
1391
1392      setSubmitting(modal, true);
1393      clearTimeout(pmTimer);
1394
1395      pmTimer = setTimeout(() => {
1396        setSubmitting(modal, false);
1397        showStep2Soft(modal, false);
1398      }, 3000);
1399
1400      return;
1401    }
1402
1403    if (d.type === 'fd-form-success') {
1404      if (token && pmToken && token !== pmToken) return;
1405
1406      clearTimeout(pmTimer);
1407      setSubmitting(modal, false);
1408      showStep2Soft(modal, true);
1409      return;
1410    }
1411  }, false);
1412
1413  rerenderAllModalPrices();
1414  rerenderAllCardPrices();
1415  initPriceSwitchers();
1416
1417})();
1418
1419
1420/* =================================== */
1421/* Danylo Oliinyk - https://iamdev.lol/ */
1422/* =====October 2025 For Statoplan =====*/
1423/* =================================== */
1424/* =================================== */
1425/* =================================== */

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.