Files
wc-bootstrap/functions.php
magdev 00872a6568 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>
2026-02-28 15:06:33 +01:00

347 lines
12 KiB
PHP

<?php
/**
* WooCommerce Bootstrap functions and definitions.
*
* Child theme of WP Bootstrap that overrides WooCommerce plugin templates
* with Bootstrap 5 structures and styling.
*
* @package WcBootstrap
* @since 0.1.0
*/
// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Define theme constants.
*
* CRITICAL: WordPress reads the version from TWO places:
* 1. style.css header "Version:" — WordPress uses THIS for admin display
* 2. This PHP constant — used internally by the theme
* Both MUST be updated on every release.
*/
define( 'WC_BOOTSTRAP_VERSION', '0.0.1' );
define( 'WC_BOOTSTRAP_PATH', get_stylesheet_directory() . '/' );
define( 'WC_BOOTSTRAP_URL', get_stylesheet_directory_uri() . '/' );
/**
* Load Composer autoloader if present.
*/
if ( file_exists( WC_BOOTSTRAP_PATH . 'vendor/autoload.php' ) ) {
require_once WC_BOOTSTRAP_PATH . 'vendor/autoload.php';
}
/**
* Sets up theme defaults and registers support for various WordPress features.
*/
function wc_bootstrap_setup(): void {
// Make theme available for translation.
load_child_theme_textdomain( 'wc-bootstrap', WC_BOOTSTRAP_PATH . 'languages' );
}
add_action( 'after_setup_theme', 'wc_bootstrap_setup' );
/**
* Initialize the WooCommerce-to-Twig template bridge.
*
* Registers the WooCommerce Twig extension with the parent theme's TwigService
* and sets up template interception hooks so the child theme's Twig templates
* are rendered instead of WooCommerce's PHP templates.
*
* Runs at 'init' priority 20 to ensure WooCommerce is fully loaded (it
* initializes at 'init' priority 0).
*/
function wc_bootstrap_init_twig_bridge(): void {
// Guard: require parent TwigService and WooCommerce.
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' ) || ! function_exists( 'WC' ) ) {
return;
}
$twig = \WPBootstrap\Twig\TwigService::getInstance();
$env = $twig->getEnvironment();
// Add child theme templates directory to the Twig loader so {% include %}
// directives in Twig templates can find other child theme templates.
$loader = $env->getLoader();
if ( $loader instanceof \Twig\Loader\FilesystemLoader ) {
$template_dir = WC_BOOTSTRAP_PATH . 'templates';
if ( is_dir( $template_dir ) ) {
$loader->prependPath( $template_dir );
}
}
// Register WooCommerce functions and filters as Twig extensions.
$env->addExtension( new \WcBootstrap\Twig\WooCommerceExtension() );
// Register template interception hooks.
$override = new \WcBootstrap\TemplateOverride();
$override->register();
}
add_action( 'init', 'wc_bootstrap_init_twig_bridge', 20 );
/**
* Enqueue child theme styles.
*
* Loads parent theme stylesheet first, then child theme overrides.
* CSS cascade order:
* 1. wp-bootstrap (parent)
* 2. woocommerce (plugin styles)
* 3. wc-bootstrap-style (child theme style.css)
* 4. wc-bootstrap-overrides (plugin CSS overrides)
*/
function wc_bootstrap_enqueue_styles(): void {
$theme_version = wp_get_theme()->get( 'Version' );
// Enqueue parent theme stylesheet.
wp_enqueue_style(
'wp-bootstrap-style',
get_template_directory_uri() . '/assets/css/style.min.css',
array(),
wp_get_theme( 'wp-bootstrap' )->get( 'Version' )
);
// Enqueue child theme stylesheet.
wp_enqueue_style(
'wc-bootstrap-style',
get_stylesheet_directory_uri() . '/style.css',
array( 'wp-bootstrap-style' ),
$theme_version
);
// Enqueue plugin Bootstrap override styles.
// Depend on plugin stylesheets so overrides always load after plugin CSS.
wp_enqueue_style(
'wc-bootstrap-overrides',
get_stylesheet_directory_uri() . '/assets/css/wc-bootstrap.css',
array( 'wc-bootstrap-style', 'woocommerce-general' ),
$theme_version
);
}
add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_styles' );
/**
* Enqueue child theme scripts.
*
* @since 0.1.0
*/
function wc_bootstrap_enqueue_scripts(): void {
$theme_version = wp_get_theme()->get( 'Version' );
// Quantity +/- button handler for Bootstrap input-group widget.
wp_enqueue_script(
'wc-bootstrap-quantity',
get_stylesheet_directory_uri() . '/assets/js/quantity.js',
array(),
$theme_version,
true
);
}
add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_scripts' );
/**
* Handle plugin page rendering via plugin render filter.
*
* Delegates page rendering to the parent theme's TwigService so that plugin pages
* share the same page shell (header, footer, layout) as native WordPress pages.
* Falls back to letting the plugin handle rendering if the parent theme is not available.
*
* @param bool $rendered Whether the page has been rendered.
* @param string $content Pre-rendered plugin HTML content.
* @param array $context Plugin template context.
* @return bool True if rendering was handled, false to let plugin use fallback.
*
* @since 0.1.0
*/
function wc_bootstrap_render_page( bool $rendered, string $content, array $context ): bool {
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' )
|| ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) {
return false; // Can't render, let plugin use its own fallback
}
$context_builder = new \WPBootstrap\Template\ContextBuilder();
$theme_context = $context_builder->build();
$twig = \WPBootstrap\Twig\TwigService::getInstance();
// Inject plugin content as the page post content so page.html.twig renders it
// inside the standard content block. Title is empty so the parent theme does not
// render its own <h1> — plugin templates handle their own headings.
$theme_context['post'] = array_merge(
$theme_context['post'] ?? [],
[
'content' => $content,
'title' => '',
'thumbnail' => '',
]
);
echo $twig->render( 'pages/page.html.twig', $theme_context );
return true;
}
add_filter( 'woocommerce_render_page', 'wc_bootstrap_render_page', 10, 3 );
/**
* Signal to the plugin that its content will be wrapped by the parent theme.
*
* When the parent theme's TwigService is available, the plugin templates should skip
* their outer wrapper elements (breadcrumbs, sidebar, page shell) to avoid double-wrapping.
*
* @param bool $wrapped Whether content is theme-wrapped.
* @return bool
*
* @since 0.1.0
*/
function wc_bootstrap_is_wrapped( bool $wrapped ): bool {
if ( class_exists( '\WPBootstrap\Twig\TwigService' ) ) {
return true;
}
return $wrapped;
}
add_filter( 'woocommerce_is_theme_wrapped', 'wc_bootstrap_is_wrapped' );
/**
* Add sticky header scroll shadow behavior.
*
* Toggles an 'is-stuck' class on the navbar when the page is scrolled,
* adding a subtle box-shadow to visually separate the header from content.
*
* @since 0.1.0
*/
function wc_bootstrap_sticky_header_script(): void {
?>
<script>
(function() {
var header = document.querySelector('header.sticky-top');
if (!header) return;
var onScroll = function() {
header.classList.toggle('is-stuck', window.scrollY > 0);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
})();
</script>
<?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 );