Files
f1den 29d10ce4cf Тема магазина по макету Figma
Каталог, карточка товара, корзина, оформление заказа и блог на WooCommerce.

Стили разложены по слоям: токены из Figma, база, компоненты, страницы,
брейкпоинты. Цвета, типографика, радиусы и отступы взяты из Variables и
текстовых стилей макета, ниже по слоям хардкода нет.

Иконки — Phosphor, вставляются инлайном как SVG, символами ничего не рисуется.
Стили WooCommerce отключены, вёрстка своя, !important не используется.
2026-07-30 22:38:49 +03:00

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();
})();