Каталог, карточка товара, корзина, оформление заказа и блог на WooCommerce. Стили разложены по слоям: токены из Figma, база, компоненты, страницы, брейкпоинты. Цвета, типографика, радиусы и отступы взяты из Variables и текстовых стилей макета, ниже по слоям хардкода нет. Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется. Стили WooCommerce отключены, вёрстка своя, !important не используется.
987 lines
36 KiB
JavaScript
987 lines
36 KiB
JavaScript
(() => {
|
|
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 <form>, 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();
|
|
})();
|