feat: implement WooCommerce AJAX product search
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -23,6 +23,14 @@ 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);
|
||||
}
|
||||
|
||||
// Localized data for REST/AJAX (nonces, i18n hints).
|
||||
wp_localize_script('xshop', 'xshopData', [
|
||||
'restUrl' => esc_url_raw(rest_url('xshop/v1/')),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX Search — reusable, accessible, combobox-like (without over-engineered ARIA).
|
||||
*
|
||||
* Reusable by: desktop header, mobile header, future overlay.
|
||||
* JS: assets/js/search.js handles fetch, debounce, stale protection, keyboard.
|
||||
*
|
||||
* Args: ['placeholder'=>string, 'action'=>string, 'id'=>string|null]
|
||||
*
|
||||
* @package XShop
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$uid = $args['id'] ?? ('xshop-ajax-search-' . wp_unique_id());
|
||||
$placeholder = $args['placeholder'] ?? esc_html__('Search products…', 'xshop');
|
||||
$action = $args['action'] ?? home_url('/');
|
||||
$minLength = 2;
|
||||
$limit = 6;
|
||||
?>
|
||||
<div class="xshop-search" data-xshop-search>
|
||||
<form role="search" method="get" action="<?php echo esc_url($action); ?>" class="xshop-search__form" data-xshop-search-form>
|
||||
<label class="xshop-sr-only" for="<?php echo esc_attr($uid); ?>"><?php esc_html_e('Search products', 'xshop'); ?></label>
|
||||
<input
|
||||
type="search"
|
||||
id="<?php echo esc_attr($uid); ?>"
|
||||
name="s"
|
||||
value="<?php echo esc_attr(get_search_query()); ?>"
|
||||
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||
autocomplete="off"
|
||||
aria-label="<?php esc_attr_e('Search products', 'xshop'); ?>"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo esc_attr($uid); ?>-listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
data-xshop-search-input
|
||||
data-min-length="<?php echo esc_attr((string) $minLength); ?>"
|
||||
data-limit="<?php echo esc_attr((string) $limit); ?>"
|
||||
/>
|
||||
<?php if (xshop_has_woocommerce()): ?>
|
||||
<input type="hidden" name="post_type" value="product" />
|
||||
<?php endif; ?>
|
||||
<button type="submit" class="xshop-btn xshop-btn--primary" aria-label="<?php esc_attr_e('Search', 'xshop'); ?>"><?php esc_html_e('Search', 'xshop'); ?></button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="<?php echo esc_attr($uid); ?>-listbox"
|
||||
class="xshop-search__dropdown"
|
||||
role="listbox"
|
||||
hidden
|
||||
data-xshop-search-dropdown
|
||||
>
|
||||
<div class="xshop-search__status xshop-sr-only" aria-live="polite" aria-atomic="true" data-xshop-search-status></div>
|
||||
|
||||
<div class="xshop-search__loading" hidden data-xshop-search-loading>
|
||||
<span class="xshop-loading__spinner" aria-hidden="true"></span>
|
||||
<span><?php esc_html_e('Searching…', 'xshop'); ?></span>
|
||||
</div>
|
||||
|
||||
<ul class="xshop-search__results" role="presentation" data-xshop-search-results></ul>
|
||||
|
||||
<div class="xshop-search__empty" hidden data-xshop-search-empty>
|
||||
<p><?php esc_html_e('No products found.', 'xshop'); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="xshop-search__error" hidden data-xshop-search-error>
|
||||
<p><?php esc_html_e('Search is temporarily unavailable. Press Enter to search.', 'xshop'); ?></p>
|
||||
</div>
|
||||
|
||||
<a href="#" class="xshop-search__viewall" hidden data-xshop-search-viewall>
|
||||
<?php esc_html_e('View all results', 'xshop'); ?> →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ $hasLogo = has_custom_logo();
|
||||
|
||||
<div class="xshop-header__actions">
|
||||
<div class="xshop-header__search xshop-header__search--desktop">
|
||||
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
|
||||
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop')]); ?>
|
||||
</div>
|
||||
|
||||
<?php if (xshop_has_woocommerce()): ?>
|
||||
@@ -75,6 +75,6 @@ $hasLogo = has_custom_logo();
|
||||
]);
|
||||
?>
|
||||
<div style="margin-block-start:var(--xshop-space-4)">
|
||||
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
|
||||
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop'), 'id' => 'xshop-ajax-search-mobile-' . wp_unique_id()]); ?>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user