Implement Phase 2: product archive and shop loop templates (Bootstrap 5)

Add 15 Twig template overrides for the product archive and shop loop:
- archive-product: 3+9 grid layout with optional filter sidebar
- content-product: card component with hook-based content injection
- content-product-cat: category card with thumbnail
- product-searchform: input-group with search icon button
- loop/loop-start, loop-end: responsive row-cols grid
- loop/header: archive title with description hook
- loop/result-count: showing X-Y of Z with aria-relevant
- loop/orderby: form-select-sm sort dropdown
- loop/pagination: delegates to components/pagination.html.twig
- loop/no-products-found: alert-info empty state
- loop/add-to-cart: btn-primary-sm with AJAX data attributes
- loop/price: fw-semibold with sale/regular markup
- loop/rating: Bootstrap Icon stars (full, half, empty)
- loop/sale-flash: badge bg-danger positioned overlay

CSS additions: product card hover, sale badge z-index, star rating sizing,
price del/ins styling, WooCommerce grid reset.

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

View File

@@ -0,0 +1,47 @@
{#
# Loop Add to Cart Button (Bootstrap 5 Override)
#
# Renders the add-to-cart button within the product loop.
#
# Expected context:
# product - WC_Product object with:
# .add_to_cart_url() - Add to cart URL
# .add_to_cart_text() - Button label
# .is_purchasable() - Whether product can be purchased
# .is_in_stock() - Whether product is in stock
# .supports('ajax_add_to_cart') - Whether AJAX add to cart is supported
# .get_id() - Product ID
# args - Array with:
# .quantity - Quantity (default: 1)
# .class - CSS classes
# .attributes - Additional HTML attributes
# .aria-describedby_text - Accessibility description
#
# WooCommerce PHP equivalent: loop/add-to-cart.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% set quantity = args.quantity|default(1) %}
{% set btn_class = 'btn btn-primary btn-sm w-100' %}
<a href="{{ product.add_to_cart_url()|esc_url }}"
class="{{ btn_class }} {{ args.class|default('') }}"
data-quantity="{{ quantity }}"
data-product_id="{{ product.get_id() }}"
{% if args.attributes is defined %}
{% for attr, value in args.attributes %}
{{ attr }}="{{ value|esc_attr }}"
{% endfor %}
{% endif %}
aria-label="{{ product.add_to_cart_text()|esc_attr }}"
rel="nofollow">
{{ product.add_to_cart_text() }}
</a>
{% if args['aria-describedby_text'] is defined and args['aria-describedby_text'] %}
<span id="woocommerce_loop_add_to_cart_link_describedby_{{ product.get_id() }}" class="visually-hidden">
{{ args['aria-describedby_text'] }}
</span>
{% endif %}

View File

@@ -0,0 +1,25 @@
{#
# 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
#
# WooCommerce PHP equivalent: loop/header.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<header class="woocommerce-products-header mb-4">
{% if show_page_title is not defined or show_page_title %}
<h1 class="woocommerce-products-header__title page-title mb-2">
{{ page_title|default('')|esc_html }}
</h1>
{% endif %}
{{ do_action('woocommerce_archive_description') }}
</header>

View File

@@ -0,0 +1,12 @@
{#
# Product Loop End (Bootstrap 5 Override)
#
# Closes the product grid container opened by loop-start.html.twig.
#
# WooCommerce PHP equivalent: loop/loop-end.php
#
# @package WcBootstrap
# @since 0.1.0
#}
</div>

View File

@@ -0,0 +1,17 @@
{#
# Product Loop Start (Bootstrap 5 Override)
#
# Opens the product grid container using Bootstrap 5 row with responsive columns.
#
# Expected context:
# columns - Number of columns (from wc_get_loop_prop)
#
# WooCommerce PHP equivalent: loop/loop-start.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% set cols = columns|default(3) %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-{{ cols < 3 ? cols : 3 }} row-cols-lg-{{ cols }} g-4 products">

View File

@@ -0,0 +1,17 @@
{#
# No Products Found (Bootstrap 5 Override)
#
# Displayed when the product archive has no results.
#
# WooCommerce PHP equivalent: loop/no-products-found.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<div class="woocommerce-no-products-found">
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle me-2" aria-hidden="true"></i>
{{ __('No products were found matching your selection.') }}
</div>
</div>

View File

@@ -0,0 +1,40 @@
{#
# Catalog Ordering / Sort Dropdown (Bootstrap 5 Override)
#
# Renders the product sort-by dropdown as a Bootstrap 5 form-select.
#
# Expected context:
# catalog_orderby_options - Associative array of { value: label } sort options
# orderby - Currently selected orderby value
# use_label - Whether to display a label (boolean)
#
# WooCommerce PHP equivalent: loop/orderby.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<form class="woocommerce-ordering d-inline-block" method="get">
{% if use_label is defined and use_label %}
<label for="woocommerce-orderby" class="form-label visually-hidden">
{{ __('Sort by') }}
</label>
{% endif %}
<select name="orderby"
id="woocommerce-orderby"
class="form-select form-select-sm"
aria-label="{{ __('Shop order') }}"
onchange="this.form.submit()">
{% if catalog_orderby_options is defined %}
{% for value, label in catalog_orderby_options %}
<option value="{{ value|esc_attr }}"{% if value == orderby %} selected{% endif %}>
{{ label|esc_html }}
</option>
{% endfor %}
{% endif %}
</select>
<input type="hidden" name="paged" value="1" />
{{ wc_query_string_form_fields() }}
</form>

View File

@@ -0,0 +1,24 @@
{#
# Product Pagination (Bootstrap 5 Override)
#
# Renders pagination for the product archive using Bootstrap 5 pagination component.
#
# Expected context:
# total - Total number of pages
# current - Current page number (1-based)
#
# WooCommerce PHP equivalent: loop/pagination.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% set max_pages = total|default(1) %}
{% set current_page = current|default(1) %}
{% if max_pages > 1 %}
{% include 'components/pagination.html.twig' with {
current_page: current_page,
max_pages: max_pages
} %}
{% endif %}

View File

@@ -0,0 +1,21 @@
{#
# Loop Product Price (Bootstrap 5 Override)
#
# Renders the product price within the shop loop.
# Price HTML is pre-formatted by WooCommerce (includes sale/regular markup).
#
# Expected context:
# product - WC_Product object with:
# .get_price_html() - Formatted price HTML (includes <del>/<ins> for sales)
#
# WooCommerce PHP equivalent: loop/price.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if product is defined %}
<span class="price fs-6 fw-semibold">
{{ product.get_price_html()|raw }}
</span>
{% endif %}

View File

@@ -0,0 +1,40 @@
{#
# Loop Product Rating (Bootstrap 5 Override)
#
# Renders star ratings for products in the shop loop.
#
# Expected context:
# product - WC_Product object with:
# .get_average_rating() - Average rating (0-5)
# .get_review_count() - Number of reviews
# reviews_enabled - Whether reviews/ratings are enabled (boolean)
#
# WooCommerce PHP equivalent: loop/rating.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if reviews_enabled is not defined or reviews_enabled %}
{% if product is defined and product.get_average_rating() > 0 %}
{% set rating = product.get_average_rating() %}
{% set count = product.get_review_count() %}
<div class="wc-star-rating d-flex align-items-center gap-1 mb-1"
role="img"
aria-label="{{ __('%s out of 5 stars')|format(rating) }}">
{% for i in 1..5 %}
{% if i <= rating|round(0, 'floor') %}
<i class="bi bi-star-fill text-warning" aria-hidden="true"></i>
{% elseif i - rating < 1 %}
<i class="bi bi-star-half text-warning" aria-hidden="true"></i>
{% else %}
<i class="bi bi-star text-warning" aria-hidden="true"></i>
{% endif %}
{% endfor %}
{% if count > 0 %}
<small class="text-body-secondary">({{ count }})</small>
{% endif %}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,33 @@
{#
# Result Count (Bootstrap 5 Override)
#
# Displays the "Showing X-Y of Z results" text.
#
# Expected context:
# total - Total number of products
# per_page - Products per page
# current - Current page number
# orderedby - Whether results are currently sorted (optional)
#
# WooCommerce PHP equivalent: loop/result-count.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<p class="woocommerce-result-count text-body-secondary mb-0"
role="alert"
aria-relevant="all"
{% if orderedby is defined and orderedby %}data-is-sorted-by="true"{% endif %}>
{% if total is defined %}
{% set first = ((current|default(1) - 1) * per_page|default(total)) + 1 %}
{% set last = current|default(1) * per_page|default(total) %}
{% if last > total %}{% set last = total %}{% endif %}
{% if total <= per_page|default(total) or total == 0 %}
{{ _n('Showing the single result', 'Showing all %d results', total)|format(total) }}
{% else %}
{{ __('Showing %1$d&ndash;%2$d of %3$d results')|format(first, last, total) }}
{% endif %}
{% endif %}
</p>

View File

@@ -0,0 +1,21 @@
{#
# Sale Badge (Bootstrap 5 Override)
#
# Renders a sale badge overlay on product cards.
#
# Expected context:
# product - WC_Product object with:
# .is_on_sale() - Whether product is currently on sale
# post - Global post object
#
# WooCommerce PHP equivalent: loop/sale-flash.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if product is defined and product.is_on_sale() %}
<span class="badge bg-danger position-absolute top-0 start-0 m-2 onsale">
{{ __('Sale!') }}
</span>
{% endif %}