feat: implement WooCommerce AJAX product filtering
This commit is contained in:
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user