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