You've already forked wc-bootstrap
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>
This commit is contained in:
@@ -2,11 +2,8 @@
|
||||
# Product Archive Header (Bootstrap 5 Override)
|
||||
#
|
||||
# Renders the archive page title and optional description.
|
||||
#
|
||||
# Expected context:
|
||||
# show_page_title - Whether to display the title (boolean, filtered)
|
||||
# page_title - Archive page title string
|
||||
# archive_description - Optional archive description HTML
|
||||
# Calls woocommerce_page_title() directly (matching the PHP template)
|
||||
# because wc_get_template('loop/header.php') passes no context args.
|
||||
#
|
||||
# WooCommerce PHP equivalent: loop/header.php
|
||||
#
|
||||
@@ -15,9 +12,9 @@
|
||||
#}
|
||||
|
||||
<header class="woocommerce-products-header mb-4">
|
||||
{% if show_page_title is not defined or show_page_title %}
|
||||
{% if apply_filters('woocommerce_show_page_title', true) %}
|
||||
<h1 class="woocommerce-products-header__title page-title mb-2">
|
||||
{{ page_title|default('')|esc_html }}
|
||||
{{ fn('woocommerce_page_title', false)|esc_html }}
|
||||
</h1>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
# @since 0.1.0
|
||||
#}
|
||||
|
||||
{% set cols = columns|default(4) %}
|
||||
{% set cols = columns|default(3) %}
|
||||
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-{{ cols }} g-4 products">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# @since 0.1.0
|
||||
#}
|
||||
|
||||
{# Match PHP template: use WC()->customer->get_downloadable_products() #}
|
||||
{% set downloads = fn('WC').customer.get_downloadable_products() %}
|
||||
{% set has_downloads = downloads is not empty %}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
# Add-to-cart form for grouped products: table of child products with quantities.
|
||||
#
|
||||
# Expected context:
|
||||
# product - WC_Product_Grouped object
|
||||
# grouped_products - Array of child WC_Product objects
|
||||
# grouped_product_columns - Array of column definitions
|
||||
# quantites_required - Whether quantities are required
|
||||
# show_add_to_cart_button - Whether to show the submit button
|
||||
# product - WC_Product_Grouped object (global, injected by TemplateOverride)
|
||||
# grouped_products - Array of child WC_Product objects
|
||||
#
|
||||
# Note: quantites_required and show_add_to_cart_button are computed inside the
|
||||
# loop (matching WooCommerce's PHP template behavior), not passed as context.
|
||||
#
|
||||
# WooCommerce PHP equivalent: single-product/add-to-cart/grouped.php
|
||||
#
|
||||
@@ -21,19 +21,48 @@
|
||||
<form class="cart grouped_form" action="{{ product.get_permalink()|esc_url }}" method="post" enctype="multipart/form-data">
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="woocommerce-grouped-product-list group_table table table-borderless align-middle">
|
||||
{% set quantites_required = false %}
|
||||
{% set show_add_to_cart_button = false %}
|
||||
|
||||
{{ do_action('woocommerce_grouped_product_list_before') }}
|
||||
|
||||
{% for grouped_product in grouped_products %}
|
||||
{% set child_id = grouped_product.get_id() %}
|
||||
<tr id="product-{{ child_id }}" class="{{ grouped_product.get_stock_status() }}">
|
||||
|
||||
{# Set up global product data for each child (matches PHP's setup_postdata). #}
|
||||
{{ wc_setup_product_data(grouped_product) }}
|
||||
|
||||
{% if grouped_product.is_purchasable() and not grouped_product.has_options() %}
|
||||
{% set quantites_required = true %}
|
||||
{% endif %}
|
||||
{% if grouped_product.is_in_stock() %}
|
||||
{% set show_add_to_cart_button = true %}
|
||||
{% endif %}
|
||||
|
||||
<tr id="product-{{ child_id }}" class="woocommerce-grouped-product-list-item {{ grouped_product.get_stock_status() }}">
|
||||
<td class="woocommerce-grouped-product-list-item__quantity" style="width: 140px;">
|
||||
{% if grouped_product.is_purchasable() and grouped_product.is_in_stock() %}
|
||||
{% if not grouped_product.is_purchasable() or grouped_product.has_options() or not grouped_product.is_in_stock() %}
|
||||
{# Non-purchasable, has options (variable), or out-of-stock: show view link #}
|
||||
{% if grouped_product.is_visible() %}
|
||||
<a href="{{ grouped_product.get_permalink()|esc_url }}" class="btn btn-sm btn-outline-secondary">
|
||||
{{ __('View product') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elseif grouped_product.is_sold_individually() %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
name="quantity[{{ child_id }}]"
|
||||
value="1"
|
||||
class="form-check-input wc-grouped-product-add-to-cart-checkbox"
|
||||
id="quantity-{{ child_id }}" />
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'global/quantity-input.html.twig' with {
|
||||
input_id: 'quantity_' ~ child_id,
|
||||
input_name: 'quantity[' ~ child_id ~ ']',
|
||||
input_value: 0,
|
||||
input_value: '',
|
||||
min_value: 0,
|
||||
max_value: grouped_product.get_max_purchase_quantity()|default(0),
|
||||
max_value: grouped_product.get_max_purchase_quantity(),
|
||||
step: 1,
|
||||
placeholder: '0',
|
||||
inputmode: 'numeric',
|
||||
@@ -46,7 +75,7 @@
|
||||
</td>
|
||||
|
||||
<td class="woocommerce-grouped-product-list-item__label">
|
||||
<label for="quantity_{{ child_id }}">
|
||||
<label for="product-{{ child_id }}">
|
||||
{% if grouped_product.is_visible() %}
|
||||
<a href="{{ grouped_product.get_permalink()|esc_url }}" class="text-decoration-none">
|
||||
{{ grouped_product.get_name()|esc_html }}
|
||||
@@ -65,14 +94,17 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{{ wp_reset_postdata() }}
|
||||
|
||||
{{ do_action('woocommerce_grouped_product_list_after') }}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if show_add_to_cart_button is not defined or show_add_to_cart_button %}
|
||||
<input type="hidden" name="add-to-cart" value="{{ product.get_id() }}" />
|
||||
|
||||
{% if quantites_required and show_add_to_cart_button %}
|
||||
{{ do_action('woocommerce_before_add_to_cart_button') }}
|
||||
|
||||
<input type="hidden" name="add-to-cart" value="{{ product.get_id() }}" />
|
||||
<button type="submit" class="btn btn-primary btn-lg single_add_to_cart_button">
|
||||
{{ product.single_add_to_cart_text() }}
|
||||
</button>
|
||||
|
||||
@@ -24,22 +24,26 @@
|
||||
action="{{ product.get_permalink()|esc_url }}"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
{{ variations_attr|default('')|raw }}>
|
||||
data-product_id="{{ product.get_id() }}"
|
||||
data-product_variations="{{ available_variations|json_encode|esc_attr }}">
|
||||
|
||||
{{ do_action('woocommerce_before_variations_form') }}
|
||||
|
||||
{% if available_variations is defined %}
|
||||
{# Variation attribute selectors #}
|
||||
{% if available_variations is not same as(false) %}
|
||||
{# Variation attribute selectors.
|
||||
WC PHP uses sanitize_title() on attribute names for name/data-attribute_name
|
||||
so they match the lowercase keys in the variation data JSON. #}
|
||||
<div class="variations mb-4">
|
||||
{% for attribute_name, options in attributes %}
|
||||
{% set sanitized_name = attribute_name|sanitize_title %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="{{ attribute_name|esc_attr }}">
|
||||
<label class="form-label fw-semibold" for="{{ sanitized_name|esc_attr }}">
|
||||
{{ wc_attribute_label(attribute_name)|esc_html }}
|
||||
</label>
|
||||
<select id="{{ attribute_name|esc_attr }}"
|
||||
<select id="{{ sanitized_name|esc_attr }}"
|
||||
class="form-select"
|
||||
name="attribute_{{ attribute_name|esc_attr }}"
|
||||
data-attribute_name="attribute_{{ attribute_name|esc_attr }}">
|
||||
name="attribute_{{ sanitized_name|esc_attr }}"
|
||||
data-attribute_name="attribute_{{ sanitized_name|esc_attr }}">
|
||||
<option value="">{{ __('Choose an option') }}</option>
|
||||
{% if options is iterable %}
|
||||
{% for option in options %}
|
||||
@@ -54,25 +58,22 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Reset link — WC JS toggles visibility based on attribute selection #}
|
||||
<a class="reset_variations btn btn-link btn-sm p-0 text-decoration-none" href="#" style="visibility: hidden;">
|
||||
{{ __('Clear') }}
|
||||
</a>
|
||||
<div class="reset_variations_alert screen-reader-text" role="alert" aria-live="polite" aria-relevant="all"></div>
|
||||
|
||||
{{ do_action('woocommerce_after_variations_table') }}
|
||||
|
||||
{# Reset link #}
|
||||
<div class="reset_variations_wrapper mb-3" style="display: none;">
|
||||
<a class="reset_variations btn btn-link btn-sm p-0 text-decoration-none" href="#">
|
||||
{{ __('Clear') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Single variation display + add-to-cart button #}
|
||||
{# Single variation display + add-to-cart button.
|
||||
The woocommerce_single_variation hook outputs:
|
||||
- priority 10: empty .single_variation div (JS populates via underscore template)
|
||||
- priority 20: .variations_button div with quantity + add-to-cart button
|
||||
No manual wrapper divs — the hooks handle the full DOM structure. #}
|
||||
<div class="single_variation_wrap">
|
||||
{{ do_action('woocommerce_before_single_variation') }}
|
||||
|
||||
<div class="woocommerce-variation single_variation"></div>
|
||||
|
||||
<div class="woocommerce-variation-add-to-cart variations_button">
|
||||
{{ do_action('woocommerce_single_variation') }}
|
||||
</div>
|
||||
|
||||
{{ do_action('woocommerce_single_variation') }}
|
||||
{{ do_action('woocommerce_after_single_variation') }}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -86,3 +87,4 @@
|
||||
</form>
|
||||
|
||||
{{ do_action('woocommerce_after_add_to_cart_form') }}
|
||||
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
{{ do_action('woocommerce_after_add_to_cart_quantity') }}
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg single_add_to_cart_button"
|
||||
disabled>
|
||||
class="btn btn-primary btn-lg single_add_to_cart_button disabled wc-variation-selection-needed">
|
||||
{{ product.single_add_to_cart_text() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
# @since 0.1.0
|
||||
#}
|
||||
|
||||
{% set cols = columns|default(4) %}
|
||||
{% set has_images = post_thumbnail_id is defined and post_thumbnail_id %}
|
||||
{# Compute image data from the product object (PHP template does this locally). #}
|
||||
{% set post_thumbnail_id = product.get_image_id() %}
|
||||
{% set gallery_image_ids = product.get_gallery_image_ids() %}
|
||||
{% set cols = apply_filters('woocommerce_product_thumbnails_columns', 4) %}
|
||||
{% set has_images = post_thumbnail_id %}
|
||||
{% set gallery_classes = 'woocommerce-product-gallery woocommerce-product-gallery--columns-' ~ cols %}
|
||||
{% if has_images %}
|
||||
{% set gallery_classes = gallery_classes ~ ' woocommerce-product-gallery--with-images' %}
|
||||
@@ -28,34 +31,37 @@
|
||||
<div class="{{ gallery_classes }}" data-columns="{{ cols }}" style="opacity: 0; transition: opacity .25s ease-in-out;">
|
||||
<div class="woocommerce-product-gallery__wrapper">
|
||||
{# Main product image #}
|
||||
{% if main_image_html is defined and main_image_html %}
|
||||
<div class="woocommerce-product-gallery__image mb-3">
|
||||
{{ main_image_html|raw }}
|
||||
</div>
|
||||
{% elseif post_thumbnail_id is defined and post_thumbnail_id %}
|
||||
{% if post_thumbnail_id %}
|
||||
<div class="woocommerce-product-gallery__image mb-3">
|
||||
<img src="{{ wp_get_attachment_url(post_thumbnail_id)|esc_url }}"
|
||||
class="img-fluid rounded"
|
||||
class="img-fluid rounded wp-post-image"
|
||||
alt="{{ product.get_name()|esc_attr }}" />
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="woocommerce-product-gallery__image mb-3">
|
||||
<div class="woocommerce-product-gallery__image woocommerce-product-gallery__image--placeholder mb-3">
|
||||
<img src="{{ wc_placeholder_img_src()|esc_url }}"
|
||||
class="img-fluid rounded"
|
||||
alt="{{ __('Placeholder')|esc_attr }}" />
|
||||
class="img-fluid rounded wp-post-image"
|
||||
alt="{{ __('Awaiting product image')|esc_attr }}" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Thumbnail gallery strip #}
|
||||
{% if gallery_image_ids is defined and gallery_image_ids|length > 0 %}
|
||||
<div class="row row-cols-{{ columns|default(4) }} g-2">
|
||||
{% for image_id in gallery_image_ids %}
|
||||
<div class="col">
|
||||
<img src="{{ wp_get_attachment_url(image_id)|esc_url }}"
|
||||
class="img-fluid rounded border cursor-pointer wc-gallery-thumb"
|
||||
alt="{{ product.get_name()|esc_attr }}"
|
||||
data-full-src="{{ wp_get_attachment_url(image_id)|esc_url }}" />
|
||||
</div>
|
||||
{# Thumbnail gallery strip — includes main image so user can switch back.
|
||||
Build a combined list: main image first, then gallery images.
|
||||
Skip any IDs that don't resolve to a valid attachment URL. #}
|
||||
{% if gallery_image_ids|length > 0 and post_thumbnail_id %}
|
||||
{% set all_thumb_ids = [post_thumbnail_id]|merge(gallery_image_ids) %}
|
||||
<div class="row row-cols-{{ cols }} g-2 mt-2">
|
||||
{% for image_id in all_thumb_ids %}
|
||||
{% set thumb_url = wp_get_attachment_url(image_id) %}
|
||||
{% if thumb_url %}
|
||||
<div class="col">
|
||||
<img src="{{ thumb_url|esc_url }}"
|
||||
class="img-fluid rounded border wc-gallery-thumb{% if loop.first %} border-primary active{% endif %}"
|
||||
alt="{{ product.get_name()|esc_attr }}"
|
||||
data-full-src="{{ thumb_url|esc_url }}"
|
||||
style="{% if loop.first %}opacity: 1{% else %}opacity: 0.6{% endif %}" />
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
13
templates/single-product/product-thumbnails.html.twig
Normal file
13
templates/single-product/product-thumbnails.html.twig
Normal file
@@ -0,0 +1,13 @@
|
||||
{#
|
||||
# Product Thumbnails (Bootstrap 5 Override)
|
||||
#
|
||||
# Intentionally empty — the product-image.html.twig template renders its own
|
||||
# thumbnail gallery strip using Bootstrap grid. This override suppresses the
|
||||
# default WC output from woocommerce_show_product_thumbnails() which would
|
||||
# render additional full-size gallery images below the thumbnail row.
|
||||
#
|
||||
# WooCommerce PHP equivalent: single-product/product-thumbnails.php
|
||||
#
|
||||
# @package WcBootstrap
|
||||
# @since 0.1.5
|
||||
#}
|
||||
@@ -23,9 +23,11 @@
|
||||
|
||||
{{ woocommerce_product_loop_start() }}
|
||||
|
||||
{% for product in related_products %}
|
||||
{% include 'content-product.html.twig' with { product: product } %}
|
||||
{% for related_product in related_products %}
|
||||
{{ wc_setup_product_data(related_product) }}
|
||||
{% include 'content-product.html.twig' %}
|
||||
{% endfor %}
|
||||
{{ wp_reset_postdata() }}
|
||||
|
||||
{{ woocommerce_product_loop_end() }}
|
||||
</section>
|
||||
|
||||
@@ -23,9 +23,11 @@
|
||||
|
||||
{{ woocommerce_product_loop_start() }}
|
||||
|
||||
{% for product in upsells %}
|
||||
{% include 'content-product.html.twig' with { product: product } %}
|
||||
{% for upsell in upsells %}
|
||||
{{ wc_setup_product_data(upsell) }}
|
||||
{% include 'content-product.html.twig' %}
|
||||
{% endfor %}
|
||||
{{ wp_reset_postdata() }}
|
||||
|
||||
{{ woocommerce_product_loop_end() }}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user