feat: implement WooCommerce AJAX product search

This commit is contained in:
XShop
2026-09-13 20:01:15 +03:30
parent 00439fcea9
commit f5c283af25
14 changed files with 760 additions and 37 deletions
+1
View File
@@ -29,6 +29,7 @@ final class Plugin {
$mods = [
'Modules/Banners/CPT.php',
'Modules/QA/CPT.php',
'REST/Search.php',
];
foreach ($mods as $rel) {
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
+202
View File
@@ -0,0 +1,202 @@
<?php
/**
* XShop REST Search — /xshop/v1/search
*
* Public read-only product search. Returns minimal fields, no private data.
*
* @package XShop\Core
*/
declare(strict_types=1);
namespace XShop\Core\REST;
defined('ABSPATH') || exit;
add_action('rest_api_init', __NAMESPACE__ . '\\register_search_route');
function register_search_route(): void {
register_rest_route('xshop/v1', '/search', [
'methods' => 'GET',
'callback' => __NAMESPACE__ . '\\handle_search',
'permission_callback' => '__return_true', // public read-only; no private data exposed
'args' => [
'search' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_search_param',
'validate_callback' => __NAMESPACE__ . '\\validate_search_param',
],
'limit' => [
'required' => false,
'type' => 'integer',
'default' => 6,
'sanitize_callback' => 'absint',
'validate_callback' => static function ($value): bool {
$v = (int) $value;
return $v >= 1 && $v <= 20;
},
],
],
]);
}
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);
}
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;
}
/**
* Handle search.
*
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
function handle_search(\WP_REST_Request $request): \WP_REST_Response {
$search = 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',
's' => $search,
'posts_per_page' => $limit * 2,
'no_found_rows' => true,
'fields' => 'ids',
]);
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',
],
],
]);
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.
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);
}
$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',
's' => $search,
'posts_per_page' => $limit,
'no_found_rows' => true,
]);
foreach ($q->posts as $post) {
$items[] = [
'id' => (int) $post->ID,
'title' => html_entity_decode(get_the_title($post->ID), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($post->ID),
'image' => get_the_post_thumbnail_url($post->ID, 'thumbnail') ?: '',
'price_html' => '',
'type' => $post->post_type,
'in_stock' => true,
];
}
$total = count($items);
wp_reset_postdata();
}
$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
*/
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(),
];
}