PageSourceSearch

https://arenda-attrakcionov.ru/js/catalog2026.js?v=tlrh7e

js arenda-attrakcionov.ru collected 2026-10-08 07:27:05 UTC 52,214 bytes, 1,016 lines download raw bytes

1/*
2 * Каталог 2026 — JS нового дизайна страницы категории.
3 * Инициализируется только если на странице есть .catalog2026.
4 */
5(function () {
6    var root = document.querySelector('.catalog2026');
7    if (!root) return;
8
9    // Класс на body — для серого фона и других page-level стилей,
10    // если :has() недоступен.
11    document.body.classList.add('catalog2026-page');
12
13    // Бэйдж с количеством товаров рядом с H1 — количество приходит
14    // из инлайн-скрипта window.Catalog2026 (см. modules/category.php).
15    var data = window.Catalog2026 || {};
16    var h1 = root.querySelector('.catalog-main-title');
17
18    if (h1 && typeof data.itemsCount === 'number' && data.itemsCount > 0) {
19        h1.setAttribute('data-count', String(data.itemsCount));
20    }
21
22    // Линки «Посмотреть видео» / «Скачать презентацию» в .top-links справа от H1.
23    if (h1 && (data.videoUrl || data.pdfUrl)) {
24        var top = document.createElement('div');
25        top.className = 'catalog2026-top';
26        var topLinks = document.createElement('div');
27        topLinks.className = 'catalog2026-top-links';
28
29        if (data.videoUrl) {
30            var videoLink = document.createElement('a');
31            videoLink.href = data.videoUrl;
32            videoLink.setAttribute('data-fancybox', 'catalog2026-video');
33            videoLink.setAttribute('target', '_blank');
34            videoLink.className = 'catalog2026-top-link';
35            videoLink.innerHTML =
36                '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="12" viewBox="0 0 17 12" fill="none">' +
37                '<path d="M11.5333 4.03353L13.6806 2.74513C14.3416 2.34856 14.672 2.15027 14.944 2.17562C15.1811 2.19772 15.3974 2.3202 15.5383 2.51214C15.7 2.73226 15.7 3.11766 15.7 3.88845V7.51194C15.7 8.28273 15.7 8.66813 15.5383 8.88825C15.3974 9.08019 15.1811 9.20267 14.944 9.22477C14.672 9.25012 14.3416 9.05183 13.6806 8.65527L11.5333 7.36686M3.36662 10.7002H8.86662C9.80004 10.7002 10.2667 10.7002 10.6233 10.5185C10.9369 10.3588 11.1918 10.1038 11.3516 9.79018C11.5333 9.43366 11.5333 8.96695 11.5333 8.03353V3.36686C11.5333 2.43344 11.5333 1.96673 11.3516 1.61021C11.1918 1.29661 10.9369 1.04164 10.6233 0.881851C10.2667 0.700195 9.80004 0.700195 8.86662 0.700195H3.36662C2.4332 0.700195 1.96649 0.700195 1.60997 0.881851C1.29636 1.04164 1.0414 1.29661 0.881607 1.61021C0.699951 1.96673 0.699951 2.43344 0.699951 3.36686V8.03353C0.699951 8.96695 0.699951 9.43366 0.881607 9.79018C1.0414 10.1038 1.29636 10.3588 1.60997 10.5185C1.96649 10.7002 2.4332 10.7002 3.36662 10.7002Z" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' +
38                '</svg>' +
39                '<span class="c26-tl-full">Посмотреть видео</span><span class="c26-tl-short">Видео</span>';
40            topLinks.appendChild(videoLink);
41        }
42
43        if (data.pdfUrl) {
44            var pdfLink = document.createElement('a');
45            pdfLink.href = data.pdfUrl;
46            pdfLink.setAttribute('target', '_blank');
47            pdfLink.className = 'catalog2026-top-link';
48            pdfLink.innerHTML =
49                '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">' +
50                '<path d="M6.00004 11.3333H10M6.00004 8.66667H10M6.00004 6H6.66671M8.66671 2H5.46671C4.71997 2 4.3466 2 4.06139 2.14532C3.8105 2.27316 3.60653 2.47713 3.4787 2.72801C3.33337 3.01323 3.33337 3.3866 3.33337 4.13333V11.8667C3.33337 12.6134 3.33337 12.9868 3.4787 13.272C3.60653 13.5229 3.8105 13.7268 4.06139 13.8547C4.3466 14 4.71997 14 5.46671 14H10.5334C11.2801 14 11.6535 14 11.9387 13.8547C12.1896 13.7268 12.3936 13.5229 12.5214 13.272C12.6667 12.9868 12.6667 12.6134 12.6667 11.8667V6M8.66671 2L12.6667 6M8.66671 2V4.93333C8.66671 5.3067 8.66671 5.49339 8.73937 5.63599C8.80329 5.76144 8.90527 5.86342 9.03071 5.92734C9.17332 6 9.36001 6 9.73337 6H12.6667" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>' +
51                '</svg>' +
52                '<span class="c26-tl-full">Скачать презентацию</span><span class="c26-tl-short">Презентация</span>';
53            topLinks.appendChild(pdfLink);
54        }
55
56        h1.parentNode.insertBefore(top, h1);
57        top.appendChild(h1);
58        top.appendChild(topLinks);
59    }
60
61    // Сайдбар: если подкатегорий больше 5 — добавляем кнопку «Показать всё (N)».
62    // Скрытие хвоста делает CSS (:nth-of-type(n+6)) — это исключает мигание на рендере.
63    // .category__place-button («Площадки для мероприятий») — это не подкатегория, считать его Ð
63½Ðµ надо.
64    //
65    // Содержимое сайдбара меняется на лету: клик по «< Каталог»
66    // (header c inline-onclick `$('#catalog_sidebar').html(leftMenuAjax)`)
67    // заменяет ссылки на родительские категории Parent=0. Кнопку при этом
68    // сносит вместе с innerHTML, поэтому навешиваем MutationObserver и
69    // пересоздаём кнопку при любой подмене содержимого.
70    var sidebarLinks = root.querySelector('.cm-catalog__sidebar-links');
71    if (sidebarLinks) {
72        var VISIBLE_LIMIT = 5;
73
74        function applyShowMore() {
75            var existing = sidebarLinks.querySelector(':scope > .catalog2026-show-more');
76            if (existing) existing.remove();
77
78            var placeBtn = sidebarLinks.querySelector(':scope > a.category__place-button');
79            // Пункты кастомного меню (/admin/categories_menu.php) приходят обёрнутыми
80            // в <noindex>, поэтому вторым селектором добираем и их.
81            var links = sidebarLinks.querySelectorAll(':scope > a.cm-catalog__sidebar-link:not(.category__place-button), :scope > noindex > a.cm-catalog__sidebar-link');
82            if (links.length <= VISIBLE_LIMIT) return;
83
84            var hiddenCount = links.length - VISIBLE_LIMIT;
85            var moreBtn = document.createElement('button');
86            moreBtn.type = 'button';
87            moreBtn.className = 'catalog2026-show-more';
88            moreBtn.innerHTML =
89                '<span>Показать всё (' + hiddenCount + ')</span>' +
90                '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6" fill="none">' +
91                '<path d="M0.599976 0.599609L4.59998 4.59961L8.59998 0.599609" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>' +
92                '</svg>';
93
94            if (placeBtn) {
95                sidebarLinks.insertBefore(moreBtn, placeBtn);
96            } else {
97                sidebarLinks.appendChild(moreBtn);
98            }
99
100            moreBtn.addEventListener('click', function () {
101                var expanded = sidebarLinks.classList.toggle('catalog2026-expanded');
102                moreBtn.querySelector('span').textContent = expanded ? 'Свернуть' : 'Показать всё (' + hiddenCount + ')';
103            });
104        }
105
106        applyShowMore();
107
108        // Inline-onclick «< Каталог» в шаблоне дёргает
109        // `$('#catalog_sidebar').html(leftMenuAjax)` — подменяет содержимое
110        // сайдбара родительскими категориями Parent=0. Пересчитываем кнопку
111        // в microtask после обработчика (queueMicrotask отрабатывает после
112        // inline-onclick, но до возврата в event loop).
113        var ajaxTrigger = root.querySelector('.cm-catalog__sidebar-head[onclick*="leftMenuAjax"]');
114        if (ajaxTrigger) {
115            ajaxTrigger.addEventListener('click', function () {
116                queueMicrotask(function () {
117                    sidebarLinks.classList.remove('catalog2026-expanded');
118                    applyShowMore();
119                });
120            });
121        }
122    }
123
124    // Hover-галерея на карточках листинга.
125    // Поверх картинки — N невидимых вертикальных «зон» (.hover-gallery-item).
126    // При наведении на N-ю зону показываем N-ю картинку (свап через .active).
127    // Уходим с карточки — возвращаем первую.
128    // Вынесено в функцию + идемпотентно (флаг hgReady), чтобы переинициализировать
129    // карточки, догруженные через «показать ещё»/фильтры (вызов из js/add.js).
130    function initHoverGallery(scope) {
131        (scope || root).querySelectorAll('.hover-images-box').forEach(function (box) {
132        if (box.dataset.hgReady) return;
133        var images = box.querySelectorAll('.hover-gallery-img');
134        if (images.length < 2) return;
135        box.dataset.hgReady = '1';
136
137        var parent = box.closest('.hover-gallery-parent') || box;
138
139        var gallery = document.createElement('div');
140        gallery.className = 'hover-gallery';
141        var dots = document.createElement('div');
142        dots.className = 'hover-gallery-dots';
143
144        for (var i = 0; i < images.length; i++) {
145            var item = document.createElement('div');
146            item.className = 'hover-gallery-item';
147            item.dataset.index = i;
148            gallery.appendChild(item);
149
150            var dot = document.createElement('div');
151            dot.className = 'hover-gallery-item-dot' + (i === 0 ? ' active' : '');
152            dots.appendChild(dot);
153        }
154
155        box.appendChild(gallery);
156        box.appendChild(dots);
157
158        gallery.addEventListener('mouseover', function (e) {
159            var item = e.target.closest('.hover-gallery-item');
160            if (!item) return;
161            var idx = parseInt(item.dataset.index, 10);
162            images.forEach(function (img) { img.classList.remove('active'); });
163            images[idx].classList.add('active');
164            dots.querySelectorAll('.hover-gallery-item-dot').forEach(function (d) { d.classList.remove('active'); });
165            dots.children[idx].classList.add('active');
166        });
167
168        parent.addEventListener('mouseleave', function () {
169            images.forEach(function (img) { img.classList.remove('active'); });
170            images[0].classList.add('active');
171            dots.querySelectorAll('.hover-gallery-item-dot').forEach(function (d) { d.classList.remove('active'); });
172            dots.children[0].classList.add('active');
173        });
174        });
175    }
176
177    initHoverGallery(root);
178    window.catalog2026InitHoverGallery = initHoverGallery;
179
180    // Карточки, догруженные через «показать ещё»/фильтры, приходят jQuery-ajax'ом
181    // ($('#loading_content').load(...) в js/add.js или js/2024/add.js — какой именно
182    // грузится, зависит от каркаса). Вместо хука в конкретный show-more вешаемся на
183    // глобальный ajaxComplete: он срабатывает после любого .load(), карточки уже
184    // в DOM. initHoverGallery идемпотентна (флаг hgReady) — лишних оверлеев не будет.
185    if (window.jQuery) {
186        window.jQuery(document).ajaxComplete(function () {
187            initHoverGallery();
188        });
189    }
190
191    // === Маска телефона +7 (xxx) xxx-xx-xx ===
192    // Применяется ко всем input[data-c26-phone-mask] внутри .catalog2026.
193    // При вводе вытаскивает только цифры (макс 10) и форматирует.
194    // Backspace корректно стирает разделители вместе с предыдущей цифрой.
195    function formatPhone(digits) {
196        digits = digits.slice(0, 10);
197        var out = '+7';
198        if (digits.length === 0) return '';
199        out += ' (' + digits.slice(0, 3);
200        if (digits.length >= 3) out += ')';
201        if (digits.length > 3) out += ' ' + digits.slice(3, 6);
202        if (digits.length > 6) out += '-' + digits.slice(6, 8);
203        if (digits.length > 8) out += '-' + digits.slice(8, 10);
204        return out;
205    }
206
207    root.querySelectorAll('input[data-c26-phone-mask]').forEach(function (input) {
208        // Backspace по разделителю — сдвигаем курсор за все не-цифры назад,
209        // чтобы дефолтный backspace удалил цифру, а не "(", ")", "-".
210        // Без этого reformat ниже возвращает разделитель и кажется, что ничего не стёрлось.
211        input.addEventListener('keydown', function (e) {
212            if (e.key !== 'Backspace') return;
213            if (input.selectionStart !== input.selectionEnd) return;
214            var pos = input.selectionStart;
215            if (pos === 0) return;
216            var v = input.value;
217            if (/\d/.test(v[pos - 1])) return;
218            var skip = 0;
219            while (pos - 1 - skip > 0 && !/\d/.test(v[pos - 1 - skip])) {
220                skip++;
221            }
222            input.setSelectionRange(pos - skip, pos - skip);
223        });
224
225        input.addEventListener('input', function () {
226            var digits = input.value.replace(/\D/g, '');
227            if (digits.length === 0) {
228                input.value = '';
229                return;
230            }
231            // обрезаем ведущую 7 или 8 если есть — нумер начинаем с (xxx)
232            if (digits[0] === '7' || digits[0] === '8') {
233                digits = digits.slice(1);
234            }
235            input.value = formatPhone(digits);
236        });
237
238        input.addEventListener('blur', function () {
239            // если осталось только "+7 (" или "+7" — очищаем, чтобы placeholder вернулся
240            if (input.value === '+7 (' || input.value === '+7') {
241                input.value = '';
242            }
243        });
244    });
245
246    // Capture-phase валидатор. Перехватывает submit ДО legacy jQuery-обработчика
247    // и блокирует отправку, если в телефоне меньше 10 цифр. Legacy JS делал
248    // `return;
248` без preventDefault — форма уходила POST'ом на action="#".
249    root.querySelectorAll('.order_form_submit').forEach(function (btn) {
250        btn.addEventListener('click', function (e) {
251            var form = btn.closest('form');
252            if (!form) return;
253            var phone = form.querySelector('input[name="phone"]');
254            var digits = phone ? phone.value.replace(/\D/g, '') : '';
255            if (digits.length < 10) {
256                e.preventDefault();
257                e.stopPropagation();
258                if (phone) phone.focus();
259            }
260        }, true);
261    });
262
263    // Попап «Спросить письменно» из врезки рядом с FAQ. Сам FAQ раскрывается
264    // нативными details — скрипт ему не нужен.
265    (function initAskModal() {
266        var modal = root.querySelector('[data-c26-ask-modal]');
267        if (!modal) return;
268
269        function close() {
270            modal.hidden = true;
271            document.documentElement.classList.remove('fixed');
272        }
273
274        root.querySelectorAll('[data-c26-ask-open]').forEach(function (btn) {
275            btn.addEventListener('click', function () {
276                modal.hidden = false;
277                document.documentElement.classList.add('fixed');
278                var phone = modal.querySelector('input[name="phone"]');
279                if (phone) phone.focus();
280            });
281        });
282
283        modal.addEventListener('click', function (e) {
284            if (e.target === modal || e.target.closest('[data-c26-ask-close]')) close();
285        });
286
287        document.addEventListener('keydown', function (e) {
288            if (e.key === 'Escape' && !modal.hidden) close();
289        });
290    })();
291
292    // ===== Мобильный попап-сайдбар с фильтрами =====
293    (function initMobileFilterPopup() {
294        var sidebar = root.querySelector('.cm-catalog__sidebar-wrapper');
295        if (!sidebar) return;
296        // Шапка попапа (только видна на мобайле)
297        if (!sidebar.querySelector('.c26-popup-head')) {
298            var head = document.createElement('div');
299            head.className = 'c26-popup-head';
300            head.innerHTML = '<div class="c26-popup-head-title">Фильтры</div>'
301                + '<button type="button" class="c26-popup-close" aria-label="Закрыть">'
302                + '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 5L15 15M15 5L5 15" stroke="#312D2B" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
303                + '</button>';
304            sidebar.insertBefore(head, sidebar.firstChild);
305        }
306        // Backdrop
307        if (!root.querySelector('.c26-filter-backdrop')) {
308            var bd = document.createElement('div');
309            bd.className = 'c26-filter-backdrop';
310            root.appendChild(bd);
311        }
312        function open()  { document.body.classList.add('c26-filter-popup-open'); }
313        function close() { document.body.classList.remove('c26-filter-popup-open'); }
314
315        root.addEventListener('click', function (e) {
316            if (e.target.closest('.c26-mobile-filter-btn')) { open(); return; }
317            if (e.target.closest('.c26-popup-close')) { close(); return; }
318            if (e.target.classList.contains('c26-filter-backdrop')) { close(); return; }
319        });
320        document.addEventListener('keydown', function (e) {
321            if (e.key === 'Escape') close();
322        });
323    })();
324
325    // ===== Smart sticky sidebar через theia-sticky-sidebar =====
326    // Готовая библиотека с Lamoda-like поведением: короткий сайдбар прилипает
327    // к верху, длинный едет с контентом и плавно переключается на bottom-pin
328    // при скролле вниз и top-pin при скролле вверх.
329    (function loadStickySidebar() {
330        if (!root.querySelector('.cm-catalog__sidebar-wrapper')) return;
331        if (window.theiaStickySidebar_loaded) {
332            initStickyJQ();
333            return;
334        }
335        var s = document.createElement('script');
336        s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/theia-sticky-sidebar.min.js';
337        s.onload = function () {
338            window.theiaStickySidebar_loaded = true;
339            initStickyJQ();
340        };
341        document.head.appendChild(s);
342    })();
343
344    function initStickyJQ() {
345        if (!window.jQuery) return;
346        // На мобиле sticky не нужен — сайдбар работает как slide-in попап
347        if (window.innerWidth <= 768) return;
348        jQuery('.catalog2026 .cm-catalog__sidebar-wrapper, .catalog2026 .cm-catalog__content').theiaStickySidebar({
349            additionalMarginTop: 100,
350            additionalMarginBottom: 20,
351            containerSelector: '.catalog2026 .cm-catalog__inner',
352            updateSidebarHeight: false
353        });
354    }
355
356    //===== Фильтры (price + color) — серверная фильтрация через GET =====
357    (function initFilters() {
358        // Фильтры теперь разнесены на 2 .sidebar-box-filters (цена / остальное),
359        // поэтому скоупимся не к одному боксу, а к root.
360        var box = root.querySelector('.sidebar-box-filters');
361        if (!box) return;
362
363        var pInner = root.querySelector('.price-filter-inner');
364        var minRange = root.querySelector('.c26-price-min');
365        var maxRange = root.querySelector('.c26-price-max');
366        var minInput = root.querySelector('.c26-price-min-input');
367        var maxInput = root.querySelector('.c26-price-max-input');
368        var rangeFill = pInner ? pInner.querySelector('.slider-range') : null;
369        var colorInputs = root.querySelectorAll('.c26-color-input');
370        var resetBtn = root.querySelector('.c26-filter-reset');
371
372        var pMin = pInner ? parseInt(pInner.dataset.c26Price.split('|')[0], 10) || 0 : 0;
373        var pMax = pInner ? parseInt(pInner.dataset.c26Price.split('|')[1], 10) || 0 : 0;
374        var pStep = pInner ? parseInt(pInner.dataset.c26Step, 10) || 100 : 100;
375
376        function updateFill() {
377            if (!rangeFill || pMax === pMin) return;
378            var lo = parseInt(minRange.value, 10);
379            var hi = parseInt(maxRange.value, 10);
380            rangeFill.style.left = ((lo - pMin) / (pMax - pMin) * 100) + '%';
381            rangeFill.style.right = (100 - (hi - pMin) / (pMax - pMin) * 100) + '%';
382        }
383
384        // Сборка URL с актуальным состоянием фильтров и AJAX-подмена листинга
385        var submitTimer = null;
386        var listingEl = root.querySelector('.listing__items') || document.querySelector('.listing__items');
387        var pagesLinksEl = document.getElementById('pages_links');
388        var navBlockEl = document.querySelector('.navigation-block');
389        var titleEl = document.querySelector('.catalog-main-title');
390
391        var paramInputs = root.querySelectorAll('.c26-param-input');
392
393        // Параметры-диапазоны (числовые слайдеры)
394        var paramRanges = [];
395        root.querySelectorAll('.param-range-inner').forEach(function (rInner) {
396            var fid = rInner.dataset.fid;
397            var bnds = (rInner.dataset.c26Range || '').split('|');
398            paramRanges.push({
399                fid:      fid,
400                pMin:     parseFloat(bnds[0]) || 0,
401                pMax:     parseFloat(bnds[1]) || 0,
402                step:     parseInt(rInner.dataset.c26Step, 10) || 1,
403                minRange: rInner.querySelector('.c26-param-range-min'),
404                maxRange: rInner.querySelector('.c26-param-range-max'),
405                minInput: rInner.querySelector('.c26-param-range-min-input'),
406                maxInput: rInner.querySelector('.c26-param-range-max-input'),
407                fill:     rInner.querySelector('.slider-range')
408            });
409        });
410
411        function buildUrl() {
412            var params = new URLSearchParams();
413            new URLSearchParams(window.location.search).forEach(function (v, k) {
414                if (k === 'pmin' || k === 'pmax' || k === 'color[]' || k === 'color') return;
415                if (/^f\d+(\[\])?$/.test(k)) return;
416                if (/^f\d+_(min|max)$/.test(k)) return;
417                params.append(k, v);
418            });
419            var lo = minRange ? parseInt(minRange.value, 10) : pMin;
420            var hi = maxRange ? parseInt(maxRange.value, 10) : pMax;
421            if (lo > pMin) params.set('pmin', lo);
422            if (hi < pMax) params.set('pmax', hi);
423            colorInputs.forEach(function (cb) {
424                if (cb.checked) params.append('color[]', cb.value);
425            });
426            paramInputs.forEach(function (cb) {
427                if (cb.checked) params.append('f' + cb.dataset.fid + '[]', cb.value);
428            });
429            paramRanges.forEach(function (pr) {
430                var lo = parseFloat(pr.minRange.value);
431                var hi = parseFloat(pr.maxRange.value);
432                if (lo > pr.pMin) params.set('f' + pr.fid + '_min', lo);
433                if (hi < pr.pMax) params.set('f' + pr.fid + '_max', hi);
434            });
435            var qs = params.toString();
436            return window.location.pathname + (qs ? '?' + qs : '');
437        }
438
439        function submitFilters(delay) {
440            clearTimeout(submitTimer);
441            submitTimer = setTimeout(function () {
442                var url = buildUrl();
443                if (listingEl) listingEl.classList.add('c26-loading');
444                fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
445                    .then(function (r) { return r.text(); })
446                    .then(function (html) {
447                        var parser = new DOMParser();
448                        var doc = parser.parseFromString(html, 'text/html');
449                        var newListing = doc.querySelector('.listing__items');
450                        var newPagesLinks = doc.getElementById('pages_links');
451                        var newNavBlock = doc.querySelector('.navigation-block');
452                        var newActiveFilters = doc.querySelector('.c26-active-filters');
453                        var activeFiltersEl = document.querySelector('.c26-active-filters');
454
455                        if (listingEl && newListing) listingEl.innerHTML = newListing.innerHTML;
456                        if (pagesLinksEl && newPagesLinks) pagesLinksEl.innerHTML = newPagesLinks.innerHTML;
457                        if (navBlockEl && newNavBlock) navBlockEl.innerHTML = newNavBlock.innerHTML;
458                        if (activeFiltersEl && newActiveFilters) activeFiltersEl.innerHTML = newActiveFilters.innerHTML;
459                        // itemsCount парсится из inline-script window.Catalog2026
460                        var m = html.match(/itemsCount:\s*(\d+)/);
461                        if (titleEl && m) titleEl.setAttribute('data-count', m[1]);
462
463                        history.pushState({}, '', url);
464                        if (listingEl) listingEl.classList.remove('c26-loading');
465                    })
466                    .catch(function () {
467                        window.location.href = url; // фолбэк — обычная перезагрузка
468                    });
469            }, delay || 0);
470        }
471
472        if (minRange && maxRange) {
473            function clamp() {
474                var lo = parseInt(minRange.value, 10);
475                var hi = parseInt(maxRange.value, 10);
476                if (lo > hi - pStep) {
477                    if (this === minRange) { minRange.value = hi - pStep; }
478                    else                   { maxRange.value = lo + pStep; }
479                }
480                if (minInput) minInput.value = minRange.value;
481                if (maxInput) maxInput.value = maxRange.value;
482                updateFill();
483            }
484            minRange.addEventListener('input', clamp);
485            maxRange.addEventListener('input', clamp);
486            // отправка только когда пользователь отпустил
487            minRange.addEventListener('change', function () { submitFilters(0); });
488            maxRange.addEventListener('change', function () { submitFilters(0); });
489        }
490
491        function syncFromInput() {
492            var lo = parseInt(minInput.value, 10);
493            var hi = parseInt(maxInput.value, 10);
494            if (isNaN(lo)) lo = pMin;
495            if (isNaN(hi)) hi = pMax;
496            lo = Math.max(pMin, Math.min(lo, pMax));
497            hi = Math.max(pMin, Math.min(hi, pMax));
498            if (lo > hi) { var t = lo; lo = hi; hi = t; }
499            minRange.value = lo;
500            maxRange.value = hi;
501            minInput.value = lo;
502            maxInput.value = hi;
503            updateFill();
504            submitFilters(0);
505        }
506        if (minInput) minInput.addEventListener('change', syncFromInput);
507        if (maxInput) maxInput.addEventListener('change', syncFromInput);
508
509        colorInputs.forEach(function (cb) {
510            cb.addEventListener('change', function () { submitFilters(0); });
511        });
512
513        paramInputs.forEach(function (cb) {
514            cb.addEventListener('change', function () { submitFilters(0); });
515        });
516
517        // Слайдеры числовых параметров
518        paramRanges.forEach(function (pr) {
519            pr.updFill = function () {
520                if (!pr.fill || pr.pMax === pr.pMin) return;
521                var lo = parseFloat(pr.minRange.value);
522                var hi = parseFloat(pr.maxRange.value);
523                pr.fill.style.left = ((lo - pr.pMin) / (pr.pMax - pr.pMin) * 100) + '%';
524                pr.fill.style.right = (100 - (hi - pr.pMin) / (pr.pMax - pr.pMin) * 100) + '%';
525            };
526            function clamp() {
527                var lo = parseFloat(pr.minRange.value);
528                var hi = parseFloat(pr.maxRange.value);
529                if (lo > hi - pr.step) {
530                    if (this === pr.minRange) pr.minRange.value = hi - pr.step;
531                    else                      pr.maxRange.value = lo + pr.step;
532                }
533                if (pr.minInput) pr.minInput.value = pr.minRange.value;
534                if (pr.maxInput) pr.maxInput.value = pr.maxRange.value;
535                pr.updFill();
536            }
537            pr.minRange.addEventListener('input', clamp);
538            pr.maxRange.addEventListener('input', clamp);
539            pr.minRange.addEventListener('change', function () { submitFilters(0); });
540            pr.maxRange.addEventListener('change', function () { submitFilters(0); });
541
542            function syncIn() {
543                var lo = parseFloat(pr.minInput.value);
544                var hi = parseFloat(pr.maxInput.value);
545                if (isNaN(lo)) lo = pr.pMin;
546                if (isNaN(hi)) hi = pr.pMax;
547                lo = Math.max(pr.pMin, Math.min(lo, pr.pMax));
548                hi = Math.max(pr.pMin, Math.min(hi, pr.pMax));
549                if (lo > hi) { var t = lo; lo = hi; hi = t; }
550                pr.minRange.value = lo;
551                pr.maxRange.value = hi;
552                pr.minInput.value = lo;
553                pr.maxInput.value = hi;
554                pr.updFill();
555                submitFilters(0);
556            }
557            if (pr.minInput) pr.minInput.addEventListener('change', syncIn);
558            if (pr.maxInput) pr.maxInput.addEventListener('change', syncIn);
559
560            pr.updFill();
561        });
562
563        // «Показать все» внутри каждого param-filter
564        root.querySelectorAll('.param-filter').forEach(function (pf) {
565            var moreBtn = pf.querySelector('.c26-param-more');
566            if (!moreBtn) return;
567            var hidden = Array.prototype.slice.call(pf.querySelectorAll('.param-label.c26-param-hidden'));
568            if (hidden.length === 0) { moreBtn.style.display = 'none'; return; }
569            moreBtn.addEventListener('click', function () {
570                var isOpen = moreBtn.classList.toggle('is-open');
571                hidden.forEach(function (el) {
572                    el.classList.toggle('c26-param-hidden', !isOpen);
573                });
574                var lbl = moreBtn.querySelector('span');
575                if (lbl) lbl.textContent = isOpen ? 'Скрыть' : 'Показать все';
576            });
577        });
578
579        // Спойлер диапазонных фильтров: заголовок раскрывает/прячет тело
580        root.querySelectorAll('.param-filter--collapsible .param-filter-head').forEach(function (head) {
581            head.addEventListener('click', function () {
582                var pf = head.closest('.param-filter--collapsible');
583                if (pf) pf.classList.toggle('is-open');
584            });
585        });
586
587        // «Показать все» / «Скрыть» цветов — тоггл
588        var colorMoreBtn = root.querySelector('.c26-color-more');
589        if (colorMoreBtn) {
590            // Запоминаем какие label'ы изначально были скрыты
591            var hiddenLabels = Array.prototype.slice.call(root.querySelectorAll('.color-label.c26-color-hidden'));
592            // Если уже всё видно (например, юзер с активным цветом за пределами топ-5) — кнопка не нужна
593            if (hiddenLabels.length === 0) {
594                colorMoreBtn.style.display = 'none';
595            } else {
596                // Стартуем со свёрнутого состояния, лейблы уже добавлены классом c26-color-hidden из PHP
597                colorMoreBtn.addEventListener('click', function () {
598                    var isOpen = colorMoreBtn.classList.toggle('is-open');
599                    hiddenLabels.forEach(function (el) {
600                        el.classList.toggle('c26-color-hidden', !isOpen);
601                    });
602                    var lbl = colorMoreBtn.querySelector('span');
603                    if (lbl) lbl.textContent = isOpen ? 'Скрыть' : 'Показать все';
604                });
605            }
606        }
607
608        function resetAll() {
609            if (minRange) minRange.value = pMin;
610            if (maxRange) maxRange.value = pMax;
611            if (minInput) minInput.value = pMin;
612            if (maxInput) maxInput.value = pMax;
613            colorInputs.forEach(function (cb) { cb.checked = false; });
614            paramInputs.forEach(function (cb) { cb.checked = false; });
615            paramRanges.forEach(function (pr) {
616                pr.minRange.value = pr.pMin;
617                pr.maxRange.value = pr.pMax;
618                if (pr.minInput) pr.minInput.value = pr.pMin;
619                if (pr.maxInput) pr.maxInput.value = pr.pMax;
620                if (pr.updFill) pr.updFill();
621            });
622            updateFill();
623            submitFilters(0);
624        }
625
626        if (resetBtn) resetBtn.addEventListener('click', resetAll);
627
628        // Делегирование клика по чипам активных фильтров и по reset-filters
629        // (блок пересоздаётся при AJAX-свапе, потому биндим на root).
630        root.addEventListener('click', function (e) {
631            var resetEl = e.target.closest('.reset-filters');
632            if (resetEl) { resetAll(); return; }
633            var chip = e.target.closest('.current-filter-label');
634            if (!chip) return;
635            var clear = chip.dataset.clear || '';
636            if (clear === 'price') {
637                if (minRange) minRange.value = pMin;
638                if (maxRange) maxRange.value = pMax;
639                if (minInput) minInput.value = pMin;
640                if (maxInput) maxInput.value = pMax;
641                updateFill();
642            } else if (clear.indexOf('color:') === 0) {
643                var cid = clear.substring(6);
644                colorInputs.forEach(function (cb) { if (cb.value === cid) cb.checked = false; });
645            } else if (/^f\d+_range$/.test(clear)) {
646                var fid = clear.replace(/^f|_range$/g, '');
647                paramRanges.forEach(function (pr) {
648                    if (pr.fid === fid) {
649                        pr.minRange.value = pr.pMin;
650                        pr.maxRange.value = pr.pMax;
651                        if (pr.minInput) pr.minInput.value = pr.pMin;
652                        if (pr.maxInput) pr.maxInput.value = pr.pMax;
653                        if (pr.updFill) pr.updFill();
654                    }
655                });
656            } else {
657                var m = clear.match(/^f(\d+):(.+)$/);
658                if (m) {
659                    var fid = m[1], val = m[2];
660                    paramInputs.forEach(function (cb) {
661                        if (cb.dataset.fid === fid && cb.value === val) cb.checked = false;
662                    });
663                }
664            }
665            submitFilters(0);
666        });
667
668        updateFill();
669    })();
670
671    // catalog-popular25 — мобильный слайдер (dynamic-bullets)
672    var popularEl = root.querySelector('.catalog-popular25 .slider-mobile');
673    if (popularEl && typeof Swiper !== 'undefined') {
674        var mql = window.matchMedia('(max-width: 768px)');
675        var syncPopular = function () {
676            if (mql.matches) {
677                if (popularEl.swiper) popularEl.swiper.destroy(true, true);
678                new Swiper(popularEl, {
679                    slidesPerView: 1.05,
680                    spaceBetween: 8,
681                    pagination: {
682                        el: '.catalog-popular25 .slider-pagination',
683                        clickable: true,
684                        dynamicBullets: true,
685                        dynamicMainBullets: 1,
686                    },
687                });
688            } else if (popularEl.swiper) {
689                popularEl.swiper.destroy(true, true);
690            }
691        };
692        syncPopular();
693        if (mql.addEventListener) mql.addEventListener('change', syncPopular);
694        else mql.addListener(syncPopular);
695    }
696
697    // cases25 — мобильный слайдер «Успешные кейсы» (только ≤768)
698    var casesEl = root.querySelector('.cases25 .inner-mobile');
699    if (casesEl && typeof Swiper !== 'undefined') {
700        var casesMql = window.matchMedia('(max-width: 768px)');
701        var syncCases = function () {
702            if (casesMql.matches) {
703                if (casesEl.swiper) casesEl.swiper.destroy(true, true);
704                new Swiper(casesEl, {
705                    slidesPerView: 1.1,
706                    spaceBetween: 8,
707                    pagination: {
708                        el: '.cases25 .slider-pagination',
709                        clickable: true,
710                        dynamicBullets: true,
711                    },
712                });
713            } else if (casesEl.swiper) {
714                casesEl.swiper.destroy(true, true);
715            }
716        };
717        syncCases();
718        if (casesMql.addEventListener) casesMql.addEventListener('change', syncCases);
719        else casesMql.addListener(syncCases);
720    }
721
722    // reviews25 — Swiper «Что говорят о нас клиенты»
723    var reviewsSlider = root.querySelector('.reviews25 .slider');
724    if (reviewsSlider && typeof Swiper !== 'undefined') {
725        new Swiper(reviewsSlider, {
726            slidesPerView: 'auto',
727            centeredSlides: true,
728            loop: true,
729            spaceBetween: 16,
730            navigation: {
731                prevEl: '.reviews25 .slider-prev',
732                nextEl: '.reviews25 .slider-next',
733            },
734            pagination: {
735                el: '.reviews25 .pagination',
736                clickable: true,
737            },
738            breakpoints: {
739                0:   { pagination: { dynamicBullets: true } },
740                768: { pagination: { dynamicBullets: false } }
741            }
742        });
743    }
744
745    // reviews25 — попап с фуллтекстом отзыва
746    if (root.querySelector('.reviews25') && !window.bfReview25PopupBound) {
747        window.bfReview25PopupBound = true;
748        document.addEventListener('click', function (e) {
749            if (e.target.closest('.bf-review25-popup__close') || e.target.classList.contains('bf-review25-popup__backdrop')) {
750                var pop = document.querySelector('.bf-review25-popup');
751                if (pop) { pop.setAttribute('hidden', ''); document.body.style.overflow = ''; }
752                return;
753            }
754            var slide = e.target.closest('.bf-review25-slide');
755            if (!slide) return;
756            var pop = document.querySelector('.bf-review25-popup');
757            if (!pop) return;
758            pop.querySelector('.bf-review25-popup__name').textContent = slide.getAttribute('data-review-name') || '';
759            pop.querySelector('.bf-review25-popup__text').textContent = slide.getAttribute('data-review-text') || '';
760            pop.removeAttribute('hidden');
761            document.body.style.overflow = 'hidden';
762        });
763        document.addEventListener('keydown', function (e) {
764            if (e.key !== 'Escape') return;
765            var pop = document.querySelector('.bf-review25-popup');
766            if (pop && !pop.hasAttribute('hidden')) { pop.setAttribute('hidden', ''); document.body.style.overflow = ''; }
767        });
768    }
769
770    // order-info25 — анимация «Механики заказа
770» по входу во вьюпорт
771    var orderInfo = root.querySelector('.order-info25');
772    if (orderInfo && 'IntersectionObserver' in window) {
773        var observer = new IntersectionObserver(function (entries, obs) {
774            entries.forEach(function (entry) {
775                if (!entry.isIntersecting) return;
776                orderInfo.classList.add('animate');
777                var lines = orderInfo.querySelector('.lines');
778                if (lines) setTimeout(function () { lines.classList.add('show'); }, 700);
779                orderInfo.querySelectorAll('.lines-item').forEach(function (item, index) {
780                    setTimeout(function () { item.classList.add('show'); }, 600 + 600 * (index + 1));
781                });
782                obs.unobserve(entry.target);
783            });
784        });
785        observer.observe(orderInfo);
786    }
787
788    // На мобилке featured-блок уезжает из листинга наверх — над меню
789    // категорий и кнопками сортировки/фильтров.
790    if (window.matchMedia('(max-width: 768px)').matches) {
791        var frHv = root.querySelector('.listing__items > .fr-hv');
792        var frHvAnchor = root.querySelector('.cm-catalog__content > .mobile-sidebar-links');
793        if (frHv && frHvAnchor) frHvAnchor.parentNode.insertBefore(frHv, frHvAnchor);
794    }
795
796    // Featured-блок первого ряда: наведение на ссылку подменяет карточку
797    // слева (картинка, тег с его цветом, заголовок и ссылка самой карточки).
798    // Ушёл курсор со списка — возвращается первая карточка группы.
799    root.querySelectorAll('.fr-hv').forEach(function (box) {
800        var card = box.querySelector('.fr-hv-card');
801        var links = box.querySelector('.fr-hv-links');
802        var items = Array.prototype.slice.call(box.querySelectorAll('.fr-hv-item'));
803        if (!card || !links || items.length < 2) return;
804
805        var img = card.querySelector('img');
806        var video = card.querySelector('video');
807        var tag = card.querySelector('.fr-hv-tag');
808        var name = card.querySelector('.fr-hv-name');
809        var current = items[0];
810        var timer;
811
812        // Предзагрузка, чтобы подмена не мигала белым.
813        items.forEach(function (el) {
814            var src = el.getAttribute('data-img');
815            if (src) {
816                var pre = new Image();
817                pre.src = src;
818            }
819        });
820
821        function show(el) {
822            if (el === current) return;
823            current = el;
824            items.forEach(function (i) { i.classList.toggle('active', i === el); });
825            card.classList.add('swap');
826            clearTimeout(timer);
827            timer = setTimeout(function () {
828                var src = el.getAttribute('data-img');
829                if (src) {
830                    img.setAttribute('src', src);
831                } else {
832                    img.removeAttribute('src');
833                }
834
835                // Видео у карточки необязательное: есть — крутим его вместо
836                // картинки (картинка становится постером), нет — снимаем src,
837                // чтобы не тянуть предыдущий ролик фоном.
838                var clip = el.getAttribute('data-video');
839                if (video && clip) {
840                    if (src) {
841                        video.setAttribute('poster', src);
842                    } else {
843                        video.removeAttribute('poster');
844                    }
845                    if (video.getAttribute('src') !== clip) {
846                        video.setAttribute('src', clip);
847                        video.load();
848                    }
849                    card.classList.add('has-video');
850                    var played = video.play();
851                    if (played && played.catch) played.catch(function () {});
852                } else if (video) {
853                    card.classList.remove('has-video');
854                    video.pause();
855                    video.removeAttribute('src');
856                    video.removeAttribute('poster');
857                    video.load();
858                }
859                tag.textContent = el.getAttribute('data-tag') || '';
860                tag.style.background = el.getAttribute('data-tagbg') || '';
861                tag.style.color = el.getAttribute('data-tagfg') || '';
862                name.textContent = el.getAttribute('data-title') || '';
863                card.setAttribute('href', el.getAttribute('href') || '#');
864                card.classList.remove('swap');
865            }, 180);
866        }
867
868        items.forEach(function (el) {
869            el.addEventListener('mouseenter', function () { show(el); });
870            el.addEventListener('focus', function () { show(el); });
871        });
872        links.addEventListener('mouseleave', function () { show(items[0]); });
873    });
874
875    // Цель Метрики на переходы из featured-блока. Параметры уходят деревом:
876    // откуда кликнули (страница категории) → что кликнули (заголовок карточки)
877    // → куда ведёт ссылка. Не инлайном в разметке: карточка слева подменяется
878    // по наведению, её заголовок и href на момент клика знает только DOM.
879    (function () {
880        // Номер счётчика на зеркале/деве подменяется в HTML (index.php, isMirror),
881        // статический JS этой подмене не подвергается — берём номер из инлайн-
882        // инициализации счётчика на странице.
883        var ymId = null;
884        function ymCounterId() {
885            if (ymId) return ymId;
886            var s = document.scripts, m;
887            for (var i = 0; i < s.length; i++) {
888                if (!s[i].src && (m = s[i].textContent.match(/ym\(\s*(\d{6,})\s*,\s*['"]init['"]/))) {
889                    ymId = parseInt(m[1], 10);
890                    return ymId;
891                }
892            }
893            ymId = 24366505;
894            return ymId;
895        }
896
897        root.querySelectorAll('.fr-hv').forEach(function (box) {
898            box.addEventListener('click', function (e) {
899                var el = e.target.closest('.fr-hv-card, .fr-hv-item');
900                if (!el || !window.ym) return;
901
902                var titleEl = el.classList.contains('fr-hv-card') ? el.querySelector('.fr-hv-name') : null;
903                var name = titleEl ? titleEl.textContent : (el.getAttribute('data-title') || el.textContent);
904                var href = el.getAttribute('href') || '';
905                name = (name || '').trim();
906                if (name === '') name = href;
907
908                var from = location.pathname;
909                var params = {};
910                params[from] = {};
911                params[from][name] = {};
912                params[from][name][href] = 1;
913                ym(ymCounterId(), 'reachGoal', 'block-banner-cat', params);
914            });
915        });
916    })();
917
918    // seo-block25 — «Раскрыть»/«Скрыть» SEO-текста
919    root.querySelectorAll('.seo-block25').forEach(function (block) {
920        var btn = block.querySelector('.btn-more');
921        var textbox = block.querySelector('.textbox');
922        if (!btn || !textbox) return;
923
924        if (textbox.scrollHeight <= textbox.clientHeight) {
925            btn.style.display = 'none';
926            return;
927        }
928
929        var label = btn.querySelector('span');
930        btn.addEventListener('click', function () {
931            textbox.classList.toggle('active');
932            btn.classList.toggle('active');
933            if (textbox.classList.contains('active')) {
934                textbox.style.height = textbox.scrollHeight + 'px';
935                if (label) label.textContent = 'Скрыть';
936            } else {
937                textbox.style.height = '341px';
938                if (label) label.textContent = 'Раскрыть';
939            }
940        });
941    });
942
943    // Попап-пояснение на плитках «Почему нам доверяют» (.catalog-popular25):
944    // тот же tip26, что в карточке (шаблон 116). Тексты и адрес ссылки лежат
945    // в data-tip-* на самой плитке, разметка попапа одна на страницу.
946    // Цель Метрики popup-link на кнопку внутри вешает js/2024/add.js.
947    (function () {
948        var nodes = root.querySelectorAll('[data-tip-title]');
949        if (!nodes.length) return;
950
951        var pop = document.createElement('div');
952        pop.className = 'tip26';
953        pop.setAttribute('role', 'dialog');
954        pop.setAttribute('aria-modal', 'true');
955        pop.innerHTML =
956            '<div class="tip26__ov" data-tip-close></div>' +
957            '<div class="tip26__box">' +
958                '<button type="button" class="tip26__close" data-tip-close aria-label="Закрыть">' +
959                    '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">' +
960                    '<path d="M1 1l10 10M11 1L1 11"/></svg>
960' +
961                '</button>' +
962                '<div class="tip26__title" data-tip-t></div>' +
963                '<p class="tip26__text" data-tip-b></p>' +
964                '<a class="tip26__link" data-tip-a target="_blank" rel="noopener" hidden></a>' +
965            '</div>';
966        document.body.appendChild(pop);
967
968        var titleEl = pop.querySelector('[data-tip-t]');
969        var textEl = pop.querySelector('[data-tip-b]');
970        var linkEl = pop.querySelector('[data-tip-a]');
971        var closeBtn = pop.querySelector('.tip26__close');
972        var lastFocus = null;
973
974        function close() {
975            if (!pop.classList.contains('is-open')) return;
976            pop.classList.remove('is-open');
977            if (lastFocus && lastFocus.focus) lastFocus.focus();
978            lastFocus = null;
979        }
980
981        function open(el) {
982            var title = el.getAttribute('data-tip-title') || '';
983            var href = el.getAttribute('href');
984            titleEl.textContent = title;
985            textEl.textContent = el.getAttribute('data-tip-text') || '';
986            if (href) {
987                linkEl.setAttribute('href', href);
988                linkEl.textContent = el.getAttribute('data-tip-link') || 'Подробнее';
989                linkEl.hidden = false;
990            } else {
991                linkEl.hidden = true;
992            }
993            pop.setAttribute('aria-label', title);
994            lastFocus = document.activeElement;
995            pop.classList.add('is-open');
996            closeBtn.focus();
997        }
998
999        Array.prototype.forEach.call(nodes, function (el) {
1000            el.addEventListener('click', function (e) {
1001                if (e.metaKey || e.ctrlKey || e.shiftKey) return;
1002                e.preventDefault();
1003                open(el);
1004            });
1005        });
1006
1007        pop.addEventListener('click', function (e) {
1008            if (e.target.closest('[data-tip-close]')) close();
1009        });
1010
1011        document.addEventListener('keydown', function (e) {
1012            if (e.key === 'Escape' || e.keyCode === 27) close();
1013        });
1014    })();
1015
1016})();

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.