– we control via loop_start // Do not override templates; use hooks for card rendering. add_action('woocommerce_shop_loop', 'xshop_wc_shop_loop_noop', 10); // Sale badge handled inside product-card; single product gallery replaces default add_action('init', 'xshop_wc_remove_default_gallery'); add_action('wp', 'xshop_wc_single_setup'); // Cart: ensure quantity wrappers accessible add_filter('woocommerce_cart_item_quantity', 'xshop_wc_cart_quantity', 10, 3); // 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'); // 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 { // Replace Woo's product images with XShop gallery component remove_action('woocommerce_before_single_product_summary', 'woocommerce_show_product_sale_flash', 10); remove_action('woocommerce_before_single_product_summary', 'woocommerce_show_product_images', 20); // Re-add sale flash inside summary? Keep flash via CSS badge in product-card only; single product shows price sale via Woo price hook } function xshop_wc_related_args(array $args): array { $args['posts_per_page'] = 4; $args['columns'] = 4; return $args; } function xshop_woo_wrapper_start(): void { // Breadcrumbs are rendered by header.php for most pages; for Woo we keep Woo breadcrumb but styled as xshop-breadcrumbs echo '
'; } function xshop_woo_wrapper_end(): void { echo '
'; } function xshop_wc_loop_start(string $html): string { // Replace Woo default