feat: implement WooCommerce AJAX product filtering

This commit is contained in:
XShop
2026-09-13 20:31:20 +03:30
parent f5c283af25
commit b78e332966
15 changed files with 1543 additions and 150 deletions
+3 -2
View File
@@ -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)];
}
+231
View File
@@ -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;
}
+12 -81
View File
@@ -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);
}