feat: implement WooCommerce AJAX product filtering

This commit is contained in:
XShop
2026-09-13 20:31:20 +03:30
parent f5c283af25
commit b78e332966
15 changed files with 1543 additions and 150 deletions
+26
View File
@@ -185,5 +185,31 @@
.xshop-search__dropdown{ position:fixed; inset-block-start:auto; inset-inline:var(--xshop-space-2); inset-block-end:var(--xshop-space-2); max-block-size:55vh; }
}
/* ---- Filters ---- */
.xshop-shop__layout{ display:grid; grid-template-columns:280px 1fr; gap:var(--xshop-space-6); align-items:start; }
@media (max-width:1024px){ .xshop-shop__layout{ grid-template-columns:1fr; } .xshop-shop__sidebar{ display:none; } }
.xshop-shop__filter-toggle{ display:none; margin-block-end:var(--xshop-space-4); }
@media (max-width:1024px){ .xshop-shop__filter-toggle{ display:inline-flex; } }
.xshop-filters{ display:flex; flex-direction:column; gap:var(--xshop-space-4); }
.xshop-filters__chips{ display:flex; flex-wrap:wrap; gap:var(--xshop-space-2); min-block-size:0; }
.xshop-filters__chips:empty{ display:none; }
.xshop-chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--xshop-radius-full); background:var(--xshop-surface); border:1px solid var(--xshop-border); font-size:var(--xshop-text-xs); cursor:pointer; }
.xshop-chip--clear{ background:var(--xshop-primary); color:#fff; border-color:var(--xshop-primary); }
.xshop-filter{ border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-3); background:var(--xshop-card); }
.xshop-filter__title{ font-weight:var(--xshop-weight-bold); font-size:var(--xshop-text-sm); margin:0 0 var(--xshop-space-2); padding:0; }
.xshop-filter__options{ display:flex; flex-direction:column; gap:var(--xshop-space-2); }
.xshop-filter__option{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); cursor:pointer; }
.xshop-filter__option input{ accent-color:var(--xshop-primary); }
.xshop-filter__count{ margin-inline-start:auto; color:var(--xshop-muted); font-size:var(--xshop-text-xs); }
.xshop-filter__price{ display:flex; align-items:center; gap:var(--xshop-space-2); }
.xshop-filter__price input{ flex:1; }
.xshop-filters__actions{ display:flex; gap:var(--xshop-space-2); }
.xshop-drawer{ position:fixed; inset-block:0; inset-inline-start:0; inline-size:min(380px, 90vw); background:var(--xshop-card); border-inline-end:1px solid var(--xshop-border); z-index:var(--xshop-z-modal); overflow:auto; padding:var(--xshop-space-4); display:flex; flex-direction:column; gap:var(--xshop-space-4); }
.xshop-drawer[hidden]{ display:none !important; }
.xshop-drawer__header{ display:flex; align-items:center; justify-content:space-between; border-block-end:1px solid var(--xshop-border); padding-block-end:var(--xshop-space-3); }
.xshop-drawer__title{ font-size:var(--xshop-text-lg); margin:0; }
.xshop-drawer__overlay{ position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:calc(var(--xshop-z-modal) - 1); }
.xshop-drawer__overlay[hidden]{ display:none !important; }
/* Star rating fallback */
.star-rating{ color:#f59e0b; }
+594
View File
@@ -0,0 +1,594 @@
/**
* XShop AJAX Filters — vanilla, URL sync, chips, debounce for price, stale protection, a11y.
* Works with template-parts/components/product-filters.php + shop toolbar + grid.
*/
(function () {
'use strict';
var DEBOUNCE_MS = 300;
function debounce(fn, ms) {
var t;
return function () {
var a = arguments, ctx = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(ctx, a); }, ms);
};
}
function qsParam(params, key) {
return params.get(key) || '';
}
function parseStateFromURL() {
var params = new URLSearchParams(window.location.search);
var state = {
search: params.get('s') || '',
category: params.get('category') || '',
min_price: params.get('min_price') || '',
max_price: params.get('max_price') || '',
stock: params.get('stock') || 'all',
on_sale: params.get('on_sale') === '1' || params.get('sale') === '1' ? '1' : '0',
rating: params.get('rating') || '0',
orderby: params.get('orderby') || 'default',
page: parseInt(params.get('page') || '1', 10) || 1,
per_page: parseInt(params.get('per_page') || '12', 10) || 12,
attributes: {}
};
// Collect pa_* attributes
params.forEach(function (val, key) {
if (key.indexOf('pa_') === 0 || key.indexOf('attribute_') === 0 || key.indexOf('filter_') === 0) {
var tax = key;
if (tax.indexOf('attribute_') === 0) tax = 'pa_' + tax.slice(10);
if (tax.indexOf('filter_') === 0) tax = 'pa_' + tax.slice(7);
// normalize comma separated to array
var terms = [];
val.split(',').forEach(function (t) { t = t.trim(); if (t) terms.push(t); });
if (terms.length) {
// Also handle repeated keys via URLSearchParams.getAll would be better, but we use forEach which iterates each entry
if (!state.attributes[tax]) state.attributes[tax] = [];
terms.forEach(function (t) { if (state.attributes[tax].indexOf(t) === -1) state.attributes[tax].push(t); });
}
}
});
// Handle array params like pa_color[]=black (PHP style)
params.forEach(function (val, key) {
if (key.indexOf('pa_') === 0 && key.indexOf('[]') !== -1) {
var base = key.replace('[]', '');
if (!state.attributes[base]) state.attributes[base] = [];
if (state.attributes[base].indexOf(val) === -1) state.attributes[base].push(val);
}
});
return state;
}
function stateToParams(state) {
var p = new URLSearchParams();
if (state.search) p.set('s', state.search);
if (state.category) p.set('category', state.category);
Object.keys(state.attributes).forEach(function (tax) {
var terms = state.attributes[tax];
if (terms && terms.length) p.set(tax, terms.join(','));
});
if (state.min_price) p.set('min_price', state.min_price);
if (state.max_price) p.set('max_price', state.max_price);
if (state.stock && state.stock !== 'all') p.set('stock', state.stock);
if (state.on_sale === '1') p.set('on_sale', '1');
if (state.rating && state.rating !== '0' && state.rating !== 0) p.set('rating', String(state.rating));
if (state.orderby && state.orderby !== 'default') p.set('orderby', state.orderby);
if (state.page && state.page > 1) p.set('page', String(state.page));
// per_page only if not default
if (state.per_page && state.per_page !== 12) p.set('per_page', String(state.per_page));
return p;
}
function stateToEndpoint(state) {
var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/';
var base = restUrl;
// base is .../wp-json/xshop/v1/ or .../wp-json/
var endpoint;
if (base.indexOf('xshop/v1') !== -1) {
endpoint = base.replace(/\/$/, '') + '/products';
// if base is /wp-json/xshop/v1/ already, this is /wp-json/xshop/v1/products
// if base is /wp-json/ we need /wp-json/xshop/v1/products
if (base.indexOf('/wp-json/xshop/v1') === -1 && base.indexOf('xshop/v1') !== -1) {
endpoint = base.replace(/\/$/, '') + '/products';
}
} else {
// base is /wp-json/
endpoint = base.replace(/\/$/, '') + '/xshop/v1/products';
}
// Fix duplicate wrapper
endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/');
var p = stateToParams(state);
// Also map search to endpoint param
if (state.search) p.set('search', state.search);
// Ensure page/per_page correct for endpoint (endpoint expects page/per_page)
var qs = p.toString();
return endpoint + (qs ? '?' + qs : '');
}
function renderProductCard(item) {
// Reuse product-card markup via JS (avoid duplicating PHP). Minimal but token-consistent.
// We keep structure close to PHP product-card for CSS reuse.
var wrap = document.createElement('li');
wrap.className = 'xshop-products__item product';
var card = document.createElement('article');
card.className = 'xshop-product-card product';
var media = document.createElement('a');
media.href = item.url;
media.className = 'xshop-product-card__media';
media.setAttribute('aria-label', item.title);
if (item.image) {
var img = document.createElement('img');
img.src = item.image;
img.alt = item.title;
img.loading = 'lazy';
img.decoding = 'async';
media.appendChild(img);
} else {
var ph = document.createElement('span');
ph.className = 'xshop-card__media--placeholder';
ph.style.cssText = 'display:flex;inline-size:100%;block-size:100%;align-items:center;justify-content:center;';
ph.textContent = 'No image';
media.appendChild(ph);
}
// Badges: we only have in_stock, sale via price_html contains del? We can infer sale via price_html contains <del>
var badges = document.createElement('span');
badges.className = 'xshop-product-card__badges';
badges.setAttribute('aria-hidden', 'true');
if (item.price_html && item.price_html.indexOf('<del') !== -1) {
var b = document.createElement('span');
b.className = 'xshop-badge xshop-badge--sale';
b.textContent = 'Sale';
badges.appendChild(b);
}
if (!item.in_stock) {
var o = document.createElement('span');
o.className = 'xshop-badge xshop-badge--outofstock';
o.textContent = 'Out of stock';
badges.appendChild(o);
}
media.appendChild(badges);
card.appendChild(media);
var body = document.createElement('div');
body.className = 'xshop-product-card__body';
var h3 = document.createElement('h3');
h3.className = 'xshop-product-card__title';
var a = document.createElement('a');
a.href = item.url;
a.textContent = item.title;
h3.appendChild(a);
body.appendChild(h3);
if (item.price_html) {
var price = document.createElement('div');
price.className = 'xshop-product-card__price';
price.innerHTML = item.price_html; // trusted from Woo
body.appendChild(price);
}
var meta = document.createElement('div');
meta.className = 'xshop-product-card__meta';
if (item.type === 'variable') {
var v = document.createElement('span');
v.textContent = 'Variable';
meta.appendChild(v);
}
if (!item.in_stock) {
var s = document.createElement('span');
s.textContent = 'Out of stock';
meta.appendChild(s);
}
body.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'xshop-product-card__actions';
var btn = document.createElement('a');
btn.href = item.url;
btn.className = 'button product_type_' + item.type;
btn.textContent = item.in_stock ? 'View' : 'Read more';
actions.appendChild(btn);
body.appendChild(actions);
card.appendChild(body);
wrap.appendChild(card);
return wrap;
}
function initFilters() {
var filtersRoots = document.querySelectorAll('[data-xshop-filters]');
var filtersRoot = filtersRoots[0];
var grid = document.querySelector('.xshop-products') || document.querySelector('[data-xshop-products]');
var resultsCountEl = document.querySelector('.woocommerce-result-count');
var paginationEl = document.querySelector('.woocommerce-pagination') || document.querySelector('[data-xshop-pagination]');
var orderingSelect = document.querySelector('.woocommerce-ordering select.orderby') || document.querySelector('[data-filter="orderby"]');
var chipsEl = document.querySelector('[data-xshop-chips]');
var forms = document.querySelectorAll('[data-xshop-filters-form]');
var form = forms[0];
var drawer = document.querySelector('[data-xshop-drawer]');
var drawerToggle = document.querySelector('[data-xshop-drawer-toggle]');
var drawerClose = document.querySelector('[data-xshop-drawer-close]');
var overlay = document.querySelector('[data-xshop-drawer-overlay]');
if (!filtersRoot) return;
// grid may be missing on empty category (has empty state) — create container if needed
var gridContainer = grid ? grid.parentElement : document.querySelector('.xshop-woo-layout') || document.body;
var state = parseStateFromURL();
var abortCtrl = null;
var seq = 0;
var isFirstLoad = true;
function syncFormFromState() {
forms.forEach(function (f) {
f.querySelectorAll('input[name="category"]').forEach(function (el) {
el.checked = el.value === state.category;
if (!state.category && el.value === '') el.checked = true;
});
f.querySelectorAll('input[data-filter="attribute"]').forEach(function (el) {
var tax = el.getAttribute('data-tax');
var val = el.value;
var active = state.attributes[tax] && state.attributes[tax].indexOf(val) !== -1;
el.checked = !!active;
});
var minEl = f.querySelector('input[name="min_price"]');
var maxEl = f.querySelector('input[name="max_price"]');
if (minEl) minEl.value = state.min_price || '';
if (maxEl) maxEl.value = state.max_price || '';
f.querySelectorAll('input[name="stock"]').forEach(function (el) { el.checked = el.value === (state.stock || 'all'); });
var saleEl = f.querySelector('input[name="on_sale"]');
if (saleEl) saleEl.checked = state.on_sale === '1';
f.querySelectorAll('input[name="rating"]').forEach(function (el) { el.checked = el.value === String(state.rating || '0'); });
var ob = f.querySelector('select[name="orderby"]');
if (ob) ob.value = state.orderby || 'default';
});
if (orderingSelect) orderingSelect.value = state.orderby || 'default';
}
function buildStateFromForm(srcForm) {
var f = srcForm || form;
if (!f) return state;
var fd = new FormData(f);
var newState = {
search: state.search,
category: (fd.get('category') || '').toString().trim(),
min_price: (fd.get('min_price') || '').toString().trim(),
max_price: (fd.get('max_price') || '').toString().trim(),
stock: (fd.get('stock') || 'all').toString(),
on_sale: fd.get('on_sale') === '1' ? '1' : '0',
rating: (fd.get('rating') || '0').toString(),
orderby: (fd.get('orderby') || 'default').toString(),
page: 1,
per_page: state.per_page,
attributes: {}
};
// Collect checked attributes from ALL forms (since sidebar+drawer duplicate)
document.querySelectorAll('input[data-filter="attribute"]:checked').forEach(function (el) {
var tax = el.getAttribute('data-tax');
if (!newState.attributes[tax]) newState.attributes[tax] = [];
if (newState.attributes[tax].indexOf(el.value) === -1) newState.attributes[tax].push(el.value);
});
return newState;
}
function renderChips() {
if (!chipsEl) return;
chipsEl.innerHTML = '';
var hasActive = false;
function addChip(label, onRemove) {
hasActive = true;
var chip = document.createElement('button');
chip.type = 'button';
chip.className = 'xshop-chip';
chip.textContent = label + ' ×';
chip.setAttribute('aria-label', 'Remove filter ' + label);
chip.addEventListener('click', onRemove);
chipsEl.appendChild(chip);
}
if (state.category) {
addChip('Category: ' + state.category, function () {
state.category = '';
state.page = 1;
syncAndFetch(true);
});
}
Object.keys(state.attributes).forEach(function (tax) {
(state.attributes[tax] || []).forEach(function (term) {
addChip(tax.replace('pa_', '') + ': ' + term, function () {
var idx = state.attributes[tax].indexOf(term);
if (idx !== -1) state.attributes[tax].splice(idx, 1);
if (!state.attributes[tax].length) delete state.attributes[tax];
state.page = 1;
syncAndFetch(true);
});
});
});
if (state.min_price) addChip('Min ' + state.min_price, function () { state.min_price = ''; state.page = 1; syncAndFetch(true); });
if (state.max_price) addChip('Max ' + state.max_price, function () { state.max_price = ''; state.page = 1; syncAndFetch(true); });
if (state.stock && state.stock !== 'all') addChip('Stock: ' + state.stock, function () { state.stock = 'all'; state.page = 1; syncAndFetch(true); });
if (state.on_sale === '1') addChip('On sale', function () { state.on_sale = '0'; state.page = 1; syncAndFetch(true); });
if (state.rating && state.rating !== '0') addChip('Rating ' + state.rating + '+', function () { state.rating = '0'; state.page = 1; syncAndFetch(true); });
if (state.search) addChip('Search: ' + state.search, function () { state.search = ''; state.page = 1; syncAndFetch(true); });
if (hasActive) {
var clearAll = document.createElement('button');
clearAll.type = 'button';
clearAll.className = 'xshop-chip xshop-chip--clear';
clearAll.textContent = 'Clear all';
clearAll.addEventListener('click', function () {
state = { search: '', category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
// Preserve category context? If on category archive, clearing all should keep category context? We clear category context as well per "clear all" semantics (go to shop). If on category page, keep category? For now clear all goes to shop base (no category)
syncAndFetch(true);
});
chipsEl.appendChild(clearAll);
}
}
function updateURL(push) {
var p = stateToParams(state);
var url = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
if (push) {
history.pushState(state, '', url);
} else {
history.replaceState(state, '', url);
}
}
function setLoading(loading) {
if (!gridContainer) return;
gridContainer.setAttribute('aria-busy', loading ? 'true' : 'false');
var loader = document.querySelector('[data-xshop-loading]');
if (loader) loader.hidden = !loading;
// toolbar still usable
}
function renderResults(data) {
var items = data.items || [];
var pagination = data.pagination || { page: 1, per_page: 12, total: 0, total_pages: 1 };
// Ensure grid exists
var targetGrid = document.querySelector('.xshop-products');
if (!targetGrid) {
// Create grid if was empty state
var emptyState = document.querySelector('.xshop-empty');
if (emptyState) emptyState.remove();
targetGrid = document.createElement('ul');
targetGrid.className = 'xshop-products products columns-4';
if (gridContainer) gridContainer.appendChild(targetGrid);
}
if (targetGrid) {
targetGrid.innerHTML = '';
if (!items.length) {
// Empty state
targetGrid.remove();
var empty = document.createElement('div');
empty.className = 'xshop-empty';
empty.innerHTML = '<h3 class="xshop-empty__title">No products found</h3><p class="xshop-empty__text">Try adjusting your filters.</p>';
if (gridContainer) gridContainer.appendChild(empty);
} else {
items.forEach(function (it) {
targetGrid.appendChild(renderProductCard(it));
});
}
}
// Result count
if (resultsCountEl) {
resultsCountEl.textContent = pagination.total + ' products';
} else {
var rc = document.querySelector('.woocommerce-result-count');
if (rc) rc.textContent = pagination.total + ' products';
}
// Pagination
renderPagination(pagination);
renderChips();
// Focus management: move focus to grid for screen readers
if (targetGrid && !isFirstLoad) {
targetGrid.setAttribute('tabindex', '-1');
// Don't steal focus aggressively; just ensure aria-live
}
isFirstLoad = false;
}
function renderPagination(pag) {
var container = paginationEl || document.querySelector('.woocommerce-pagination');
if (!container) return;
container.innerHTML = '';
if (pag.total_pages <= 1) return;
var nav = document.createElement('nav');
nav.setAttribute('aria-label', 'Pagination');
nav.className = 'xshop-pagination';
var inner = document.createElement('div');
inner.className = 'nav-links';
for (var i = 1; i <= pag.total_pages; i++) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'page-numbers' + (i === pag.page ? ' current' : '');
btn.textContent = String(i);
btn.setAttribute('aria-label', 'Page ' + i);
if (i === pag.page) btn.setAttribute('aria-current', 'page');
(function (pageNum) {
btn.addEventListener('click', function () {
state.page = pageNum;
syncAndFetch(true);
});
})(i);
inner.appendChild(btn);
}
nav.appendChild(inner);
container.appendChild(nav);
}
function syncAndFetch(pushURL) {
syncFormFromState(); // ensure form reflects state (for back/forward)
renderChips();
updateURL(pushURL);
fetchAndRender();
}
var debouncedPriceFetch = debounce(function () {
state.page = 1;
syncAndFetch(true);
}, DEBOUNCE_MS);
function fetchAndRender() {
var mySeq = ++seq;
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
setLoading(true);
var url = stateToEndpoint(state);
// Update URL already done
fetch(url, { signal: abortCtrl.signal, headers: { 'Accept': 'application/json' } })
.then(function (res) {
if (!res.ok) throw new Error('http ' + res.status);
return res.json();
})
.then(function (data) {
if (mySeq !== seq) return; // stale
setLoading(false);
renderResults(data);
})
.catch(function (err) {
if (err && err.name === 'AbortError') return;
if (mySeq !== seq) return;
setLoading(false);
// show error, keep existing grid
var errEl = document.querySelector('[data-xshop-error]');
if (errEl) {
errEl.hidden = false;
errEl.textContent = 'Could not load products. Please try again.';
setTimeout(function () { errEl.hidden = true; }, 3000);
}
});
}
// Attach change listeners to all forms
forms.forEach(function (f) {
f.addEventListener('change', function (e) {
var target = e.target;
if (!target) return;
if (target.getAttribute('data-filter') === 'price') return;
var newState = buildStateFromForm(f);
newState.search = state.search;
newState.per_page = state.per_page;
state = newState;
syncAndFetch(true);
});
});
var priceInputs = document.querySelectorAll('input[name="min_price"], input[name="max_price"]');
priceInputs.forEach(function (el) {
el.addEventListener('input', function () {
// Read from closest form
var f = el.closest('form');
var fd = f ? new FormData(f) : new FormData(form);
state.min_price = (fd.get('min_price') || '').toString().trim();
state.max_price = (fd.get('max_price') || '').toString().trim();
state.page = 1;
renderChips();
updateURL(true);
debouncedPriceFetch();
});
});
// Ordering select (toolbar and form)
if (orderingSelect) {
orderingSelect.addEventListener('change', function () {
state.orderby = orderingSelect.value || 'default';
state.page = 1;
// sync form select too
var formOb = form.querySelector('select[name="orderby"]');
if (formOb) formOb.value = state.orderby;
syncAndFetch(true);
});
}
var formOrderby = form.querySelector('select[name="orderby"]');
if (formOrderby && formOrderby !== orderingSelect) {
formOrderby.addEventListener('change', function () {
state.orderby = formOrderby.value || 'default';
if (orderingSelect) orderingSelect.value = state.orderby;
state.page = 1;
syncAndFetch(true);
});
}
forms.forEach(function (f) {
f.addEventListener('submit', function (e) {
e.preventDefault();
var newState = buildStateFromForm(f);
newState.search = state.search;
newState.per_page = state.per_page;
state = newState;
syncAndFetch(true);
closeDrawer();
});
var clearAllLink = f.querySelector('[data-xshop-clear-all]');
if (clearAllLink) {
clearAllLink.addEventListener('click', function (e) {
e.preventDefault();
state = { search: state.search, category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
syncAndFetch(true);
});
}
});
// Drawer
function openDrawer() {
if (!drawer) return;
drawer.hidden = false;
drawer.classList.add('is-open');
if (overlay) overlay.hidden = false;
var first = drawer.querySelector('input, select, button');
if (first) first.focus();
document.body.style.overflow = 'hidden';
if (drawerToggle) drawerToggle.setAttribute('aria-expanded', 'true');
}
function closeDrawer() {
if (!drawer) return;
drawer.hidden = true;
drawer.classList.remove('is-open');
if (overlay) overlay.hidden = true;
document.body.style.overflow = '';
if (drawerToggle) {
drawerToggle.setAttribute('aria-expanded', 'false');
drawerToggle.focus();
}
}
if (drawerToggle) {
drawerToggle.addEventListener('click', function () {
var isOpen = drawer && !drawer.hidden;
if (isOpen) closeDrawer(); else openDrawer();
});
}
if (drawerClose) drawerClose.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && drawer && !drawer.hidden) closeDrawer();
});
// Browser back/forward
window.addEventListener('popstate', function (e) {
var newState = e.state ? e.state : parseStateFromURL();
// e.state may be null on initial load; parse from URL
if (!e.state) newState = parseStateFromURL();
state = newState;
syncFormFromState();
renderChips();
fetchAndRender();
// Don't push URL again
});
// Initial: parse URL, sync form, render chips, but don't fetch if server-rendered matches? We will fetch to ensure AJAX grid matches server (in case of direct URL with filters, server already rendered correctly, but we want to ensure pagination etc sync). To avoid double fetch on initial load with no filters, we skip fetch if no filters and not paginated.
syncFormFromState();
renderChips();
// If URL has any filter param (category/attributes/price etc) or page>1 or search, we should ensure grid reflects it (server already did if no-JS). But to keep AJAX in sync, we could fetch once if filters present? For now, we only fetch on user interaction; initial server render is sufficient. But to support back/forward after JS navigation, we need fetch on popstate. So initial load does not auto-fetch unless needed.
// However, if user lands on /shop/?category=... directly, server already rendered filtered products (no-JS fallback). That's correct. No need to double fetch.
// Expose for testing
window.xshopFilters = { state: state, fetch: fetchAndRender, parse: parseStateFromURL };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFilters);
} else {
initFilters();
}
})();