(() => { const body = document.body; const menuButton = document.querySelector(".menu-toggle"); /* * Icons rendered from script come from the same `assets/icons/*.svg` files * the PHP component uses; the theme passes the markup through * `window.vyatkaShopIcons`. Never draw an icon with a text glyph. */ const getIcon = (name) => { const svg = (window.vyatkaShopIcons || {})[name]; return typeof svg === "string" ? svg : ""; }; menuButton?.addEventListener("click", () => { const isOpen = body.classList.toggle("menu-open"); menuButton.setAttribute("aria-expanded", String(isOpen)); }); const syncQuantityControl = (input) => { const wrapper = input?.closest(".quantity-control"); if (!wrapper) return; const minimumValue = Number.parseInt(input.min || "1", 10); const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER; const currentValue = Number.parseInt(input.value || "1", 10); const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1; const maximum = Number.isFinite(maximumValue) && maximumValue > 0 ? Math.max(maximumValue, minimum) : Number.MAX_SAFE_INTEGER; const current = Number.isFinite(currentValue) ? currentValue : minimum; const decrease = wrapper.querySelector('[data-quantity-action="decrease"]'); const increase = wrapper.querySelector('[data-quantity-action="increase"]'); if (decrease) decrease.disabled = current <= minimum; if (increase) increase.disabled = current >= maximum; }; const clampQuantity = (input) => { if (!input) return "1"; const minimumValue = Number.parseInt(input.min || "1", 10); const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER; const currentValue = Number.parseInt(input.value || "1", 10); const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1; const maximum = Number.isFinite(maximumValue) && maximumValue > 0 ? Math.max(maximumValue, minimum) : Number.MAX_SAFE_INTEGER; const current = Number.isFinite(currentValue) ? currentValue : minimum; input.value = String(Math.min(maximum, Math.max(minimum, current))); syncQuantityControl(input); return input.value; }; document .querySelectorAll('.quantity-control input[type="number"]') .forEach((input) => syncQuantityControl(input)); document.addEventListener("input", (event) => { const input = event.target.closest('.quantity-control input[type="number"]'); if (input) syncQuantityControl(input); }); document.addEventListener("click", (event) => { const button = event.target.closest("[data-quantity-action]"); if (!button) return; const wrapper = button.closest(".quantity-control"); const input = wrapper?.querySelector('input[type="number"]'); if (!input) return; const currentValue = Number.parseInt(input.value || "1", 10); const minimumValue = Number.parseInt(input.min || "1", 10); const maximumValue = input.max ? Number.parseInt(input.max, 10) : Number.MAX_SAFE_INTEGER; const current = Number.isFinite(currentValue) ? currentValue : 1; const minimum = Number.isFinite(minimumValue) && minimumValue > 0 ? minimumValue : 1; const maximum = Number.isFinite(maximumValue) && maximumValue > 0 ? Math.max(maximumValue, minimum) : Number.MAX_SAFE_INTEGER; const delta = button.dataset.quantityAction === "increase" ? 1 : -1; input.value = String(Math.min(maximum, Math.max(minimum, current + delta))); syncQuantityControl(input); input.dispatchEvent(new Event("change", { bubbles: true })); }); const getToastStack = () => { let stack = document.querySelector(".shop-toast-stack"); if (stack) return stack; stack = document.createElement("div"); stack.className = "shop-toast-stack"; stack.setAttribute("aria-live", "polite"); stack.setAttribute("aria-atomic", "false"); document.body.append(stack); return stack; }; const showToast = (message, type = "success") => { const toast = document.createElement("div"); toast.className = `shop-toast shop-toast-${type}`; toast.setAttribute("role", type === "error" ? "alert" : "status"); const icon = document.createElement("span"); icon.className = "shop-toast-icon"; icon.setAttribute("aria-hidden", "true"); icon.innerHTML = getIcon(type === "success" ? "check-circle-fill" : "x-circle-fill"); const copy = document.createElement("span"); copy.className = "shop-toast-copy"; copy.textContent = message; const dismiss = document.createElement("button"); dismiss.className = "shop-toast-dismiss"; dismiss.type = "button"; dismiss.setAttribute("aria-label", "Закрыть уведомление"); dismiss.innerHTML = getIcon("x"); let fadeTimer; let removeTimer; const removeToast = (duration = 300) => { window.clearTimeout(fadeTimer); window.clearTimeout(removeTimer); toast.style.setProperty("--toast-fade-duration", `${duration}ms`); toast.classList.add("is-leaving"); window.setTimeout(() => toast.remove(), duration); }; dismiss.addEventListener("click", () => removeToast()); toast.append(icon, copy, dismiss); getToastStack().append(toast); window.requestAnimationFrame(() => toast.classList.add("is-visible")); fadeTimer = window.setTimeout(() => { toast.style.setProperty("--toast-fade-duration", "1000ms"); toast.classList.add("is-leaving"); }, 6000); removeTimer = window.setTimeout(() => toast.remove(), 7000); }; const cartConfig = window.vyatkaShopCart || { ajaxUrl: "/wp-admin/admin-ajax.php", nonce: "", }; let cartMutationQueue = Promise.resolve(); const sendCartMutation = async (payload) => { const cartPage = document.querySelector(".cart-page"); cartPage?.classList.add("is-updating"); cartPage?.setAttribute("aria-busy", "true"); const requestBody = new URLSearchParams({ action: "vyatka_cart_mutation", nonce: cartConfig.nonce, ...payload, }); try { const response = await fetch(cartConfig.ajaxUrl, { method: "POST", body: requestBody, credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", }, }); const result = await response.json(); if (!response.ok || !result.success) { throw new Error(result?.data?.message || "Не удалось обновить корзину"); } const currentCartPage = document.querySelector(".cart-page"); if (currentCartPage && result.data?.html) { currentCartPage.outerHTML = result.data.html; } return result.data; } finally { const currentCartPage = document.querySelector(".cart-page"); currentCartPage?.classList.remove("is-updating"); currentCartPage?.removeAttribute("aria-busy"); } }; const queueCartMutation = (payload, successMessage = "") => { cartMutationQueue = cartMutationQueue .catch(() => {}) .then(() => sendCartMutation(payload)) .then((result) => { if (successMessage) showToast(successMessage); return result; }) .catch((error) => { showToast(error.message || "Нет соединения с сервером", "error"); return null; }); return cartMutationQueue; }; document.addEventListener("change", (event) => { const input = event.target.closest('.quantity-control input[type="number"]'); if (!input) return; const quantity = clampQuantity(input); const cartItem = input.closest("[data-cart-item-key]"); if (!cartItem) return; queueCartMutation({ operation: "update", cart_item_key: cartItem.dataset.cartItemKey, quantity, }); }); document.addEventListener("focusout", (event) => { const input = event.target.closest('.quantity-control input[type="number"]'); if (input) clampQuantity(input); }); document.addEventListener("click", (event) => { const remove = event.target.closest(".cart-item-remove"); if (!remove) return; const cartItem = remove.closest("[data-cart-item-key]"); if (!cartItem) return; event.preventDefault(); queueCartMutation({ operation: "remove", cart_item_key: cartItem.dataset.cartItemKey, }); }); document.addEventListener("submit", async (event) => { const form = event.target.closest("form[data-ajax-add-to-cart]"); if (!form) return; event.preventDefault(); if (form.dataset.addingToCart === "true") return; const button = event.submitter || form.querySelector('[name="add-to-cart"]'); const productId = button?.value || form.querySelector('[name="add-to-cart"]')?.value; if (!productId) { showToast("Не удалось определить товар", "error"); return; } const quantity = clampQuantity(form.querySelector('input[name="quantity"]')); form.dataset.addingToCart = "true"; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } try { await queueCartMutation( { operation: "add", product_id: productId, quantity, }, "Товар успешно добавлен в корзину", ); } finally { form.dataset.addingToCart = "false"; if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } } }); const productFilterForm = document.querySelector("[data-product-filters]"); const productCards = Array.from(document.querySelectorAll("[data-filter-results] [data-product-card]")); const filterEmpty = document.querySelector("[data-filter-empty]"); const applyProductFilters = () => { if (!productFilterForm || !productCards.length) return; const selectedFeatures = Array.from( productFilterForm.querySelectorAll('input[name="feature[]"]:checked') ) .map((input) => input.value) .filter((value) => value && value !== "all"); const selectedCollection = productFilterForm.querySelector('input[name="collection"]:checked')?.value || ""; const priceFilter = productFilterForm.querySelector("[data-price-filter]"); const priceActive = priceFilter?.dataset.priceActive === "true"; const minimumPrice = Number.parseFloat(priceFilter?.querySelector('[data-price-field="min"]')?.value || "0"); const maximumPrice = Number.parseFloat(priceFilter?.querySelector('[data-price-field="max"]')?.value || String(Number.MAX_SAFE_INTEGER)); let visibleProducts = 0; productCards.forEach((card) => { const features = (card.dataset.features || "").split(/\s+/).filter(Boolean); const collectionMatches = !selectedCollection || card.dataset.collection === selectedCollection; const featureMatches = !selectedFeatures.length || selectedFeatures.some((feature) => features.includes(feature)); const price = Number.parseFloat(card.dataset.price || "0"); const priceMatches = !priceActive || (price >= minimumPrice && price <= maximumPrice); const visible = collectionMatches && featureMatches && priceMatches; card.hidden = !visible; if (visible) visibleProducts += 1; }); if (filterEmpty) filterEmpty.hidden = visibleProducts > 0; }; const updateProductFilterUrl = () => { if (!productFilterForm) return; const url = new URL(productFilterForm.action || window.location.href, window.location.href); url.searchParams.delete("feature[]"); url.searchParams.delete("collection"); productFilterForm.querySelectorAll('input[name="feature[]"]:checked').forEach((input) => { url.searchParams.append("feature[]", input.value); }); const selectedCollection = productFilterForm.querySelector('input[name="collection"]:checked')?.value || ""; if (selectedCollection) url.searchParams.set("collection", selectedCollection); const priceFilter = productFilterForm.querySelector("[data-price-filter]"); if (priceFilter?.dataset.priceActive === "true") { url.searchParams.set("filter_min_price", priceFilter.querySelector('[data-price-field="min"]').value); url.searchParams.set("filter_max_price", priceFilter.querySelector('[data-price-field="max"]').value); } else { url.searchParams.delete("filter_min_price"); url.searchParams.delete("filter_max_price"); } window.history.replaceState({}, "", url); }; document.querySelectorAll("[data-price-filter]").forEach((filter) => { const minimumInput = filter.querySelector('[data-price-field="min"]'); const maximumInput = filter.querySelector('[data-price-field="max"]'); const minimumRange = filter.querySelector('[data-price-range="min"]'); const maximumRange = filter.querySelector('[data-price-range="max"]'); const range = filter.querySelector(".price-range"); if (!minimumInput || !maximumInput || !minimumRange || !maximumRange || !range) return; const lowerBound = Number.parseInt(minimumRange.min, 10); const upperBound = Number.parseInt(minimumRange.max, 10); const gap = Number.parseInt(minimumRange.step || "1", 10); const clamp = (value) => Math.min(upperBound, Math.max(lowerBound, value)); const syncRangeTrack = () => { const min = Number.parseInt(minimumRange.value, 10); const max = Number.parseInt(maximumRange.value, 10); const size = upperBound - lowerBound; range.style.setProperty("--range-min", `${((min - lowerBound) / size) * 100}%`); range.style.setProperty("--range-max", `${((max - lowerBound) / size) * 100}%`); minimumInput.value = String(min); maximumInput.value = String(max); }; minimumRange.addEventListener("input", () => { filter.dataset.priceActive = "true"; minimumRange.value = String(Math.min(clamp(Number.parseInt(minimumRange.value, 10)), Number.parseInt(maximumRange.value, 10) - gap)); syncRangeTrack(); applyProductFilters(); }); maximumRange.addEventListener("input", () => { filter.dataset.priceActive = "true"; maximumRange.value = String(Math.max(clamp(Number.parseInt(maximumRange.value, 10)), Number.parseInt(minimumRange.value, 10) + gap)); syncRangeTrack(); applyProductFilters(); }); const syncFields = () => { let min = clamp(Number.parseInt(minimumInput.value || String(lowerBound), 10)); let max = clamp(Number.parseInt(maximumInput.value || String(upperBound), 10)); if (min > max - gap) min = max - gap; if (max < min + gap) max = min + gap; minimumRange.value = String(min); maximumRange.value = String(max); syncRangeTrack(); }; [minimumInput, maximumInput].forEach((input) => { input.addEventListener("input", () => { filter.dataset.priceActive = "true"; syncFields(); applyProductFilters(); }); input.addEventListener("change", () => { updateProductFilterUrl(); productFilterForm?.requestSubmit(); }); }); minimumRange.addEventListener("change", () => { updateProductFilterUrl(); productFilterForm?.requestSubmit(); }); maximumRange.addEventListener("change", () => { updateProductFilterUrl(); productFilterForm?.requestSubmit(); }); syncFields(); }); /* * Below the mobile breakpoint the filter sidebar becomes the sheet from the * design. It stays the same
, so filtering behaves identically — only * its presentation changes. */ const filterPanel = document.querySelector(".filters"); const filterOpener = document.querySelector("[data-filter-panel-open]"); const setFilterPanel = (open) => { if (!filterPanel) return; filterPanel.classList.toggle("is-open", open); body.classList.toggle("filters-open-lock", open); filterOpener?.setAttribute("aria-expanded", String(open)); if (open) { filterPanel.querySelector("[data-filter-panel-close]")?.focus(); } else { filterOpener?.focus(); } }; filterOpener?.addEventListener("click", () => setFilterPanel(true)); document.querySelectorAll("[data-filter-panel-close]").forEach((button) => { button.addEventListener("click", () => setFilterPanel(false)); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && filterPanel?.classList.contains("is-open")) { setFilterPanel(false); } }); // Leaving the breakpoint must not strand the page in the locked state. window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches) setFilterPanel(false); }); document.querySelectorAll("[data-filter-toggle]").forEach((button) => { button.addEventListener("click", () => { const group = button.closest(".filter-group"); const content = group?.querySelector(".filter-content"); if (!group || !content) return; window.clearTimeout(content.filterAnimationTimer); if (content.filterAnimation) { content.filterAnimation.cancel(); } const collapse = !group.classList.contains("filter-collapsed"); const expandedHeight = content.scrollHeight; const startHeight = collapse ? expandedHeight : 0; const endHeight = collapse ? 0 : expandedHeight; content.classList.add("is-animating"); content.hidden = false; content.style.height = `${startHeight}px`; content.style.opacity = collapse ? "1" : "0"; if (collapse) { group.classList.add("filter-collapsed"); } else { group.classList.remove("filter-collapsed"); } button.setAttribute("aria-expanded", String(!collapse)); const animation = content.animate( [ { height: `${startHeight}px`, opacity: collapse ? 1 : 0 }, { height: `${endHeight}px`, opacity: collapse ? 0 : 1 }, ], { duration: 180, easing: "ease-in-out", fill: "forwards", }, ); content.filterAnimation = animation; const finishAnimation = () => { if (content.filterAnimation !== animation) return; window.clearTimeout(content.filterAnimationTimer); content.classList.remove("is-animating"); content.hidden = collapse; content.style.height = ""; content.style.opacity = ""; content.filterAnimation = null; }; animation.finished.then(finishAnimation).catch(() => {}); content.filterAnimationTimer = window.setTimeout(finishAnimation, 240); }); }); document.querySelectorAll("[data-auto-submit]").forEach((control) => { control.addEventListener("change", () => control.form?.requestSubmit()); }); document.querySelectorAll("[data-facet-filter]").forEach((control) => { control.addEventListener("change", () => { applyProductFilters(); updateProductFilterUrl(); productFilterForm?.requestSubmit(); }); }); document.querySelectorAll("[data-filter-more]").forEach((button) => { button.addEventListener("click", () => { const content = button.closest(".filter-content"); const extras = content?.querySelectorAll("[data-filter-extra]"); if (!extras?.length) return; const expanded = button.getAttribute("aria-expanded") !== "true"; extras.forEach((option) => { option.hidden = !expanded; }); button.setAttribute("aria-expanded", String(expanded)); button.textContent = expanded ? "Скрыть" : "Показать все"; }); }); const customSelects = []; document.querySelectorAll("[data-custom-select], .woocommerce-ordering select").forEach((select) => { if (select.dataset.customSelectReady === "true") return; const wrapper = document.createElement("div"); const trigger = document.createElement("button"); const value = document.createElement("span"); const menu = document.createElement("div"); wrapper.className = "custom-select"; trigger.className = "custom-select-trigger"; trigger.type = "button"; trigger.setAttribute("aria-haspopup", "listbox"); trigger.setAttribute("aria-expanded", "false"); value.className = "custom-select-value"; menu.className = "custom-select-menu"; menu.setAttribute("role", "listbox"); menu.hidden = true; select.parentNode.insertBefore(wrapper, select); wrapper.append(select, trigger, menu); select.classList.add("custom-select-native"); select.dataset.customSelectReady = "true"; trigger.append(value); const close = () => { wrapper.classList.remove("is-open"); trigger.setAttribute("aria-expanded", "false"); menu.hidden = true; }; const sync = () => { const selectedOption = select.options[select.selectedIndex]; value.textContent = selectedOption?.textContent || ""; menu.querySelectorAll(".custom-select-option").forEach((option) => { const selected = option.dataset.value === select.value; option.classList.toggle("is-selected", selected); option.setAttribute("aria-selected", String(selected)); }); }; Array.from(select.options).forEach((sourceOption) => { const option = document.createElement("button"); option.className = "custom-select-option"; option.type = "button"; option.setAttribute("role", "option"); option.dataset.value = sourceOption.value; option.textContent = sourceOption.textContent; option.addEventListener("click", () => { select.value = sourceOption.value; sync(); close(); select.dispatchEvent(new Event("change", { bubbles: true })); }); menu.append(option); }); trigger.addEventListener("click", () => { const open = !wrapper.classList.contains("is-open"); customSelects.forEach((item) => { if (item.wrapper !== wrapper) item.close(); }); wrapper.classList.toggle("is-open", open); trigger.setAttribute("aria-expanded", String(open)); menu.hidden = !open; }); trigger.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); if (event.key === "ArrowDown" && menu.hidden) { event.preventDefault(); trigger.click(); menu.querySelector(".is-selected, .custom-select-option")?.focus(); } }); select.addEventListener("change", sync); if (select.closest(".woocommerce-ordering")) { select.addEventListener("change", () => select.form?.requestSubmit()); } customSelects.push({ wrapper, close }); sync(); }); document.addEventListener("click", (event) => { customSelects.forEach((item) => { if (!item.wrapper.contains(event.target)) item.close(); }); }); document.querySelectorAll(".gallery-favorite").forEach((button) => { button.addEventListener("click", () => { const active = button.classList.toggle("is-active"); button.setAttribute("aria-pressed", String(active)); }); }); document.querySelectorAll( ".woocommerce-message, .woocommerce-info, .woocommerce-error, .woocommerce-warning" ).forEach((notice) => { if (notice.querySelector(".woocommerce-notice-dismiss")) return; const dismiss = document.createElement("button"); dismiss.className = "woocommerce-notice-dismiss"; dismiss.type = "button"; dismiss.setAttribute("aria-label", "Закрыть сообщение"); dismiss.addEventListener("click", () => { notice.classList.add("woocommerce-notice-closing"); window.setTimeout(() => notice.remove(), 190); }); notice.append(dismiss); }); document.querySelectorAll(".tab-list a[data-tab]").forEach((link) => { link.addEventListener("click", (event) => { event.preventDefault(); const tabs = link.closest(".product-tabs"); tabs?.querySelectorAll(".tab-list li").forEach((item) => item.classList.remove("active")); tabs?.querySelectorAll('.tab-list [role="tab"]').forEach((tab) => { tab.setAttribute("aria-selected", String(tab === link)); }); link.parentElement?.classList.add("active"); tabs?.querySelectorAll("[data-tab-panel]").forEach((panel) => { panel.hidden = panel.dataset.tabPanel !== link.dataset.tab; }); }); }); document.querySelectorAll("[data-product-gallery]").forEach((galleryImages) => { const gallery = galleryImages.closest(".product-gallery"); const slides = Array.from(galleryImages.querySelectorAll("[data-gallery-slide]")); const dots = Array.from(gallery?.querySelectorAll("[data-gallery-dot]") || []); const previous = gallery?.querySelector(".gallery-arrow-prev"); const next = gallery?.querySelector(".gallery-arrow-next"); let activeIndex = 0; const render = () => { slides.forEach((slide, index) => { slide.hidden = index !== activeIndex; }); dots.forEach((dot, index) => { dot.classList.toggle("active", index === activeIndex); }); }; previous?.addEventListener("click", () => { activeIndex = (activeIndex - 1 + slides.length) % slides.length; render(); }); next?.addEventListener("click", () => { activeIndex = (activeIndex + 1) % slides.length; render(); }); render(); }); document.querySelectorAll(".delivery-switch").forEach((deliverySwitch) => { const form = deliverySwitch.closest("form"); const panels = Array.from(form?.querySelectorAll("[data-delivery-panel]") || []); const radios = Array.from(deliverySwitch.querySelectorAll('input[name="delivery_type"]')); const syncDeliveryPanels = () => { const selected = radios.find((radio) => radio.checked)?.value || "pickup"; deliverySwitch.classList.toggle("is-delivery", selected === "delivery"); panels.forEach((panel) => { const active = panel.dataset.deliveryPanel === selected; panel.hidden = !active; panel.querySelectorAll("input, select, textarea").forEach((control) => { control.disabled = !active; if (control.hasAttribute("data-delivery-required")) control.required = active; }); }); }; radios.forEach((radio) => radio.addEventListener("change", syncDeliveryPanels)); syncDeliveryPanels(); }); /* * Carousels. * * The track holds every item in one row and is translated by a page at a * time. Instead of rewinding when it runs out, the items that just left the * view are moved to the other end of the track while the transition is off, * so the loop is endless and never snaps back to the start. * * Without JS the container stays the grid it was authored as and shows * everything, so nothing depends on this running. */ document.querySelectorAll("[data-carousel]").forEach((carousel) => { const controls = document.querySelector(`[data-carousel-controls="${carousel.id}"]`); const previous = controls?.querySelector("[data-carousel-prev]"); const next = controls?.querySelector("[data-carousel-next]"); const cards = Array.from(carousel.children); if (cards.length === 0) return; const track = document.createElement("div"); track.className = "carousel-track"; cards.forEach((card) => track.append(card)); carousel.textContent = ""; carousel.append(track); carousel.classList.add("is-carousel"); const perView = () => { if (window.innerWidth <= 639) return 1; if (window.innerWidth <= 1023) return 2; if (carousel.classList.contains("news-grid")) return 3; return window.innerWidth <= 1439 ? 3 : 4; }; const items = () => Array.from(track.children); const canScroll = () => items().length > perView(); const applyPerView = () => { track.style.setProperty("--carousel-per-view", String(perView())); const idle = !canScroll(); if (previous) previous.disabled = idle; if (next) next.disabled = idle; }; // Distance of one card plus the gap between cards. const stride = () => { const first = track.children[0]; if (!first) return 0; const gap = Number.parseFloat(getComputedStyle(track).gap) || 0; return first.getBoundingClientRect().width + gap; }; // Re-order without animating, so the seam is never visible. const settle = (mutate) => { track.classList.add("is-settling"); mutate(); track.style.transform = "translateX(0px)"; void track.offsetWidth; track.classList.remove("is-settling"); }; let busy = false; const page = (direction) => { if (busy || !canScroll()) return; const count = Math.min(perView(), items().length - 1); const distance = stride() * count; if (distance <= 0) return; busy = true; const finish = () => { track.removeEventListener("transitionend", finish); window.clearTimeout(guard); if (direction > 0) { settle(() => { for (let i = 0; i < count; i += 1) track.append(track.children[0]); }); } busy = false; }; // A transition that never fires must not lock the control for good. const guard = window.setTimeout(finish, 800); track.addEventListener("transitionend", finish); if (direction > 0) { track.style.transform = `translateX(${-distance}px)`; return; } // Going back: bring the tail to the front first, jump there without // animating, then slide into place. settle(() => { for (let i = 0; i < count; i += 1) { track.prepend(track.children[track.children.length - 1]); } track.style.transform = `translateX(${-distance}px)`; }); void track.offsetWidth; track.style.transform = "translateX(0px)"; }; previous?.addEventListener("click", () => page(-1)); next?.addEventListener("click", () => page(1)); window.addEventListener("resize", () => { applyPerView(); settle(() => {}); }); applyPerView(); }); /* * Checkout validation. * * The design shows problems on the field itself — a red edge and a red * caption under it — not as one banner listing everything. So the fields are * validated here first, and any error WooCommerce still returns is mapped * back onto the field it belongs to. */ const checkoutForm = document.querySelector("form.checkout"); if (checkoutForm) { const MESSAGES = { valueMissing: "Заполните это поле", typeMismatch: "Введены не верные данные", patternMismatch: "Введены не верные данные", tooShort: "Введены не верные данные", }; const wrapperOf = (input) => input.closest(".checkout-input"); const setFieldError = (input, message) => { const wrapper = wrapperOf(input); if (!wrapper) return; const slot = wrapper.querySelector("[data-field-error]"); wrapper.classList.toggle("is-error", Boolean(message)); input.setAttribute("aria-invalid", message ? "true" : "false"); if (slot) slot.textContent = message || ""; }; const messageFor = (input) => { if (input.validity.valid) return ""; const key = Object.keys(MESSAGES).find((k) => input.validity[k]); return MESSAGES[key] || "Введены не верные данные"; }; const validatableFields = () => Array.from(checkoutForm.querySelectorAll(".checkout-input input")).filter( (input) => !input.disabled && input.willValidate ); const validateField = (input) => { const message = messageFor(input); setFieldError(input, message); return !message; }; // Re-check as soon as the visitor leaves a field, and clear a shown error // as soon as they start fixing it. checkoutForm.addEventListener( "blur", (event) => { const input = event.target.closest(".checkout-input input"); if (input) validateField(input); }, true ); checkoutForm.addEventListener("input", (event) => { const input = event.target.closest(".checkout-input input"); if (input && wrapperOf(input)?.classList.contains("is-error")) validateField(input); }); checkoutForm.addEventListener( "submit", (event) => { const fields = validatableFields(); const invalid = fields.filter((input) => !validateField(input)); if (!invalid.length) return; event.preventDefault(); event.stopImmediatePropagation(); invalid[0].focus(); invalid[0].scrollIntoView({ block: "center", behavior: "smooth" }); }, true ); /* * WooCommerce renders its own notice group when the server rejects the * order. Each message starts with the field label, so it can be attached * to that field; the banner is hidden only when every message found a home. */ const mapServerErrors = () => { const group = document.querySelector(".woocommerce-NoticeGroup-checkout, .checkout .woocommerce-error"); if (!group) return; const items = group.matches("ul") ? Array.from(group.querySelectorAll("li")) : Array.from(group.querySelectorAll("li, p")); const texts = (items.length ? items : [group]).map((n) => n.textContent.trim()).filter(Boolean); if (!texts.length) return; let mappedAll = texts.length > 0; texts.forEach((text) => { const field = validatableFields().find((input) => { const label = wrapperOf(input)?.querySelector(".field-label")?.textContent.trim(); return label && text.toLowerCase().startsWith(label.toLowerCase()); }); if (field) { setFieldError(field, /обязательное/i.test(text) ? MESSAGES.valueMissing : MESSAGES.typeMismatch); } else { mappedAll = false; } }); if (mappedAll) { document .querySelectorAll(".woocommerce-NoticeGroup-checkout, .checkout .woocommerce-error") .forEach((el) => el.classList.add("is-field-level")); } }; /* * The consent box gates the order button: without it ticked there is no * lawful basis to process the order, so the button is disabled rather * than letting the submit fail somewhere later. */ const consent = checkoutForm.querySelector("[data-consent]"); const placeOrder = checkoutForm.querySelector("[data-consent-gated]"); if (consent && placeOrder) { const syncConsent = () => { placeOrder.disabled = !consent.checked; placeOrder.setAttribute("aria-disabled", String(!consent.checked)); }; consent.addEventListener("change", syncConsent); syncConsent(); } mapServerErrors(); // WooCommerce replaces the notice group over AJAX rather than reloading. document.body.addEventListener("checkout_error", () => window.setTimeout(mapServerErrors, 50)); new MutationObserver(() => mapServerErrors()).observe(checkoutForm, { childList: true, subtree: true }); } /* * Quick filters mirror the collection radio group in the sidebar. Clicking a * chip selects the matching radio and submits the same form, so the two * controls can never disagree. */ document.querySelectorAll("[data-quick-filter]").forEach((chip) => { chip.addEventListener("click", () => { const value = chip.dataset.quickFilter; const radio = document.querySelector(`input[name="collection"][value="${CSS.escape(value)}"]`); if (!radio) return; radio.checked = true; radio.dispatchEvent(new Event("change", { bubbles: true })); }); }); /* * Subcategory row. The next button pages the scroller by a whole card and * hides itself once there is nothing further to reveal. */ document.querySelectorAll("[data-subcategories]").forEach((row) => { const track = row.querySelector("[data-subcategory-track]"); const next = row.querySelector("[data-subcategory-next]"); if (!track || !next) return; const step = () => { const card = track.querySelector("li"); const gap = Number.parseFloat(getComputedStyle(track).gap) || 16; return card ? card.getBoundingClientRect().width + gap : 232; }; const sync = () => { const atEnd = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2; const fits = track.scrollWidth <= track.clientWidth + 2; next.hidden = fits; next.querySelector(".icon")?.classList.toggle("icon-flip", atEnd && !fits); }; next.addEventListener("click", () => { const atEnd = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2; track.scrollBy({ left: atEnd ? -track.scrollWidth : step(), behavior: "smooth" }); }); track.addEventListener("scroll", sync, { passive: true }); window.addEventListener("resize", sync); sync(); }); applyProductFilters(); })();