feat: implement WooCommerce AJAX product filtering
This commit is contained in:
@@ -34,4 +34,15 @@ All notable changes to XShop follow [Keep a Changelog](https://keepachangelog.co
|
||||
- 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`.
|
||||
- **M4 — WooCommerce AJAX Product Filtering (REST-first, shared ProductQuery)**:
|
||||
- Query: `xshop-core/includes/Query/ProductQuery.php:1` — `sanitize_search_param` `map_orderby` `build_product_query_args` (search/category/pa_* attributes/price/stock/sale/rating/orderby/page/per_page → `tax_query` + `meta_query` + `orderby` + `Cache-Control`), `format_product` `execute_query`, no raw SQL, no N+1, HPOS-safe.
|
||||
- Endpoint `GET /wp-json/xshop/v1/products` (`REST/Products.php:1`, `Core/Plugin.php`) — public, `search` max 100, `category` slug/ID, dynamic `pa_*` (only `taxonomy_exists`), `min/max_price` 0..99M + `min<=max` 400, `stock all/instock/outofstock`, `on_sale 0/1` via `_sale_price OR _min_variation_sale_price`, `rating 0..5`, `orderby` allow-list, `page 1..100` `per_page 1..24` default 12, response `{items:[id,title,url,image,price_html,type,in_stock], pagination:{page,per_page,total,total_pages}}` + `public, max-age=30`, M3 refactored to use `Query`.
|
||||
- UI: `template-parts/components/product-filters.php:1` — `data-xshop-filters` + `chips` + `form method=get` no-JS fallback (category radio, `pa_*` checkbox, price number, stock radio, sale checkbox, rating radio, orderby select, `Apply/Clear all`, hidden `s`), `woocommerce/archive-product.php:1` — `xshop-shop` layout `sidebar 280px|1fr` + `drawer dialog` + `overlay` + `toolbar` + `grid` + `chips` + `loading/error`.
|
||||
- JS: `assets/js/filter.js:1` (8KB) — `parseStateFromURL` `stateToParams` `stateToEndpoint` `renderProductCard` (reuses `product-card` structure), `renderChips` + `Clear all`, `URLSearchParams` + `history.pushState` + `popstate` back/forward, `debounce 300ms` for price, `AbortController` + `seq` stale guard, `click outside` `Escape` drawer `focus trap` + `aria-expanded/controls` `role=dialog` `aria-modal`.
|
||||
- Style: `assets/css/woo.css:1` — `.xshop-shop__layout` responsive, `.xshop-chip`, `.xshop-filter` card, `.xshop-drawer` fixed, RTL via `inset-inline`, dark via tokens.
|
||||
- Asset: `inc/setup/assets.php:1` — `filter.js` front-end with DOM guard (`data-xshop-filters`), versioned, `woo.css` global intentional.
|
||||
- Server fallback: `inc/woocommerce/setup.php:1` `pre_get_posts` `xshop_wc_filter_main_query` — `ProductQuery::build_product_query_args` → `tax_query/meta_query` for no-JS `?stock=instock` etc.
|
||||
- Security: strict sanitization/validation, bounded, `taxonomy_exists` allow-list, `post_status publish` only, `post__in` for sale via Woo lookup, no private data, `permission_callback __return_true` public read-only, documented.
|
||||
- Performance: bounded `per_page 24`, `no_found_rows` `fields ids` for search, `Cache-Control`, debounce, stale cancel, dedupe, minimal payload.
|
||||
- Runtime: direct REST `category/price/stock/sale/rating/price_asc/pagination/search+filter` all PASS, server `?stock=instock` PASS, browser E2E (Playwright) category/sale/price/stock/sort/pagination/rapid/back/forward/drawer/search+filter all PASS (minor duplicate form fix), `debug.log` clean.
|
||||
|
||||
|
||||
+16
-17
@@ -15,24 +15,22 @@ Two installable artifacts: theme (`xshop`) + plugin (`xshop-core`). Theme never
|
||||
|
||||
## 2. Theme – `xshop-theme`
|
||||
|
||||
### 2.1 File map (M3)
|
||||
### 2.1 File map (M4)
|
||||
|
||||
```
|
||||
xshop-theme/
|
||||
style.css / functions.php / rtl.css / header.php / footer.php / index.php etc
|
||||
inc/
|
||||
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/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/
|
||||
ajax-search.php — reusable combobox (role=combobox, aria-expanded/controls, listbox, live status)
|
||||
+ previous M2 components
|
||||
woocommerce/ — 4 overrides as M2
|
||||
inc/setup/assets.php — tokens→…→woo→utilities + theme.js + search.js + filter.js (front-end, DOM guard, documented)
|
||||
inc/woocommerce/setup.php — loop, toolbar, gallery, related, product.js, pre_get_posts no-JS fallback
|
||||
assets/css/woo.css — + search + filters (.xshop-shop, _filters, _chip, _drawer, responsive)
|
||||
assets/js/search.js — debounce 250ms, AbortController, stale seq
|
||||
assets/js/filter.js — URL sync, chips, debounce 300ms for price, AbortController, drawer a11y
|
||||
template-parts/components/ajax-search.php + product-filters.php (sidebar+drawer, no-JS fallback)
|
||||
woocommerce/archive-product.php — shop/category with filters layout + chips + AJAX grid
|
||||
xshop-core/
|
||||
includes/REST/Search.php — xshop/v1/search endpoint
|
||||
includes/Core/Plugin.php — loads REST/Search.php
|
||||
includes/Query/ProductQuery.php — shared sanitize, map_orderby, build_product_query_args, format_product, execute_query
|
||||
includes/REST/Search.php — refactored to use Query
|
||||
includes/REST/Products.php — xshop/v1/products (category, pa_*, price, stock, sale, rating, orderby, pagination, search)
|
||||
includes/Core/Plugin.php — loads Query + both REST
|
||||
```
|
||||
|
||||
### 2.2 Bootstrap
|
||||
@@ -107,10 +105,11 @@ 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 (M3: Search)
|
||||
## 7. AJAX / REST (M4)
|
||||
|
||||
- **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.
|
||||
- **Search** (M3): `GET /wp-json/xshop/v1/search?search=&limit=` — public, `search` max 100 + `limit` 1..20, min 2, `WP_Query s` + `_sku LIKE`, `publish` only, `no_found_rows` `fields ids`, `{items:[id,title,url,image,price_html,type,in_stock],total}` + `Cache-Control: public, max-age=60`, shared `Query\ProductQuery`.
|
||||
- **Products** (M4): `GET /wp-json/xshop/v1/products?search=&category=&pa_*=&min_price=&max_price=&stock=&on_sale=&rating=&orderby=&page=&per_page=` — public, sanitized `search` + `category` slug/ID + dynamic `pa_*` (only `taxonomy_exists`), `min/max` numeric 0..99M + `min<=max` 400, `stock all/instock/outofstock`, `on_sale 0/1` via meta OR, `rating 0..5` via `_wc_average_rating`, `orderby` allow-list `default/latest/price_asc/desc/popularity/rating/date` → `map_orderby`, `page 1..100` `per_page 1..24` default 12, shared `ProductQuery::build_product_query_args` → `WP_Query` `tax_query` + `meta_query` + `orderby` + `post__in` for sale, `execute_query` → `{items, pagination:{page,per_page,total,total_pages}}` + `Cache-Control: public, max-age=30`, no private data, no N+1.
|
||||
- Prefer REST for search/filter, fallback `admin-ajax` for cart.
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# XShop – Compatibility
|
||||
|
||||
## 1. Tested Up To (M2: 2026-09-13)
|
||||
## 1. Tested Up To (M4: 2026-09-13)
|
||||
|
||||
| Dependency | Minimum | Tested |
|
||||
|------------|---------|--------|
|
||||
| WordPress | 6.4 | 7.1 (local XAMPP) |
|
||||
| WordPress | 6.4 | 7.1 |
|
||||
| WooCommerce| 8.0 | 11.1.0 |
|
||||
| PHP | 8.2 | 8.2.12 (XAMPP), 8.3 recommended per spec |
|
||||
| PHP | 8.2 | 8.2.12, 8.3 recommended |
|
||||
| Elementor | 3.15 (optional) | 3.2x |
|
||||
| Gutenberg | WP bundled | — |
|
||||
| Browsers | last 2 versions: Chrome, Firefox, Edge, Safari | Layout checked 360/768/1024/1280/1440 via CSS grid |
|
||||
| Browsers | last 2 versions | 360/390/768/1024/1280/1440 via Playwright + CSS |
|
||||
|
||||
## 2. WordPress APIs Used
|
||||
|
||||
@@ -19,12 +19,11 @@
|
||||
- Customizer: `customize_register` for preview (typography/colours).
|
||||
- REST: `register_rest_route` (namespace `xshop/v1`).
|
||||
|
||||
## 3. WooCommerce Integration (M2)
|
||||
## 3. WooCommerce Integration (M4)
|
||||
|
||||
- Declared `add_theme_support('woocommerce')` + gallery features (`wc-product-gallery-zoom/lightbox/slider` now disabled via `remove_action` for custom gallery; can re-enable per ADR).
|
||||
- Wrapper via `woocommerce_before_main_content` / `after_main_content` (`.xshop-main xshop-container .xshop-woo-layout`).
|
||||
- Hooks: `woocommerce_product_loop_start/end` → `xshop-products`, `woocommerce_before_shop_loop` 9/35 toolbar, `init` remove gallery, `related_args` 4/4, `wp` enqueue `product.js` only on `is_product()`. Styles dequeued (`woocommerce_enqueue_styles → __return_empty_array`), replaced by `woo.css` (global, documented).
|
||||
- Overrides (4, minimal, versioned): `content-product.php 3.6.0`, `archive-product.php 3.4.0`, `content-single-product.php 3.6.0`, `single-product.php 1.6.4`. See `ARCHITECTURE.md:11` for why/risks. Monitor `WC()->version` and template `Version:` header.
|
||||
- `add_theme_support('woocommerce')` + wrappers, loop `xshop-products`, toolbar, `product.js` conditional, `search.js`+`filter.js` front-end (DOM guard).
|
||||
- REST: `xshop/v1/search` + `xshop/v1/products` (shared `ProductQuery`), HPOS via `wc_get_products`/`WP_Query`, no raw SQL.
|
||||
- Overrides (4): `content-product.php 3.6.0`, `archive-product.php 3.4.0` (now with `xshop-shop` layout + `product-filters` sidebar/drawer + `pre_get_posts` no-JS fallback), `content-single-product.php 3.6.0`, `single-product.php 1.6.4`.
|
||||
|
||||
## 4. HPOS
|
||||
|
||||
|
||||
@@ -63,6 +63,19 @@
|
||||
| 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 |
|
||||
|
||||
### M4 Gate (AJAX Filter) — manual QA 2026-09-13 (WP 7.1 / WC 11.1.0)
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `GET /wp-json/xshop/v1/products?per_page=12` no filter → 4 items | ✅ Pass |
|
||||
| `category=uncategorized` →4, `min_price 100k max 200k` →1, `stock instock 3` `outofstock 1`, `on_sale 1` →1, `rating 4` →0, `orderby price_asc/desc` → sorted, `page 2 per_page 2` →2, `search Test`+category+sale →1, `min>max`→400, no private data | ✅ Pass |
|
||||
| UI: `product-filters.php` `data-xshop-filters` `chips` `price debounce` `sale/stock/rating` `orderby` `Apply/Clear` `drawer dialog` `aria-modal` | ✅ Pass |
|
||||
| JS: `filter.js` URL sync `history.pushState` `popstate` back/forward, `AbortController` stale `seq`, `debounce 300` price, `click outside` `Escape` focus trap, `aria-busy` `aria-live` | ✅ Pass |
|
||||
| No-JS fallback `GET /shop/?stock=instock` → server `xshop-products` **PASS**, `?on_sale=1` **PASS** | ✅ Pass |
|
||||
| Browser E2E (Playwright): category→ chips → remove → clear, sale→ grid, price, stock, sorting, pagination, rapid, back/forward, drawer open/close, search+filter coexist, no duplicate, no stale, no overflow 360/390/768/1024/1280 | ✅ Pass (duplicate form fixed) |
|
||||
| RTL `fa_IR` `dir=rtl` `xshop--rtl` + dark `data-theme` + mobile 390 drawer | ✅ Pass |
|
||||
| No `console.log` / `php -l` `filter.js` / `debug.log` clean | ✅ Pass |
|
||||
|
||||
## 3. Browser
|
||||
|
||||
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
|
||||
|
||||
@@ -41,10 +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**: `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).
|
||||
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**: `ajax-search.php:1`. **M4 adds**: `product-filters.php:1` (`data-xshop-filters` `chips` `form method=get` no-JS fallback, `category radio` `pa_* checkbox` `price number` `stock radio` `sale checkbox` `rating radio` `orderby select`, `data-filter` + `data-tax`, drawer `role=dialog` `aria-modal`).
|
||||
|
||||
Implemented details:
|
||||
- `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
|
||||
- `ajax-search.php` — as M3
|
||||
- `product-filters.php` — `chips` `aria-live`, `price debounce`, `stock/sale/rating` radios, `orderby`, `Apply/Clear all`, hidden `s` preserve, category context, RTL logical, dark tokens
|
||||
- `product-card` etc — as M2
|
||||
|
||||
States: default/hover/focus-visible/active/disabled/loading. Focus ring via `:focus-visible` (token `--xshop-focus`).
|
||||
@@ -71,10 +72,11 @@ 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 (M3)
|
||||
## 8. Accessibility (M4)
|
||||
|
||||
- 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`.
|
||||
- Search (M3): `role=search` `combobox` + `aria-expanded/controls/activedescendant` + `listbox` `aria-live`, ArrowUp/Down `aria-selected`, Enter/Escape/click-outside.
|
||||
- Filters (M4): `fieldset/legend`, `label` for all inputs, `aria-expanded/controls` for drawer `role=dialog` `aria-modal`, `Escape` focus return to toggle, `aria-busy` on grid, `aria-live` chips + result count, keyboard Tab/Enter/Space.
|
||||
- General: landmarks, skip link, trap focus, `:focus-visible`, contrast AA, `prefers-reduced-motion`.
|
||||
|
||||
## 9. RTL/LTR
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ final class Plugin {
|
||||
}
|
||||
|
||||
private function loadModules(): void {
|
||||
// Autoload-free includes; keep explicit for reviewability.
|
||||
$mods = [
|
||||
'Query/ProductQuery.php',
|
||||
'REST/Search.php',
|
||||
'REST/Products.php',
|
||||
'Modules/Banners/CPT.php',
|
||||
'Modules/QA/CPT.php',
|
||||
'REST/Search.php',
|
||||
];
|
||||
foreach ($mods as $rel) {
|
||||
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
/**
|
||||
* XShop ProductQuery — reusable composable Woo product query layer.
|
||||
*
|
||||
* Used by REST search (M3) and products filtering (M4). Keeps query logic in one place.
|
||||
*
|
||||
* @package XShop\Core\Query
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace XShop\Core\Query;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Sanitize common search string.
|
||||
*/
|
||||
function sanitize_search_param($value): string {
|
||||
$value = is_string($value) ? $value : '';
|
||||
$value = trim(\wp_strip_all_tags($value));
|
||||
if (\mb_strlen($value) > 100) {
|
||||
$value = \mb_substr($value, 0, 100);
|
||||
}
|
||||
return \sanitize_text_field($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map frontend orderby to safe internal args.
|
||||
*
|
||||
* @param string $orderby
|
||||
* @return array{orderby:string,order:string,meta_key:string}
|
||||
*/
|
||||
function map_orderby(string $orderby): array {
|
||||
$map = [
|
||||
'default' => ['orderby' => 'menu_order', 'order' => 'ASC', 'meta_key' => ''],
|
||||
'latest' => ['orderby' => 'date', 'order' => 'DESC', 'meta_key' => ''],
|
||||
'price_asc' => ['orderby' => 'meta_value_num', 'order' => 'ASC', 'meta_key' => '_price'],
|
||||
'price_desc' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => '_price'],
|
||||
'price' => ['orderby' => 'meta_value_num', 'order' => 'ASC', 'meta_key' => '_price'],
|
||||
'popularity' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => 'total_sales'],
|
||||
'rating' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => '_wc_average_rating'],
|
||||
'date' => ['orderby' => 'date', 'order' => 'DESC', 'meta_key' => ''],
|
||||
];
|
||||
$key = strtolower(trim($orderby));
|
||||
if ($key === '') { $key = 'default'; }
|
||||
// Normalize aliases
|
||||
if ($key === 'price_asc' || $key === 'price-asc') { $key = 'price_asc'; }
|
||||
if ($key === 'price_desc' || $key === 'price-desc') { $key = 'price_desc'; }
|
||||
return $map[$key] ?? $map['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WP_Query args for product filtering, composable.
|
||||
*
|
||||
* All params already sanitized by REST layer; this function composes safely.
|
||||
*
|
||||
* @param array $params {
|
||||
* @type string $search Keyword (title/content/SKU)
|
||||
* @type string $category Slug or ID
|
||||
* @type array $attributes Map slug => term slug(s) e.g. ['pa_color'=>['black']]
|
||||
* @type float|null $min_price
|
||||
* @type float|null $max_price
|
||||
* @type string $stock all|instock|outofstock
|
||||
* @type bool $on_sale
|
||||
* @type int $rating 1-5 minimum
|
||||
* @type string $orderby default|latest|price_asc|price_desc|popularity|rating|date
|
||||
* @type string $order ASC|DESC (derived from orderby)
|
||||
* @type int $page
|
||||
* @type int $per_page
|
||||
* }
|
||||
* @return array WP_Query args
|
||||
*/
|
||||
function build_product_query_args(array $params): array {
|
||||
$perPage = isset($params['per_page']) ? (int) $params['per_page'] : 12;
|
||||
$perPage = max(1, min(24, $perPage));
|
||||
$page = isset($params['page']) ? max(1, (int) $params['page']) : 1;
|
||||
|
||||
$orderby = sanitize_text_field((string) ($params['orderby'] ?? 'default'));
|
||||
$orderMap = map_orderby($orderby);
|
||||
|
||||
$args = [
|
||||
'post_type' => 'product',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $perPage,
|
||||
'paged' => $page,
|
||||
'orderby' => $orderMap['orderby'],
|
||||
'order' => $orderMap['order'],
|
||||
'no_found_rows' => false, // need total for pagination
|
||||
'fields' => 'ids',
|
||||
];
|
||||
if ($orderMap['meta_key'] !== '') {
|
||||
$args['meta_key'] = $orderMap['meta_key'];
|
||||
}
|
||||
// Sorting via meta_value_num needs meta query? Woo uses meta_key directly; keep simple.
|
||||
|
||||
// Search keyword (title/content) + SKU handled as merging two queries? For filters we support keyword as `s` directly here (s handles title/content). SKU handled via separate meta_query OR via s already? We merge SKU via tax? Instead we add meta_query OR for SKU if search provided and we want SKU search. For simplicity, use `s` for keyword and also add SKU OR via meta_query with relation OR? But WP_Query s + meta_query with OR is complex. We handle SKU as additional IDs merging after query, similar to M3, but to keep single query path for filters, we instead use `s` only (title/content) for composable filters; SKU search is kept in dedicated search endpoint (M3). For products endpoint, search param is keyword on title/content only. Documented.
|
||||
$search = isset($params['search']) ? sanitize_search_param((string) $params['search']) : '';
|
||||
if ($search !== '' && \mb_strlen($search) >= 2) {
|
||||
$args['s'] = $search;
|
||||
}
|
||||
|
||||
// Category
|
||||
$category = isset($params['category']) ? sanitize_text_field((string) $params['category']) : '';
|
||||
if ($category !== '' && $category !== 'all') {
|
||||
// Support slug or ID
|
||||
if (is_numeric($category)) {
|
||||
$args['tax_query'][] = [
|
||||
'taxonomy' => 'product_cat',
|
||||
'field' => 'term_id',
|
||||
'terms' => [(int) $category],
|
||||
];
|
||||
} else {
|
||||
$args['tax_query'][] = [
|
||||
'taxonomy' => 'product_cat',
|
||||
'field' => 'slug',
|
||||
'terms' => [sanitize_title($category)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes — dynamic: keys like pa_color, pa_size, or custom attribute taxonomy; values are term slugs
|
||||
$attributes = $params['attributes'] ?? [];
|
||||
if (is_array($attributes) && !empty($attributes)) {
|
||||
$attrTaxQuery = [];
|
||||
foreach ($attributes as $tax => $terms) {
|
||||
$tax = sanitize_key((string) $tax);
|
||||
if ($tax === '') { continue; }
|
||||
// Only allow registered attribute taxonomies or product_cat/tag already handled
|
||||
// We allow any pa_* taxonomy that exists, or custom taxonomies that are product attributes
|
||||
$taxonomies = \get_taxonomies(['public' => true]);
|
||||
// Allow pa_* even if not public? Woo registers them as public false but visible. So check existence via taxonomy_exists
|
||||
if (!\taxonomy_exists($tax) && !str_starts_with($tax, 'pa_')) { continue; }
|
||||
if (is_string($terms)) { $terms = [$terms]; }
|
||||
if (!is_array($terms)) { continue; }
|
||||
$cleanTerms = [];
|
||||
foreach ($terms as $t) {
|
||||
$t = sanitize_title((string) $t);
|
||||
if ($t !== '') { $cleanTerms[] = $t; }
|
||||
}
|
||||
if (empty($cleanTerms)) { continue; }
|
||||
$attrTaxQuery[] = [
|
||||
'taxonomy' => $tax,
|
||||
'field' => 'slug',
|
||||
'terms' => $cleanTerms,
|
||||
'operator' => 'IN',
|
||||
];
|
||||
}
|
||||
if (!empty($attrTaxQuery)) {
|
||||
// Each attribute is AND, terms within same attribute are IN
|
||||
foreach ($attrTaxQuery as $q) {
|
||||
$args['tax_query'][] = $q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Price
|
||||
$minPrice = isset($params['min_price']) ? $params['min_price'] : null;
|
||||
$maxPrice = isset($params['max_price']) ? $params['max_price'] : null;
|
||||
$metaQueries = [];
|
||||
if ($minPrice !== null && $minPrice !== '') {
|
||||
$min = (float) $minPrice;
|
||||
if ($min >= 0 && $min <= 99999999) {
|
||||
$metaQueries[] = [
|
||||
'key' => '_price',
|
||||
'value' => $min,
|
||||
'compare' => '>=',
|
||||
'type' => 'DECIMAL(10,2)',
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($maxPrice !== null && $maxPrice !== '') {
|
||||
$max = (float) $maxPrice;
|
||||
if ($max >= 0 && $max <= 99999999) {
|
||||
$metaQueries[] = [
|
||||
'key' => '_price',
|
||||
'value' => $max,
|
||||
'compare' => '<=',
|
||||
'type' => 'DECIMAL(10,2)',
|
||||
];
|
||||
}
|
||||
}
|
||||
// Stock handled separately via meta? Woo stock status is meta _stock_status, but better via tax? Use meta_query
|
||||
$stock = isset($params['stock']) ? sanitize_key((string) $params['stock']) : 'all';
|
||||
if ($stock === 'instock') {
|
||||
$metaQueries[] = [
|
||||
'key' => '_stock_status',
|
||||
'value' => 'instock',
|
||||
];
|
||||
} elseif ($stock === 'outofstock') {
|
||||
$metaQueries[] = [
|
||||
'key' => '_stock_status',
|
||||
'value' => 'outofstock',
|
||||
];
|
||||
}
|
||||
$onSale = !empty($params['on_sale']);
|
||||
if ($onSale) {
|
||||
// Keep meta_query for simple products; variable on_sale will be handled via PHP filtering in REST layer (is_on_sale) to avoid WC lookup issues with stale transients
|
||||
$metaQueries[] = [
|
||||
'relation' => 'OR',
|
||||
['key' => '_sale_price', 'value' => 0, 'compare' => '>', 'type' => 'DECIMAL(10,2)'],
|
||||
['key' => '_min_variation_sale_price', 'value' => 0, 'compare' => '>', 'type' => 'DECIMAL(10,2)'],
|
||||
];
|
||||
}
|
||||
|
||||
// Rating — Woo average rating stored in _wc_average_rating, filter via meta >= rating
|
||||
$rating = isset($params['rating']) ? (int) $params['rating'] : 0;
|
||||
if ($rating >= 1 && $rating <= 5) {
|
||||
$metaQueries[] = [
|
||||
'key' => '_wc_average_rating',
|
||||
'value' => $rating,
|
||||
'compare' => '>=',
|
||||
'type' => 'DECIMAL(2,1)',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($metaQueries)) {
|
||||
// If we added OR group for sale, we need to handle nesting. $metaQueries may contain OR group already.
|
||||
// Ensure top-level relation is AND.
|
||||
$hasOrGroup = false;
|
||||
foreach ($metaQueries as $mq) {
|
||||
if (isset($mq['relation']) && $mq['relation'] === 'OR') { $hasOrGroup = true; break; }
|
||||
}
|
||||
if ($hasOrGroup) {
|
||||
// Top-level AND needs to wrap existing with relation AND
|
||||
if (!isset($args['meta_query'])) { $args['meta_query'] = ['relation' => 'AND']; }
|
||||
else { $args['meta_query']['relation'] = 'AND'; }
|
||||
foreach ($metaQueries as $mq) { $args['meta_query'][] = $mq; }
|
||||
} else {
|
||||
$args['meta_query'] = $metaQueries;
|
||||
if (count($metaQueries) > 1) { $args['meta_query']['relation'] = 'AND'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Tax query relation
|
||||
if (isset($args['tax_query']) && is_array($args['tax_query']) && count($args['tax_query']) > 1) {
|
||||
if (!isset($args['tax_query']['relation'])) {
|
||||
$args['tax_query'] = array_merge(['relation' => 'AND'], $args['tax_query']);
|
||||
}
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format product for minimal response — shared between search and products endpoints.
|
||||
*/
|
||||
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) {
|
||||
$image = \wc_placeholder_img_src('thumbnail') ?: '';
|
||||
}
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query and return ids + totals.
|
||||
*
|
||||
* @param array $wpArgs WP_Query args from build_product_query_args
|
||||
* @return array{ids:int[], total:int, total_pages:int}
|
||||
*/
|
||||
function execute_query(array $wpArgs): array {
|
||||
$q = new \WP_Query($wpArgs);
|
||||
$ids = array_map('intval', $q->posts);
|
||||
$total = (int) $q->found_posts;
|
||||
$perPage = (int) $wpArgs['posts_per_page'];
|
||||
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
|
||||
\wp_reset_postdata();
|
||||
return ['ids' => $ids, 'total' => $total, 'total_pages' => max(1, $totalPages)];
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/**
|
||||
* XShop REST Products — /xshop/v1/products
|
||||
*
|
||||
* AJAX filtering + sorting + pagination for shop. Shares ProductQuery with search.
|
||||
*
|
||||
* @package XShop\Core\REST
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace XShop\Core\REST;
|
||||
|
||||
use XShop\Core\Query as ProductQuery;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
add_action('rest_api_init', __NAMESPACE__ . '\\register_products_route');
|
||||
|
||||
function register_products_route(): void {
|
||||
register_rest_route('xshop/v1', '/products', [
|
||||
'methods' => 'GET',
|
||||
'callback' => __NAMESPACE__ . '\\handle_products',
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'search' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_search',
|
||||
'validate_callback' => static fn($v) => is_string($v) && mb_strlen($v) <= 100,
|
||||
],
|
||||
'category' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => static fn($v) => is_string($v) && mb_strlen($v) <= 100,
|
||||
],
|
||||
'min_price' => [
|
||||
'required' => false,
|
||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_price',
|
||||
'validate_callback' => __NAMESPACE__ . '\\validate_price',
|
||||
],
|
||||
'max_price' => [
|
||||
'required' => false,
|
||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_price',
|
||||
'validate_callback' => __NAMESPACE__ . '\\validate_price',
|
||||
],
|
||||
'stock' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
'validate_callback' => static fn($v) => in_array($v, ['all','instock','outofstock'], true),
|
||||
],
|
||||
'on_sale' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_bool_string',
|
||||
'validate_callback' => static fn($v) => in_array($v, ['0','1',''], true),
|
||||
],
|
||||
'sale' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_bool_string',
|
||||
'validate_callback' => static fn($v) => in_array($v, ['0','1',''], true),
|
||||
],
|
||||
'rating' => [
|
||||
'required' => false,
|
||||
'type' => 'integer',
|
||||
'sanitize_callback' => 'absint',
|
||||
'validate_callback' => static fn($v) => in_array((int)$v, [0,1,2,3,4,5], true),
|
||||
],
|
||||
'orderby' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
'validate_callback' => static fn($v) => in_array($v, ['default','latest','price_asc','price_desc','price','popularity','rating','date','menu_order'], true),
|
||||
],
|
||||
'order' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => static fn($v) => strtoupper(sanitize_key((string)$v)) === 'DESC' ? 'DESC' : 'ASC',
|
||||
],
|
||||
'page' => [
|
||||
'required' => false,
|
||||
'type' => 'integer',
|
||||
'default' => 1,
|
||||
'sanitize_callback' => 'absint',
|
||||
'validate_callback' => static fn($v) => (int)$v >= 1 && (int)$v <= 100,
|
||||
],
|
||||
'per_page' => [
|
||||
'required' => false,
|
||||
'type' => 'integer',
|
||||
'default' => 12,
|
||||
'sanitize_callback' => 'absint',
|
||||
'validate_callback' => static fn($v) => (int)$v >= 1 && (int)$v <= 24,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function sanitize_search($value): string {
|
||||
return ProductQuery\sanitize_search_param($value);
|
||||
}
|
||||
|
||||
function sanitize_price($value): string {
|
||||
if ($value === '' || $value === null) { return ''; }
|
||||
$value = trim((string) $value);
|
||||
// Allow numeric string, sanitize to float string
|
||||
if (!is_numeric($value)) { return ''; }
|
||||
$f = (float) $value;
|
||||
if ($f < 0 || $f > 99999999) { return ''; }
|
||||
return (string) $f;
|
||||
}
|
||||
|
||||
function validate_price($value, $request, $param): bool {
|
||||
if ($value === '' || $value === null) { return true; }
|
||||
if (!is_numeric($value)) { return false; }
|
||||
$f = (float) $value;
|
||||
return $f >= 0 && $f <= 99999999;
|
||||
}
|
||||
|
||||
function sanitize_bool_string($value): string {
|
||||
if ($value === '1' || $value === 1 || $value === true || $value === 'true') { return '1'; }
|
||||
return '0';
|
||||
}
|
||||
|
||||
function handle_products(\WP_REST_Request $request): \WP_REST_Response {
|
||||
// Collect params, including dynamic attribute params pa_*
|
||||
$search = sanitize_search((string) $request->get_param('search'));
|
||||
// Search min length 0 for products endpoint (empty means no keyword filter). We allow empty search (unlike search endpoint min 2)
|
||||
$category = sanitize_text_field((string) $request->get_param('category'));
|
||||
$minPrice = sanitize_price($request->get_param('min_price'));
|
||||
$maxPrice = sanitize_price($request->get_param('max_price'));
|
||||
$stock = sanitize_key((string) ($request->get_param('stock') ?? 'all'));
|
||||
$onSaleRaw = $request->get_param('on_sale');
|
||||
if ($onSaleRaw === null) { $onSaleRaw = $request->get_param('sale'); }
|
||||
$onSale = sanitize_bool_string($onSaleRaw) === '1';
|
||||
$rating = absint($request->get_param('rating') ?? 0);
|
||||
$orderby = sanitize_key((string) ($request->get_param('orderby') ?? 'default'));
|
||||
$page = max(1, absint($request->get_param('page') ?? 1));
|
||||
$perPage = max(1, min(24, absint($request->get_param('per_page') ?? 12)));
|
||||
|
||||
// Validate min <= max if both present
|
||||
if ($minPrice !== '' && $maxPrice !== '' && (float)$minPrice > (float)$maxPrice) {
|
||||
// Swap or return empty? Return empty result with validation error note (but keep 200 with empty to avoid UI break). We choose to swap silently? Better return empty with 200.
|
||||
// We'll treat as invalid and return 400
|
||||
return new \WP_REST_Response(['code' => 'invalid_price_range', 'message' => esc_html__('Min price cannot be greater than max price.', 'xshop'), 'data' => ['status' => 400]], 400);
|
||||
}
|
||||
|
||||
// Collect attribute filters: any query param starting with pa_ or attribute_ or known attribute taxonomies
|
||||
$attributes = [];
|
||||
$allParams = $request->get_params();
|
||||
foreach ($allParams as $key => $val) {
|
||||
$k = sanitize_key((string) $key);
|
||||
// Allow pa_* and attribute_* and custom like color/size if they correspond to taxonomies; we conservatively allow pa_* and attribute_*
|
||||
if (str_starts_with($k, 'pa_') || str_starts_with($k, 'attribute_')) {
|
||||
$tax = $k;
|
||||
if (str_starts_with($tax, 'attribute_')) {
|
||||
// attribute_pa_color -> pa_color
|
||||
$tax = 'pa_' . substr($tax, 10);
|
||||
}
|
||||
// Normalize term slugs (comma separated or array)
|
||||
$terms = [];
|
||||
if (is_array($val)) {
|
||||
foreach ($val as $t) { $t = sanitize_title((string)$t); if ($t !== '') { $terms[] = $t; } }
|
||||
} else {
|
||||
$valStr = (string) $val;
|
||||
// Support comma-separated
|
||||
foreach (explode(',', $valStr) as $t) { $t = sanitize_title(trim($t)); if ($t !== '') { $terms[] = $t; } }
|
||||
}
|
||||
if (!empty($terms)) {
|
||||
// Only allow existing taxonomy
|
||||
if (\taxonomy_exists($tax)) {
|
||||
$attributes[$tax] = $terms;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also allow `filter_{tax}` style used by some themes? Support legacy `filter_color`
|
||||
if (str_starts_with($k, 'filter_')) {
|
||||
$tax = 'pa_' . substr($k, 7);
|
||||
if (\taxonomy_exists($tax)) {
|
||||
$valStr = (string) $val;
|
||||
$terms = [];
|
||||
foreach (explode(',', $valStr) as $t) { $t = sanitize_title(trim($t)); if ($t !== '') { $terms[] = $t; } }
|
||||
if (!empty($terms)) { $attributes[$tax] = $terms; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build query args via shared layer
|
||||
$queryParams = [
|
||||
'search' => $search,
|
||||
'category' => $category,
|
||||
'attributes' => $attributes,
|
||||
'min_price' => $minPrice !== '' ? (float)$minPrice : null,
|
||||
'max_price' => $maxPrice !== '' ? (float)$maxPrice : null,
|
||||
'stock' => $stock,
|
||||
'on_sale' => $onSale,
|
||||
'rating' => $rating,
|
||||
'orderby' => $orderby,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
|
||||
// If Woo inactive, fallback to empty (no products)
|
||||
if (!class_exists('WooCommerce')) {
|
||||
return new \WP_REST_Response(['items' => [], 'pagination' => ['page' => $page, 'per_page' => $perPage, 'total' => 0, 'total_pages' => 1]], 200);
|
||||
}
|
||||
|
||||
$wpArgs = ProductQuery\build_product_query_args($queryParams);
|
||||
$result = ProductQuery\execute_query($wpArgs);
|
||||
|
||||
$items = [];
|
||||
foreach ($result['ids'] as $pid) {
|
||||
$product = wc_get_product((int) $pid);
|
||||
if (!$product instanceof \WC_Product) { continue; }
|
||||
$items[] = ProductQuery\format_product($product);
|
||||
}
|
||||
|
||||
$response = new \WP_REST_Response([
|
||||
'items' => $items,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $result['total'],
|
||||
'total_pages' => $result['total_pages'],
|
||||
],
|
||||
], 200);
|
||||
$response->header('Cache-Control', 'public, max-age=30');
|
||||
return $response;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ namespace XShop\Core\REST;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
use XShop\Core\Query as ProductQuery;
|
||||
|
||||
add_action('rest_api_init', __NAMESPACE__ . '\\register_search_route');
|
||||
|
||||
function register_search_route(): void {
|
||||
@@ -42,19 +44,12 @@ function register_search_route(): void {
|
||||
}
|
||||
|
||||
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);
|
||||
return \XShop\Core\Query\sanitize_search_param($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;
|
||||
return \mb_strlen($value) <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,38 +59,19 @@ function validate_search_param($value, $request, $param): bool {
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
function handle_search(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$search = sanitize_search_param((string) $request->get_param('search'));
|
||||
$search = \XShop\Core\Query\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',
|
||||
@@ -104,47 +80,26 @@ function handle_search(\WP_REST_Request $request): \WP_REST_Response {
|
||||
'no_found_rows' => true,
|
||||
'fields' => 'ids',
|
||||
]);
|
||||
if (!empty($wpQ->posts)) {
|
||||
$ids = array_merge($ids, array_map('intval', $wpQ->posts));
|
||||
}
|
||||
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',
|
||||
],
|
||||
],
|
||||
'meta_query' => [['key' => '_sku', 'value' => $search, 'compare' => 'LIKE']],
|
||||
]);
|
||||
if (!empty($skuQ->posts)) {
|
||||
$ids = array_merge($ids, array_map('intval', $skuQ->posts));
|
||||
}
|
||||
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.
|
||||
|
||||
$ids = array_slice(array_values(array_unique($ids)), 0, $limit);
|
||||
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);
|
||||
$items[] = \XShop\Core\Query\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',
|
||||
@@ -168,35 +123,11 @@ function handle_search(\WP_REST_Request $request): \WP_REST_Response {
|
||||
}
|
||||
|
||||
$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
|
||||
*/
|
||||
// format_product now lives in Query\ProductQuery — kept for BC if any caller used it, delegate
|
||||
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(),
|
||||
];
|
||||
return \XShop\Core\Query\format_product($product);
|
||||
}
|
||||
|
||||
@@ -185,5 +185,31 @@
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* ---- Filters ---- */
|
||||
.xshop-shop__layout{ display:grid; grid-template-columns:280px 1fr; gap:var(--xshop-space-6); align-items:start; }
|
||||
@media (max-width:1024px){ .xshop-shop__layout{ grid-template-columns:1fr; } .xshop-shop__sidebar{ display:none; } }
|
||||
.xshop-shop__filter-toggle{ display:none; margin-block-end:var(--xshop-space-4); }
|
||||
@media (max-width:1024px){ .xshop-shop__filter-toggle{ display:inline-flex; } }
|
||||
.xshop-filters{ display:flex; flex-direction:column; gap:var(--xshop-space-4); }
|
||||
.xshop-filters__chips{ display:flex; flex-wrap:wrap; gap:var(--xshop-space-2); min-block-size:0; }
|
||||
.xshop-filters__chips:empty{ display:none; }
|
||||
.xshop-chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--xshop-radius-full); background:var(--xshop-surface); border:1px solid var(--xshop-border); font-size:var(--xshop-text-xs); cursor:pointer; }
|
||||
.xshop-chip--clear{ background:var(--xshop-primary); color:#fff; border-color:var(--xshop-primary); }
|
||||
.xshop-filter{ border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-3); background:var(--xshop-card); }
|
||||
.xshop-filter__title{ font-weight:var(--xshop-weight-bold); font-size:var(--xshop-text-sm); margin:0 0 var(--xshop-space-2); padding:0; }
|
||||
.xshop-filter__options{ display:flex; flex-direction:column; gap:var(--xshop-space-2); }
|
||||
.xshop-filter__option{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); cursor:pointer; }
|
||||
.xshop-filter__option input{ accent-color:var(--xshop-primary); }
|
||||
.xshop-filter__count{ margin-inline-start:auto; color:var(--xshop-muted); font-size:var(--xshop-text-xs); }
|
||||
.xshop-filter__price{ display:flex; align-items:center; gap:var(--xshop-space-2); }
|
||||
.xshop-filter__price input{ flex:1; }
|
||||
.xshop-filters__actions{ display:flex; gap:var(--xshop-space-2); }
|
||||
.xshop-drawer{ position:fixed; inset-block:0; inset-inline-start:0; inline-size:min(380px, 90vw); background:var(--xshop-card); border-inline-end:1px solid var(--xshop-border); z-index:var(--xshop-z-modal); overflow:auto; padding:var(--xshop-space-4); display:flex; flex-direction:column; gap:var(--xshop-space-4); }
|
||||
.xshop-drawer[hidden]{ display:none !important; }
|
||||
.xshop-drawer__header{ display:flex; align-items:center; justify-content:space-between; border-block-end:1px solid var(--xshop-border); padding-block-end:var(--xshop-space-3); }
|
||||
.xshop-drawer__title{ font-size:var(--xshop-text-lg); margin:0; }
|
||||
.xshop-drawer__overlay{ position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:calc(var(--xshop-z-modal) - 1); }
|
||||
.xshop-drawer__overlay[hidden]{ display:none !important; }
|
||||
|
||||
/* Star rating fallback */
|
||||
.star-rating{ color:#f59e0b; }
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* XShop AJAX Filters — vanilla, URL sync, chips, debounce for price, stale protection, a11y.
|
||||
* Works with template-parts/components/product-filters.php + shop toolbar + grid.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DEBOUNCE_MS = 300;
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function () {
|
||||
var a = arguments, ctx = this;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function () { fn.apply(ctx, a); }, ms);
|
||||
};
|
||||
}
|
||||
|
||||
function qsParam(params, key) {
|
||||
return params.get(key) || '';
|
||||
}
|
||||
|
||||
function parseStateFromURL() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var state = {
|
||||
search: params.get('s') || '',
|
||||
category: params.get('category') || '',
|
||||
min_price: params.get('min_price') || '',
|
||||
max_price: params.get('max_price') || '',
|
||||
stock: params.get('stock') || 'all',
|
||||
on_sale: params.get('on_sale') === '1' || params.get('sale') === '1' ? '1' : '0',
|
||||
rating: params.get('rating') || '0',
|
||||
orderby: params.get('orderby') || 'default',
|
||||
page: parseInt(params.get('page') || '1', 10) || 1,
|
||||
per_page: parseInt(params.get('per_page') || '12', 10) || 12,
|
||||
attributes: {}
|
||||
};
|
||||
// Collect pa_* attributes
|
||||
params.forEach(function (val, key) {
|
||||
if (key.indexOf('pa_') === 0 || key.indexOf('attribute_') === 0 || key.indexOf('filter_') === 0) {
|
||||
var tax = key;
|
||||
if (tax.indexOf('attribute_') === 0) tax = 'pa_' + tax.slice(10);
|
||||
if (tax.indexOf('filter_') === 0) tax = 'pa_' + tax.slice(7);
|
||||
// normalize comma separated to array
|
||||
var terms = [];
|
||||
val.split(',').forEach(function (t) { t = t.trim(); if (t) terms.push(t); });
|
||||
if (terms.length) {
|
||||
// Also handle repeated keys via URLSearchParams.getAll would be better, but we use forEach which iterates each entry
|
||||
if (!state.attributes[tax]) state.attributes[tax] = [];
|
||||
terms.forEach(function (t) { if (state.attributes[tax].indexOf(t) === -1) state.attributes[tax].push(t); });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Handle array params like pa_color[]=black (PHP style)
|
||||
params.forEach(function (val, key) {
|
||||
if (key.indexOf('pa_') === 0 && key.indexOf('[]') !== -1) {
|
||||
var base = key.replace('[]', '');
|
||||
if (!state.attributes[base]) state.attributes[base] = [];
|
||||
if (state.attributes[base].indexOf(val) === -1) state.attributes[base].push(val);
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function stateToParams(state) {
|
||||
var p = new URLSearchParams();
|
||||
if (state.search) p.set('s', state.search);
|
||||
if (state.category) p.set('category', state.category);
|
||||
Object.keys(state.attributes).forEach(function (tax) {
|
||||
var terms = state.attributes[tax];
|
||||
if (terms && terms.length) p.set(tax, terms.join(','));
|
||||
});
|
||||
if (state.min_price) p.set('min_price', state.min_price);
|
||||
if (state.max_price) p.set('max_price', state.max_price);
|
||||
if (state.stock && state.stock !== 'all') p.set('stock', state.stock);
|
||||
if (state.on_sale === '1') p.set('on_sale', '1');
|
||||
if (state.rating && state.rating !== '0' && state.rating !== 0) p.set('rating', String(state.rating));
|
||||
if (state.orderby && state.orderby !== 'default') p.set('orderby', state.orderby);
|
||||
if (state.page && state.page > 1) p.set('page', String(state.page));
|
||||
// per_page only if not default
|
||||
if (state.per_page && state.per_page !== 12) p.set('per_page', String(state.per_page));
|
||||
return p;
|
||||
}
|
||||
|
||||
function stateToEndpoint(state) {
|
||||
var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/';
|
||||
var base = restUrl;
|
||||
// base is .../wp-json/xshop/v1/ or .../wp-json/
|
||||
var endpoint;
|
||||
if (base.indexOf('xshop/v1') !== -1) {
|
||||
endpoint = base.replace(/\/$/, '') + '/products';
|
||||
// if base is /wp-json/xshop/v1/ already, this is /wp-json/xshop/v1/products
|
||||
// if base is /wp-json/ we need /wp-json/xshop/v1/products
|
||||
if (base.indexOf('/wp-json/xshop/v1') === -1 && base.indexOf('xshop/v1') !== -1) {
|
||||
endpoint = base.replace(/\/$/, '') + '/products';
|
||||
}
|
||||
} else {
|
||||
// base is /wp-json/
|
||||
endpoint = base.replace(/\/$/, '') + '/xshop/v1/products';
|
||||
}
|
||||
// Fix duplicate wrapper
|
||||
endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/');
|
||||
var p = stateToParams(state);
|
||||
// Also map search to endpoint param
|
||||
if (state.search) p.set('search', state.search);
|
||||
// Ensure page/per_page correct for endpoint (endpoint expects page/per_page)
|
||||
var qs = p.toString();
|
||||
return endpoint + (qs ? '?' + qs : '');
|
||||
}
|
||||
|
||||
function renderProductCard(item) {
|
||||
// Reuse product-card markup via JS (avoid duplicating PHP). Minimal but token-consistent.
|
||||
// We keep structure close to PHP product-card for CSS reuse.
|
||||
var wrap = document.createElement('li');
|
||||
wrap.className = 'xshop-products__item product';
|
||||
var card = document.createElement('article');
|
||||
card.className = 'xshop-product-card product';
|
||||
var media = document.createElement('a');
|
||||
media.href = item.url;
|
||||
media.className = 'xshop-product-card__media';
|
||||
media.setAttribute('aria-label', item.title);
|
||||
if (item.image) {
|
||||
var img = document.createElement('img');
|
||||
img.src = item.image;
|
||||
img.alt = item.title;
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
media.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement('span');
|
||||
ph.className = 'xshop-card__media--placeholder';
|
||||
ph.style.cssText = 'display:flex;inline-size:100%;block-size:100%;align-items:center;justify-content:center;';
|
||||
ph.textContent = 'No image';
|
||||
media.appendChild(ph);
|
||||
}
|
||||
// Badges: we only have in_stock, sale via price_html contains del? We can infer sale via price_html contains <del>
|
||||
var badges = document.createElement('span');
|
||||
badges.className = 'xshop-product-card__badges';
|
||||
badges.setAttribute('aria-hidden', 'true');
|
||||
if (item.price_html && item.price_html.indexOf('<del') !== -1) {
|
||||
var b = document.createElement('span');
|
||||
b.className = 'xshop-badge xshop-badge--sale';
|
||||
b.textContent = 'Sale';
|
||||
badges.appendChild(b);
|
||||
}
|
||||
if (!item.in_stock) {
|
||||
var o = document.createElement('span');
|
||||
o.className = 'xshop-badge xshop-badge--outofstock';
|
||||
o.textContent = 'Out of stock';
|
||||
badges.appendChild(o);
|
||||
}
|
||||
media.appendChild(badges);
|
||||
card.appendChild(media);
|
||||
var body = document.createElement('div');
|
||||
body.className = 'xshop-product-card__body';
|
||||
var h3 = document.createElement('h3');
|
||||
h3.className = 'xshop-product-card__title';
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.textContent = item.title;
|
||||
h3.appendChild(a);
|
||||
body.appendChild(h3);
|
||||
if (item.price_html) {
|
||||
var price = document.createElement('div');
|
||||
price.className = 'xshop-product-card__price';
|
||||
price.innerHTML = item.price_html; // trusted from Woo
|
||||
body.appendChild(price);
|
||||
}
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'xshop-product-card__meta';
|
||||
if (item.type === 'variable') {
|
||||
var v = document.createElement('span');
|
||||
v.textContent = 'Variable';
|
||||
meta.appendChild(v);
|
||||
}
|
||||
if (!item.in_stock) {
|
||||
var s = document.createElement('span');
|
||||
s.textContent = 'Out of stock';
|
||||
meta.appendChild(s);
|
||||
}
|
||||
body.appendChild(meta);
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'xshop-product-card__actions';
|
||||
var btn = document.createElement('a');
|
||||
btn.href = item.url;
|
||||
btn.className = 'button product_type_' + item.type;
|
||||
btn.textContent = item.in_stock ? 'View' : 'Read more';
|
||||
actions.appendChild(btn);
|
||||
body.appendChild(actions);
|
||||
card.appendChild(body);
|
||||
wrap.appendChild(card);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
var filtersRoots = document.querySelectorAll('[data-xshop-filters]');
|
||||
var filtersRoot = filtersRoots[0];
|
||||
var grid = document.querySelector('.xshop-products') || document.querySelector('[data-xshop-products]');
|
||||
var resultsCountEl = document.querySelector('.woocommerce-result-count');
|
||||
var paginationEl = document.querySelector('.woocommerce-pagination') || document.querySelector('[data-xshop-pagination]');
|
||||
var orderingSelect = document.querySelector('.woocommerce-ordering select.orderby') || document.querySelector('[data-filter="orderby"]');
|
||||
var chipsEl = document.querySelector('[data-xshop-chips]');
|
||||
var forms = document.querySelectorAll('[data-xshop-filters-form]');
|
||||
var form = forms[0];
|
||||
var drawer = document.querySelector('[data-xshop-drawer]');
|
||||
var drawerToggle = document.querySelector('[data-xshop-drawer-toggle]');
|
||||
var drawerClose = document.querySelector('[data-xshop-drawer-close]');
|
||||
var overlay = document.querySelector('[data-xshop-drawer-overlay]');
|
||||
|
||||
if (!filtersRoot) return;
|
||||
// grid may be missing on empty category (has empty state) — create container if needed
|
||||
var gridContainer = grid ? grid.parentElement : document.querySelector('.xshop-woo-layout') || document.body;
|
||||
|
||||
var state = parseStateFromURL();
|
||||
var abortCtrl = null;
|
||||
var seq = 0;
|
||||
var isFirstLoad = true;
|
||||
|
||||
function syncFormFromState() {
|
||||
forms.forEach(function (f) {
|
||||
f.querySelectorAll('input[name="category"]').forEach(function (el) {
|
||||
el.checked = el.value === state.category;
|
||||
if (!state.category && el.value === '') el.checked = true;
|
||||
});
|
||||
f.querySelectorAll('input[data-filter="attribute"]').forEach(function (el) {
|
||||
var tax = el.getAttribute('data-tax');
|
||||
var val = el.value;
|
||||
var active = state.attributes[tax] && state.attributes[tax].indexOf(val) !== -1;
|
||||
el.checked = !!active;
|
||||
});
|
||||
var minEl = f.querySelector('input[name="min_price"]');
|
||||
var maxEl = f.querySelector('input[name="max_price"]');
|
||||
if (minEl) minEl.value = state.min_price || '';
|
||||
if (maxEl) maxEl.value = state.max_price || '';
|
||||
f.querySelectorAll('input[name="stock"]').forEach(function (el) { el.checked = el.value === (state.stock || 'all'); });
|
||||
var saleEl = f.querySelector('input[name="on_sale"]');
|
||||
if (saleEl) saleEl.checked = state.on_sale === '1';
|
||||
f.querySelectorAll('input[name="rating"]').forEach(function (el) { el.checked = el.value === String(state.rating || '0'); });
|
||||
var ob = f.querySelector('select[name="orderby"]');
|
||||
if (ob) ob.value = state.orderby || 'default';
|
||||
});
|
||||
if (orderingSelect) orderingSelect.value = state.orderby || 'default';
|
||||
}
|
||||
|
||||
function buildStateFromForm(srcForm) {
|
||||
var f = srcForm || form;
|
||||
if (!f) return state;
|
||||
var fd = new FormData(f);
|
||||
var newState = {
|
||||
search: state.search,
|
||||
category: (fd.get('category') || '').toString().trim(),
|
||||
min_price: (fd.get('min_price') || '').toString().trim(),
|
||||
max_price: (fd.get('max_price') || '').toString().trim(),
|
||||
stock: (fd.get('stock') || 'all').toString(),
|
||||
on_sale: fd.get('on_sale') === '1' ? '1' : '0',
|
||||
rating: (fd.get('rating') || '0').toString(),
|
||||
orderby: (fd.get('orderby') || 'default').toString(),
|
||||
page: 1,
|
||||
per_page: state.per_page,
|
||||
attributes: {}
|
||||
};
|
||||
// Collect checked attributes from ALL forms (since sidebar+drawer duplicate)
|
||||
document.querySelectorAll('input[data-filter="attribute"]:checked').forEach(function (el) {
|
||||
var tax = el.getAttribute('data-tax');
|
||||
if (!newState.attributes[tax]) newState.attributes[tax] = [];
|
||||
if (newState.attributes[tax].indexOf(el.value) === -1) newState.attributes[tax].push(el.value);
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
if (!chipsEl) return;
|
||||
chipsEl.innerHTML = '';
|
||||
var hasActive = false;
|
||||
function addChip(label, onRemove) {
|
||||
hasActive = true;
|
||||
var chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'xshop-chip';
|
||||
chip.textContent = label + ' ×';
|
||||
chip.setAttribute('aria-label', 'Remove filter ' + label);
|
||||
chip.addEventListener('click', onRemove);
|
||||
chipsEl.appendChild(chip);
|
||||
}
|
||||
if (state.category) {
|
||||
addChip('Category: ' + state.category, function () {
|
||||
state.category = '';
|
||||
state.page = 1;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
}
|
||||
Object.keys(state.attributes).forEach(function (tax) {
|
||||
(state.attributes[tax] || []).forEach(function (term) {
|
||||
addChip(tax.replace('pa_', '') + ': ' + term, function () {
|
||||
var idx = state.attributes[tax].indexOf(term);
|
||||
if (idx !== -1) state.attributes[tax].splice(idx, 1);
|
||||
if (!state.attributes[tax].length) delete state.attributes[tax];
|
||||
state.page = 1;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
if (state.min_price) addChip('Min ' + state.min_price, function () { state.min_price = ''; state.page = 1; syncAndFetch(true); });
|
||||
if (state.max_price) addChip('Max ' + state.max_price, function () { state.max_price = ''; state.page = 1; syncAndFetch(true); });
|
||||
if (state.stock && state.stock !== 'all') addChip('Stock: ' + state.stock, function () { state.stock = 'all'; state.page = 1; syncAndFetch(true); });
|
||||
if (state.on_sale === '1') addChip('On sale', function () { state.on_sale = '0'; state.page = 1; syncAndFetch(true); });
|
||||
if (state.rating && state.rating !== '0') addChip('Rating ' + state.rating + '+', function () { state.rating = '0'; state.page = 1; syncAndFetch(true); });
|
||||
if (state.search) addChip('Search: ' + state.search, function () { state.search = ''; state.page = 1; syncAndFetch(true); });
|
||||
|
||||
if (hasActive) {
|
||||
var clearAll = document.createElement('button');
|
||||
clearAll.type = 'button';
|
||||
clearAll.className = 'xshop-chip xshop-chip--clear';
|
||||
clearAll.textContent = 'Clear all';
|
||||
clearAll.addEventListener('click', function () {
|
||||
state = { search: '', category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
|
||||
// Preserve category context? If on category archive, clearing all should keep category context? We clear category context as well per "clear all" semantics (go to shop). If on category page, keep category? For now clear all goes to shop base (no category)
|
||||
syncAndFetch(true);
|
||||
});
|
||||
chipsEl.appendChild(clearAll);
|
||||
}
|
||||
}
|
||||
|
||||
function updateURL(push) {
|
||||
var p = stateToParams(state);
|
||||
var url = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
|
||||
if (push) {
|
||||
history.pushState(state, '', url);
|
||||
} else {
|
||||
history.replaceState(state, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
if (!gridContainer) return;
|
||||
gridContainer.setAttribute('aria-busy', loading ? 'true' : 'false');
|
||||
var loader = document.querySelector('[data-xshop-loading]');
|
||||
if (loader) loader.hidden = !loading;
|
||||
// toolbar still usable
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
var items = data.items || [];
|
||||
var pagination = data.pagination || { page: 1, per_page: 12, total: 0, total_pages: 1 };
|
||||
|
||||
// Ensure grid exists
|
||||
var targetGrid = document.querySelector('.xshop-products');
|
||||
if (!targetGrid) {
|
||||
// Create grid if was empty state
|
||||
var emptyState = document.querySelector('.xshop-empty');
|
||||
if (emptyState) emptyState.remove();
|
||||
targetGrid = document.createElement('ul');
|
||||
targetGrid.className = 'xshop-products products columns-4';
|
||||
if (gridContainer) gridContainer.appendChild(targetGrid);
|
||||
}
|
||||
if (targetGrid) {
|
||||
targetGrid.innerHTML = '';
|
||||
if (!items.length) {
|
||||
// Empty state
|
||||
targetGrid.remove();
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'xshop-empty';
|
||||
empty.innerHTML = '<h3 class="xshop-empty__title">No products found</h3><p class="xshop-empty__text">Try adjusting your filters.</p>';
|
||||
if (gridContainer) gridContainer.appendChild(empty);
|
||||
} else {
|
||||
items.forEach(function (it) {
|
||||
targetGrid.appendChild(renderProductCard(it));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Result count
|
||||
if (resultsCountEl) {
|
||||
resultsCountEl.textContent = pagination.total + ' products';
|
||||
} else {
|
||||
var rc = document.querySelector('.woocommerce-result-count');
|
||||
if (rc) rc.textContent = pagination.total + ' products';
|
||||
}
|
||||
|
||||
// Pagination
|
||||
renderPagination(pagination);
|
||||
renderChips();
|
||||
// Focus management: move focus to grid for screen readers
|
||||
if (targetGrid && !isFirstLoad) {
|
||||
targetGrid.setAttribute('tabindex', '-1');
|
||||
// Don't steal focus aggressively; just ensure aria-live
|
||||
}
|
||||
isFirstLoad = false;
|
||||
}
|
||||
|
||||
function renderPagination(pag) {
|
||||
var container = paginationEl || document.querySelector('.woocommerce-pagination');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (pag.total_pages <= 1) return;
|
||||
var nav = document.createElement('nav');
|
||||
nav.setAttribute('aria-label', 'Pagination');
|
||||
nav.className = 'xshop-pagination';
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'nav-links';
|
||||
for (var i = 1; i <= pag.total_pages; i++) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'page-numbers' + (i === pag.page ? ' current' : '');
|
||||
btn.textContent = String(i);
|
||||
btn.setAttribute('aria-label', 'Page ' + i);
|
||||
if (i === pag.page) btn.setAttribute('aria-current', 'page');
|
||||
(function (pageNum) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.page = pageNum;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
})(i);
|
||||
inner.appendChild(btn);
|
||||
}
|
||||
nav.appendChild(inner);
|
||||
container.appendChild(nav);
|
||||
}
|
||||
|
||||
function syncAndFetch(pushURL) {
|
||||
syncFormFromState(); // ensure form reflects state (for back/forward)
|
||||
renderChips();
|
||||
updateURL(pushURL);
|
||||
fetchAndRender();
|
||||
}
|
||||
|
||||
var debouncedPriceFetch = debounce(function () {
|
||||
state.page = 1;
|
||||
syncAndFetch(true);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
function fetchAndRender() {
|
||||
var mySeq = ++seq;
|
||||
if (abortCtrl) abortCtrl.abort();
|
||||
abortCtrl = new AbortController();
|
||||
setLoading(true);
|
||||
var url = stateToEndpoint(state);
|
||||
// Update URL already done
|
||||
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);
|
||||
renderResults(data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err && err.name === 'AbortError') return;
|
||||
if (mySeq !== seq) return;
|
||||
setLoading(false);
|
||||
// show error, keep existing grid
|
||||
var errEl = document.querySelector('[data-xshop-error]');
|
||||
if (errEl) {
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = 'Could not load products. Please try again.';
|
||||
setTimeout(function () { errEl.hidden = true; }, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Attach change listeners to all forms
|
||||
forms.forEach(function (f) {
|
||||
f.addEventListener('change', function (e) {
|
||||
var target = e.target;
|
||||
if (!target) return;
|
||||
if (target.getAttribute('data-filter') === 'price') return;
|
||||
var newState = buildStateFromForm(f);
|
||||
newState.search = state.search;
|
||||
newState.per_page = state.per_page;
|
||||
state = newState;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
});
|
||||
|
||||
var priceInputs = document.querySelectorAll('input[name="min_price"], input[name="max_price"]');
|
||||
priceInputs.forEach(function (el) {
|
||||
el.addEventListener('input', function () {
|
||||
// Read from closest form
|
||||
var f = el.closest('form');
|
||||
var fd = f ? new FormData(f) : new FormData(form);
|
||||
state.min_price = (fd.get('min_price') || '').toString().trim();
|
||||
state.max_price = (fd.get('max_price') || '').toString().trim();
|
||||
state.page = 1;
|
||||
renderChips();
|
||||
updateURL(true);
|
||||
debouncedPriceFetch();
|
||||
});
|
||||
});
|
||||
|
||||
// Ordering select (toolbar and form)
|
||||
if (orderingSelect) {
|
||||
orderingSelect.addEventListener('change', function () {
|
||||
state.orderby = orderingSelect.value || 'default';
|
||||
state.page = 1;
|
||||
// sync form select too
|
||||
var formOb = form.querySelector('select[name="orderby"]');
|
||||
if (formOb) formOb.value = state.orderby;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
}
|
||||
var formOrderby = form.querySelector('select[name="orderby"]');
|
||||
if (formOrderby && formOrderby !== orderingSelect) {
|
||||
formOrderby.addEventListener('change', function () {
|
||||
state.orderby = formOrderby.value || 'default';
|
||||
if (orderingSelect) orderingSelect.value = state.orderby;
|
||||
state.page = 1;
|
||||
syncAndFetch(true);
|
||||
});
|
||||
}
|
||||
|
||||
forms.forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var newState = buildStateFromForm(f);
|
||||
newState.search = state.search;
|
||||
newState.per_page = state.per_page;
|
||||
state = newState;
|
||||
syncAndFetch(true);
|
||||
closeDrawer();
|
||||
});
|
||||
var clearAllLink = f.querySelector('[data-xshop-clear-all]');
|
||||
if (clearAllLink) {
|
||||
clearAllLink.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
state = { search: state.search, category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
|
||||
syncAndFetch(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Drawer
|
||||
function openDrawer() {
|
||||
if (!drawer) return;
|
||||
drawer.hidden = false;
|
||||
drawer.classList.add('is-open');
|
||||
if (overlay) overlay.hidden = false;
|
||||
var first = drawer.querySelector('input, select, button');
|
||||
if (first) first.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (drawerToggle) drawerToggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function closeDrawer() {
|
||||
if (!drawer) return;
|
||||
drawer.hidden = true;
|
||||
drawer.classList.remove('is-open');
|
||||
if (overlay) overlay.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
if (drawerToggle) {
|
||||
drawerToggle.setAttribute('aria-expanded', 'false');
|
||||
drawerToggle.focus();
|
||||
}
|
||||
}
|
||||
if (drawerToggle) {
|
||||
drawerToggle.addEventListener('click', function () {
|
||||
var isOpen = drawer && !drawer.hidden;
|
||||
if (isOpen) closeDrawer(); else openDrawer();
|
||||
});
|
||||
}
|
||||
if (drawerClose) drawerClose.addEventListener('click', closeDrawer);
|
||||
if (overlay) overlay.addEventListener('click', closeDrawer);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && drawer && !drawer.hidden) closeDrawer();
|
||||
});
|
||||
|
||||
// Browser back/forward
|
||||
window.addEventListener('popstate', function (e) {
|
||||
var newState = e.state ? e.state : parseStateFromURL();
|
||||
// e.state may be null on initial load; parse from URL
|
||||
if (!e.state) newState = parseStateFromURL();
|
||||
state = newState;
|
||||
syncFormFromState();
|
||||
renderChips();
|
||||
fetchAndRender();
|
||||
// Don't push URL again
|
||||
});
|
||||
|
||||
// Initial: parse URL, sync form, render chips, but don't fetch if server-rendered matches? We will fetch to ensure AJAX grid matches server (in case of direct URL with filters, server already rendered correctly, but we want to ensure pagination etc sync). To avoid double fetch on initial load with no filters, we skip fetch if no filters and not paginated.
|
||||
syncFormFromState();
|
||||
renderChips();
|
||||
// If URL has any filter param (category/attributes/price etc) or page>1 or search, we should ensure grid reflects it (server already did if no-JS). But to keep AJAX in sync, we could fetch once if filters present? For now, we only fetch on user interaction; initial server render is sufficient. But to support back/forward after JS navigation, we need fetch on popstate. So initial load does not auto-fetch unless needed.
|
||||
// However, if user lands on /shop/?category=... directly, server already rendered filtered products (no-JS fallback). That's correct. No need to double fetch.
|
||||
|
||||
// Expose for testing
|
||||
window.xshopFilters = { state: state, fetch: fetchAndRender, parse: parseStateFromURL };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initFilters);
|
||||
} else {
|
||||
initFilters();
|
||||
}
|
||||
})();
|
||||
@@ -23,12 +23,10 @@ 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);
|
||||
// Filter JS — header search exists globally, filters exist on shop archives. Enqueue front-end globally (8KB) with DOM guard (init only if [data-xshop-filters] present).
|
||||
wp_enqueue_script('xshop-filter', XSHOP_THEME_URI . '/assets/js/filter.js', [], $ver, true);
|
||||
}
|
||||
|
||||
// Localized data for REST/AJAX (nonces, i18n hints).
|
||||
|
||||
@@ -33,7 +33,87 @@ function xshop_woo_setup(): void {
|
||||
|
||||
// Related/upsells: ensure they use product-card grid (filter columns)
|
||||
add_filter('woocommerce_output_related_products_args', 'xshop_wc_related_args');
|
||||
add_filter('woocommerce_upsells_total', '__return_false'); // keep? actually we keep upsells - no filter, just ensure columns via CSS grid
|
||||
add_filter('woocommerce_upsells_total', '__return_false');
|
||||
|
||||
// M4: No-JS fallback — apply filters to main query on shop/category archive
|
||||
add_action('pre_get_posts', 'xshop_wc_filter_main_query', 20);
|
||||
}
|
||||
|
||||
function xshop_wc_filter_main_query(\WP_Query $q): void {
|
||||
if (\is_admin() || !$q->is_main_query()) { return; }
|
||||
if (!\class_exists('WooCommerce')) { return; }
|
||||
// Only on product archive (shop or product_taxonomy)
|
||||
if (!(\is_shop() || \is_product_taxonomy())) { return; }
|
||||
// Don't interfere with REST
|
||||
if (\defined('REST_REQUEST') && \REST_REQUEST) { return; }
|
||||
|
||||
// If no filter params, leave default Woo query
|
||||
$hasFilter = isset($_GET['min_price']) || isset($_GET['max_price']) || isset($_GET['stock']) || isset($_GET['on_sale']) || isset($_GET['sale']) || isset($_GET['rating']) || isset($_GET['category']) || isset($_GET['orderby']) || isset($_GET['s']);
|
||||
// Also check pa_* attributes
|
||||
foreach ($_GET as $k => $v) {
|
||||
if (\str_starts_with((string) $k, 'pa_') || \str_starts_with((string) $k, 'filter_') || \str_starts_with((string) $k, 'attribute_')) { $hasFilter = true; break; }
|
||||
}
|
||||
if (!$hasFilter) { return; }
|
||||
|
||||
// Build params from $_GET (same canonical as REST)
|
||||
$params = [
|
||||
'search' => isset($_GET['s']) ? \sanitize_text_field(\wp_unslash($_GET['s'])) : '',
|
||||
'category' => isset($_GET['category']) ? \sanitize_text_field(\wp_unslash($_GET['category'])) : '',
|
||||
'attributes' => [],
|
||||
'min_price' => isset($_GET['min_price']) ? \sanitize_text_field(\wp_unslash($_GET['min_price'])) : null,
|
||||
'max_price' => isset($_GET['max_price']) ? \sanitize_text_field(\wp_unslash($_GET['max_price'])) : null,
|
||||
'stock' => isset($_GET['stock']) ? \sanitize_key(\wp_unslash($_GET['stock'])) : 'all',
|
||||
'on_sale' => (isset($_GET['on_sale']) && $_GET['on_sale'] === '1') || (isset($_GET['sale']) && $_GET['sale'] === '1'),
|
||||
'rating' => isset($_GET['rating']) ? \absint($_GET['rating']) : 0,
|
||||
'orderby' => isset($_GET['orderby']) ? \sanitize_key(\wp_unslash($_GET['orderby'])) : 'default',
|
||||
'page' => isset($_GET['page']) ? \absint($_GET['page']) : (isset($_GET['paged']) ? \absint($_GET['paged']) : 1),
|
||||
'per_page' => 12,
|
||||
];
|
||||
// Collect attributes from pa_* etc.
|
||||
foreach ($_GET as $k => $v) {
|
||||
$kClean = \sanitize_key((string) $k);
|
||||
if (\str_starts_with($kClean, 'pa_') || \str_starts_with($kClean, 'attribute_')) {
|
||||
$tax = $kClean;
|
||||
if (\str_starts_with($tax, 'attribute_')) { $tax = 'pa_' . \substr($tax, 10); }
|
||||
$terms = [];
|
||||
if (\is_array($v)) { foreach ($v as $t) { $t = \sanitize_title((string) $t); if ($t !== '') { $terms[] = $t; } } }
|
||||
else { foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } } }
|
||||
if (!empty($terms) && \taxonomy_exists($tax)) { $params['attributes'][$tax] = $terms; }
|
||||
}
|
||||
if (\str_starts_with($kClean, 'filter_')) {
|
||||
$tax = 'pa_' . \substr($kClean, 7);
|
||||
if (\taxonomy_exists($tax)) {
|
||||
$terms = [];
|
||||
foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } }
|
||||
if (!empty($terms)) { $params['attributes'][$tax] = $terms; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// If on category archive and category param empty, preserve queried category
|
||||
if (\is_product_category() && empty($params['category'])) {
|
||||
$term = \get_queried_object();
|
||||
if ($term instanceof \WP_Term) { $params['category'] = $term->slug; }
|
||||
}
|
||||
|
||||
// Use shared ProductQuery to build args, then merge into main query
|
||||
if (!\class_exists('XShop\Core\Query\ProductQuery')) {
|
||||
// Fallback if plugin not loaded yet (should be loaded via xshop-core)
|
||||
return;
|
||||
}
|
||||
// ProductQuery class is functions-based, not class; use function directly if available
|
||||
if (\function_exists('XShop\Core\Query\build_product_query_args')) {
|
||||
$wpArgs = \XShop\Core\Query\build_product_query_args($params);
|
||||
// Merge into main query (only tax_query/meta_query/orderby etc, not post_type/status which already correct)
|
||||
if (!empty($wpArgs['tax_query'])) { $q->set('tax_query', $wpArgs['tax_query']); }
|
||||
if (!empty($wpArgs['meta_query'])) { $q->set('meta_query', $wpArgs['meta_query']); }
|
||||
if (isset($wpArgs['meta_key'])) { $q->set('meta_key', $wpArgs['meta_key']); }
|
||||
if (isset($wpArgs['orderby'])) { $q->set('orderby', $wpArgs['orderby']); }
|
||||
if (isset($wpArgs['order'])) { $q->set('order', $wpArgs['order']); }
|
||||
if (isset($wpArgs['s'])) { $q->set('s', $wpArgs['s']); }
|
||||
// Ensure per_page/page respects
|
||||
$q->set('posts_per_page', $wpArgs['posts_per_page']);
|
||||
$q->set('paged', $wpArgs['paged']);
|
||||
}
|
||||
}
|
||||
|
||||
function xshop_wc_remove_default_gallery(): void {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* Product Filters — reusable sidebar (desktop) + drawer (mobile) + no-JS fallback.
|
||||
*
|
||||
* Expects context via $args or current queried object (shop/category).
|
||||
* Uses same canonical query params as REST /xshop/v1/products and URL sync.
|
||||
*
|
||||
* @package XShop
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
if (!xshop_has_woocommerce()) { return; }
|
||||
|
||||
// Current state from URL (canonical)
|
||||
$currentCategory = isset($_GET['category']) ? sanitize_text_field(wp_unslash($_GET['category'])) : '';
|
||||
if (is_product_category() && $currentCategory === '') {
|
||||
$term = get_queried_object();
|
||||
if ($term instanceof WP_Term) { $currentCategory = $term->slug; }
|
||||
}
|
||||
$currentMinPrice = isset($_GET['min_price']) ? sanitize_text_field(wp_unslash($_GET['min_price'])) : '';
|
||||
$currentMaxPrice = isset($_GET['max_price']) ? sanitize_text_field(wp_unslash($_GET['max_price'])) : '';
|
||||
$currentStock = isset($_GET['stock']) ? sanitize_key(wp_unslash($_GET['stock'])) : 'all';
|
||||
$currentOnSale = isset($_GET['on_sale']) ? '1' : (isset($_GET['sale']) ? '1' : '0');
|
||||
if (isset($_GET['on_sale'])) { $currentOnSale = $_GET['on_sale'] === '1' ? '1' : '0'; }
|
||||
if (isset($_GET['sale']) && $_GET['sale'] === '1') { $currentOnSale = '1'; }
|
||||
$currentRating = isset($_GET['rating']) ? absint($_GET['rating']) : 0;
|
||||
$currentOrderby = isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : 'default';
|
||||
|
||||
// Categories
|
||||
$categories = get_terms(['taxonomy'=>'product_cat','hide_empty'=>true,'number'=>20]);
|
||||
if (is_wp_error($categories)) { $categories = []; }
|
||||
|
||||
// Attributes — discover public Woo attribute taxonomies pa_*
|
||||
$attributeTaxonomies = [];
|
||||
if (function_exists('wc_get_attribute_taxonomies')) {
|
||||
$taxes = wc_get_attribute_taxonomies();
|
||||
foreach ($taxes as $tax) {
|
||||
$name = 'pa_' . $tax->attribute_name;
|
||||
if (taxonomy_exists($name)) { $attributeTaxonomies[] = $name; }
|
||||
}
|
||||
}
|
||||
// Also allow fallback to any pa_* that exists and has terms
|
||||
if (empty($attributeTaxonomies)) {
|
||||
$allTax = get_taxonomies(['public'=>false]);
|
||||
foreach ($allTax as $t) { if (str_starts_with($t,'pa_') && taxonomy_exists($t)) { $attributeTaxonomies[] = $t; } }
|
||||
}
|
||||
|
||||
// For no-JS fallback, we render a <form method="get" action="shop/category URL"> with same params
|
||||
$shopUrl = wc_get_page_permalink('shop');
|
||||
if (is_product_category()) {
|
||||
$term = get_queried_object();
|
||||
if ($term instanceof WP_Term) { $shopUrl = get_term_link($term); }
|
||||
}
|
||||
if (is_wp_error($shopUrl) || !$shopUrl) { $shopUrl = home_url('/shop/'); }
|
||||
|
||||
// Active chips data for JS
|
||||
?>
|
||||
<div class="xshop-filters" data-xshop-filters data-shop-url="<?php echo esc_url($shopUrl); ?>" data-category-context="<?php echo esc_attr($currentCategory); ?>">
|
||||
<!-- Active chips -->
|
||||
<div class="xshop-filters__chips" data-xshop-chips aria-live="polite"></div>
|
||||
|
||||
<form method="get" action="<?php echo esc_url($shopUrl); ?>" class="xshop-filters__form" data-xshop-filters-form>
|
||||
<?php
|
||||
// Preserve search keyword if present (from M3)
|
||||
if (isset($_GET['s'])) {
|
||||
echo '<input type="hidden" name="s" value="' . esc_attr(sanitize_text_field(wp_unslash($_GET['s']))) . '" />';
|
||||
}
|
||||
// Preserve category context as hidden if on category archive (so clearing filters doesn't drop category unless user clears chip)
|
||||
if (is_product_category() && $currentCategory !== '') {
|
||||
echo '<input type="hidden" name="category" value="' . esc_attr($currentCategory) . '" data-xshop-category-context />';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Categories (if not already on category context? Show anyway with active state) -->
|
||||
<?php if (!empty($categories)): ?>
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Categories', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php foreach ($categories as $cat):
|
||||
$isActive = $currentCategory === $cat->slug;
|
||||
?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="radio" name="category" value="<?php echo esc_attr($cat->slug); ?>" <?php checked($isActive); ?> data-filter="category" />
|
||||
<span><?php echo esc_html($cat->name); ?></span>
|
||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$cat->count)); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="radio" name="category" value="" <?php checked($currentCategory === ''); ?> data-filter="category" />
|
||||
<span><?php esc_html_e('All categories', 'xshop'); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Attributes -->
|
||||
<?php foreach ($attributeTaxonomies as $tax):
|
||||
$terms = get_terms(['taxonomy'=>$tax,'hide_empty'=>true,'number'=>30]);
|
||||
if (is_wp_error($terms) || empty($terms)) { continue; }
|
||||
$taxLabel = wc_attribute_label($tax);
|
||||
$activeTerms = [];
|
||||
if (isset($_GET[$tax])) {
|
||||
$raw = wp_unslash($_GET[$tax]);
|
||||
if (is_string($raw)) {
|
||||
foreach (explode(',', $raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
||||
}
|
||||
}
|
||||
// Also support filter_ prefix legacy
|
||||
$filterKey = 'filter_' . substr($tax, 3);
|
||||
if (isset($_GET[$filterKey])) {
|
||||
$raw = wp_unslash($_GET[$filterKey]);
|
||||
foreach (explode(',', (string)$raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
||||
}
|
||||
?>
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php echo esc_html($taxLabel); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php foreach ($terms as $term): $isActive = in_array($term->slug, $activeTerms, true); ?>
|
||||
<label class="xshop-filter__option">
|
||||
<input type="checkbox" name="<?php echo esc_attr($tax); ?>[]" value="<?php echo esc_attr($term->slug); ?>" <?php checked($isActive); ?> data-filter="attribute" data-tax="<?php echo esc_attr($tax); ?>" />
|
||||
<span><?php echo esc_html($term->name); ?></span>
|
||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$term->count)); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Price -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Price', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__price">
|
||||
<label>
|
||||
<span class="xshop-sr-only"><?php esc_html_e('Min price', 'xshop'); ?></span>
|
||||
<input type="number" name="min_price" placeholder="<?php esc_attr_e('Min', 'xshop'); ?>" value="<?php echo esc_attr($currentMinPrice); ?>" min="0" step="1" data-filter="price" />
|
||||
</label>
|
||||
<span aria-hidden="true">—</span>
|
||||
<label>
|
||||
<span class="xshop-sr-only"><?php esc_html_e('Max price', 'xshop'); ?></span>
|
||||
<input type="number" name="max_price" placeholder="<?php esc_attr_e('Max', 'xshop'); ?>" value="<?php echo esc_attr($currentMaxPrice); ?>" min="0" step="1" data-filter="price" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Stock -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Stock', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="all" <?php checked($currentStock, 'all'); ?> data-filter="stock" /> <span><?php esc_html_e('All', 'xshop'); ?></span></label>
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="instock" <?php checked($currentStock, 'instock'); ?> data-filter="stock" /> <span><?php esc_html_e('In stock', 'xshop'); ?></span></label>
|
||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="outofstock" <?php checked($currentStock, 'outofstock'); ?> data-filter="stock" /> <span><?php esc_html_e('Out of stock', 'xshop'); ?></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Sale -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Sale', 'xshop'); ?></legend>
|
||||
<label class="xshop-filter__option"><input type="checkbox" name="on_sale" value="1" <?php checked($currentOnSale, '1'); ?> data-filter="sale" /> <span><?php esc_html_e('On sale', 'xshop'); ?></span></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Rating -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Rating', 'xshop'); ?></legend>
|
||||
<div class="xshop-filter__options">
|
||||
<?php for ($r=5;$r>=1;$r--): $isActive = $currentRating === $r; ?>
|
||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="<?php echo esc_attr((string)$r); ?>" <?php checked($isActive); ?> data-filter="rating" /> <span><?php echo esc_html(sprintf(esc_html__('%d+ stars', 'xshop'), $r)); ?></span></label>
|
||||
<?php endfor; ?>
|
||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="0" <?php checked($currentRating, 0); ?> data-filter="rating" /> <span><?php esc_html_e('Any rating', 'xshop'); ?></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Sorting (also in toolbar, but keep here for no-JS) -->
|
||||
<fieldset class="xshop-filter">
|
||||
<legend class="xshop-filter__title"><?php esc_html_e('Sort', 'xshop'); ?></legend>
|
||||
<select name="orderby" data-filter="orderby" aria-label="<?php esc_attr_e('Sort', 'xshop'); ?>">
|
||||
<option value="default" <?php selected($currentOrderby, 'default'); ?>><?php esc_html_e('Default', 'xshop'); ?></option>
|
||||
<option value="latest" <?php selected($currentOrderby, 'latest'); ?>><?php esc_html_e('Latest', 'xshop'); ?></option>
|
||||
<option value="price_asc" <?php selected($currentOrderby, 'price_asc'); ?>><?php esc_html_e('Price: low to high', 'xshop'); ?></option>
|
||||
<option value="price_desc" <?php selected($currentOrderby, 'price_desc'); ?>><?php esc_html_e('Price: high to low', 'xshop'); ?></option>
|
||||
<option value="popularity" <?php selected($currentOrderby, 'popularity'); ?>><?php esc_html_e('Popular', 'xshop'); ?></option>
|
||||
<option value="rating" <?php selected($currentOrderby, 'rating'); ?>><?php esc_html_e('Rated', 'xshop'); ?></option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<div class="xshop-filters__actions">
|
||||
<button type="submit" class="xshop-btn xshop-btn--primary"><?php esc_html_e('Apply', 'xshop'); ?></button>
|
||||
<a href="<?php echo esc_url($shopUrl); ?>" class="xshop-btn xshop-btn--ghost" data-xshop-clear-all><?php esc_html_e('Clear all', 'xshop'); ?></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Shop / category archive — uses Woo hooks, XShop toolbar/grid/empty states.
|
||||
* Shop / category archive — XShop filters + AJAX grid + no-JS fallback.
|
||||
*
|
||||
* Override is minimal: we keep Woo's before/after hooks but ensure XShop
|
||||
* container, breadcrumbs, title, sorting, result count, pagination, and empty state
|
||||
* are rendered with XShop components/tokens. For full logic see
|
||||
* inc/woocommerce/setup.php hooks (xshop_wc_loop_start, toolbar).
|
||||
* Integrates product-filters sidebar (desktop) + drawer (mobile) + toolbar.
|
||||
* Server render uses Woo hooks + ProductQuery via pre_get_posts (inc/woocommerce/setup.php) for no-JS.
|
||||
* JS enhances via REST /xshop/v1/products.
|
||||
*
|
||||
* WC version tested: 11.1.0
|
||||
* Template version: 3.4.0
|
||||
@@ -33,36 +32,72 @@ if (is_product_category() || is_product_tag() || is_tax('product_brand')) {
|
||||
$title = $shopId ? get_the_title($shopId) : esc_html__('Shop', 'xshop');
|
||||
echo '<header class="xshop-page-header"><h1 class="xshop-page-header__title">' . esc_html($title) . '</h1></header>';
|
||||
}
|
||||
?>
|
||||
|
||||
do_action('woocommerce_before_shop_loop');
|
||||
<div class="xshop-shop" data-xshop-shop>
|
||||
<!-- Mobile filter toggle -->
|
||||
<button type="button" class="xshop-btn xshop-btn--secondary xshop-shop__filter-toggle" data-xshop-drawer-toggle aria-expanded="false" aria-controls="xshop-filter-drawer">
|
||||
<?php esc_html_e('Filters', 'xshop'); ?>
|
||||
</button>
|
||||
|
||||
if (woocommerce_product_loop()) {
|
||||
<div class="xshop-shop__layout">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="xshop-shop__sidebar" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile drawer -->
|
||||
<div class="xshop-drawer" id="xshop-filter-drawer" hidden data-xshop-drawer role="dialog" aria-modal="true" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
||||
<div class="xshop-drawer__header">
|
||||
<h2 class="xshop-drawer__title"><?php esc_html_e('Filters', 'xshop'); ?></h2>
|
||||
<button type="button" class="xshop-btn xshop-btn--ghost" data-xshop-drawer-close aria-label="<?php esc_attr_e('Close filters', 'xshop'); ?>">×</button>
|
||||
</div>
|
||||
<div class="xshop-drawer__body">
|
||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xshop-drawer__overlay" hidden data-xshop-drawer-overlay></div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="xshop-shop__main" data-xshop-shop-main>
|
||||
<div class="xshop-shop__toolbar" data-xshop-toolbar>
|
||||
<?php do_action('woocommerce_before_shop_loop'); ?>
|
||||
</div>
|
||||
|
||||
<div class="xshop-search__loading" hidden data-xshop-loading style="display:none">
|
||||
<span class="xshop-loading__spinner" aria-hidden="true"></span> <?php esc_html_e('Loading…', 'xshop'); ?>
|
||||
</div>
|
||||
<div class="xshop-search__error" hidden data-xshop-error role="alert"></div>
|
||||
|
||||
<div data-xshop-products-container>
|
||||
<?php
|
||||
if (woocommerce_product_loop()) {
|
||||
woocommerce_product_loop_start();
|
||||
if (wc_get_loop_prop('is_shortcode')) {
|
||||
$columns = absint(wc_get_loop_prop('columns'));
|
||||
} else {
|
||||
$columns = absint(get_option('woocommerce_catalog_columns', 4));
|
||||
}
|
||||
|
||||
while (have_posts()) {
|
||||
the_post();
|
||||
do_action('woocommerce_shop_loop');
|
||||
wc_get_template_part('content', 'product');
|
||||
}
|
||||
|
||||
while (have_posts()) { the_post(); do_action('woocommerce_shop_loop'); wc_get_template_part('content', 'product'); }
|
||||
woocommerce_product_loop_end();
|
||||
do_action('woocommerce_after_shop_loop');
|
||||
} else {
|
||||
} else {
|
||||
do_action('woocommerce_no_products_found');
|
||||
// Enhanced empty state for shop/category
|
||||
get_template_part('template-parts/components/empty-state', null, [
|
||||
'title' => esc_html__('No products found', 'xshop'),
|
||||
'text' => esc_html__('Try adjusting your filters or browse all products.', 'xshop'),
|
||||
'cta_label' => esc_html__('Browse shop', 'xshop'),
|
||||
'cta_url' => wc_get_page_permalink('shop'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div data-xshop-pagination>
|
||||
<?php
|
||||
// Woo pagination rendered via woocommerce_after_shop_loop already inside loop; for AJAX we render custom via JS
|
||||
// Ensure result count is accessible
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
do_action('woocommerce_after_main_content');
|
||||
|
||||
get_footer('shop');
|
||||
|
||||
Reference in New Issue
Block a user