/** * ============================================= * Скрипты страницы категории * ============================================= * Переписано с jQuery на чистый JavaScript (ES6+) * Используется современный подход: делегирование событий, * requestAnimationFrame, Web Animations API и т.д. * ============================================= */ // ============================================ // Утилиты (вспомогательные функции) // ============================================ /** * Безопасный querySelector — возвращает элемент или null без выброса ошибки * @param {string} selector - CSS-селектор * @param {Element|Document} context - контекст поиска (по умолчанию document) * @returns {Element|null} */ const qs = (selector, context = document) => context.querySelector(selector); /** * Безопасный querySelectorAll — возвращает NodeList * @param {string} selector - CSS-селектор * @param {Element|Document} context - контекст поиска (по умолчанию document) * @returns {NodeList} */ const qsa = (selector, context = document) => context.querySelectorAll(selector); /** * Получение смещения элемента относительно документа (аналог jQuery .offset()) * @param {Element} el - DOM-элемент * @returns {{top: number, left: number}} */ function getOffset(el) { const rect = el.getBoundingClientRect(); return { top: rect.top + window.scrollY, left: rect.left + window.scrollX, }; } /** * Получение вычисленной ширины элемента без padding и border (аналог jQuery .width()) * @param {Element} el - DOM-элемент * @returns {number} */ function getWidth(el) { const styles = window.getComputedStyle(el); return ( el.clientWidth - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight) ); } // ============================================ // Индикатор первой загрузки скриптов // ============================================ if (typeof window.firstLoad === 'undefined') { window.categoryJsScripts_FirstLoad = false; } // ============================================ // Основная функция скриптов страницы категории // ============================================ window.categoryJsScripts = () => { // ----------------------------------------- // Селектор обёртки баблов (фильтров-кнопок) // ----------------------------------------- const tabsWrapSelector = '.filters-n__bubles-wrap'; // ----------------------------------------- // Горизонтальная прокрутка баблов колёсиком мыши // ----------------------------------------- /** * Обработчик прокрутки колёсика — преобразует вертикальный скролл в горизонтальный * @param {WheelEvent} e - событие колёсика */ function scrollHorizontally(e) { const wrapper = qs(tabsWrapSelector); if (!wrapper) return; // Определяем направление прокрутки (нормализуем значение в диапазоне -1..1) const delta = Math.max(-1, Math.min(1, e.wheelDelta || -e.detail)); // Сдвигаем горизонтальную прокрутку на 40px за «тик» колёсика wrapper.scrollLeft -= delta * 40; // Предотвращаем стандартное поведение (вертикальный скролл страницы) e.preventDefault(); } const tabsWrapElement = qs(tabsWrapSelector); if (tabsWrapElement) { // Используем passive: false, чтобы можно было вызвать preventDefault() tabsWrapElement.addEventListener('wheel', scrollHorizontally, { passive: false }); } // ----------------------------------------- // Стрелки прокрутки баблов (влево / вправо) // ----------------------------------------- /** * Определяет, нужно ли показывать стрелки прокрутки баблов * Логика: если активный элемент выходит за границы видимой области — показываем стрелку * @param {Element} el - обёртка баблов (.filters-n__bubles-wrap) */ function tabsArrowInit(el) { if (!el) return; // Ищем активный элемент внутри обёртки const activeItem = qs('.filters-n__bubles-item--active', el); if (!activeItem) return; // Координаты внешнего контейнера const outerLeft = getOffset(el).left; const outerRight = outerLeft + getWidth(el); // Координаты активного элемента const innerLeft = getOffset(activeItem).left; const innerRight = innerLeft + getWidth(activeItem); // Стрелки const arrowLeft = qs('.filters-n__bubles-arrow-left', el); const arrowRight = qs('.filters-n__bubles-arrow-right', el); // Если активный элемент уехал влево за пределы контейнера — показываем левую стрелку if (arrowLeft) { arrowLeft.style.display = outerLeft - innerLeft > 30 ? 'flex' : 'none'; } // Если активный элемент уехал вправо — показываем правую стрелку if (arrowRight) { arrowRight.style.display = parseInt(innerRight - outerRight) > 0 ? 'flex' : 'none'; } } // Инициализация стрелок при загрузке и ресайзе окна if (tabsWrapElement) { const initArrows = () => tabsArrowInit(tabsWrapElement); window.addEventListener('load', initArrows); window.addEventListener('resize', initArrows); // Пересчитываем при прокрутке самого контейнера tabsWrapElement.addEventListener('scroll', () => { tabsArrowInit(tabsWrapElement); }); } // ----------------------------------------- // Клик по стрелкам — плавная прокрутка на 200px // ----------------------------------------- qsa('.filters-n__bubles-arrow').forEach((arrow) => { arrow.addEventListener('click', () => { const wrap = arrow.closest('.filters-n__bubles-wrap'); if (!wrap) return; const items = qs('.filters-n__bubles-items', wrap); if (!items) return; // Текущее смещение (абсолютная позиция items относительно wrap) const startPosition = wrap.offsetLeft; const position = Math.abs(items.getBoundingClientRect().left - wrap.getBoundingClientRect().left); // Размер шага прокрутки const delta = 200; let start = position; let end = position; // Определяем направление if (arrow.classList.contains('filters-n__bubles-arrow-left')) { end = position - delta; } else if (arrow.classList.contains('filters-n__bubles-arrow-right')) { end = position + delta; } // Плавная анимация прокрутки через requestAnimationFrame const duration = 200; // мс const startTime = performance.now(); /** * Функция easing (swing) — имитирует jQuery swing easing * @param {number} t - прогресс от 0 до 1 * @returns {number} */ function easeSwing(t) { return 0.5 - Math.cos(t * Math.PI) / 2; } function animateScroll(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // от 0 до 1 const easedProgress = easeSwing(progress); // Интерполяция значения прокрутки wrap.scrollLeft = start + (end - start) * easedProgress; if (progress < 1) { requestAnimationFrame(animateScroll); } } requestAnimationFrame(animateScroll); }); }); // ----------------------------------------- // Ползунок прокрутки (scroll runner) // ----------------------------------------- const bublesWrapper = qs('.filters-n__bubles-wrap'); /** * Вычисляет и устанавливает положение ползунка прокрутки * на основе соотношения видимой области к полной ширине контента * @param {number} scrollLeft - текущее значение scrollLeft контейнера */ function setScrollRunner(scrollLeft = 0) { const wrapper = qs('.filters-n__bubles-wrap'); const activeItem = qs('.filters-n__bubles-item--active'); const scrollRunner = qs('.filters-n__scroll-runner'); // Если нет активного элемента или ползунка — выходим if (!activeItem || !scrollRunner || !wrapper) return; // Коэффициент — отношение ширины видимой области к полной ширине контента const coef = wrapper.clientWidth / activeItem.clientWidth; // Вычисляем позиции left и right для ползунка const left = parseInt(coef * wrapper.scrollLeft); const right = parseInt((activeItem.clientWidth - wrapper.clientWidth - scrollLeft) * coef); scrollRunner.style.left = `${left}px`; scrollRunner.style.right = `${right}px`; } // Первоначальная установка ползунка setScrollRunner(); // Обновление ползунка при прокрутке контейнера if (bublesWrapper) { bublesWrapper.addEventListener('scroll', function () { setScrollRunner(this.scrollLeft); }); } // Обновление ползунка при ресайзе окна const observer = new ResizeObserver(() => { setScrollRunner(); }); observer.observe(document.documentElement); // ----------------------------------------- // AJAX-загрузка результатов категории по URL // ----------------------------------------- /** * Загружает содержимое страницы категории по AJAX (fetch) * и заменяет соответствующие блоки на странице * @param {string} url - URL для загрузки */ function loadAjaxResults(url) { // Если есть query-параметры в текущем URL — мержим их if (window.location.search) { let urlParams = window.getAllUrlParams(url + window.location.search); try { // Удаляем параметры, которые не нужны при AJAX-запросе urlParams = window.deleteUrlParam(urlParams, 'page'); urlParams = window.deleteUrlParam(urlParams, 'query'); const urlParamsString = window.joinUrlParams(urlParams); if (urlParamsString) { url = `${url}?${urlParamsString}`; } } catch (err) { console.log(err); } } // Блок с результатами mSearch2 const mse2Results = qs('#mse2_results'); if (!mse2Results) return; // Показываем пользователю, что идёт загрузка (полупрозрачность) prodpage.style.opacity = 0.5; // Выполняем GET-запрос на указанный URL fetch(url, { method: 'GET', headers: { 'Accept': 'text/plain', }, }) .then((response) => { if (!response.ok) { throw new Error('Network response was not ok'); } return response.text(); }) .then((data) => { // Подставляем URL в адресную строку браузера без перезагрузки window.history.pushState({}, '', url); if (!data) { prodpage.style.opacity = 1; return; } // --- Парсинг HTML-ответа по маркерам (HTML-комментариям) --- /** * Извлекает контент между маркерами-комментариями * @param {string} html - полный HTML ответа * @param {string} markerStart - начальный маркер * @param {string} markerEnd - конечный маркер * @returns {string|null} */ function extractContent(html, markerStart, markerEnd) { const regex = new RegExp(`${markerStart}(.+)${markerEnd}`, 'ms'); const match = regex.exec(html); return match?.[1] ?? null; } // Обновляем страницы const titleMatch = /<title>(.+?)<\/title>/ms.exec(data); if (titleMatch?.[1]) { document.title = titleMatch[1]; } // Обновляем карточки результатов const resultsContent = extractContent(data, '<!--mse2_results-->', '<!--mse2_results_end-->'); mse2Results.innerHTML = resultsContent ?? ''; // Обновляем количество результатов const totalContent = extractContent(data, '<!--mse2_results_total-->', '<!--mse2_results_total_end-->'); if (totalContent && totalResults) { totalResults.innerHTML = totalContent.trim(); } // Обновляем классы размеров карточек товаров const cardSizeContent = extractContent(data, '<!--categorycard_size-->', '<!--categorycard_size_end-->'); if (cardSizeContent && productCategoryWrapper) { const classes = cardSizeContent.split(/\s+/).filter((cls) => cls.trim()); // Сбрасываем классы до значения по умолчанию const defaultClasses = productCategoryWrapper.getAttribute('data-default-classes'); productCategoryWrapper.setAttribute('class', defaultClasses); // Добавляем новые классы classes.forEach((cssClass) => { productCategoryWrapper.classList.add(cssClass.trim()); }); } // Обновляем заголовок ресурса const titleContent = extractContent(data, '<!--resource_title-->', '<!--resource_title_end-->'); if (titleContent && resourceTitle) { resourceTitle.innerHTML = titleContent.trim(); } // Обновляем introtext ресурса const introtextContent = extractContent(data, '<!--resource_introtext-->', '<!--resource_introtext_end-->'); if (resourceIntrotext) { resourceIntrotext.innerHTML = introtextContent ? introtextContent.trim() : ''; } // Обновляем хлебные крошки const breadcrumbsContent = extractContent(data, '<!--breadcrumbs_items-->', '<!--breadcrumbs_items_end-->'); if (breadcrumbsContent && breadCrumbs) { breadCrumbs.innerHTML = breadcrumbsContent.trim(); } // Обновляем пагинацию const paginationEl = qs('.mse2_pagination'); if (paginationEl) { const paginationContent = extractContent(data, '<!--pagination-->', '<!--pagination_end-->'); if (paginationContent) { paginationEl.innerHTML = paginationContent; paginationEl.style.opacity = '1'; } } // Обновляем конфигурацию mSearch2 const configMatch = /mse2Config\s*=\s*(\{.+?\})/ms.exec(data); if (configMatch?.[1]) { window.mse2Config = JSON.parse(configMatch[1]); } // Очищаем строку поиска if (searchInput) { searchInput.value = ''; } // Обновляем контент нижней части страницы категории const bottomContentEl = qs('.category-page-bottom'); if (bottomContentEl) { const bottomContent = extractContent(data, '<!--category_bottom-->', '<!--category_bottom_end-->'); if (bottomContent) { bottomContentEl.innerHTML = bottomContent; } } // Обновляем lazy-load изображений (если доступен глобальный экземпляр) (async () => { await window.waitForGlobalVar('lazyLoadInstance'); window.lazyLoadUpdate(); })(); // Пересчитываем стрелки баблов tabsArrowInit(tabsWrapElement); // Убираем полупрозрачность — загрузка завершена prodpage.style.opacity = 1; }) .catch((error) => { console.log(error); // На случай ошибки — восстанавливаем видимость prodpage.style.opacity = 1; }); } // ----------------------------------------- // Активация блока баблов для текущей категории // ----------------------------------------- /** * Показывает нужный набор баблов в зависимости от активной категории * @param {Element} filtersEl - корневой элемент фильтров */ function bublesActivity(filtersEl) { // Ищем активную категорию const catActive = qs('.filters-n__cats-item--active', filtersEl); if (!catActive) return; const catIdx = catActive.getAttribute('data-idx'); // Ищем бабл, соответствующий активной категории по data-idx const activeBuble = qs(`.filters-n__bubles-item[data-idx="${catIdx}"]`, filtersEl); if (activeBuble) { // Показываем только если бабл не пустой if (activeBuble.innerHTML?.trim()) { filtersEl.classList.add('filters-n__bubles-show'); activeBuble.classList.add('filters-n__bubles-item--active'); } } // Обновляем ползунок прокрутки setScrollRunner(); } // ----------------------------------------- // Ссылки на DOM-элементы страницы // ----------------------------------------- const prodpage = document.getElementById('pdopage'); const filters = qs('.filters-n', prodpage); const circleCats = filters ? qsa('.filters-n__cats-item', filters) : []; const bubles = filters ? qsa('.filters-n__buble', filters) : []; const totalResults = filters ? qs('.filters-n__sort-total', filters) : null; const resourceTitle = filters ? qs('.title-n__title-h1', filters) : null; const resourceIntrotext = filters ? qs('.filters-n__introtext', filters) : null; const breadCrumbs = prodpage ? qs('.breadcrumbs-n', prodpage) : null; const productCategoryWrapper = prodpage ? qs('.product-n--category-wrapper', prodpage) : null; const searchInput = filters ? qs('.filters-n__search-form input[name="query"]', filters) : null; // ----------------------------------------- // Обработка кликов по круглым категориям // ----------------------------------------- circleCats.forEach((cat) => { cat.addEventListener('click', (event) => { // Если элемент находится внутри области, прокручиваемой мышью — // на десктопе клик интерпретируется как скролл, а не навигация const scrollByMouse = cat.closest('.scrollByMouse--active'); if (scrollByMouse && window.innerWidth > 1199) { return; } // Если у категории отключён AJAX — используем стандартное поведение ссылки if (cat.getAttribute('data-ajax-off')) return; // Если категория уже активна — сбрасываем до корневой if (cat.classList.contains('filters-n__cats-item--active')) { const resetUrl = cat.getAttribute('data-reset-url'); if (resetUrl) { cat.classList.remove('filters-n__cats-item--active'); deactivateBubles(); loadAjaxResults(resetUrl); } return; } // Блокируем переход по ссылке cat.setAttribute('onclick', 'return false;'); event.preventDefault(); // Деактивируем все баблы deactivateBubles(); // Снимаем активность с предыдущей категории const currentActiveCat = qs('.filters-n__cats-item--active', filters); if (currentActiveCat) { currentActiveCat.classList.remove('filters-n__cats-item--active'); } // Делаем текущую категорию активной cat.classList.add('filters-n__cats-item--active'); // Обновляем баблы bublesActivity(filters); // Загружаем результаты по href категории const href = cat.getAttribute('href'); if (href) { loadAjaxResults(href); } }); }); // ----------------------------------------- // Деактивация всех баблов // ----------------------------------------- /** * Снимает активность со всех баблов и скрывает панель баблов */ function deactivateBubles() { // Снимаем класс активности у всех активных баблов qsa('.filters-n__buble--active', filters).forEach((el) => { el.classList.remove('filters-n__buble--active'); }); // Снимаем активность у текущего активного списка баблов qsa('.filters-n__bubles-item--active', filters).forEach((el) => { el.classList.remove('filters-n__bubles-item--active'); }); // Скрываем панель баблов const bublesShow = filters?.parentNode?.querySelector('.filters-n__bubles-show'); if (bublesShow) { bublesShow.classList.remove('filters-n__bubles-show'); } // Обновляем ползунок setScrollRunner(); } // ----------------------------------------- // Обработка кликов по баблам (подкатегориям / тегам) // ----------------------------------------- bubles.forEach((buble) => { buble.addEventListener('click', (event) => { // Аналогичная проверка на скролл мышью const scrollByMouse = buble.closest('.scrollByMouse--active'); if (scrollByMouse && window.innerWidth > 1199) { return; } // Если бабл уже активен — деактивируем и возвращаемся к родительскому URL if (buble.classList.contains('filters-n__buble--active')) { const parentUrl = buble.getAttribute('data-parent-url'); if (parentUrl) { buble.classList.remove('filters-n__buble--active'); loadAjaxResults(parentUrl); } } else { // Активируем бабл и загружаем его результаты const href = buble.getAttribute('href'); if (href) { deactivateBubles(); bublesActivity(filters); buble.classList.add('filters-n__buble--active'); loadAjaxResults(href); } } }); }); // ----------------------------------------- // Инициализация тултипов (Tippy.js) при наведении на категории // ----------------------------------------- const cats = qsa('.filters-n__cats-item'); if (cats.length > 0) { document.addEventListener('DOMContentLoaded', async () => { await window.waitForGlobalVar('loadScripts'); // Динамическая загрузка библиотеки Tippy.js loadScripts( ['/front/site/libs/tippyjs/tippy.all.min.js'], { position: 'append', target: document.body, onReady: async () => { // Ждём, пока tippy станет доступен глобально await window.waitForGlobalVar('tippy'); cats.forEach((cat) => { // Инициализируем тултип только при первом наведении (once: true) cat.addEventListener( 'mouseover', () => { const idx = cat.getAttribute('data-idx'); const selector = `.filters-n__cats-tooltipe-${idx}`; const tt = qs(selector); if (tt) { const ttText = tt.getAttribute('data-text'); if (ttText) { // Создаём экземпляр tippy для данной категории tippy(`.filters-n__cats-item[data-idx="${idx}"]`, { allowHTML: true, placement: 'bottom', delay: [700, 0], theme: 'dark', trigger: 'mouseenter', content: () => ttText, }); } } }, { once: true } ); }); }, } ).catch(console.error); }); } }; // ============================================ // Инициализация при первой загрузке страницы // ============================================ document.addEventListener('DOMContentLoaded', () => { window.categoryJsScripts(); window.categoryJsScripts_FirstLoad = true; }); // Повторная инициализация, если скрипт загружается повторно (например, через AJAX) if (typeof window.categoryJsScripts_FirstLoad !== 'undefined' && window.categoryJsScripts_FirstLoad) { window.categoryJsScripts(); } // ============================================ // Сортировка // ============================================ document.addEventListener('DOMContentLoaded', () => { // ----------------------------------------- // Скрытие/показ списка сортировок при клике на бабл сортировки // ----------------------------------------- const filtersSort = qs('.filters-n__sort-list'); if (filtersSort) { filtersSort.addEventListener('click', () => { if (filtersSort.classList.contains('filters-n__sort-list--active')) { // Закрытие: сначала убираем translate (анимация), потом сам класс active filtersSort.classList.remove('filters-n__sort-list--translate'); setTimeout(() => { filtersSort.classList.remove('filters-n__sort-list--active'); }, 200); } else { // Открытие: сначала active, потом translate (анимация появления) filtersSort.classList.add('filters-n__sort-list--active'); setTimeout(() => { filtersSort.classList.add('filters-n__sort-list--translate'); }, 100); } }); } // ----------------------------------------- // Подстановка заголовка списка сортировки при загрузке mSearch2 // (кастомное событие 'mse2_load', генерируемое mSearch2) // ----------------------------------------- (async () => { await window.waitForGlobalVar('$'); $(document).on('mse2_load', function (e, data) { const active = qs('.filters-n__sort-list-item.active'); const titleEl = qs('.filters-n__sort-header-title'); if (!titleEl) return; // Если есть активный элемент сортировки — берём его короткое название, // иначе — показываем заголовок по умолчанию const titleDefault = titleEl.getAttribute('title-default'); const title = active ? active.getAttribute('title-short') : titleDefault; titleEl.textContent = title; }); })(); }); // ----------------------------------------- // Скрытие выпадающего списка сортировки при клике вне его области // ----------------------------------------- document.addEventListener('click', (e) => { // Если клик был не внутри элемента сортировки if (!e.target.closest('.filters-n__sort-list')) { const filtersList = qs('.filters-n__sort-list--active'); if (filtersList) { // Плавно скрываем: убираем translate, затем active filtersList.classList.remove('filters-n__sort-list--translate'); setTimeout(() => { filtersList.classList.remove('filters-n__sort-list--active'); }, 200); } } }); // ============================================ // Дополнительные интерактивные элементы // ============================================ document.addEventListener('DOMContentLoaded', () => { // ----------------------------------------- // Визуализация прокрутки фильтров при загрузке // (подсказка пользователю, что область прокручивается) // ----------------------------------------- setTimeout(() => { const filtersEl = qs('.filters-n__cats-items'); if (!filtersEl) return; // Прокручиваем вправо на 150px filtersEl.scrollBy({ left: 150, behavior: 'smooth', }); // Через 1 секунду — прокручиваем обратно влево setTimeout(() => { filtersEl.scrollBy({ left: -150, behavior: 'smooth', }); }, 1000); }); // ----------------------------------------- // Раскрытие поля поиска по клику на иконку // ----------------------------------------- setTimeout(() => { const filtersEl = qs('.filters-n'); const filtersSearchEl = qs('.filters-n__search'); /** * Раскрывает или сворачивает поле поиска * @param {boolean} expand - true = раскрыть, false = свернуть */ function searchExpand(expand) { if (!filtersEl || !filtersSearchEl) return; if (expand) { // Раскрываем поле поиска filtersEl.classList.add('filters-n--search-focus'); filtersSearchEl.classList.remove('filters-n__search--category'); // Ставим фокус в инпут с небольшой задержкой (чтобы анимация успела начаться) setTimeout(() => { const input = qs('input', filtersSearchEl); if (input) input.focus(); }, 100); } else { // Сворачиваем поле поиска filtersEl.classList.remove('filters-n--search-focus'); filtersSearchEl.classList.add('filters-n__search--category'); } } // Сворачивание при потере фокуса инпутом (только на странице категории) const categorySearchInput = qs('.filters-n__search--category-page .filters-n__search-form input'); if (categorySearchInput) { categorySearchInput.addEventListener('focusout', () => { searchExpand(false); }); } // Раскрытие при клике на иконку поиска const searchIcon = qs('.filters-n__search-icon'); if (searchIcon) { searchIcon.addEventListener('click', () => { searchExpand(true); }); } }); });