From 00872a6568c09bcfae1beabf6572921f6cbb7ccb Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 28 Feb 2026 15:06:33 +0100 Subject: [PATCH] Add Bootstrap 5 product archive with card grid and sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace WooCommerce's default shop/category page rendering with a Bootstrap 5 card grid layout featuring responsive columns, sale badges, star ratings, and an offcanvas sidebar for filters on mobile. Key implementation details: - Bypass parent theme's TemplateController for product archives via wp_bootstrap_should_render_template filter, render at template_redirect priority 11 using the same page shell injection pattern as plugin pages - Add archive-product.php (Bootstrap layout with optional sidebar) and content-product.php (PHP bridge for wc_get_template_part interception) - Inject global $product into Twig context in TemplateOverride to fix empty price/add-to-cart/rating/sale-flash in loop sub-templates — Twig has isolated variable scopes and cannot access PHP globals directly - Fix pagination URLs: use get_pagenum_link() instead of ?page= query param (WordPress uses 'paged' for archive pagination, not 'page') - Fix double-escaped – in result count by adding |raw filter - Reset WooCommerce float-based layout CSS (woocommerce-layout.css) for shop pages to prevent conflicts with Bootstrap flex grid - Register shop-sidebar widget area with Bootstrap-styled markup Co-Authored-By: Claude Opus 4.6 --- archive-product.php | 104 +++++++++++++++++ assets/css/wc-bootstrap.css | 136 +++++++++++++++++++++- content-product.php | 33 ++++++ functions.php | 121 +++++++++++++++++++ inc/TemplateOverride.php | 13 ++- templates/components/pagination.html.twig | 6 +- templates/content-product.html.twig | 20 ++-- templates/loop/loop-start.html.twig | 4 +- templates/loop/result-count.html.twig | 2 +- 9 files changed, 416 insertions(+), 23 deletions(-) create mode 100644 archive-product.php create mode 100644 content-product.php diff --git a/archive-product.php b/archive-product.php new file mode 100644 index 0000000..f2e99c7 --- /dev/null +++ b/archive-product.php @@ -0,0 +1,104 @@ + +
+ + +
+ +
+ + + +
+
+ markup. + * + * WooCommerce's wc_get_template_part('content', 'product') uses locate_template() + * which finds this file in the child theme before falling back to the plugin template. + * Unlike wc_get_template(), wc_get_template_part() does NOT fire the + * woocommerce_before_template_part / woocommerce_after_template_part hooks, + * so the TemplateOverride class cannot intercept it — this bridge file is needed. + * + * @package WcBootstrap + * @since 0.1.0 + */ + +defined( 'ABSPATH' ) || exit; + +global $product; + +// Ensure the product is valid and visible (same guard as WooCommerce's default template). +if ( ! is_a( $product, WC_Product::class ) || ! $product->is_visible() ) { + return; +} + +if ( class_exists( '\WPBootstrap\Twig\TwigService' ) ) { + $twig = \WPBootstrap\Twig\TwigService::getInstance(); + echo $twig->render( 'content-product.html.twig', [] ); +} else { + // Fallback: include WooCommerce's default template directly. + include WC()->plugin_path() . '/templates/content-product.php'; +} diff --git a/functions.php b/functions.php index 07abe74..e94f253 100644 --- a/functions.php +++ b/functions.php @@ -14,6 +14,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } + /** * Define theme constants. * @@ -223,3 +224,123 @@ function wc_bootstrap_sticky_header_script(): void { __( 'Shop Sidebar', 'wc-bootstrap' ), + 'id' => 'shop-sidebar', + 'description' => __( 'Add widgets here to appear in the shop sidebar.', 'wc-bootstrap' ), + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '', + ) ); +} +add_action( 'widgets_init', 'wc_bootstrap_register_sidebars' ); + +/** + * Set the number of product columns in the shop loop. + * + * @return int Number of columns. + * @since 0.1.0 + */ +function wc_bootstrap_loop_columns(): int { + return 4; +} +add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' ); + +/** + * Remove WooCommerce's default sidebar hook. + * + * The child theme's archive-product.php renders the sidebar inline within the + * Bootstrap grid layout, so the default woocommerce_sidebar hook must not render + * a second sidebar outside the layout. + * + * @since 0.1.0 + */ +function wc_bootstrap_remove_default_sidebar(): void { + remove_action( 'woocommerce_sidebar', 'woocommerce_get_sidebar', 10 ); +} +add_action( 'init', 'wc_bootstrap_remove_default_sidebar' ); + +/** + * Prevent the parent theme from rendering product archives. + * + * The parent theme's TemplateController hooks template_redirect at priority 10 + * and renders pages/archive.html.twig for all archives (then exits). For product + * archives, we need to render our own WooCommerce-specific Bootstrap layout instead. + * Returning false tells the parent theme to skip rendering for this request. + * + * @param bool $should_render Whether the parent theme should render. + * @return bool + * + * @since 0.1.0 + */ +function wc_bootstrap_skip_parent_archive( bool $should_render ): bool { + if ( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) { + return false; + } + if ( function_exists( 'is_product_taxonomy' ) && is_product_taxonomy() ) { + return false; + } + return $should_render; +} +add_filter( 'wp_bootstrap_should_render_template', 'wc_bootstrap_skip_parent_archive' ); + +/** + * Render product archive pages with Bootstrap 5 layout. + * + * Since the parent theme's TemplateController is blocked for product archives + * (via wp_bootstrap_should_render_template filter), we render the page ourselves + * at priority 11 using the parent theme's TwigService and page shell. + * + * The archive-product.php file provides the Bootstrap layout (sidebar + product + * grid) and is captured via output buffering, then injected into the parent + * theme's page template — the same pattern as wc_bootstrap_render_page(). + * + * @since 0.1.0 + */ +function wc_bootstrap_render_product_archive(): void { + $is_shop = is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ); + $is_tax = function_exists( 'is_product_taxonomy' ) && is_product_taxonomy(); + + if ( ! $is_shop && ! $is_tax ) { + return; + } + + if ( ! class_exists( '\WPBootstrap\Twig\TwigService' ) + || ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) { + return; + } + + // Capture WooCommerce archive content via output buffering. + ob_start(); + include get_stylesheet_directory() . '/archive-product.php'; + $content = ob_get_clean(); + + // Build parent theme context and inject archive content into page shell. + $context_builder = new \WPBootstrap\Template\ContextBuilder(); + $theme_context = $context_builder->build(); + $twig = \WPBootstrap\Twig\TwigService::getInstance(); + + $theme_context['post'] = array_merge( + $theme_context['post'] ?? [], + [ + 'content' => $content, + 'title' => '', + 'thumbnail' => '', + ] + ); + + echo $twig->render( 'pages/page.html.twig', $theme_context ); + exit; +} +add_action( 'template_redirect', 'wc_bootstrap_render_product_archive', 11 ); diff --git a/inc/TemplateOverride.php b/inc/TemplateOverride.php index 20ab7ee..5406b34 100644 --- a/inc/TemplateOverride.php +++ b/inc/TemplateOverride.php @@ -78,8 +78,17 @@ class TemplateOverride { } try { - $twig = TwigService::getInstance(); - echo $twig->render( $twigTemplate, $args ); + $twig = TwigService::getInstance(); + $context = $args; + + // Inject the global $product into the Twig context. + // WooCommerce PHP templates access it via `global $product;` but Twig + // templates have isolated variable scopes and need it passed explicitly. + if ( ! isset( $context['product'] ) && ! empty( $GLOBALS['product'] ) ) { + $context['product'] = $GLOBALS['product']; + } + + echo $twig->render( $twigTemplate, $context ); // Buffer the upcoming PHP include so we can discard it. ob_start(); diff --git a/templates/components/pagination.html.twig b/templates/components/pagination.html.twig index bda0663..b004f5c 100644 --- a/templates/components/pagination.html.twig +++ b/templates/components/pagination.html.twig @@ -16,7 +16,7 @@
    {# Previous button #}
  • - +
  • @@ -29,7 +29,7 @@ {% elseif i == 1 or i == max_pages or (i >= current_page - 2 and i <= current_page + 2) %}
  • - {{ i }} + {{ i }}
  • {% elseif i == current_page - 3 or i == current_page + 3 %}
  • @@ -40,7 +40,7 @@ {# Next button #}
  • - +
  • diff --git a/templates/content-product.html.twig b/templates/content-product.html.twig index 9075991..65c190e 100644 --- a/templates/content-product.html.twig +++ b/templates/content-product.html.twig @@ -2,17 +2,17 @@ # Product Content in Loop (Bootstrap 5 Override) # # Renders a single product card within the shop loop grid. - # Uses Bootstrap 5 card component with stretched-link. + # Uses Bootstrap 5 card component with WooCommerce hook output. # - # Expected context: - # product - WC_Product object with: - # .name - Product name - # .permalink - Product URL - # .image - Product thumbnail HTML - # .price_html - Formatted price HTML - # .rating_html - Star rating HTML - # .is_on_sale - Whether product is on sale - # .add_to_cart - Add-to-cart button context + # Rendered via the content-product.php bridge file (not TemplateOverride) + # because wc_get_template_part() does not fire the template_part hooks. + # + # Hook output structure: + # woocommerce_before_shop_loop_item → link open + # woocommerce_before_shop_loop_item_title → sale badge, product image + # woocommerce_shop_loop_item_title →

    product title + # woocommerce_after_shop_loop_item_title → star rating, price + # woocommerce_after_shop_loop_item → link close, add-to-cart button # # WooCommerce PHP equivalent: content-product.php # diff --git a/templates/loop/loop-start.html.twig b/templates/loop/loop-start.html.twig index b89b25d..e6eb719 100644 --- a/templates/loop/loop-start.html.twig +++ b/templates/loop/loop-start.html.twig @@ -12,6 +12,6 @@ # @since 0.1.0 #} -{% set cols = columns|default(3) %} +{% set cols = columns|default(4) %} -
    +
    diff --git a/templates/loop/result-count.html.twig b/templates/loop/result-count.html.twig index 9830183..13f3af7 100644 --- a/templates/loop/result-count.html.twig +++ b/templates/loop/result-count.html.twig @@ -27,7 +27,7 @@ {% if total <= per_page|default(total) or total == 0 %} {{ _n('Showing the single result', 'Showing all %d results', total)|format(total) }} {% else %} - {{ __('Showing %1$d–%2$d of %3$d results')|format(first, last, total) }} + {{ __('Showing %1$d–%2$d of %3$d results')|format(first, last, total)|raw }} {% endif %} {% endif %}