From b78e33296696beed888ec400382506da0c6b497a Mon Sep 17 00:00:00 2001 From: XShop Date: Sun, 13 Sep 2026 20:31:20 +0330 Subject: [PATCH] feat: implement WooCommerce AJAX product filtering --- shop/CHANGELOG.md | 11 + shop/docs/ARCHITECTURE.md | 33 +- shop/docs/COMPATIBILITY.md | 17 +- shop/docs/TESTING.md | 13 + shop/docs/UI-SPEC.md | 12 +- shop/xshop-core/includes/Core/Plugin.php | 5 +- .../includes/Query/ProductQuery.php | 280 +++++++++ shop/xshop-core/includes/REST/Products.php | 231 +++++++ shop/xshop-core/includes/REST/Search.php | 93 +-- shop/xshop-theme/assets/css/woo.css | 26 + shop/xshop-theme/assets/js/filter.js | 594 ++++++++++++++++++ shop/xshop-theme/inc/setup/assets.php | 6 +- shop/xshop-theme/inc/woocommerce/setup.php | 82 ++- .../components/product-filters.php | 193 ++++++ .../woocommerce/archive-product.php | 97 ++- 15 files changed, 1543 insertions(+), 150 deletions(-) create mode 100644 shop/xshop-core/includes/Query/ProductQuery.php create mode 100644 shop/xshop-core/includes/REST/Products.php create mode 100644 shop/xshop-theme/assets/js/filter.js create mode 100644 shop/xshop-theme/template-parts/components/product-filters.php diff --git a/shop/CHANGELOG.md b/shop/CHANGELOG.md index e0dc3a1..1abdbc2 100644 --- a/shop/CHANGELOG.md +++ b/shop/CHANGELOG.md @@ -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. diff --git a/shop/docs/ARCHITECTURE.md b/shop/docs/ARCHITECTURE.md index a5945b4..dd6b27e 100644 --- a/shop/docs/ARCHITECTURE.md +++ b/shop/docs/ARCHITECTURE.md @@ -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 diff --git a/shop/docs/COMPATIBILITY.md b/shop/docs/COMPATIBILITY.md index 4d0a381..2937ba5 100644 --- a/shop/docs/COMPATIBILITY.md +++ b/shop/docs/COMPATIBILITY.md @@ -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 diff --git a/shop/docs/TESTING.md b/shop/docs/TESTING.md index 3389c7f..99a53c1 100644 --- a/shop/docs/TESTING.md +++ b/shop/docs/TESTING.md @@ -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. diff --git a/shop/docs/UI-SPEC.md b/shop/docs/UI-SPEC.md index 630eb3b..da3eebc 100644 --- a/shop/docs/UI-SPEC.md +++ b/shop/docs/UI-SPEC.md @@ -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 diff --git a/shop/xshop-core/includes/Core/Plugin.php b/shop/xshop-core/includes/Core/Plugin.php index 97931c0..ec0d7d2 100644 --- a/shop/xshop-core/includes/Core/Plugin.php +++ b/shop/xshop-core/includes/Core/Plugin.php @@ -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; diff --git a/shop/xshop-core/includes/Query/ProductQuery.php b/shop/xshop-core/includes/Query/ProductQuery.php new file mode 100644 index 0000000..14efedb --- /dev/null +++ b/shop/xshop-core/includes/Query/ProductQuery.php @@ -0,0 +1,280 @@ + 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)]; +} diff --git a/shop/xshop-core/includes/REST/Products.php b/shop/xshop-core/includes/REST/Products.php new file mode 100644 index 0000000..8fa23ae --- /dev/null +++ b/shop/xshop-core/includes/REST/Products.php @@ -0,0 +1,231 @@ + '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; +} diff --git a/shop/xshop-core/includes/REST/Search.php b/shop/xshop-core/includes/REST/Search.php index 66fa3e5..6b05590 100644 --- a/shop/xshop-core/includes/REST/Search.php +++ b/shop/xshop-core/includes/REST/Search.php @@ -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); } diff --git a/shop/xshop-theme/assets/css/woo.css b/shop/xshop-theme/assets/css/woo.css index 0b1a75d..cd381ab 100644 --- a/shop/xshop-theme/assets/css/woo.css +++ b/shop/xshop-theme/assets/css/woo.css @@ -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; } diff --git a/shop/xshop-theme/assets/js/filter.js b/shop/xshop-theme/assets/js/filter.js new file mode 100644 index 0000000..c0afe8a --- /dev/null +++ b/shop/xshop-theme/assets/js/filter.js @@ -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 + var badges = document.createElement('span'); + badges.className = 'xshop-product-card__badges'; + badges.setAttribute('aria-hidden', 'true'); + if (item.price_html && item.price_html.indexOf('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(); + } +})(); diff --git a/shop/xshop-theme/inc/setup/assets.php b/shop/xshop-theme/inc/setup/assets.php index 723dd1c..a108531 100644 --- a/shop/xshop-theme/inc/setup/assets.php +++ b/shop/xshop-theme/inc/setup/assets.php @@ -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). diff --git a/shop/xshop-theme/inc/woocommerce/setup.php b/shop/xshop-theme/inc/woocommerce/setup.php index 09c9671..d4d75f8 100644 --- a/shop/xshop-theme/inc/woocommerce/setup.php +++ b/shop/xshop-theme/inc/woocommerce/setup.php @@ -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 { diff --git a/shop/xshop-theme/template-parts/components/product-filters.php b/shop/xshop-theme/template-parts/components/product-filters.php new file mode 100644 index 0000000..5d029f1 --- /dev/null +++ b/shop/xshop-theme/template-parts/components/product-filters.php @@ -0,0 +1,193 @@ +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
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 +?> +
+ +
+ + + '; + } + // 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 ''; + } + ?> + + + +
+ +
+ slug; + ?> + + + +
+
+ + + + $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; } } + } + ?> +
+ +
+ slug, $activeTerms, true); ?> + + +
+
+ + + +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ + +
+ + +
+ + +
+ +
+ =1;$r--): $isActive = $currentRating === $r; ?> + + + +
+
+ + +
+ + +
+ +
+ + +
+ +
diff --git a/shop/xshop-theme/woocommerce/archive-product.php b/shop/xshop-theme/woocommerce/archive-product.php index 8e2992a..de6bf76 100644 --- a/shop/xshop-theme/woocommerce/archive-product.php +++ b/shop/xshop-theme/woocommerce/archive-product.php @@ -1,11 +1,10 @@

' . esc_html($title) . '

'; } +?> -do_action('woocommerce_before_shop_loop'); +
+ + -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'); - } + + + - woocommerce_product_loop_end(); - do_action('woocommerce_after_shop_loop'); -} 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'), - ]); -} + +
+
+ +
+ + + +
+ 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'), + ]); + } + ?> +
+ +
+ +
+
+
+
+ +