feat: implement WooCommerce AJAX product search
This commit is contained in:
@@ -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`.
|
- 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.
|
- 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.
|
- 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
@@ -15,33 +15,24 @@ Two installable artifacts: theme (`xshop`) + plugin (`xshop-core`). Theme never
|
|||||||
|
|
||||||
## 2. Theme – `xshop-theme`
|
## 2. Theme – `xshop-theme`
|
||||||
|
|
||||||
### 2.1 File map (M2)
|
### 2.1 File map (M3)
|
||||||
|
|
||||||
```
|
```
|
||||||
xshop-theme/
|
xshop-theme/
|
||||||
style.css
|
style.css / functions.php / rtl.css / header.php / footer.php / index.php etc
|
||||||
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
|
|
||||||
inc/
|
inc/
|
||||||
setup/ # theme-support, menus, sidebars, body-classes, assets (woo.css global, documented)
|
setup/assets.php — tokens→base→layout→components→woo→utilities→style.css + theme.js + search.js (front-end only where data-xshop-search exists, documented)
|
||||||
helpers/ # sanitize, template (xshop_has_woocommerce etc)
|
woocommerce/setup.php — loop_start/end, toolbar, gallery remove, related 4/4, product.js conditional
|
||||||
customization/ # options
|
|
||||||
compatibility/ # gutenberg, elementor
|
|
||||||
woocommerce/ # wrappers, loop_start/end, toolbar, remove gallery, related args, product.js enqueue, cart quantity filter
|
|
||||||
performance/ # defer
|
|
||||||
assets/
|
assets/
|
||||||
css/ # tokens, base, layout, components, woo (shop/product/cart/account/checkout), utilities
|
css/woo.css — + search dropdown (.xshop-search, _dropdown, _results, _item, responsive fixed @640)
|
||||||
js/ # theme.js + product.js (gallery thumbs, keyboard)
|
js/search.js — debounce 250ms, AbortController, stale seq, keyboard, a11y
|
||||||
template-parts/components/
|
template-parts/components/
|
||||||
site-header/-footer, breadcrumbs, pagination, search-form, post-card,
|
ajax-search.php — reusable combobox (role=combobox, aria-expanded/controls, listbox, live status)
|
||||||
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
|
+ previous M2 components
|
||||||
woocommerce/
|
woocommerce/ — 4 overrides as M2
|
||||||
content-product.php (3.6.0) — delegates to product-card
|
xshop-core/
|
||||||
archive-product.php (3.4.0) — shop/category header + toolbar + grid + empty-state CTA
|
includes/REST/Search.php — xshop/v1/search endpoint
|
||||||
content-single-product.php (3.6.0) — grid gallery+summary, hooks preserved
|
includes/Core/Plugin.php — loads REST/Search.php
|
||||||
single-product.php (1.6.4) — delegator to content-single-product
|
|
||||||
languages/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Bootstrap
|
### 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.
|
- 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).
|
- 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
|
## 8. Security Model
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,19 @@
|
|||||||
- `System Status` reports: PHP/WP/WC versions, active plugins, asset sizes, transient hit rate.
|
- `System Status` reports: PHP/WP/WC versions, active plugins, asset sizes, transient hit rate.
|
||||||
- Lighthouse CI optional (not required for v1).
|
- 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.
|
- Global `wp_enqueue_script` without condition.
|
||||||
- Querying all products to compute filters.
|
- Querying all products to compute filters.
|
||||||
|
|||||||
@@ -165,3 +165,45 @@ Same XAMPP WP 7.1 / WC 11.1.0 / PHP 8.2.12, with additional products:
|
|||||||
## Conclusion
|
## 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.
|
**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
@@ -58,7 +58,16 @@
|
|||||||
- [ ] No raw SQL
|
- [ ] No raw SQL
|
||||||
- [ ] No unsafe redirect/unserialize/upload
|
- [ ] 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.
|
- PHPCS `WordPress.Security` + manual review before each release.
|
||||||
- Security note in CHANGELOG.md.
|
- Security note in CHANGELOG.md.
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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
|
## 3. Browser
|
||||||
|
|
||||||
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
|
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
|
||||||
|
|||||||
+6
-10
@@ -41,13 +41,11 @@ CSS layering (M2): `tokens→base→layout→components→woo→utilities→styl
|
|||||||
|
|
||||||
## 3. Components
|
## 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:
|
Implemented details:
|
||||||
- `breadcrumbs.php:1` — semantic, Woo-aware, filterable
|
- `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
|
||||||
- `pagination.php:1` — RTL flip, hidden when 1 page
|
- `product-card` etc — as M2
|
||||||
- `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
|
|
||||||
|
|
||||||
States: default/hover/focus-visible/active/disabled/loading. Focus ring via `:focus-visible` (token `--xshop-focus`).
|
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.
|
`[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.
|
- 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`.
|
||||||
- Keyboard nav for menus/drawers/modals (trap focus, Esc to close).
|
- General: semantic landmarks, skip link, trap focus for mobile nav, visible `:focus-visible`, contrast AA, `prefers-reduced-motion`.
|
||||||
- Labels for all form controls, error messaging with `aria-describedby`.
|
|
||||||
- Colour contrast AA, reduced-motion `@media (prefers-reduced-motion)`.
|
|
||||||
|
|
||||||
## 9. RTL/LTR
|
## 9. RTL/LTR
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ final class Plugin {
|
|||||||
$mods = [
|
$mods = [
|
||||||
'Modules/Banners/CPT.php',
|
'Modules/Banners/CPT.php',
|
||||||
'Modules/QA/CPT.php',
|
'Modules/QA/CPT.php',
|
||||||
|
'REST/Search.php',
|
||||||
];
|
];
|
||||||
foreach ($mods as $rel) {
|
foreach ($mods as $rel) {
|
||||||
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
|
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -155,5 +155,35 @@
|
|||||||
/* Empty states */
|
/* Empty states */
|
||||||
.xshop-empty--shop{ text-align:center; }
|
.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 fallback */
|
||||||
.star-rating{ color:#f59e0b; }
|
.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.
|
// Theme JS is tiny; feature modules conditionally enqueued elsewhere.
|
||||||
wp_enqueue_script('xshop', XSHOP_THEME_URI . '/assets/js/theme.js', [], $ver, true);
|
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).
|
// Localized data for REST/AJAX (nonces, i18n hints).
|
||||||
wp_localize_script('xshop', 'xshopData', [
|
wp_localize_script('xshop', 'xshopData', [
|
||||||
'restUrl' => esc_url_raw(rest_url('xshop/v1/')),
|
'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__actions">
|
||||||
<div class="xshop-header__search xshop-header__search--desktop">
|
<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>
|
</div>
|
||||||
|
|
||||||
<?php if (xshop_has_woocommerce()): ?>
|
<?php if (xshop_has_woocommerce()): ?>
|
||||||
@@ -75,6 +75,6 @@ $hasLogo = has_custom_logo();
|
|||||||
]);
|
]);
|
||||||
?>
|
?>
|
||||||
<div style="margin-block-start:var(--xshop-space-4)">
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user