You've already forked wc-bootstrap
Hierarchical category navigation with collapsible sub-levels up to 3 levels deep, using Bootstrap 5 list-group and collapse components. Sidebar renders on both archive/shop and single product pages with responsive offcanvas on mobile. Active category highlighting and ancestor auto-expand for intuitive navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
523 lines
17 KiB
PHP
523 lines
17 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.
|
|
*/
|
|
define( 'WC_BOOTSTRAP_PATH', get_stylesheet_directory() . '/' );
|
|
|
|
/**
|
|
* 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
|
|
);
|
|
|
|
// Product gallery thumbnail handler for single product pages.
|
|
if ( function_exists( 'is_product' ) && is_product() ) {
|
|
wp_enqueue_script(
|
|
'wc-bootstrap-gallery',
|
|
get_stylesheet_directory_uri() . '/assets/js/product-gallery.js',
|
|
array(),
|
|
$theme_version,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_scripts' );
|
|
|
|
/**
|
|
* Build the parent theme context for a page render.
|
|
*
|
|
* Caches the ContextBuilder result per request to avoid redundant database
|
|
* queries when multiple WooCommerce rendering functions need the same context.
|
|
*
|
|
* @since 0.1.1
|
|
*
|
|
* @return array Theme context array.
|
|
*/
|
|
function wc_bootstrap_get_theme_context(): array {
|
|
static $cached_context = null;
|
|
|
|
if ( null === $cached_context ) {
|
|
$context_builder = new \WPBootstrap\Template\ContextBuilder();
|
|
$cached_context = $context_builder->build();
|
|
}
|
|
|
|
return $cached_context;
|
|
}
|
|
|
|
/**
|
|
* Render content inside the parent theme's page shell.
|
|
*
|
|
* Injects the given HTML content into the parent theme's page template,
|
|
* replacing the post content. Title and thumbnail are blanked so the
|
|
* parent theme does not render its own headings — the content handles that.
|
|
*
|
|
* @since 0.1.1
|
|
*
|
|
* @param string $content HTML content to render inside the page shell.
|
|
*/
|
|
function wc_bootstrap_render_in_page_shell( string $content ): void {
|
|
$theme_context = wc_bootstrap_get_theme_context();
|
|
$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 );
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
wc_bootstrap_render_in_page_shell( $content );
|
|
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 3;
|
|
}
|
|
add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' );
|
|
|
|
/**
|
|
* Build a hierarchical product category tree up to a given depth.
|
|
*
|
|
* Returns a nested array of category objects with children, suitable for
|
|
* rendering a multi-level navigation sidebar. Each node contains the
|
|
* WP_Term object plus a 'children' array and an 'is_active'/'is_ancestor'
|
|
* flag based on the current query.
|
|
*
|
|
* @since 0.1.7
|
|
*
|
|
* @param int $max_depth Maximum nesting depth (1 = top-level only, 3 = three levels).
|
|
* @return array Hierarchical category tree.
|
|
*/
|
|
function wc_bootstrap_get_category_tree( int $max_depth = 3 ): array {
|
|
$terms = get_terms( [
|
|
'taxonomy' => 'product_cat',
|
|
'hide_empty' => true,
|
|
'orderby' => 'name',
|
|
'order' => 'ASC',
|
|
] );
|
|
|
|
if ( is_wp_error( $terms ) || empty( $terms ) ) {
|
|
return [];
|
|
}
|
|
|
|
// Determine the currently viewed category (if any).
|
|
$current_term_id = 0;
|
|
$ancestor_ids = [];
|
|
|
|
if ( is_product_category() ) {
|
|
$queried = get_queried_object();
|
|
if ( $queried instanceof \WP_Term ) {
|
|
$current_term_id = $queried->term_id;
|
|
$ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' );
|
|
}
|
|
} elseif ( is_product() ) {
|
|
// On single product pages, highlight the first assigned category.
|
|
global $product;
|
|
if ( $product ) {
|
|
$cat_ids = $product->get_category_ids();
|
|
if ( ! empty( $cat_ids ) ) {
|
|
$current_term_id = $cat_ids[0];
|
|
$ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' );
|
|
}
|
|
}
|
|
}
|
|
|
|
// Index terms by parent for efficient tree building.
|
|
$by_parent = [];
|
|
foreach ( $terms as $term ) {
|
|
$by_parent[ $term->parent ][] = $term;
|
|
}
|
|
|
|
/**
|
|
* Recursively build tree nodes from the parent-indexed map.
|
|
*/
|
|
$build = function ( int $parent_id, int $depth ) use ( &$build, $by_parent, $max_depth, $current_term_id, $ancestor_ids ): array {
|
|
if ( $depth > $max_depth || ! isset( $by_parent[ $parent_id ] ) ) {
|
|
return [];
|
|
}
|
|
|
|
$nodes = [];
|
|
foreach ( $by_parent[ $parent_id ] as $term ) {
|
|
$children = $build( $term->term_id, $depth + 1 );
|
|
$is_active = ( $term->term_id === $current_term_id );
|
|
$is_ancestor = in_array( $term->term_id, $ancestor_ids, true );
|
|
|
|
$nodes[] = [
|
|
'term_id' => $term->term_id,
|
|
'name' => $term->name,
|
|
'slug' => $term->slug,
|
|
'count' => $term->count,
|
|
'url' => get_term_link( $term ),
|
|
'is_active' => $is_active,
|
|
'is_ancestor' => $is_ancestor,
|
|
'children' => $children,
|
|
];
|
|
}
|
|
|
|
return $nodes;
|
|
};
|
|
|
|
return $build( 0, 1 );
|
|
}
|
|
|
|
/**
|
|
* 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' );
|
|
|
|
/**
|
|
* Replace WooCommerce's content wrapper with a no-op.
|
|
*
|
|
* The parent theme's page shell already wraps content in a .container,
|
|
* so WooCommerce's default wrapper (another .container + #primary + <main>)
|
|
* creates a double-nested container that constrains width. Remove it and
|
|
* let the parent theme handle the outer layout.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
function wc_bootstrap_remove_content_wrappers(): void {
|
|
remove_action( 'woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10 );
|
|
remove_action( 'woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10 );
|
|
}
|
|
add_action( 'init', 'wc_bootstrap_remove_content_wrappers' );
|
|
|
|
/**
|
|
* Prevent the parent theme from rendering WooCommerce pages.
|
|
*
|
|
* The parent theme's TemplateController hooks template_redirect at priority 10
|
|
* and renders its own templates for all requests (then exits). For WooCommerce
|
|
* pages we need to render our own Bootstrap layouts 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_template( 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;
|
|
}
|
|
if ( function_exists( 'is_product' ) && is_product() ) {
|
|
return false;
|
|
}
|
|
return $should_render;
|
|
}
|
|
add_filter( 'wp_bootstrap_should_render_template', 'wc_bootstrap_skip_parent_template' );
|
|
|
|
/**
|
|
* Disable WooCommerce's block template compatibility layer.
|
|
*
|
|
* The parent theme has theme.json which makes wp_is_block_theme() return true.
|
|
* WooCommerce detects this and removes classic template hooks (title, price,
|
|
* add-to-cart, etc.) from single product and archive pages, expecting blocks
|
|
* to handle rendering instead. Since we render via classic hooks + Twig, we
|
|
* need the hooks to stay registered.
|
|
*
|
|
* @param bool $disabled Whether the compatibility layer is disabled.
|
|
* @return bool
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
function wc_bootstrap_disable_block_compatibility( bool $disabled ): bool {
|
|
return true;
|
|
}
|
|
add_filter( 'woocommerce_disable_compatibility_layer', 'wc_bootstrap_disable_block_compatibility' );
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
ob_start();
|
|
include get_stylesheet_directory() . '/woocommerce/archive-product.php';
|
|
$content = ob_get_clean();
|
|
|
|
wc_bootstrap_render_in_page_shell( $content );
|
|
exit;
|
|
}
|
|
add_action( 'template_redirect', 'wc_bootstrap_render_product_archive', 11 );
|
|
|
|
/**
|
|
* Render single product pages with Bootstrap 5 layout.
|
|
*
|
|
* Since the parent theme's TemplateController is blocked for single products
|
|
* (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 single-product.php file fires WooCommerce hooks and is captured via output
|
|
* buffering, then injected into the parent theme's page template — the same
|
|
* pattern as wc_bootstrap_render_product_archive().
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
function wc_bootstrap_render_single_product(): void {
|
|
if ( ! function_exists( 'is_product' ) || ! is_product() ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' )
|
|
|| ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) {
|
|
return;
|
|
}
|
|
|
|
ob_start();
|
|
include get_stylesheet_directory() . '/woocommerce/single-product.php';
|
|
$content = ob_get_clean();
|
|
|
|
wc_bootstrap_render_in_page_shell( $content );
|
|
exit;
|
|
}
|
|
add_action( 'template_redirect', 'wc_bootstrap_render_single_product', 11 );
|