/** * ============================================ * Главный файл скриптов сайта * Переписан с jQuery на чистый JS (ES6+) * ============================================ */ document.addEventListener('DOMContentLoaded', async () => { // ========================================== // Футер: раскрытие/скрытие блоков // ========================================== document.querySelectorAll('.footer__title-expand').forEach(el => { el.addEventListener('click', (event) => { // Переключаем класс активности el.classList.toggle('footer__title_active'); // На мобильных устройствах предотвращаем стандартное поведение if (window.innerWidth < 991) { event.preventDefault(); } }); }); // ========================================== // Универсальная функция показа/скрытия текста // с плавной анимацией (slideToggle аналог) // ========================================== /** * Плавное раскрытие элемента (аналог jQuery slideDown) * @param {HTMLElement} element - DOM-элемент для анимации * @param {number} duration - длительность анимации в мс * @returns {Promise} - промис завершения анимации */ window.slideDown = function (element, duration = 400) { return new Promise((resolve) => { // Убираем display: none, чтобы можно было измерить высоту element.style.display = 'block'; element.style.overflow = 'hidden'; // Получаем натуральную высоту элемента const height = element.scrollHeight; // Начинаем с нулевой высоты element.style.height = '0px'; element.style.transition = `height ${duration}ms ease`; // Форсируем reflow, чтобы браузер "увидел" начальное состояние element.offsetHeight; // eslint-disable-line no-unused-expressions // Устанавливаем целевую высоту element.style.height = `${height}px`; // По завершении анимации сбрасываем инлайн-стили const onEnd = () => { element.style.height = ''; element.style.overflow = ''; element.style.transition = ''; element.removeEventListener('transitionend', onEnd); resolve(); }; element.addEventListener('transitionend', onEnd, { once: true }); }); } /** * Плавное сворачивание элемента (аналог jQuery slideUp) * @param {HTMLElement} element - DOM-элемент для анимации * @param {number} duration - длительность анимации в мс * @returns {Promise} - промис завершения анимации */ window.slideUp = function (element, duration = 400) { return new Promise((resolve) => { element.style.overflow = 'hidden'; element.style.height = `${element.scrollHeight}px`; element.style.transition = `height ${duration}ms ease`; // Форсируем reflow element.offsetHeight; // eslint-disable-line no-unused-expressions // Сворачиваем до нуля element.style.height = '0px'; const onEnd = () => { element.style.display = 'none'; element.style.height = ''; element.style.overflow = ''; element.style.transition = ''; element.removeEventListener('transitionend', onEnd); resolve(); }; element.addEventListener('transitionend', onEnd, { once: true }); }); } /** * Переключение видимости элемента с анимацией (аналог jQuery slideToggle) * @param {HTMLElement} element - DOM-элемент * @param {number} duration - длительность анимации в мс * @returns {Promise} */ window.slideToggle = function (element, duration = 400) { // Проверяем, виден ли элемент const isHidden = element.style.display === 'none' || getComputedStyle(element).display === 'none'; return isHidden ? slideDown(element, duration) : slideUp(element, duration); } // ========================================== // Expand text: показ/скрытие развёрнутого текста // Используем делегирование событий, чтобы не // навешивать повторные обработчики // ========================================== /** * Обработчик клика на кнопку "Показать/скрыть" текст. * Работает через делегирование — не нужно повторно вызывать при mse2_load. */ document.addEventListener('click', async (e) => { const btn = e.target.closest('.expand'); if (!btn) return; const showText = btn.getAttribute('data-show-text'); const hideText = btn.getAttribute('data-hide-text'); if (!btn.classList.contains('expanded')) { // --- Режим «развернуть» --- btn.style.display = 'none'; // Следующий сиблинг — скрытый контент, разворачиваем его const nextEl = btn.nextElementSibling; await slideToggle(nextEl, 600); // Помечаем кнопку как «развёрнуто» btn.classList.add('expanded'); // Перемещаем кнопку после контента (insertAfter) nextEl.after(btn); // Обновляем текст и показываем кнопку btn.textContent = hideText; btn.style.display = ''; } else { // --- Режим «свернуть» --- btn.style.display = 'none'; // Предыдущий сиблинг — развёрнутый контент, сворачиваем const prevEl = btn.previousElementSibling; await slideToggle(prevEl, 600); // Убираем метку btn.classList.remove('expanded'); // Перемещаем кнопку перед контентом (insertBefore) prevEl.before(btn); // Обновляем текст и показываем кнопку btn.textContent = showText; btn.style.display = ''; // Прокручиваем к кнопке с отступом const position = btn.getBoundingClientRect().top + window.scrollY - 50; window.scrollTo({ top: position, behavior: 'instant' }); } }); // ========================================== // Каталог эпитафий: раскрытие/сворачивание // левого меню // ========================================== document.querySelectorAll('.catalog-g__all').forEach(btn => { btn.addEventListener('click', () => { const nextEl = btn.nextElementSibling; if (btn.classList.contains('catalog-g__all_expanded')) { // Сворачиваем btn.classList.remove('catalog-g__all_expanded'); slideUp(nextEl); } else { // Разворачиваем btn.classList.add('catalog-g__all_expanded'); slideDown(nextEl); } }); }); // ========================================== // FAQ-аккордеон: вопросы-ответы // ========================================== document.querySelectorAll('.faq__question').forEach(question => { question.addEventListener('click', () => { // Переключаем класс «expanded» question.classList.toggle('expanded'); // Плавно показываем/скрываем ответ (следующий элемент) slideToggle(question.nextElementSibling, 400); }); }); // ========================================== // Раскрытие полного текста отзыва Yandex // Делегирование, чтобы работало для // динамически подгруженных отзывов // ========================================== document.addEventListener('click', (e) => { // Ищем клик внутри .review__else по ссылке const link = e.target.closest('.review__else a'); if (!link) return; e.preventDefault(); // Находим родительский контейнер текста отзыва const parent = link.closest('.review__text'); if (!parent) return; // Скрываем многоточие, показываем скрытый текст, прячем кнопку const dots = parent.querySelector('.review__dots'); const hidden = parent.querySelector('.review__hide'); const elseBlock = parent.querySelector('.review__else'); if (dots) dots.style.display = 'none'; if (hidden) hidden.style.display = ''; if (elseBlock) elseBlock.classList.add('review__else_hide'); }); // ========================================== // Навигация каталога: оверлей при наведении // ========================================== document.querySelectorAll('.topnav_parent').forEach(navParent => { const overlay = document.querySelector('.center-section__overlay'); navParent.addEventListener('mouseleave', () => { if (overlay) overlay.style.display = 'none'; }); navParent.addEventListener('mouseover', () => { // Показываем оверлей только если есть подменю if (navParent.querySelector('.topnav-expand') && overlay) { overlay.style.display = ''; } }); }); // ========================================== // Загрузка файла: отображение имени и удаление // ========================================== // Событие выбора файла document.querySelectorAll('.cta-form__file input[type=file]').forEach(input => { input.addEventListener('change', () => { const wrapper = input.closest('.cta-form__file-wrapper'); if (!wrapper) return; // Извлекаем имя файла (без пути) const fileName = input.value.replace(/.*\\/, ''); // Показываем блок с именем файла const filesBlock = wrapper.querySelector('.cta-form__files'); const filenameInput = wrapper.querySelector('.cta-form__filename'); const filesName = wrapper.querySelector('.cta-form_files-name'); if (filesBlock) filesBlock.style.display = ''; if (filenameInput) filenameInput.value = fileName; if (filesName) filesName.textContent = fileName; }); }); // Кнопка удаления выбранного файла document.querySelectorAll('.cta-form_files-delete').forEach(btn => { btn.addEventListener('click', () => { const wrapper = btn.closest('.cta-form__file-wrapper'); if (!wrapper) return; // Сбрасываем input[type=file] const fileInput = wrapper.querySelector('input[type=file]'); if (fileInput) fileInput.value = ''; // Скрываем блок и очищаем имя const filesBlock = wrapper.querySelector('.cta-form__files'); const filenameInput = wrapper.querySelector('.cta-form__filename'); const filesName = wrapper.querySelector('.cta-form_files-name'); if (filesBlock) filesBlock.style.display = 'none'; if (filenameInput) filenameInput.value = ''; if (filesName) filesName.textContent = ''; }); }); // ========================================== // Кастомный select для сортировки // (с задержкой, чтобы элементы успели отрендериться) // ========================================== setTimeout(() => { // Клик по заголовку селекта — открыть/закрыть список document.querySelectorAll('.sort-select__header').forEach(header => { header.addEventListener('click', () => { const list = header.parentElement.querySelector('.sort-select__list'); const title = header.querySelector('.sort-select__header-title'); if (header.classList.contains('open')) { header.classList.remove('open'); if (list) slideUp(list); if (title) title.style.visibility = 'visible'; } else { header.classList.add('open'); if (list) slideDown(list); if (title) title.style.visibility = 'hidden'; } }); }); // Клик по пункту списка — выбрать значение document.querySelectorAll('.sort-select__link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const selectRoot = link.closest('.sort-select'); if (!selectRoot) return; const header = selectRoot.querySelector('.sort-select__header'); const spanContainer = header?.querySelector('span'); const list = link.closest('.sort-select__list') || link.parentElement; // Убираем класс open и обновляем текст if (header) header.classList.remove('open'); if (spanContainer) { spanContainer.innerHTML = `Сортировать` + `${link.textContent}`; } // Сворачиваем список if (list) slideUp(list); }); }); }, 0); }); // ========================================== // Раскрытие меню каталога (глобальная функция) // ========================================== (async () => { window.menuCatalogExpand = function () { const catalog = document.querySelector('.topnav-catalog'); const overlay = document.querySelector('.center-section__overlay'); if (catalog) catalog.style.display = ''; if (overlay) overlay.style.display = ''; } })(); // ========================================== // Инициализация Mmenu (мобильное меню) // ========================================== document.addEventListener('DOMContentLoaded', async () => { await window.waitForGlobalVar('loadScripts'); loadScripts([ '/front/site/libs/mmenu/dist/mmenu.js', ], { position: 'append', target: document.body, onReady: async () => { await window.waitForGlobalVar('Mmenu'); // Формируем ссылку с телефоном const mmenuSitePhone = `${sitePhone}`; let mmenu = false; // Опции Mmenu const mmenuOptions = { theme: 'white', setSelected: { hover: true, parent: true, }, navbars: [ { content: ['prev', 'breadcrumbs', 'close'], }, { position: 'bottom', content: [ '
', ], }, ], }; // Конфигурация Mmenu const mmenuConfig = { language: 'ru', offCanvas: { page: { nodetype: 'div', }, }, }; /** * Глобальная функция открытия мобильного меню. * При первом вызове инициализирует Mmenu, * затем копирует содержимое «Вы смотрели» и «Корзина» из верхнего меню. */ window.mmenuOpen = () => { // Ленивая инициализация — создаём Mmenu только при первом открытии if (!mmenu) { mmenu = new Mmenu('#mmenu', mmenuOptions, mmenuConfig); } // Копируем «Вы смотрели» из основной навигации const topNavFavorites = document.querySelector('.topnav__favorites'); const mmenuVisited = document.querySelector('.mmenu__visited'); if (topNavFavorites && mmenuVisited) { mmenuVisited.innerHTML = topNavFavorites.outerHTML; } // Копируем «Корзину» из основной навигации const topNavCard = document.querySelector('.topnav__card'); const mmenuCard = document.querySelector('.mmenu__card'); if (topNavCard && mmenuCard) { // Удаляем старую SVG-иконку из копируемого блока const topNavCardSvg = topNavCard.querySelector('svg'); if (topNavCardSvg) topNavCardSvg.remove(); // Вставляем новую SVG + содержимое корзины const newSvg = ``; mmenuCard.innerHTML = newSvg + topNavCard.outerHTML; } }; } }).catch(console.error); }); // ========================================== // Показ/скрытие расписания в шапке // ========================================== window.headerScheduleShow = function () { const schedule = document.querySelector('.schedule'); if (schedule) { schedule.classList.toggle('schedule_expand'); } } // ========================================== // Тултипы на странице памятника (tippy.js) // ========================================== document.addEventListener('DOMContentLoaded', async () => { // Инициализация tippy await window.waitForGlobalVar('tippy'); tippy('.mon__tooltipe', { allowHTML: true, content(reference) { const contentEl = reference.querySelector('.mon__tooltipe-content'); return contentEl ? contentEl.innerHTML : ''; }, }); }); // ========================================== // Закрытие выпадающего расписания телефона // при клике вне его области // ========================================== document.addEventListener('mouseup', (e) => { const schedule = document.querySelector('.phone__schedule'); if (schedule && schedule.classList.contains('phone__schedule_expand')) { // Если клик не по расписанию и не по его дочерним элементам — закрываем if (!schedule.contains(e.target)) { schedule.classList.remove('phone__schedule_expand'); } } }); // ========================================== // Форматирование чисел с разделителями разрядов // ========================================== /** * Форматирует число с разделителями разрядов и десятичной частью. * * @param {number|string} number - исходное число * @param {number} decimals - количество знаков после запятой (по умолчанию 2) * @param {string} dec_point - разделитель десятичной части (по умолчанию ',') * @param {string} thousands_sep - разделитель тысяч (по умолчанию '.') * @returns {string} - отформатированная строка */ window.numberFormat = function numberFormat(number, decimals, dec_point, thousands_sep) { // Валидация и значения по умолчанию if (isNaN(decimals = Math.abs(decimals))) { decimals = 2; } if (dec_point === undefined) { dec_point = ','; } if (thousands_sep === undefined) { thousands_sep = '.'; } // Приводим к числу и фиксируем десятичные знаки const fixed = (+number || 0).toFixed(decimals); const intPart = parseInt(fixed) + ''; // Определяем смещение для первой группы цифр const offset = intPart.length > 3 ? intPart.length % 3 : 0; // Собираем результат: первая группа + остальные группы по 3 + десятичная часть const prefix = offset ? intPart.substr(0, offset) + thousands_sep : ''; const groups = intPart.substr(offset).replace(/(\d{3})(?=\d)/g, '$1' + thousands_sep); const decimal = decimals ? dec_point + Math.abs(fixed - intPart).toFixed(decimals).replace(/-/, '0').slice(2) : ''; return prefix + groups + decimal; }; // ========================================== // Получение текущей даты в формате YYYY.MM.DD HH:MM:SS // ========================================== window.getCurrentDate = () => { const now = new Date(); // Pad — добавление ведущего нуля const pad = (n) => String(n).padStart(2, '0'); const year = String(now.getFullYear()); const month = pad(now.getMonth() + 1); const day = pad(now.getDate()); const hours = pad(now.getHours()); const minutes = pad(now.getMinutes()); const seconds = pad(now.getSeconds()); return `${year}.${month}.${day} ${hours}:${minutes}:${seconds}`; }; // ========================================== // Форматирование цены для miniShop2 // ========================================== window.formatPrice = function formatPrice(price) { const pf = miniShop2Config.price_format; // Форматируем по настройкам магазина price = window.numberFormat(price, pf[0], pf[1], pf[2]); // Убираем хвостовые нули, если это разрешено в настройках if (miniShop2Config.price_format_no_zeros && pf[0] > 0) { price = price.replace(/(0+)$/, ''); // убираем нули в конце price = price.replace(/[^0-9]$/, ''); // убираем висячий разделитель } return price; }; // ========================================== // Кнопка «Наверх» — используем IntersectionObserver // вместо $(window).scroll для лучшей производительности // ========================================== /** * IntersectionObserver следит за «сторожевым» элементом в верхней части страницы. * Когда этот элемент уходит из видимости (пользователь прокрутил вниз), * показываем кнопку «Наверх». Когда он снова виден — скрываем. */ document.addEventListener('DOMContentLoaded', async () => { const scrollUpBtn = document.querySelector('.scroll-up'); if (!scrollUpBtn) return; // Создаём невидимый сторожевой элемент в начале body const sentinel = document.createElement('div'); sentinel.style.height = '300px'; // «зона» в 300px от верха sentinel.style.width = '1px'; sentinel.style.position = 'absolute'; sentinel.style.top = '0'; sentinel.style.left = '0'; sentinel.style.pointerEvents = 'none'; // не мешает кликам sentinel.style.visibility = 'hidden'; // невидимый sentinel.setAttribute('aria-hidden', 'true'); document.body.prepend(sentinel); // Наблюдатель: когда сторожевой элемент выходит из вьюпорта — показываем кнопку const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { // Сторожевой элемент виден → мы вверху страницы → прячем кнопку scrollUpBtn.style.display = 'none'; } else { // Сторожевой элемент не виден → прокрутили далеко → показываем кнопку scrollUpBtn.style.display = 'inline-flex'; } }, { // threshold: 0 означает «хотя бы 1 пиксель виден/невидим» threshold: 0, } ); observer.observe(sentinel); // Клик по кнопке — плавная прокрутка наверх scrollUpBtn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); // ========================================== // Обновление количества избранного // ========================================== /** * Обновляет счётчик избранного во всех местах на странице. * @param {number|false} count - новое количество (false — ничего не делать) */ window.reloadFavoritesCount = function (count = false) { // Валидация: принимаем только числа if (count === false) return; if (count !== parseInt(count, 10)) return; count = parseInt(count, 10); // Обновляем текст во всех счётчиках document.querySelectorAll('.heart__count-number').forEach(el => { el.textContent = count; }); // Переключаем класс active у иконок сердца document.querySelectorAll('.heart').forEach(heart => { if (count > 0) { heart.classList.add('heart_active'); } else { heart.classList.remove('heart_active'); } }); } // ========================================== // Обработка успешной отправки формы (AjaxForm) // Используем делегирование через кастомное событие // ========================================== window.addEventListener('load', async () => { await window.waitForGlobalVar('$'); /** * AjaxForm (MODX) генерирует кастомное событие 'af_complete' на document. * Слушаем его через jQuery-совместимый способ, т.к. AjaxForm * сам использует jQuery для диспатча событий. * * ПРИМЕЧАНИЕ: если AjaxForm триггерит событие через jQuery, * перехватить его чистым JS addEventListener нельзя. * Поэтому здесь оставляем $(document).on — это единственное * место, где jQuery необходим для совместимости с AjaxForm. */ if (typeof $ !== 'undefined' && typeof $.fn !== 'undefined') { $(document).on('af_complete', function (event, response) { if (response.success) { // Отправляем цели в Яндекс.Метрику window.yandexTargetYm('all-forms-sent'); window.yandexTargetYm('183291811'); // Извлекаем ID модального окна из сообщения ответа let idMessage = response.message; const match = idMessage.match(/(#.+?)_.*/i); if (match && typeof match[1] !== 'undefined') { // Открываем модальное окно (jQuery modal plugin) $(match[1]).modal({ fadeDuration: 100, fadeDelay: 0.40, }); } } // Очищаем сообщение, чтобы AjaxForm не показал его повторно response.message = ''; }); } });