feat: implement WooCommerce AJAX product search

This commit is contained in:
XShop
2026-09-13 20:01:15 +03:30
parent 00439fcea9
commit f5c283af25
14 changed files with 760 additions and 37 deletions
+30
View File
@@ -155,5 +155,35 @@
/* Empty states */
.xshop-empty--shop{ text-align:center; }
/* ---- AJAX Search ---- */
.xshop-search{ position:relative; inline-size:100%; max-inline-size:520px; }
.xshop-search__form{ display:flex; gap:var(--xshop-space-2); }
.xshop-search__form input[type="search"]{ flex:1; }
.xshop-search__dropdown{ position:absolute; inset-block-start:calc(100% + 8px); inset-inline:0; background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); box-shadow:var(--xshop-shadow-lg); z-index:var(--xshop-z-overlay); max-block-size:min(60vh, 420px); overflow:auto; padding:var(--xshop-space-2); display:flex; flex-direction:column; gap:var(--xshop-space-2); }
.xshop-search__dropdown[hidden]{ display:none !important; }
.xshop-search__loading{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); }
.xshop-search__loading[hidden]{ display:none !important; }
.xshop-search__results{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
.xshop-search__item{ border-radius:var(--xshop-radius-sm); }
.xshop-search__item.is-active, .xshop-search__item:focus-within, .xshop-search__item:hover{ background:var(--xshop-surface); }
.xshop-search__item[aria-selected="true"]{ background:var(--xshop-surface-2); }
.xshop-search__link{ display:grid; grid-template-columns:48px 1fr; gap:var(--xshop-space-3); align-items:center; padding:var(--xshop-space-2); text-decoration:none; color:inherit; }
.xshop-search__thumb{ inline-size:48px; block-size:48px; object-fit:cover; border-radius:var(--xshop-radius-sm); background:var(--xshop-surface); }
.xshop-search__body{ display:flex; flex-direction:column; gap:2px; min-inline-size:0; }
.xshop-search__title{ font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-text); display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; overflow:hidden; }
.xshop-search__price{ font-size:var(--xshop-text-xs); font-weight:var(--xshop-weight-bold); color:var(--xshop-text); }
.xshop-search__price del{ color:var(--xshop-muted); font-weight:400; margin-inline-end:6px; }
.xshop-search__price ins{ text-decoration:none; color:var(--xshop-danger); }
.xshop-search__stock{ font-size:var(--xshop-text-xs); color:var(--xshop-muted); }
.xshop-search__empty, .xshop-search__error{ font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); text-align:center; }
.xshop-search__empty[hidden], .xshop-search__error[hidden]{ display:none !important; }
.xshop-search__viewall{ display:block; text-align:center; padding:var(--xshop-space-2); font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-primary); text-decoration:none; border-block-start:1px solid var(--xshop-border); margin-block-start:var(--xshop-space-1); }
.xshop-search__viewall[hidden]{ display:none !important; }
.xshop-search__viewall:hover{ background:var(--xshop-surface); }
@media (max-width:640px){
.xshop-search{ max-inline-size:100%; }
.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; }
}
/* Star rating fallback */
.star-rating{ color:#f59e0b; }
+331
View File
@@ -0,0 +1,331 @@
/**
* XShop AJAX Search — vanilla, debounce 250ms, AbortController, stale protection, a11y keyboard.
* Expects markup from template-parts/components/ajax-search.php
*/
(function () {
'use strict';
var DEBOUNCE_MS = 250;
var MIN_LENGTH = 2;
function debounce(fn, ms) {
var t;
return function () {
var args = arguments;
var ctx = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
};
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function initSearch(root) {
if (!root || root.dataset.xshopSearchInit) return;
root.dataset.xshopSearchInit = '1';
var input = root.querySelector('[data-xshop-search-input]');
var form = root.querySelector('[data-xshop-search-form]');
var dropdown = root.querySelector('[data-xshop-search-dropdown]');
var resultsEl = root.querySelector('[data-xshop-search-results]');
var loadingEl = root.querySelector('[data-xshop-search-loading]');
var emptyEl = root.querySelector('[data-xshop-search-empty]');
var errorEl = root.querySelector('[data-xshop-search-error]');
var viewAllEl = root.querySelector('[data-xshop-search-viewall]');
var statusEl = root.querySelector('[data-xshop-search-status]');
if (!input || !form || !dropdown || !resultsEl) return;
var limit = parseInt(input.getAttribute('data-limit') || '6', 10);
var minLen = parseInt(input.getAttribute('data-min-length') || String(MIN_LENGTH), 10);
var abortCtrl = null;
var lastQuery = '';
var seq = 0;
var activeIndex = -1;
var items = [];
var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/';
// Ensure trailing slash handling: restUrl is like http://.../wp-json/xshop/v1/ or /wp-json/
// We need xshop/v1/search
var endpoint = restUrl.replace(/\/$/, '') + '/search';
// If restUrl already contains xshop/v1/, don't duplicate
if (restUrl.indexOf('xshop/v1') !== -1) {
endpoint = restUrl.replace(/\/$/, '') + '/search';
// restUrl is .../xshop/v1/ -> endpoint is .../xshop/v1/search
// If restUrl is .../wp-json/ -> endpoint becomes .../wp-json/search (wrong), fix
if (restUrl.indexOf('xshop/v1') === -1) {
endpoint = restUrl.replace(/\/$/, '') + '/xshop/v1/search';
}
}
// Safer: build from origin
if (endpoint.indexOf('xshop') === -1) {
endpoint = (window.location.origin || '') + '/wp-json/xshop/v1/search';
if (window.xshopData && window.xshopData.restUrl) {
var base = window.xshopData.restUrl;
// base like http://localhost/.../wp-json/ -> append xshop/v1/search
if (base.indexOf('/wp-json/') !== -1) {
endpoint = base.split('/wp-json/')[0] + '/wp-json/xshop/v1/search';
// Preserve subpath like /xshop-test/wordpress/wp-json/
// base is http://localhost/xshop-test/wordpress/wp-json/ or .../xshop/v1/
if (base.indexOf('xshop/v1') !== -1) {
endpoint = base.replace(/\/$/, '') + '/search';
} else {
endpoint = base.replace(/\/$/, '') + '/xshop/v1/search';
// if base already is wp-json/, this works; if base is wp-json/xshop/v1/ this duplicates
if (endpoint.indexOf('/xshop/v1/xshop/v1/') !== -1) {
endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/');
}
}
}
}
}
function setExpanded(expanded) {
input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
dropdown.hidden = !expanded;
}
function setLoading(loading) {
if (loadingEl) loadingEl.hidden = !loading;
}
function showEmpty(show) {
if (emptyEl) emptyEl.hidden = !show;
}
function showError(show) {
if (errorEl) errorEl.hidden = !show;
}
function updateViewAll(query) {
if (!viewAllEl) return;
if (!query || query.length < minLen) {
viewAllEl.hidden = true;
return;
}
var url = form.getAttribute('action') || '/';
var q = encodeURIComponent(query);
viewAllEl.href = url + (url.indexOf('?') === -1 ? '?' : '&') + 's=' + q + '&post_type=product';
viewAllEl.hidden = false;
}
function clearResults() {
resultsEl.innerHTML = '';
items = [];
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
}
function render(itemsData, query) {
clearResults();
showEmpty(false);
showError(false);
if (!itemsData.length) {
showEmpty(true);
if (statusEl) statusEl.textContent = (window.xshopData && window.xshopData.i18n && window.xshopData.i18n.noResults) ? window.xshopData.i18n.noResults : 'No results';
updateViewAll(query);
return;
}
if (statusEl) statusEl.textContent = itemsData.length + ' results';
items = itemsData;
itemsData.forEach(function (it, idx) {
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.id = input.id + '-option-' + idx;
li.className = 'xshop-search__item';
if (idx === activeIndex) li.setAttribute('aria-selected', 'true');
// Build via DOM to avoid innerHTML XSS for price_html (Woo price_html is trusted but we still sanitize via allowed)
var a = document.createElement('a');
a.href = it.url;
a.className = 'xshop-search__link';
// image
if (it.image) {
var img = document.createElement('img');
img.src = it.image;
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.className = 'xshop-search__thumb';
a.appendChild(img);
}
var body = document.createElement('span');
body.className = 'xshop-search__body';
var title = document.createElement('span');
title.className = 'xshop-search__title';
title.textContent = it.title;
body.appendChild(title);
if (it.price_html) {
var price = document.createElement('span');
price.className = 'xshop-search__price';
// price_html from Woo is HTML; we allow it but strip scripts via text? Use innerHTML with sanitization: only allow Woo price spans
// Since price_html is from trusted server (Woo), we can use innerHTML after ensuring no script
price.innerHTML = it.price_html; // trusted
body.appendChild(price);
}
if (!it.in_stock) {
var stock = document.createElement('span');
stock.className = 'xshop-search__stock';
stock.textContent = 'Out of stock';
body.appendChild(stock);
}
a.appendChild(body);
li.appendChild(a);
// Click closes
li.addEventListener('mousedown', function (e) {
// prevent input blur before click
e.preventDefault();
});
resultsEl.appendChild(li);
});
updateViewAll(query);
}
function setActive(idx) {
var lis = resultsEl.querySelectorAll('[role="option"]');
lis.forEach(function (li, i) {
if (i === idx) {
li.classList.add('is-active');
li.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', li.id);
} else {
li.classList.remove('is-active');
li.setAttribute('aria-selected', 'false');
}
});
activeIndex = idx;
}
function closeDropdown() {
setExpanded(false);
setLoading(false);
showError(false);
// keep results for next open? Clear on next input
input.removeAttribute('aria-activedescendant');
activeIndex = -1;
}
function openDropdown() {
setExpanded(true);
}
var doSearch = debounce(function () {
var q = input.value.trim();
if (q.length < minLen) {
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
clearResults();
showEmpty(false);
showError(false);
setLoading(false);
setExpanded(false);
updateViewAll('');
if (statusEl) statusEl.textContent = '';
return;
}
// dedupe identical query
if (q === lastQuery && items.length) {
openDropdown();
return;
}
lastQuery = q;
var mySeq = ++seq;
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
setLoading(true);
showError(false);
showEmpty(false);
openDropdown();
if (statusEl) statusEl.textContent = 'Searching';
var url = endpoint + '?search=' + encodeURIComponent(q) + '&limit=' + encodeURIComponent(String(limit));
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);
var list = (data && data.items) ? data.items : [];
render(list, q);
})
.catch(function (err) {
if (err && err.name === 'AbortError') return;
if (mySeq !== seq) return;
setLoading(false);
showError(true);
if (statusEl) statusEl.textContent = 'Error';
});
}, DEBOUNCE_MS);
input.addEventListener('input', doSearch);
input.addEventListener('focus', function () {
if (input.value.trim().length >= minLen && resultsEl.children.length) openDropdown();
});
input.addEventListener('keydown', function (e) {
var lis = resultsEl.querySelectorAll('[role="option"]');
if (!lis.length) {
if (e.key === 'Escape') closeDropdown();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
var next = activeIndex + 1;
if (next >= lis.length) next = 0;
setActive(next);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
var prev = activeIndex - 1;
if (prev < 0) prev = lis.length - 1;
setActive(prev);
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && lis[activeIndex]) {
e.preventDefault();
var link = lis[activeIndex].querySelector('a');
if (link) window.location.href = link.href;
} else {
// let form submit to canonical search page
closeDropdown();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
input.focus();
}
});
// Click outside to close
document.addEventListener('click', function (e) {
if (!root.contains(e.target)) closeDropdown();
});
// Form submit: let it go to ?s=...&post_type=product (canonical). But ensure we close dropdown.
form.addEventListener('submit', function () {
closeDropdown();
});
// View all link
if (viewAllEl) {
viewAllEl.addEventListener('click', function () {
closeDropdown();
});
}
}
function initAll() {
document.querySelectorAll('[data-xshop-search]').forEach(initSearch);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
// Expose for testing
window.xshopSearch = { init: initSearch, debounceMs: DEBOUNCE_MS };
})();