Files
wc-bootstrap/functions.php
magdev 784b400c46
All checks were successful
Create Release Package / PHP Lint (push) Successful in 44s
Create Release Package / Build Release (push) Successful in 52s
Fix 10 known bugs: catalog, single product, and account pages (v0.1.5)
Catalog: page title via woocommerce_page_title(), breadcrumbs, category
template rename (underscore), 3-column grid, single chevron on sort.

Single product: variable form data attributes + disabled CSS class fix
(WC JS only toggles CSS classes, not HTML disabled attribute), dark mode
select specificity (0,5,1) to beat WC's (0,4,3) background shorthand,
gallery main image in thumbnail strip with empty URL guard, related/
upsells setup_postdata for correct global $product, grouped product
loop logic rewrite.

Account: downloads via wc_get_customer_available_downloads().

New: product-gallery.js, sanitize_title filter, wc_setup_product_data()
and wp_reset_postdata() Twig functions, product-thumbnails.html.twig
suppressor. Removed obsolete PLAN.md and SETUP.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:33:31 +01:00

438 lines
14 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' );
/**
* 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 );