feat: implement WooCommerce AJAX product filtering
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user