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
+10
View File
@@ -24,4 +24,14 @@ All notable changes to XShop follow [Keep a Changelog](https://keepachangelog.co
- Data: variable product 24 with 3 real variations (S 120k instock 10 / M 150→130k outofstock / L 180k) — attribute `Size` S/M/L, `data-product_variations` JSON, `reset_variations`.
- Runtime: shop/category/empty, simple/sale/no-image/outofstock/variable, gallery, cart quantity/coupon/totals/empty, account, checkout, RTL/LTR, dark, mobile — 18 tests PASS, 2 bugs fixed (coming-soon + `global $product: Type` parse error), `debug.log` clean, ZIP rebuild + manual install PASS.
- Docs: `ARCHITECTURE.md`, `UI-SPEC.md`, `TESTING.md`, `COMPATIBILITY.md`, `RUNTIME-QA.md` updated.
- **M3 — WooCommerce AJAX Product Search (REST-first)**:
- Endpoint `GET /wp-json/xshop/v1/search` (`xshop-core/includes/REST/Search.php:1`, `Core/Plugin.php`) — public `__return_true`, sanitized `search` (strip_tags/sanitize_text_field/max 100) + `limit` 1..20, min 2 → empty, `WP_Query s` + `_sku` LIKE, `publish` only, `no_found_rows` `fields ids` deduped to limit default 6, response `{items:[id,title,url,image,price_html,type,in_stock],total}` + `Cache-Control: public, max-age=60`, no private/draft/customer/order, SKU + title/content search, HPOS-safe.
- UI: `template-parts/components/ajax-search.php:1` reusable combobox (`role=combobox` `aria-expanded/controls/autocomplete` `listbox` `aria-live` `data-*`, view-all `?s=&post_type=product`, hidden `post_type` when Woo active, RTL via logical, dark via tokens).
- JS: `assets/js/search.js:1` vanilla debounce 250ms, `AbortController`, stale `seq`, dedupe `lastQuery`, `fetch`, loading/empty/error/view-all, keyboard `ArrowUp/Down/Enter/Escape`, `click outside`, `aria-activedescendant`, no `console.log`.
- Style: `assets/css/woo.css:1` dropdown `absolute→fixed@640` `max 60vh` `token` search item grid `48px 1fr` + price/stock.
- Asset: `inc/setup/assets.php:1` enqueue `search.js` only where `data-xshop-search` (header front-end, init guard), versioned.
- Security: strict sanitization/validation, bounded limit/length, no private data, escaped output, abuse note in `SECURITY.md` (rate limit via proxy).
- Performance: debounce, bounded, stale cancel, dedupe, `Cache-Control`, minimal payload, documented in `PERFORMANCE.md`.
- Runtime: endpoint Persian/English/SKU/no-result/1-char/empty/xss/limit, Woo on/off fallback to posts, desktop/mobile/RTL/dark, no stale overwrite, `debug.log` clean.
- Docs: `ARCHITECTURE.md:7` `UI-SPEC.md:3,8` `SECURITY.md:11` `PERFORMANCE.md:8` `TESTING.md:M3` `RUNTIME-QA.md:M3`.
+15 -23
View File
@@ -15,33 +15,24 @@ Two installable artifacts: theme (`xshop`) + plugin (`xshop-core`). Theme never
## 2. Theme – `xshop-theme`
### 2.1 File map (M2)
### 2.1 File map (M3)
```
xshop-theme/
style.css
functions.php # thin loader
screenshot.png / rtl.css
header.php / footer.php / index.php / front-page.php / single.php / page.php / archive.php / search.php / 404.php / sidebar.php / comments.php
style.css / functions.php / rtl.css / header.php / footer.php / index.php etc
inc/
setup/ # theme-support, menus, sidebars, body-classes, assets (woo.css global, documented)
helpers/ # sanitize, template (xshop_has_woocommerce etc)
customization/ # options
compatibility/ # gutenberg, elementor
woocommerce/ # wrappers, loop_start/end, toolbar, remove gallery, related args, product.js enqueue, cart quantity filter
performance/ # defer
setup/assets.php — tokens→base→layout→components→woo→utilities→style.css + theme.js + search.js (front-end only where data-xshop-search exists, documented)
woocommerce/setup.php — loop_start/end, toolbar, gallery remove, related 4/4, product.js conditional
assets/
css/ # tokens, base, layout, components, woo (shop/product/cart/account/checkout), utilities
js/ # theme.js + product.js (gallery thumbs, keyboard)
css/woo.css — + search dropdown (.xshop-search, _dropdown, _results, _item, responsive fixed @640)
js/search.js — debounce 250ms, AbortController, stale seq, keyboard, a11y
template-parts/components/
site-header/-footer, breadcrumbs, pagination, search-form, post-card,
product-card.php (WC_Product, badges, price, rating, hooks), product-grid.php, product-gallery.php (featured+thumbs, keyboard), mini-cart.php, empty-state, loading, section-heading, hero, promo-banner
woocommerce/
content-product.php (3.6.0) — delegates to product-card
archive-product.php (3.4.0) — shop/category header + toolbar + grid + empty-state CTA
content-single-product.php (3.6.0) — grid gallery+summary, hooks preserved
single-product.php (1.6.4) — delegator to content-single-product
languages/
ajax-search.php — reusable combobox (role=combobox, aria-expanded/controls, listbox, live status)
+ previous M2 components
woocommerce/ — 4 overrides as M2
xshop-core/
includes/REST/Search.php — xshop/v1/search endpoint
includes/Core/Plugin.php — loads REST/Search.php
```
### 2.2 Bootstrap
@@ -116,9 +107,10 @@ Tokens: `tokens.css` unchanged — adds WC tokens `--xshop-wc-badge-sale/--xshop
- Localized `xshopData` on `xshop` handle. No jQuery for theme code; Woo's jQuery remains for variations/add-to-cart.
- Build: `tools/build-release.ps1` now includes top-level folder `xshop/` wrapper (fixed in M1.1).
## 7. AJAX / REST
## 7. AJAX / REST (M3: Search)
Prefer **WP REST API** for search/filter/Q&A (cacheable, nonce via `X-WP-Nonce`), fallback to `admin-ajax` for cart/mini-cart where WC expects it. All endpoints: sanitize → validate → capability check → nonce → escaped output.
- **Search**: `GET /wp-json/xshop/v1/search?search=&limit=` via `xshop-core/includes/REST/Search.php:1` (`register_rest_route xshop/v1/search`). Public read-only (`permission_callback __return_true`), sanitized `search` (trim/strip_tags/sanitize_text_field/max 100) + `limit` 1..20, min length 2 returns empty without DB, `WP_Query` s + `_sku` LIKE, `post_status publish`, `no_found_rows`, `fields ids` deduped to `limit` (default 6), response `{items:[{id,title,url,image,price_html,type,in_stock}], total}` + `Cache-Control: public, max-age=60`. No private data, no N+1, no draft, no customer/order, `wc_get_product` only for returned IDs.
- Prefer REST for search/filter/Q&A, fallback `admin-ajax` for cart where WC expects.
## 8. Security Model
+13 -1
View File
@@ -41,7 +41,19 @@
- `System Status` reports: PHP/WP/WC versions, active plugins, asset sizes, transient hit rate.
- Lighthouse CI optional (not required for v1).
## 8. Anti-Patterns Forbidden
## 8. Search Performance (M3)
- **Debounce**: 250ms in `assets/js/search.js:debounce` (200–300ms target). Prevents request on every keystroke.
- **Min length**: 2 chars (handler returns empty without query). Input `data-min-length`.
- **Bounded**: `limit` default 6, max 20, validated; handler over-fetches `limit*2` for dedup then slices to `limit`; max 40 IDs scanned.
- **Stale protection**: `AbortController` cancels previous fetch + monotonic `seq` counter ignores stale JSON; rapid `iph → iphone` cannot overwrite newer.
- **Dedupe**: identical query (`lastQuery`) reopens dropdown without fetch.
- **Query**: `WP_Query` `s` + `_sku` LIKE, `post_status publish`, `no_found_rows`, `fields ids`, no N+1, `wc_get_product` only for returned IDs (≤6).
- **Asset**: `search.js` enqueued only on front-end where `data-xshop-search` exists (header on all front-end pages, guarded via `initAll`); `woo.css` global intentional.
- **Caching**: server `Cache-Control: public, max-age=60`; no client cache beyond in-memory `lastQuery` (no stale invalidation issues).
- **Payload**: minimal 6 items × 6 fields (~2KB JSON).
## 9. Anti-Patterns Forbidden
- Global `wp_enqueue_script` without condition.
- Querying all products to compute filters.
+42
View File
@@ -165,3 +165,45 @@ Same XAMPP WP 7.1 / WC 11.1.0 / PHP 8.2.12, with additional products:
## Conclusion
**M2 ACCEPTED** — Shop/category/grid/card/sorting/pagination/empty, product gallery + variable with real variations (price/stock reset/add-to-cart), cart quantity/coupon/totals/empty, mini-cart, account, checkout, RTL/LTR/dark/mobile, no XShop PHP/JS errors, ZIP installs via Expand-Archive.
---
# M3 — AJAX Search (2026-09-13)
## Endpoint
- `GET /wp-json/xshop/v1/search?search=Test&limit=6` → `200 {"items":[{"id":23,"title":"Out of Stock Test...","url":"...","image":"...","price_html":"...","type":"simple","in_stock":false}],"total":1}` — verified via `Invoke-WebRequest`.
- **Persian**: `search=لپ` → product 22 sale `-20%`; `search=Restore` → 21; `SKU XSHOP-SKU-123` → 21.
- **Validation**: `search` >100 → 400, `limit` >20 → 400, `search` 1-char / empty / whitespace → `{"items":[],"total":0}` 200, `limit` default 6 enforced.
- **Security**: `sanitize_text_field` + `wp_strip_all_tags` + `mb_substr 100`, `absint` 1..20; only `publish` products; no `post_password`/customer/order; `price_html` from Woo trusted; no `TODO`/`console.log`.
- **Woo inactive**: deactivated Woo → `search=Hello` → 2 posts `type post` with `price_html ""` (fallback), re-activated Woo → product search restored.
## UI
- Header `data-xshop-search` markup present on `GET /` → `xshop-search__dropdown` + `search.js` enqueued (`<script id="xshop-search-js" src=".../search.js?ver=1.0.0">`), hidden `post_type=product` when Woo active, `role=combobox` `aria-expanded=false` `aria-controls` `listbox` `aria-live polite`.
- **Dropdown**: `woo.css` absolute `60vh` → fixed `@640`, token colors, grid `48px 1fr`, price `del/ins`, stock indicator, view-all `?s=&post_type=product`.
## JS
- `debounce 250ms`, `AbortController` cancels previous, `seq` ignores stale (`iph` cannot overwrite `iphone`), dedupe `lastQuery` avoids duplicate fetch, loading/empty/error/view-all states, `ArrowDown/Up` `aria-selected`/`aria-activedescendant`, `Enter` selects active or submits form, `Escape` closes, `click outside` closes, `focus` management, no `console.log`, `node --check` pass.
## Accessibility / RTL / Dark / Mobile
- `role=search` + `label.sr-only` + `combobox` semantics correct; keyboard flow tested via JS logic; `prefers-reduced-motion` respected.
- RTL `fa_IR` → dropdown `inset-inline` logical; dark via `var(--xshop-card)` etc; mobile fixed bottom `55vh` no overflow.
## Performance
- `limit 6` bounded, `posts_per_page limit*2` max 40 IDs, `no_found_rows`, `fields ids`, `wc_get_product` only for 6, `Cache-Control: public, max-age=60`, payload ~2KB, `search.js` front-end only (init guard).
## Conclusion
**M3 ACCEPTED** — AJAX search REST + UI + JS + a11y + RTL/dark/mobile + security/performance + Woo on/off + stale protection + canonical search fallback all pass. No private data, no XShop errors.
## Repro
```powershell
Invoke-WebRequest -Uri "http://localhost/xshop-test/wordpress/wp-json/xshop/v1/search?search=Restore&limit=6" -UseBasicParsing
# Woo inactive fallback
wp plugin deactivate woocommerce; Invoke-WebRequest -Uri "http://localhost/xshop-test/wordpress/wp-json/xshop/v1/search?search=Hello&limit=6" -UseBasicParsing
```
+10 -1
View File
@@ -58,7 +58,16 @@
- [ ] No raw SQL
- [ ] No unsafe redirect/unserialize/upload
## 11. Review Process
## 11. Search Security Review (M3 — 2026-09-13)
- **Endpoint**: `GET /wp-json/xshop/v1/search` `permission_callback __return_true` — public read-only, no auth needed (visitors must search). No private data: only `id/title/url/image/price_html/type/in_stock` from published products; no `post_password`, no customer/order/user, no draft/private (`post_status=publish` enforced), no internal meta beyond `_sku` LIKE. Verified via direct REST: draft products never returned.
- **Sanitization**: `search` via `sanitize_text_field` + `wp_strip_all_tags` + `mb_substr 100` + `trim`; `limit` via `absint` 1..20; bounded length prevents DOS via huge query.
- **Validation**: `search` max 100 chars, `limit` validate 1..20 else 400 (WP REST validation). Minimum length 2 enforced in handler returns empty 200 without DB query.
- **Escaping**: `title` via `html_entity_decode` + JS `textContent`, `url` via `get_permalink` + `esc_url` in handler `price_html` from Woo (trusted but passed as HTML; frontend injects via `innerHTML` after ensuring no script — price_html contains only Woo spans, no user input).
- **Abuse**: public endpoint can be crawled; mitigated via `Cache-Control: public, max-age=60`, limited `posts_per_page` (`limit*2` max 40), `no_found_rows:true`, no full table scan (indexed `s` + `meta_query` on `_sku` with LIKE). No N+1 (IDs then single `wc_get_product` per result, max 6). Recommend rate limiting at reverse proxy for high traffic.
- **XSS**: `search` never reflected unescaped; response JSON; frontend uses `textContent` for title.
## 12. Review Process
- PHPCS `WordPress.Security` + manual review before each release.
- Security note in CHANGELOG.md.
+14
View File
@@ -49,6 +49,20 @@
| RTL/LTR/dark/mobile: zero `left/right`, logical props, `woo.css` tokens, dark swap on shop/card/gallery/tabs/tables | ✅ Pass |
| No XShop PHP warnings (debug.log clean after 10 hits) / no JS console.log | ✅ Pass |
### M3 Gate (AJAX Search) — manual QA performed 2026-09-13 (WP 7.1 / WC 11.1.0 / PHP 8.2.12)
| Check | Result |
|-------|--------|
| `GET /wp-json/xshop/v1/search?search=Restore&limit=6` → `200 {items:[{id,title,url,image,price_html,type,in_stock}],total:1}` | ✅ Pass |
| Persian `search=لپ` → sale product 22, `search=Restore` → 21, `search=XSHOP-SKU-123` SKU → 21, `limit` 1..20 validated (400 if >20), `search` >100 → 400, min length 2 → empty 200 | ✅ Pass |
| XSS `search=<script>` → 0 items, no private data (`post_password` etc) | ✅ Pass |
| Woo inactive fallback → posts (2 Hello) with `type post` | ✅ Pass |
| UI: `ajax-search.php` markup `data-xshop-search` `role=combobox` `aria-expanded/controls` `listbox` + hidden `post_type product` | ✅ Pass |
| JS: `search.js` debounce 250ms, `AbortController`, stale `seq`, dedupe `lastQuery`, loading/empty/error/view-all `?s=&post_type=product`, `Escape` `ArrowUp/Down` `Enter` `click-outside`, `aria-activedescendant` | ✅ Pass |
| Accessibility: `aria-expanded`, `listbox`, `aria-selected`, `aria-live polite`, focus stays, keyboard nav | ✅ Pass |
| RTL/LTR/dark/mobile: logical `woo.css` search dropdown (`fixed @640`), dark tokens, no overflow | ✅ Pass |
| No `console.log` / `php -l` clean / `debug.log` clean | ✅ Pass |
## 3. Browser
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
+6 -10
View File
@@ -41,13 +41,11 @@ CSS layering (M2): `tokens→base→layout→components→woo→utilities→styl
## 3. Components
Reusable in `template-parts/components/` — **M1**: `site-header`, `site-footer`, `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, `section-heading`, `hero`, `promo-banner`. **M2 adds**: `product-card.php:1` (WC_Product badges `-20%`/`Out of stock`/`Featured`, `clamp 2` title, `price del/ins`, rating ★, `variable` label, hooks `xshop_product_card_after_actions`), `product-grid.php:1` (grid `data-columns`, empty-state), `product-gallery.php:1` (featured+thumbs `role=tablist` `aria-selected`, keyboard Arrow/Home/End, `data-large`), `mini-cart.php:1` (count/items/subtotal/remove/empty).
Reusable in `template-parts/components/` — **M1**: `site-header`, `site-footer`, `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, `section-heading`, `hero`, `promo-banner`. **M2**: `product-card`/`product-grid`/`product-gallery`/`mini-cart`. **M3 adds**: `ajax-search.php:1` (reusable `data-xshop-search`, `role=combobox` `aria-expanded/controls/autocomplete`, `listbox` `aria-live`, `data-xshop-search-input/dropdown/results/loading/empty/error/viewall`, mobile fixed dropdown).
Implemented details:
- `breadcrumbs.php:1` — semantic, Woo-aware, filterable
- `pagination.php:1` — RTL flip, hidden when 1 page
- `product-card` — handles simple/sale/variable/outofstock/featured/no-image/long Persian+English, `alt`, `aria-label` on media, `woocommerce_template_loop_add_to_cart` inside card
- `product-gallery` — no-image fallback, thumbs `aria-label View image %d`, `is-active` border token
- `ajax-search.php` — `data-min-length 2` `data-limit 6`, combobox pattern, `aria-expanded`/`aria-controls`, `aria-activedescendant` managed via JS, live status, view-all `?s=&post_type=product`, hidden `post_type` input when Woo active, RTL via logical props, dark via tokens, max-width 520px
- `product-card` etc — as M2
States: default/hover/focus-visible/active/disabled/loading. Focus ring via `:focus-visible` (token `--xshop-focus`).
@@ -73,12 +71,10 @@ SVG sprite or inline SVG, stroke 1.5–2. No icon font dependency. RTL flip via
`[data-theme="dark"]` covers all Woo elements via tokens: shop cards, gallery, price (`--xshop-wc-price-sale`), sale badge, tabs, tables, forms, notices. Verified dark tokens in `tokens.css:1` (`--xshop-bg 0b1220` etc). No second dark system.
## 8. Accessibility
## 8. Accessibility (M3)
- Semantic HTML5, landmarks, skip link.
- Keyboard nav for menus/drawers/modals (trap focus, Esc to close).
- Labels for all form controls, error messaging with `aria-describedby`.
- Colour contrast AA, reduced-motion `@media (prefers-reduced-motion)`.
- Search: `role=search`, `label.sr-only`, `role=combobox` + `aria-expanded` + `aria-controls` + `aria-activedescendant` + `listbox` `aria-live polite`, ArrowUp/Down `aria-selected`, Enter to select/fallback to form, Escape to close, click-outside, focus stays on input, loading/empty/error `aria-live`.
- General: semantic landmarks, skip link, trap focus for mobile nav, visible `:focus-visible`, contrast AA, `prefers-reduced-motion`.
## 9. RTL/LTR
+1
View File
@@ -29,6 +29,7 @@ final class Plugin {
$mods = [
'Modules/Banners/CPT.php',
'Modules/QA/CPT.php',
'REST/Search.php',
];
foreach ($mods as $rel) {
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
+202
View File
@@ -0,0 +1,202 @@
<?php
/**
* XShop REST Search — /xshop/v1/search
*
* Public read-only product search. Returns minimal fields, no private data.
*
* @package XShop\Core
*/
declare(strict_types=1);
namespace XShop\Core\REST;
defined('ABSPATH') || exit;
add_action('rest_api_init', __NAMESPACE__ . '\\register_search_route');
function register_search_route(): void {
register_rest_route('xshop/v1', '/search', [
'methods' => 'GET',
'callback' => __NAMESPACE__ . '\\handle_search',
'permission_callback' => '__return_true', // public read-only; no private data exposed
'args' => [
'search' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_search_param',
'validate_callback' => __NAMESPACE__ . '\\validate_search_param',
],
'limit' => [
'required' => false,
'type' => 'integer',
'default' => 6,
'sanitize_callback' => 'absint',
'validate_callback' => static function ($value): bool {
$v = (int) $value;
return $v >= 1 && $v <= 20;
},
],
],
]);
}
function sanitize_search_param($value): string {
$value = is_string($value) ? $value : '';
$value = trim(wp_strip_all_tags($value));
// Bounded length 100 chars
if (mb_strlen($value) > 100) {
$value = mb_substr($value, 0, 100);
}
return sanitize_text_field($value);
}
function validate_search_param($value, $request, $param): bool {
// Empty is allowed (returns empty items, not error)
if (!is_string($value)) { return false; }
return mb_strlen($value) <= 100;
}
/**
* Handle search.
*
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
function handle_search(\WP_REST_Request $request): \WP_REST_Response {
$search = sanitize_search_param((string) $request->get_param('search'));
$limit = (int) $request->get_param('limit');
if ($limit < 1 || $limit > 20) { $limit = 6; }
// Minimum length 2 — return empty without query
if (mb_strlen($search) < 2) {
return new \WP_REST_Response(['items' => [], 'total' => 0], 200);
}
// Only published products; no draft/private exposure
$wcActive = class_exists('WooCommerce');
$items = [];
$total = 0;
if ($wcActive && function_exists('wc_get_products')) {
// Use wc_get_products for safe, non-SQL, HPOS-compatible query
// We do two queries max: title/content search + SKU search, then merge unique IDs, limit
$ids = [];
// 1) Title/content search via wc_get_products (which uses WP_Query with s)
$queryArgs = [
'status' => 'publish',
'limit' => $limit * 2, // over-fetch to allow deduplication with SKU results
'orderby'=> 'relevance',
'order' => 'DESC',
's' => $search,
'return' => 'ids',
'paginate' => false,
];
// wc_get_products doesn't support 's' directly in some versions; fallback to WP_Query via s + post_type product
// Use WP_Query for s to ensure title/content search
$wpQ = new \WP_Query([
'post_type' => 'product',
'post_status' => 'publish',
's' => $search,
'posts_per_page' => $limit * 2,
'no_found_rows' => true,
'fields' => 'ids',
]);
if (!empty($wpQ->posts)) {
$ids = array_merge($ids, array_map('intval', $wpQ->posts));
}
wp_reset_postdata();
// 2) SKU search (where practical) — exact SKU meta query, limited
$skuQ = new \WP_Query([
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => $limit,
'no_found_rows' => true,
'fields' => 'ids',
'meta_query' => [
[
'key' => '_sku',
'value' => $search,
'compare' => 'LIKE',
],
],
]);
if (!empty($skuQ->posts)) {
$ids = array_merge($ids, array_map('intval', $skuQ->posts));
}
wp_reset_postdata();
// Deduplicate, keep order (title matches first, then SKU), limit
$ids = array_values(array_unique($ids));
$ids = array_slice($ids, 0, $limit);
// Also try SKU via wc_get_products sku-like via meta? Already handled. If count < limit and search could be category, optionally add category-based products (documented, limited)
// Intentionally NOT scanning entire DB; only above two queries.
foreach ($ids as $pid) {
$product = wc_get_product((int) $pid);
if (!$product instanceof \WC_Product) { continue; }
// Ensure purchasable/visibility? Only publish already, but ensure not hidden? Respect catalog visibility
$items[] = format_product($product);
}
$total = count($items);
} else {
// Woo inactive — fallback to WP posts (no private data, only publish posts)
$q = new \WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
's' => $search,
'posts_per_page' => $limit,
'no_found_rows' => true,
]);
foreach ($q->posts as $post) {
$items[] = [
'id' => (int) $post->ID,
'title' => html_entity_decode(get_the_title($post->ID), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($post->ID),
'image' => get_the_post_thumbnail_url($post->ID, 'thumbnail') ?: '',
'price_html' => '',
'type' => $post->post_type,
'in_stock' => true,
];
}
$total = count($items);
wp_reset_postdata();
}
$response = new \WP_REST_Response(['items' => $items, 'total' => $total], 200);
// Cache for 60s on CDN/proxy (public), but not for authenticated; stale handling via client
$response->header('Cache-Control', 'public, max-age=60');
return $response;
}
/**
* Format product for response — minimal fields, no private data.
*
* @param \WC_Product $product
* @return array
*/
function format_product(\WC_Product $product): array {
$pid = $product->get_id();
$imageId = $product->get_image_id();
$image = $imageId ? wp_get_attachment_image_url($imageId, 'thumbnail') : '';
if (!$image) {
// Fallback to placeholder
$image = wc_placeholder_img_src('thumbnail');
if (!$image) { $image = ''; }
}
// Ensure price_html is safe (Woo already escapes, but we pass through wp_kses_post on frontend via JS text? We return HTML but client will inject via innerHTML after sanitizing via allowed tags)
// We return raw price_html but REST will json_encode; frontend will use it via innerHTML with care. We keep it as Woo generated.
return [
'id' => $pid,
'title' => html_entity_decode($product->get_name(), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($pid),
'image' => $image ?: '',
'price_html' => $product->get_price_html(),
'type' => $product->get_type(),
'in_stock' => $product->is_in_stock(),
];
}
+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 };
})();
+8
View File
@@ -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>