Implement Phase 3: single product page templates (Bootstrap 5)

Add 21 Twig template overrides for the single product page:

Product layout:
- product-image: gallery with thumbnail strip, img-fluid rounded
- title: h1 entry-title
- price: fs-3 fw-bold with sale del/ins markup
- short-description: lead text-body-secondary
- meta: dl row with SKU, categories, tags
- rating: Bootstrap Icon stars with half-star, review count link
- stock: badge (bg-success/bg-danger/bg-warning) per status
- sale-flash: badge bg-danger fs-6
- share: hook-only wrapper
- product-attributes: table-sm table-striped

Related/upsells:
- related, up-sells: section with product loop grid

Tabs:
- tabs: nav-tabs + tab-content with fade transitions
- description: tab-pane with prose content
- additional-information: tab-pane with attributes hook

Add to cart (4 product types + variation JS):
- simple: input-group quantity + btn-primary btn-lg
- variable: form-select per attribute + variation display
- grouped: table-borderless with quantity per child product
- external: btn-outline-primary with external link icon
- variation: Underscore.js script templates (Bootstrap-styled)
- variation-add-to-cart-button: quantity + submit with hidden fields

CSS additions: gallery thumbnail hover, variation selector spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 10:28:13 +01:00
parent c9c99a6b88
commit 9917105951
23 changed files with 843 additions and 21 deletions

View File

@@ -0,0 +1,32 @@
{#
# External/Affiliate Product Add to Cart (Bootstrap 5 Override)
#
# Renders a link button to the external product URL.
#
# Expected context:
# product_url - External product URL
# button_text - Button label text
#
# WooCommerce PHP equivalent: single-product/add-to-cart/external.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_add_to_cart_form') }}
<form class="cart" action="{{ product_url|default('#')|esc_url }}" method="get">
{{ do_action('woocommerce_before_add_to_cart_button') }}
<a href="{{ product_url|default('#')|esc_url }}"
class="btn btn-outline-primary btn-lg single_add_to_cart_button"
target="_blank"
rel="noopener noreferrer nofollow">
{{ button_text|default(__('Buy product'))|esc_html }}
<i class="bi bi-box-arrow-up-right ms-2" aria-hidden="true"></i>
</a>
{{ do_action('woocommerce_after_add_to_cart_button') }}
</form>
{{ do_action('woocommerce_after_add_to_cart_form') }}

View File

@@ -0,0 +1,84 @@
{#
# Grouped Product Add to Cart (Bootstrap 5 Override)
#
# 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
#
# WooCommerce PHP equivalent: single-product/add-to-cart/grouped.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_add_to_cart_form') }}
<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">
{{ 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() }}">
<td class="woocommerce-grouped-product-list-item__quantity" style="width: 140px;">
{% if grouped_product.is_purchasable() and grouped_product.is_in_stock() %}
{% include 'global/quantity-input.html.twig' with {
input_id: 'quantity_' ~ child_id,
input_name: 'quantity[' ~ child_id ~ ']',
input_value: 0,
min_value: 0,
max_value: grouped_product.get_max_purchase_quantity()|default(0),
step: 1,
placeholder: '0',
inputmode: 'numeric',
classes: 'qty',
readonly: false,
type: 'number',
args: { product_name: grouped_product.get_name() }
} %}
{% endif %}
</td>
<td class="woocommerce-grouped-product-list-item__label">
<label for="quantity_{{ 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 }}
</a>
{% else %}
{{ grouped_product.get_name()|esc_html }}
{% endif %}
</label>
</td>
<td class="woocommerce-grouped-product-list-item__price text-end">
<span class="price">
{{ grouped_product.get_price_html()|raw }}
</span>
</td>
</tr>
{% endfor %}
{{ do_action('woocommerce_grouped_product_list_after') }}
</table>
</div>
{% if show_add_to_cart_button is not defined or 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>
{{ do_action('woocommerce_after_add_to_cart_button') }}
{% endif %}
</form>
{{ do_action('woocommerce_after_add_to_cart_form') }}

View File

@@ -0,0 +1,53 @@
{#
# Simple Product Add to Cart (Bootstrap 5 Override)
#
# Add-to-cart form for simple products: quantity input + button.
#
# Expected context:
# product - WC_Product_Simple object
#
# WooCommerce PHP equivalent: single-product/add-to-cart/simple.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if product.is_purchasable() and product.is_in_stock() %}
{{ do_action('woocommerce_before_add_to_cart_form') }}
<form class="cart" action="{{ product.get_permalink()|esc_url }}" method="post" enctype="multipart/form-data">
{{ do_action('woocommerce_before_add_to_cart_button') }}
<div class="d-flex align-items-end gap-3 mb-3">
{{ do_action('woocommerce_before_add_to_cart_quantity') }}
{% include 'global/quantity-input.html.twig' with {
input_id: 'quantity_' ~ product.get_id(),
input_name: 'quantity',
input_value: 1,
min_value: product.get_min_purchase_quantity()|default(1),
max_value: product.get_max_purchase_quantity()|default(0),
step: 1,
placeholder: '',
inputmode: 'numeric',
classes: 'qty',
readonly: false,
type: 'number',
args: { product_name: product.get_name() }
} %}
{{ do_action('woocommerce_after_add_to_cart_quantity') }}
<button type="submit"
name="add-to-cart"
value="{{ product.get_id() }}"
class="btn btn-primary btn-lg single_add_to_cart_button">
{{ product.single_add_to_cart_text() }}
</button>
</div>
{{ do_action('woocommerce_after_add_to_cart_button') }}
</form>
{{ do_action('woocommerce_after_add_to_cart_form') }}
{% endif %}

View File

@@ -0,0 +1,88 @@
{#
# Variable Product Add to Cart (Bootstrap 5 Override)
#
# Add-to-cart form for variable products with attribute selectors.
#
# Expected context:
# product - WC_Product_Variable object
# attributes - Array of attribute taxonomies
# available_variations - JSON-encoded variations data
# attribute_keys - Array of attribute keys
# variations_json - Variations data as JSON string
# variations_attr - Data attribute string for the form
# selected_attributes - Currently selected attribute values
#
# WooCommerce PHP equivalent: single-product/add-to-cart/variable.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_add_to_cart_form') }}
<form class="variations_form cart"
action="{{ product.get_permalink()|esc_url }}"
method="post"
enctype="multipart/form-data"
{{ variations_attr|default('')|raw }}>
{{ do_action('woocommerce_before_variations_form') }}
{% if available_variations is defined %}
{# Variation attribute selectors #}
<div class="variations mb-4">
{% for attribute_name, options in attributes %}
<div class="mb-3">
<label class="form-label fw-semibold" for="{{ attribute_name|esc_attr }}">
{{ wc_attribute_label(attribute_name)|esc_html }}
</label>
<select id="{{ attribute_name|esc_attr }}"
class="form-select"
name="attribute_{{ attribute_name|esc_attr }}"
data-attribute_name="attribute_{{ attribute_name|esc_attr }}">
<option value="">{{ __('Choose an option') }}</option>
{% if options is iterable %}
{% for option in options %}
{% set selected_value = selected_attributes[attribute_name]|default('') %}
<option value="{{ option|esc_attr }}"{% if option == selected_value %} selected{% endif %}>
{{ option|esc_html }}
</option>
{% endfor %}
{% endif %}
</select>
</div>
{% endfor %}
</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 #}
<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_after_single_variation') }}
</div>
{% else %}
{# Out of stock / unavailable #}
<p class="stock out-of-stock alert alert-warning">
{{ __('This product is currently out of stock and unavailable.') }}
</p>
{% endif %}
{{ do_action('woocommerce_after_variations_form') }}
</form>
{{ do_action('woocommerce_after_add_to_cart_form') }}

View File

@@ -0,0 +1,51 @@
{#
# Variation Add to Cart Button (Bootstrap 5 Override)
#
# Renders the quantity input and add-to-cart button for variable products
# after variation selection.
#
# Expected context:
# product - WC_Product_Variable object
#
# WooCommerce PHP equivalent: single-product/add-to-cart/variation-add-to-cart-button.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<div class="woocommerce-variation-add-to-cart variations_button">
{{ do_action('woocommerce_before_add_to_cart_button') }}
<div class="d-flex align-items-end gap-3 mb-3">
{{ do_action('woocommerce_before_add_to_cart_quantity') }}
{% include 'global/quantity-input.html.twig' with {
input_id: 'quantity_' ~ product.get_id(),
input_name: 'quantity',
input_value: 1,
min_value: product.get_min_purchase_quantity()|default(1),
max_value: product.get_max_purchase_quantity()|default(0),
step: 1,
placeholder: '',
inputmode: 'numeric',
classes: 'qty',
readonly: false,
type: 'number',
args: { product_name: product.get_name() }
} %}
{{ do_action('woocommerce_after_add_to_cart_quantity') }}
<button type="submit"
class="btn btn-primary btn-lg single_add_to_cart_button"
disabled>
{{ product.single_add_to_cart_text() }}
</button>
</div>
<input type="hidden" name="add-to-cart" value="{{ product.get_id() }}" />
<input type="hidden" name="product_id" value="{{ product.get_id() }}" />
<input type="hidden" name="variation_id" class="variation_id" value="0" />
{{ do_action('woocommerce_after_add_to_cart_button') }}
</div>

View File

@@ -0,0 +1,30 @@
{#
# Variation Templates (Bootstrap 5 Override)
#
# JavaScript templates (Underscore.js syntax) used by WooCommerce to render
# variation details dynamically when a user selects product attributes.
# These are script templates, not rendered server-side.
#
# WooCommerce PHP equivalent: single-product/add-to-cart/variation.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<script type="text/template" id="tmpl-variation-template">
<div class="woocommerce-variation-description mb-2">
{{{ data.variation.variation_description }}}
</div>
<div class="woocommerce-variation-price mb-3">
<span class="price fs-3 fw-bold">{{{ data.variation.price_html }}}</span>
</div>
<div class="woocommerce-variation-availability mb-2">
{{{ data.variation.availability_html }}}
</div>
</script>
<script type="text/template" id="tmpl-unavailable-variation-template">
<p class="alert alert-warning mb-0">
{{ __('Sorry, this product is unavailable. Please choose a different combination.') }}
</p>
</script>