(() => { 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