/**
* =============================================
* Скрипты страницы категории
* =============================================
* Переписано с 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>/ms.exec(data);
if (titleMatch?.[1]) {
document.title = titleMatch[1];
}
// Обновляем карточки результатов
const resultsContent = extractContent(data, '', '');
mse2Results.innerHTML = resultsContent ?? '';
// Обновляем количество результатов
const totalContent = extractContent(data, '', '');
if (totalContent && totalResults) {
totalResults.innerHTML = totalContent.trim();
}
// Обновляем классы размеров карточек товаров
const cardSizeContent = extractContent(data, '', '');
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, '', '');
if (titleContent && resourceTitle) {
resourceTitle.innerHTML = titleContent.trim();
}
// Обновляем introtext ресурса
const introtextContent = extractContent(data, '', '');
if (resourceIntrotext) {
resourceIntrotext.innerHTML = introtextContent ? introtextContent.trim() : '';
}
// Обновляем хлебные крошки
const breadcrumbsContent = extractContent(data, '', '');
if (breadcrumbsContent && breadCrumbs) {
breadCrumbs.innerHTML = breadcrumbsContent.trim();
}
// Обновляем пагинацию
const paginationEl = qs('.mse2_pagination');
if (paginationEl) {
const paginationContent = extractContent(data, '', '');
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, '', '');
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);
});
}
});
});