You've already forked wc-bootstrap
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:
104
archive-product.php
Normal file
104
archive-product.php
Normal 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' );
|
||||
}
|
||||
@@ -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
33
content-product.php
Normal 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';
|
||||
}
|
||||
121
functions.php
121
functions.php
@@ -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 );
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">«</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">»</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user