feat: implement WooCommerce AJAX product filtering
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -23,12 +23,10 @@ function xshop_enqueue_assets(): void {
|
||||
// Theme JS is tiny; feature modules conditionally enqueued elsewhere.
|
||||
wp_enqueue_script('xshop', XSHOP_THEME_URI . '/assets/js/theme.js', [], $ver, true);
|
||||
|
||||
// Search JS — only where ajax-search component is rendered (header has it globally, so enqueue on front-end when search UI exists)
|
||||
// We check for header search presence via has_action or simply enqueue on all front-end where header is rendered (is not admin/customizer preview irrelevant)
|
||||
// To satisfy "only where useful" while keeping header simple, enqueue on non-admin front-end (header renders on all front-end pages)
|
||||
if (!is_admin() && !wp_is_json_request()) {
|
||||
// Only enqueue if theme header will render (which is all front-end). We further guard via DOM marker in JS (init guard).
|
||||
wp_enqueue_script('xshop-search', XSHOP_THEME_URI . '/assets/js/search.js', [], $ver, true);
|
||||
// Filter JS — header search exists globally, filters exist on shop archives. Enqueue front-end globally (8KB) with DOM guard (init only if [data-xshop-filters] present).
|
||||
wp_enqueue_script('xshop-filter', XSHOP_THEME_URI . '/assets/js/filter.js', [], $ver, true);
|
||||
}
|
||||
|
||||
// Localized data for REST/AJAX (nonces, i18n hints).
|
||||
|
||||
@@ -33,7 +33,87 @@ function xshop_woo_setup(): void {
|
||||
|
||||
// Related/upsells: ensure they use product-card grid (filter columns)
|
||||
add_filter('woocommerce_output_related_products_args', 'xshop_wc_related_args');
|
||||
add_filter('woocommerce_upsells_total', '__return_false'); // keep? actually we keep upsells - no filter, just ensure columns via CSS grid
|
||||
add_filter('woocommerce_upsells_total', '__return_false');
|
||||
|
||||
// M4: No-JS fallback — apply filters to main query on shop/category archive
|
||||
add_action('pre_get_posts', 'xshop_wc_filter_main_query', 20);
|
||||
}
|
||||
|
||||
function xshop_wc_filter_main_query(\WP_Query $q): void {
|
||||
if (\is_admin() || !$q->is_main_query()) { return; }
|
||||
if (!\class_exists('WooCommerce')) { return; }
|
||||
// Only on product archive (shop or product_taxonomy)
|
||||
if (!(\is_shop() || \is_product_taxonomy())) { return; }
|
||||
// Don't interfere with REST
|
||||
if (\defined('REST_REQUEST') && \REST_REQUEST) { return; }
|
||||
|
||||
// If no filter params, leave default Woo query
|
||||
$hasFilter = isset($_GET['min_price']) || isset($_GET['max_price']) || isset($_GET['stock']) || isset($_GET['on_sale']) || isset($_GET['sale']) || isset($_GET['rating']) || isset($_GET['category']) || isset($_GET['orderby']) || isset($_GET['s']);
|
||||
// Also check pa_* attributes
|
||||
foreach ($_GET as $k => $v) {
|
||||
if (\str_starts_with((string) $k, 'pa_') || \str_starts_with((string) $k, 'filter_') || \str_starts_with((string) $k, 'attribute_')) { $hasFilter = true; break; }
|
||||
}
|
||||
if (!$hasFilter) { return; }
|
||||
|
||||
// Build params from $_GET (same canonical as REST)
|
||||
$params = [
|
||||
'search' => isset($_GET['s']) ? \sanitize_text_field(\wp_unslash($_GET['s'])) : '',
|
||||
'category' => isset($_GET['category']) ? \sanitize_text_field(\wp_unslash($_GET['category'])) : '',
|
||||
'attributes' => [],
|
||||
'min_price' => isset($_GET['min_price']) ? \sanitize_text_field(\wp_unslash($_GET['min_price'])) : null,
|
||||
'max_price' => isset($_GET['max_price']) ? \sanitize_text_field(\wp_unslash($_GET['max_price'])) : null,
|
||||
'stock' => isset($_GET['stock']) ? \sanitize_key(\wp_unslash($_GET['stock'])) : 'all',
|
||||
'on_sale' => (isset($_GET['on_sale']) && $_GET['on_sale'] === '1') || (isset($_GET['sale']) && $_GET['sale'] === '1'),
|
||||
'rating' => isset($_GET['rating']) ? \absint($_GET['rating']) : 0,
|
||||
'orderby' => isset($_GET['orderby']) ? \sanitize_key(\wp_unslash($_GET['orderby'])) : 'default',
|
||||
'page' => isset($_GET['page']) ? \absint($_GET['page']) : (isset($_GET['paged']) ? \absint($_GET['paged']) : 1),
|
||||
'per_page' => 12,
|
||||
];
|
||||
// Collect attributes from pa_* etc.
|
||||
foreach ($_GET as $k => $v) {
|
||||
$kClean = \sanitize_key((string) $k);
|
||||
if (\str_starts_with($kClean, 'pa_') || \str_starts_with($kClean, 'attribute_')) {
|
||||
$tax = $kClean;
|
||||
if (\str_starts_with($tax, 'attribute_')) { $tax = 'pa_' . \substr($tax, 10); }
|
||||
$terms = [];
|
||||
if (\is_array($v)) { foreach ($v as $t) { $t = \sanitize_title((string) $t); if ($t !== '') { $terms[] = $t; } } }
|
||||
else { foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } } }
|
||||
if (!empty($terms) && \taxonomy_exists($tax)) { $params['attributes'][$tax] = $terms; }
|
||||
}
|
||||
if (\str_starts_with($kClean, 'filter_')) {
|
||||
$tax = 'pa_' . \substr($kClean, 7);
|
||||
if (\taxonomy_exists($tax)) {
|
||||
$terms = [];
|
||||
foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } }
|
||||
if (!empty($terms)) { $params['attributes'][$tax] = $terms; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// If on category archive and category param empty, preserve queried category
|
||||
if (\is_product_category() && empty($params['category'])) {
|
||||
$term = \get_queried_object();
|
||||
if ($term instanceof \WP_Term) { $params['category'] = $term->slug; }
|
||||
}
|
||||
|
||||
// Use shared ProductQuery to build args, then merge into main query
|
||||
if (!\class_exists('XShop\Core\Query\ProductQuery')) {
|
||||
// Fallback if plugin not loaded yet (should be loaded via xshop-core)
|
||||
return;
|
||||
}
|
||||
// ProductQuery class is functions-based, not class; use function directly if available
|
||||
if (\function_exists('XShop\Core\Query\build_product_query_args')) {
|
||||
$wpArgs = \XShop\Core\Query\build_product_query_args($params);
|
||||
// Merge into main query (only tax_query/meta_query/orderby etc, not post_type/status which already correct)
|
||||
if (!empty($wpArgs['tax_query'])) { $q->set('tax_query', $wpArgs['tax_query']); }
|
||||
if (!empty($wpArgs['meta_query'])) { $q->set('meta_query', $wpArgs['meta_query']); }
|
||||
if (isset($wpArgs['meta_key'])) { $q->set('meta_key', $wpArgs['meta_key']); }
|
||||
if (isset($wpArgs['orderby'])) { $q->set('orderby', $wpArgs['orderby']); }
|
||||
if (isset($wpArgs['order'])) { $q->set('order', $wpArgs['order']); }
|
||||
if (isset($wpArgs['s'])) { $q->set('s', $wpArgs['s']); }
|
||||
// Ensure per_page/page respects
|
||||
$q->set('posts_per_page', $wpArgs['posts_per_page']);
|
||||
$q->set('paged', $wpArgs['paged']);
|
||||
}
|
||||
}
|
||||
|
||||
function xshop_wc_remove_default_gallery(): void {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* Product Filters — reusable sidebar (desktop) + drawer (mobile) + no-JS fallback.
|
||||
*
|
||||
* Expects context via $args or current queried object (shop/category).
|
||||
* Uses same canonical query params as REST /xshop/v1/products and URL sync.
|
||||
*
|
||||
* @package XShop
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
if (!xshop_has_woocommerce()) { return; }
|
||||
|
||||
// Current state from URL (canonical)
|
||||
$currentCategory = isset($_GET['category']) ? sanitize_text_field(wp_unslash($_GET['category'])) : '';
|
||||
if (is_product_category() && $currentCategory === '') {
|
||||
$term = get_queried_object();
|
||||
if ($term instanceof WP_Term) { $currentCategory = $term->slug; }
|
||||
}
|
||||
$currentMinPrice = isset($_GET['min_price']) ? sanitize_text_field(wp_unslash($_GET['min_price'])) : '';
|
||||
$currentMaxPrice = isset($_GET['max_price']) ? sanitize_text_field(wp_unslash($_GET['max_price'])) : '';
|
||||
$currentStock = isset($_GET['stock']) ? sanitize_key(wp_unslash($_GET['stock'])) : 'all';
|
||||
$currentOnSale = isset($_GET['on_sale']) ? '1' : (isset($_GET['sale']) ? '1' : '0');
|
||||
if (isset($_GET['on_sale'])) { $currentOnSale = $_GET['on_sale'] === '1' ? '1' : '0'; }
|
||||
if (isset($_GET['sale']) && $_GET['sale'] === '1') { $currentOnSale = '1'; }
|
||||
$currentRating = isset($_GET['rating']) ? absint($_GET['rating']) : 0;
|
||||
$currentOrderby = isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : 'default';
|
||||
|
||||
// Categories
|
||||
$categories = get_terms(['taxonomy'=>'product_cat','hide_empty'=>true,'number'=>20]);
|
||||
if (is_wp_error($categories)) { $categories = []; }
|
||||
|
||||
// Attributes — discover public Woo attribute taxonomies pa_*
|
||||
$attributeTaxonomies = [];
|
||||
if (function_exists('wc_get_attribute_taxonomies')) {
|
||||
$taxes = wc_get_attribute_taxonomies();
|
||||
foreach ($taxes as $tax) {
|
||||
$name = 'pa_' . $tax->attribute_name;
|
||||
if (taxonomy_exists($name)) { $attributeTaxonomies[] = $name; }
|
||||
}
|
||||
}
|
||||
// Also allow fallback to any pa_* that exists and has terms
|
||||
if (empty($attributeTaxonomies)) {
|
||||
$allTax = get_taxonomies(['public'=>false]);
|
||||
foreach ($allTax as $t) { if (str_starts_with($t,'pa_') && taxonomy_exists($t)) { $attributeTaxonomies[] = $t; } }
|
||||
}
|
||||
|
||||
// For no-JS fallback, we render a <form method="get" action="shop/category URL"> with same params
|
||||
$shopUrl = wc_get_page_permalink('shop');
|
||||
if (is_product_category()) {
|
||||
$term = get_queried_object();
|
||||
if ($term instanceof WP_Term) { $shopUrl = get_term_link($term); }
|
||||
}
|
||||
if (is_wp_error($shopUrl) || !$shopUrl) { $shopUrl = home_url('/shop/'); }
|
||||
|
||||
// Active chips data for JS
|
||||
?>
|
||||
<div class="xshop-filters" data-xshop-filters data-shop-url="<?php echo esc_url($shopUrl); ?>" data-category-context="<?php echo esc_attr($currentCategory); ?>">
|
||||
<!-- Active chips -->
|
||||
<div class="xshop-filters__chips" data-xshop-chips aria-live="polite"></div>
|
||||
|
||||
<form method="get" action="<?php echo esc_url($shopUrl); ?>" class="xshop-filters__form" data-xshop-filters-form>
|
||||
<?php
|
||||
// Preserve search keyword if present (from M3)
|
||||
if (isset($_GET['s'])) {
|
||||
echo '<input type="hidden" name="s" value="' . esc_attr(sanitize_text_field(wp_unslash($_GET['s']))) . '" />';
|
||||
}
|
||||
// Preserve category context as hidden if on category archive (so clearing filters doesn't drop category unless user clears chip)
|
||||
if (is_product_category() && $currentCategory !== '') {
|
||||
echo '<input type="hidden" name="category" value="' . esc_attr($currentCategory) . '" data-xshop-category-context />';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Categories (if not already on category context? Show anyway with active state) -->
|
||||
<?php if (!empty($categories)): ?>
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Categories', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php foreach ($categories as $cat):
|
||||
$isActive = $currentCategory === $cat->slug;
|
||||
?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="radio" name="category" value="<?php echo esc_attr($cat->slug); ?>" <?php checked($isActive); ?> data-filter="category" />
|
||||
<span><?php echo esc_html($cat->name); ?></span>
|
||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$cat->count)); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="radio" name="category" value="" <?php checked($currentCategory === ''); ?> data-filter="category" />
|
||||
<span><?php esc_html_e('All categories', 'xshop'); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Attributes -->
|
||||
<?php foreach ($attributeTaxonomies as $tax):
|
||||
$terms = get_terms(['taxonomy'=>$tax,'hide_empty'=>true,'number'=>30]);
|
||||
if (is_wp_error($terms) || empty($terms)) { continue; }
|
||||
$taxLabel = wc_attribute_label($tax);
|
||||
$activeTerms = [];
|
||||
if (isset($_GET[$tax])) {
|
||||
$raw = wp_unslash($_GET[$tax]);
|
||||
if (is_string($raw)) {
|
||||
foreach (explode(',', $raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
||||
}
|
||||
}
|
||||
// Also support filter_ prefix legacy
|
||||
$filterKey = 'filter_' . substr($tax, 3);
|
||||
if (isset($_GET[$filterKey])) {
|
||||
$raw = wp_unslash($_GET[$filterKey]);
|
||||
foreach (explode(',', (string)$raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
||||
}
|
||||
?>
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php echo esc_html($taxLabel); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php foreach ($terms as $term): $isActive = in_array($term->slug, $activeTerms, true); ?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="checkbox" name="<?php echo esc_attr($tax); ?>[]" value="<?php echo esc_attr($term->slug); ?>" <?php checked($isActive); ?> data-filter="attribute" data-tax="<?php echo esc_attr($tax); ?>" />
|
||||
<span><?php echo esc_html($term->name); ?></span>
|
||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$term->count)); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Price -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Price', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__price">
|
||||
<label>
|
||||
<span class="xshop-sr-only"><?php esc_html_e('Min price', 'xshop'); ?></span>
|
||||
<input type="number" name="min_price" placeholder="<?php esc_attr_e('Min', 'xshop'); ?>" value="<?php echo esc_attr($currentMinPrice); ?>" min="0" step="1" data-filter="price" />
|
||||
</label>
|
||||
<span aria-hidden="true">—</span>
|
||||
<label>
|
||||
<span class="xshop-sr-only"><?php esc_html_e('Max price', 'xshop'); ?></span>
|
||||
<input type="number" name="max_price" placeholder="<?php esc_attr_e('Max', 'xshop'); ?>" value="<?php echo esc_attr($currentMaxPrice); ?>" min="0" step="1" data-filter="price" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Stock -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Stock', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="all" <?php checked($currentStock, 'all'); ?> data-filter="stock" /> <span><?php esc_html_e('All', 'xshop'); ?></span></label>
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="instock" <?php checked($currentStock, 'instock'); ?> data-filter="stock" /> <span><?php esc_html_e('In stock', 'xshop'); ?></span></label>
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="outofstock" <?php checked($currentStock, 'outofstock'); ?> data-filter="stock" /> <span><?php esc_html_e('Out of stock', 'xshop'); ?></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Sale -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Sale', 'xshop'); ?></legend>
|
||||
<label class="xshop-filter__option"><input type="checkbox" name="on_sale" value="1" <?php checked($currentOnSale, '1'); ?> data-filter="sale" /> <span><?php esc_html_e('On sale', 'xshop'); ?></span></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Rating -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Rating', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php for ($r=5;$r>=1;$r--): $isActive = $currentRating === $r; ?>
|
||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="<?php echo esc_attr((string)$r); ?>" <?php checked($isActive); ?> data-filter="rating" /> <span><?php echo esc_html(sprintf(esc_html__('%d+ stars', 'xshop'), $r)); ?></span></label>
|
||||
<?php endfor; ?>
|
||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="0" <?php checked($currentRating, 0); ?> data-filter="rating" /> <span><?php esc_html_e('Any rating', 'xshop'); ?></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Sorting (also in toolbar, but keep here for no-JS) -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Sort', 'xshop'); ?></legend>
|
||||
<select name="orderby" data-filter="orderby" aria-label="<?php esc_attr_e('Sort', 'xshop'); ?>">
|
||||
<option value="default" <?php selected($currentOrderby, 'default'); ?>><?php esc_html_e('Default', 'xshop'); ?></option>
|
||||
<option value="latest" <?php selected($currentOrderby, 'latest'); ?>><?php esc_html_e('Latest', 'xshop'); ?></option>
|
||||
<option value="price_asc" <?php selected($currentOrderby, 'price_asc'); ?>><?php esc_html_e('Price: low to high', 'xshop'); ?></option>
|
||||
<option value="price_desc" <?php selected($currentOrderby, 'price_desc'); ?>><?php esc_html_e('Price: high to low', 'xshop'); ?></option>
|
||||
<option value="popularity" <?php selected($currentOrderby, 'popularity'); ?>><?php esc_html_e('Popular', 'xshop'); ?></option>
|
||||
<option value="rating" <?php selected($currentOrderby, 'rating'); ?>><?php esc_html_e('Rated', 'xshop'); ?></option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<div class="xshop-filters__actions">
|
||||
<button type="submit" class="xshop-btn xshop-btn--primary"><?php esc_html_e('Apply', 'xshop'); ?></button>
|
||||
<a href="<?php echo esc_url($shopUrl); ?>" class="xshop-btn xshop-btn--ghost" data-xshop-clear-all><?php esc_html_e('Clear all', 'xshop'); ?></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Shop / category archive — uses Woo hooks, XShop toolbar/grid/empty states.
|
||||
* Shop / category archive — XShop filters + AJAX grid + no-JS fallback.
|
||||
*
|
||||
* Override is minimal: we keep Woo's before/after hooks but ensure XShop
|
||||
* container, breadcrumbs, title, sorting, result count, pagination, and empty state
|
||||
* are rendered with XShop components/tokens. For full logic see
|
||||
* inc/woocommerce/setup.php hooks (xshop_wc_loop_start, toolbar).
|
||||
* Integrates product-filters sidebar (desktop) + drawer (mobile) + toolbar.
|
||||
* Server render uses Woo hooks + ProductQuery via pre_get_posts (inc/woocommerce/setup.php) for no-JS.
|
||||
* JS enhances via REST /xshop/v1/products.
|
||||
*
|
||||
* WC version tested: 11.1.0
|
||||
* Template version: 3.4.0
|
||||
@@ -33,36 +32,72 @@ if (is_product_category() || is_product_tag() || is_tax('product_brand')) {
|
||||
$title = $shopId ? get_the_title($shopId) : esc_html__('Shop', 'xshop');
|
||||
echo '<header class="xshop-page-header"><h1 class="xshop-page-header__title">' . esc_html($title) . '</h1></header>';
|
||||
}
|
||||
?>
|
||||
|
||||
do_action('woocommerce_before_shop_loop');
|
||||
<div class="xshop-shop" data-xshop-shop>
|
||||
<!-- Mobile filter toggle -->
|
||||
<button type="button" class="xshop-btn xshop-btn--secondary xshop-shop__filter-toggle" data-xshop-drawer-toggle aria-expanded="false" aria-controls="xshop-filter-drawer">
|
||||
<?php esc_html_e('Filters', 'xshop'); ?>
|
||||
</button>
|
||||
|
||||
if (woocommerce_product_loop()) {
|
||||
woocommerce_product_loop_start();
|
||||
if (wc_get_loop_prop('is_shortcode')) {
|
||||
$columns = absint(wc_get_loop_prop('columns'));
|
||||
} else {
|
||||
$columns = absint(get_option('woocommerce_catalog_columns', 4));
|
||||
}
|
||||
<div class="xshop-shop__layout">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="xshop-shop__sidebar" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
||||
</aside>
|
||||
|
||||
while (have_posts()) {
|
||||
the_post();
|
||||
do_action('woocommerce_shop_loop');
|
||||
wc_get_template_part('content', 'product');
|
||||
}
|
||||
<!-- Mobile drawer -->
|
||||
<div class="xshop-drawer" id="xshop-filter-drawer" hidden data-xshop-drawer role="dialog" aria-modal="true" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
||||
<div class="xshop-drawer__header">
|
||||
<h2 class="xshop-drawer__title"><?php esc_html_e('Filters', 'xshop'); ?></h2>
|
||||
<button type="button" class="xshop-btn xshop-btn--ghost" data-xshop-drawer-close aria-label="<?php esc_attr_e('Close filters', 'xshop'); ?>">×</button>
|
||||
</div>
|
||||
<div class="xshop-drawer__body">
|
||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xshop-drawer__overlay" hidden data-xshop-drawer-overlay></div>
|
||||
|
||||
woocommerce_product_loop_end();
|
||||
do_action('woocommerce_after_shop_loop');
|
||||
} else {
|
||||
do_action('woocommerce_no_products_found');
|
||||
// Enhanced empty state for shop/category
|
||||
get_template_part('template-parts/components/empty-state', null, [
|
||||
'title' => esc_html__('No products found', 'xshop'),
|
||||
'text' => esc_html__('Try adjusting your filters or browse all products.', 'xshop'),
|
||||
'cta_label' => esc_html__('Browse shop', 'xshop'),
|
||||
'cta_url' => wc_get_page_permalink('shop'),
|
||||
]);
|
||||
}
|
||||
<!-- Main -->
|
||||
<div class="xshop-shop__main" data-xshop-shop-main>
|
||||
<div class="xshop-shop__toolbar" data-xshop-toolbar>
|
||||
<?php do_action('woocommerce_before_shop_loop'); ?>
|
||||
</div>
|
||||
|
||||
<div class="xshop-search__loading" hidden data-xshop-loading style="display:none">
|
||||
<span class="xshop-loading__spinner" aria-hidden="true"></span> <?php esc_html_e('Loading…', 'xshop'); ?>
|
||||
</div>
|
||||
<div class="xshop-search__error" hidden data-xshop-error role="alert"></div>
|
||||
|
||||
<div data-xshop-products-container>
|
||||
<?php
|
||||
if (woocommerce_product_loop()) {
|
||||
woocommerce_product_loop_start();
|
||||
while (have_posts()) { the_post(); do_action('woocommerce_shop_loop'); wc_get_template_part('content', 'product'); }
|
||||
woocommerce_product_loop_end();
|
||||
do_action('woocommerce_after_shop_loop');
|
||||
} else {
|
||||
do_action('woocommerce_no_products_found');
|
||||
get_template_part('template-parts/components/empty-state', null, [
|
||||
'title' => esc_html__('No products found', 'xshop'),
|
||||
'text' => esc_html__('Try adjusting your filters or browse all products.', 'xshop'),
|
||||
'cta_label' => esc_html__('Browse shop', 'xshop'),
|
||||
'cta_url' => wc_get_page_permalink('shop'),
|
||||
]);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div data-xshop-pagination>
|
||||
<?php
|
||||
// Woo pagination rendered via woocommerce_after_shop_loop already inside loop; for AJAX we render custom via JS
|
||||
// Ensure result count is accessible
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
do_action('woocommerce_after_main_content');
|
||||
|
||||
get_footer('shop');
|
||||
|
||||
Reference in New Issue
Block a user