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