Add Bootstrap 5 product archive with card grid and sidebar

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 15:06:33 +01:00
parent 7034134678
commit 00872a6568
9 changed files with 416 additions and 23 deletions

104
archive-product.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
/**
* Product Archive / Shop Page (Bootstrap 5 Layout)
*
* Renders the WooCommerce product archive content with a Bootstrap 5 layout
* featuring a responsive sidebar (offcanvas on mobile) and product card grid.
*
* This file is NOT included directly by WordPress. Instead, it is captured via
* output buffering by wc_bootstrap_render_product_archive() and injected into
* the parent theme's page shell (pages/page.html.twig). Therefore it does NOT
* call get_header()/get_footer() or render the wrapper hooks.
*
* @package WcBootstrap
* @since 0.1.0
*/
defined( 'ABSPATH' ) || exit;
// Fire structured data hook (normally on woocommerce_before_main_content at priority 30).
do_action( 'woocommerce_shop_loop_header' );
$has_sidebar = is_active_sidebar( 'shop-sidebar' );
if ( woocommerce_product_loop() ) {
/**
* Hook: woocommerce_before_shop_loop.
*
* @hooked woocommerce_output_all_notices - 10
* @hooked woocommerce_result_count - 20
* @hooked woocommerce_catalog_ordering - 30
*/
do_action( 'woocommerce_before_shop_loop' );
?>
<div class="row g-4">
<?php if ( $has_sidebar ) : ?>
<aside class="col-lg-3 mb-4 mb-lg-0">
<button class="btn btn-outline-secondary w-100 d-lg-none mb-3"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#shopSidebar"
aria-controls="shopSidebar">
<i class="bi bi-funnel me-1" aria-hidden="true"></i>
<?php esc_html_e( 'Filters', 'wc-bootstrap' ); ?>
</button>
<div class="offcanvas-lg offcanvas-start"
id="shopSidebar"
tabindex="-1"
aria-labelledby="shopSidebarLabel">
<div class="offcanvas-header d-lg-none">
<h5 class="offcanvas-title" id="shopSidebarLabel">
<?php esc_html_e( 'Filters', 'wc-bootstrap' ); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
</div>
<div class="offcanvas-body p-0">
<?php dynamic_sidebar( 'shop-sidebar' ); ?>
</div>
</div>
</aside>
<div class="col-lg-9">
<?php else : ?>
<div class="col-12">
<?php endif; ?>
<?php
woocommerce_product_loop_start();
if ( wc_get_loop_prop( 'total' ) ) {
while ( have_posts() ) {
the_post();
/**
* Hook: woocommerce_shop_loop.
*/
do_action( 'woocommerce_shop_loop' );
wc_get_template_part( 'content', 'product' );
}
}
woocommerce_product_loop_end();
/**
* Hook: woocommerce_after_shop_loop.
*
* @hooked woocommerce_pagination - 10
*/
do_action( 'woocommerce_after_shop_loop' );
?>
</div>
</div>
<?php
} else {
/**
* Hook: woocommerce_no_products_found.
*
* @hooked wc_no_products_found - 10
*/
do_action( 'woocommerce_no_products_found' );
}

View File

@@ -109,6 +109,104 @@
width: 100%;
}
/* Product link wrapping card content — remove underline, inherit text color */
.product.card a.woocommerce-LoopProduct-link {
text-decoration: none;
color: inherit;
}
/* Product title in card body */
.product.card .woocommerce-loop-product__title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
/* Push price to bottom of card body for even card heights */
.product.card .card-body .price {
margin-top: auto;
}
/* Add-to-cart button — Bootstrap btn-outline-primary style */
.product.card .button {
display: inline-block;
width: 100%;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: var(--bs-border-radius);
color: var(--bs-primary);
border: var(--bs-border-width) solid var(--bs-primary);
background-color: transparent;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.product.card .button:hover {
color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
/* "View options" button for variable products */
.product.card .button.product_type_variable {
color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
.product.card .button.product_type_variable:hover {
color: #fff;
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
/* "Read more" button for external/non-purchasable products */
.product.card .button.product_type_external {
color: var(--bs-info);
border-color: var(--bs-info);
}
.product.card .button.product_type_external:hover {
color: #fff;
background-color: var(--bs-info);
border-color: var(--bs-info);
}
/* Added-to-cart visual feedback */
.product.card .added_to_cart {
display: inline-block;
width: 100%;
text-align: center;
font-size: 0.875rem;
margin-top: 0.5rem;
color: var(--bs-success);
text-decoration: none;
}
/* WooCommerce result count and ordering bar */
.woocommerce-result-count {
margin-bottom: 0;
line-height: 2.5;
}
.woocommerce-ordering select {
display: inline-block;
padding: 0.375rem 2.25rem 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
appearance: auto;
}
/* ==========================================================================
Sale Badge
Positioning for the sale overlay badge on product cards.
@@ -179,12 +277,37 @@
}
/* ==========================================================================
WooCommerce Grid Override
Reset WooCommerce's default grid to let Bootstrap handle layout.
Shop / Archive Layout
Reset WooCommerce's default float grid to let Bootstrap handle layout.
Override woocommerce-layout.css float-based widths and clearfixes.
========================================================================== */
.woocommerce ul.products {
display: contents;
/* Reset float-based result count / ordering bar — use flexbox instead */
.post-type-archive-product .woocommerce-result-count,
.tax-product_cat .woocommerce-result-count,
.tax-product_tag .woocommerce-result-count,
.post-type-archive-product .woocommerce-ordering,
.tax-product_cat .woocommerce-ordering,
.tax-product_tag .woocommerce-ordering {
float: none;
}
/* Reset WooCommerce's product grid floats and widths */
.woocommerce ul.products,
.woocommerce .products {
list-style: none;
padding: 0;
margin: 0;
width: 100%;
clear: both;
}
.woocommerce ul.products li.product,
.woocommerce .products .product {
float: none;
width: auto;
margin: 0;
padding: 0;
}
/* ==========================================================================
@@ -285,7 +408,10 @@ header.sticky-top.is-stuck {
.woocommerce-account main .woocommerce,
.woocommerce-cart main .woocommerce,
.woocommerce-checkout main .woocommerce {
.woocommerce-checkout main .woocommerce,
.post-type-archive-product main .woocommerce,
.tax-product_cat main .woocommerce,
.tax-product_tag main .woocommerce {
max-width: none;
}

33
content-product.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
/**
* Product Content in Loop — PHP Bridge to Twig
*
* Bridge file that renders the Bootstrap 5 card template (content-product.html.twig)
* via the parent theme's TwigService instead of WooCommerce's default <li> 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';
}

View File

@@ -14,6 +14,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Define theme constants.
*
@@ -223,3 +224,123 @@ function wc_bootstrap_sticky_header_script(): void {
<?php
}
add_action( 'wp_footer', 'wc_bootstrap_sticky_header_script' );
/**
* Register the shop sidebar widget area.
*
* Provides a widget area for product filters and shop-specific widgets.
* Uses Bootstrap-styled markup matching the parent theme's sidebar pattern.
*
* @since 0.1.0
*/
function wc_bootstrap_register_sidebars(): void {
register_sidebar( array(
'name' => __( 'Shop Sidebar', 'wc-bootstrap' ),
'id' => 'shop-sidebar',
'description' => __( 'Add widgets here to appear in the shop sidebar.', 'wc-bootstrap' ),
'before_widget' => '<div id="%1$s" class="widget mb-4 %2$s">',
'after_widget' => '</div>',
'before_title' => '<h3 class="sidebar-heading h6 text-uppercase fw-semibold">',
'after_title' => '</h3>',
) );
}
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 );

View File

@@ -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();

View File

@@ -16,7 +16,7 @@
<ul class="pagination justify-content-center">
{# Previous button #}
<li class="page-item{% if current_page <= 1 %} disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}" aria-label="{{ __('Previous') }}">
<a class="page-link" href="{{ fn('get_pagenum_link', current_page > 1 ? current_page - 1 : 1)|esc_url }}" aria-label="{{ __('Previous') }}">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@@ -29,7 +29,7 @@
</li>
{% elseif i == 1 or i == max_pages or (i >= current_page - 2 and i <= current_page + 2) %}
<li class="page-item">
<a class="page-link" href="?page={{ i }}">{{ i }}</a>
<a class="page-link" href="{{ fn('get_pagenum_link', i)|esc_url }}">{{ i }}</a>
</li>
{% elseif i == current_page - 3 or i == current_page + 3 %}
<li class="page-item disabled">
@@ -40,7 +40,7 @@
{# Next button #}
<li class="page-item{% if current_page >= max_pages %} disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}" aria-label="{{ __('Next') }}">
<a class="page-link" href="{{ fn('get_pagenum_link', current_page + 1)|esc_url }}" aria-label="{{ __('Next') }}">
<span aria-hidden="true">&raquo;</span>
</a>
</li>

View File

@@ -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 → <a> link open
# woocommerce_before_shop_loop_item_title → sale badge, product image
# woocommerce_shop_loop_item_title → <h2> product title
# woocommerce_after_shop_loop_item_title → star rating, price
# woocommerce_after_shop_loop_item → </a> link close, add-to-cart button
#
# WooCommerce PHP equivalent: content-product.php
#

View File

@@ -12,6 +12,6 @@
# @since 0.1.0
#}
{% set cols = columns|default(3) %}
{% set cols = columns|default(4) %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-{{ cols < 3 ? cols : 3 }} row-cols-lg-{{ cols }} g-4 products">
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-{{ cols }} g-4 products">

View File

@@ -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&ndash;%2$d of %3$d results')|format(first, last, total) }}
{{ __('Showing %1$d&ndash;%2$d of %3$d results')|format(first, last, total)|raw }}
{% endif %}
{% endif %}
</p>