Implement Phase 4 & 5: cart and checkout templates (Bootstrap 5, HPOS)

Phase 4 - Cart (9 templates):
- cart: 8+4 column layout, table-responsive items, coupon input-group
- cart-empty: centered empty state with cart-x icon
- cart-item-data: inline dl for variation details
- cart-totals: card with list-group-flush rows, sticky sidebar
- cart-shipping: form-check radio per shipping method
- cross-sells: product loop grid section
- mini-cart: offcanvas-compatible item list with remove buttons
- proceed-to-checkout-button: btn-primary btn-lg w-100
- shipping-calculator: collapsible form with form-select/form-control

Phase 5 - Checkout (12 templates):
- form-checkout: 7+5 column layout, sticky order review sidebar
- form-billing: card with field wrapper, optional account creation
- form-shipping: card with ship-to-different-address collapse toggle
- form-coupon: collapsible input-group with alert-info toggle
- form-login: collapsible login reusing global/form-login.html.twig
- review-order: card with table-sm, tfoot subtotal/shipping/total
- payment: list-group of payment gateways with radio selection
- payment-method: form-check with description collapse
- terms: form-check checkbox with T&C link
- thankyou: HPOS compatible, alert-success + order details list-group
- order-received: confirmation message
- cart-errors: alert-danger with return-to-cart button

All order data accessed via WC_Order methods (HPOS compatible).
CSS additions: cart thumbnail sizing, checkout form field overrides,
payment box transitions, dark mode focus states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 10:33:49 +01:00
parent 9917105951
commit 594d810439
23 changed files with 1295 additions and 22 deletions

View File

@@ -0,0 +1,23 @@
{#
# Empty Cart (Bootstrap 5 Override)
#
# Displayed when the cart has no items.
#
# WooCommerce PHP equivalent: cart/cart-empty.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_cart_is_empty') }}
<div class="text-center py-5">
<i class="bi bi-cart-x display-1 text-body-secondary mb-4" aria-hidden="true"></i>
<p class="lead text-body-secondary mb-4">{{ __('Your cart is currently empty.') }}</p>
{% if wc_get_page_id('shop') > 0 %}
<a class="btn btn-primary btn-lg" href="{{ wc_get_page_permalink('shop')|esc_url }}">
{{ __('Return to shop') }}
</a>
{% endif %}
</div>

View File

@@ -0,0 +1,24 @@
{#
# Cart Item Data / Variation Details (Bootstrap 5 Override)
#
# Renders variation/custom data for a cart item.
#
# Expected context:
# item_data - Array of variation data, each with:
# .key - Attribute label
# .value - Attribute value (HTML)
#
# WooCommerce PHP equivalent: cart/cart-item-data.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if item_data is defined and item_data|length > 0 %}
<dl class="variation small text-body-secondary mb-0 mt-1">
{% for data in item_data %}
<dt class="d-inline">{{ data.key|raw }}:</dt>
<dd class="d-inline me-2">{{ data.value|raw }}</dd>
{% endfor %}
</dl>
{% endif %}

View File

@@ -0,0 +1,69 @@
{#
# Cart Shipping Methods (Bootstrap 5 Override)
#
# Renders available shipping methods as Bootstrap form-check radios.
#
# Expected context:
# available_methods - Array of shipping method objects
# chosen_method - Currently chosen method ID
# formatted_destination - Formatted shipping address
# has_calculated_shipping - Whether shipping has been calculated
# show_shipping_calculator - Whether to show the calculator
# package_name - Package name/label
# index - Package index
#
# WooCommerce PHP equivalent: cart/cart-shipping.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<div class="woocommerce-shipping-totals shipping">
<strong class="d-block mb-2">{{ package_name|default(__('Shipping'))|esc_html }}</strong>
{% if available_methods is defined and available_methods|length > 0 %}
<ul id="shipping_method_{{ index|default(0) }}" class="list-unstyled mb-2">
{% for method_id, method in available_methods %}
<li class="form-check">
{% if available_methods|length > 1 %}
<input type="radio"
name="shipping_method[{{ index|default(0) }}]"
id="shipping_method_{{ index|default(0) }}_{{ method_id|esc_attr }}"
value="{{ method_id|esc_attr }}"
class="form-check-input shipping_method"
data-index="{{ index|default(0) }}"
{% if method_id == chosen_method %}checked{% endif %} />
{% else %}
<input type="hidden"
name="shipping_method[{{ index|default(0) }}]"
value="{{ method_id|esc_attr }}"
class="shipping_method"
data-index="{{ index|default(0) }}" />
{% endif %}
<label class="form-check-label" for="shipping_method_{{ index|default(0) }}_{{ method_id|esc_attr }}">
{{ method.get_label()|raw }}
</label>
{{ do_action('woocommerce_after_shipping_rate', method, index|default(0)) }}
</li>
{% endfor %}
</ul>
{% if formatted_destination is defined and formatted_destination %}
<p class="woocommerce-shipping-destination small text-body-secondary mb-0">
{{ formatted_destination|raw }}
</p>
{% endif %}
{% elseif not has_calculated_shipping|default(false) %}
<p class="text-body-secondary small mb-0">
{{ __('Shipping costs are calculated during checkout.') }}
</p>
{% else %}
<p class="text-body-secondary small mb-0">
{{ __('No shipping options were found.') }}
</p>
{% endif %}
{% if show_shipping_calculator|default(false) %}
{% include 'cart/shipping-calculator.html.twig' %}
{% endif %}
</div>

View File

@@ -0,0 +1,82 @@
{#
# Cart Totals (Bootstrap 5 Override)
#
# Renders the cart totals as a Bootstrap card with list-group rows.
#
# Expected context:
# cart - WC()->cart object (subtotal, fees, coupons, shipping, total)
# cart_subtotal - Pre-rendered subtotal HTML
# cart_total - Pre-rendered total HTML
# coupons - Array of applied coupon objects
# fees - Array of fee objects
#
# WooCommerce PHP equivalent: cart/cart-totals.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<div class="cart_totals">
{{ do_action('woocommerce_before_cart_totals') }}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="h5 mb-0">{{ __('Cart totals') }}</h2>
</div>
<ul class="list-group list-group-flush">
{# Subtotal #}
<li class="list-group-item d-flex justify-content-between">
<span>{{ __('Subtotal') }}</span>
<span>{{ cart_subtotal|default(cart.get_cart_subtotal())|raw }}</span>
</li>
{# Coupons #}
{% if coupons is defined %}
{% for coupon in coupons %}
<li class="list-group-item d-flex justify-content-between">
<span>{{ __('Coupon:') }} {{ coupon.code|esc_html }}</span>
<span>{{ coupon.discount_html|raw }}</span>
</li>
{% endfor %}
{% endif %}
{# Shipping #}
{{ do_action('woocommerce_cart_totals_before_shipping') }}
{% if wc_shipping_enabled() is defined and wc_shipping_enabled() %}
<li class="list-group-item cart-shipping">
{{ do_action('woocommerce_cart_totals_shipping') }}
</li>
{% endif %}
{{ do_action('woocommerce_cart_totals_after_shipping') }}
{# Fees #}
{% if fees is defined %}
{% for fee in fees %}
<li class="list-group-item d-flex justify-content-between">
<span>{{ fee.name|esc_html }}</span>
<span>{{ fee.total_html|raw }}</span>
</li>
{% endfor %}
{% endif %}
{{ do_action('woocommerce_cart_totals_before_order_total') }}
{# Order total #}
<li class="list-group-item d-flex justify-content-between fw-bold fs-5">
<span>{{ __('Total') }}</span>
<span>{{ cart_total|default(cart.get_total())|raw }}</span>
</li>
{{ do_action('woocommerce_cart_totals_after_order_total') }}
</ul>
<div class="card-body">
{{ do_action('woocommerce_proceed_to_checkout') }}
</div>
</div>
{{ do_action('woocommerce_after_cart_totals') }}
</div>

View File

@@ -0,0 +1,171 @@
{#
# Cart Page (Bootstrap 5 Override)
#
# Main cart page with items table and totals sidebar.
# Layout: col-lg-8 (items) + col-lg-4 (totals).
#
# Expected context:
# cart_items - Array from WC()->cart->get_cart(), each with:
# .key - Cart item key
# .product - WC_Product object
# .product_id - Product ID
# .product_name - Filtered product name
# .permalink - Product URL
# .quantity - Quantity
# .subtotal - Line subtotal HTML
# .price - Unit price HTML
# .thumbnail - Product thumbnail HTML
# .item_data_html - Variation data HTML
# .remove_url - Remove item URL
# .visible - Whether item is visible
# .css_class - Item CSS class
#
# WooCommerce PHP equivalent: cart/cart.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_cart') }}
<div class="row g-4">
{# Cart items table #}
<div class="col-lg-8">
<form class="woocommerce-cart-form" action="{{ wc_get_cart_url()|esc_url }}" method="post">
{{ do_action('woocommerce_before_cart_table') }}
<div class="table-responsive">
<table class="table align-middle shop_table shop_table_responsive cart woocommerce-cart-form__contents">
<thead class="table-light">
<tr>
<th class="product-thumbnail" scope="col" style="width: 80px;">
<span class="visually-hidden">{{ __('Thumbnail') }}</span>
</th>
<th class="product-name" scope="col">{{ __('Product') }}</th>
<th class="product-price" scope="col">{{ __('Price') }}</th>
<th class="product-quantity" scope="col" style="width: 160px;">{{ __('Quantity') }}</th>
<th class="product-subtotal" scope="col">{{ __('Subtotal') }}</th>
<th class="product-remove" scope="col" style="width: 50px;">
<span class="visually-hidden">{{ __('Remove') }}</span>
</th>
</tr>
</thead>
<tbody>
{{ do_action('woocommerce_before_cart_contents') }}
{% if cart_items is defined %}
{% for item in cart_items %}
{% if item.visible is not defined or item.visible %}
<tr class="woocommerce-cart-form__cart-item {{ item.css_class|default('') }}">
<td class="product-thumbnail">
{% if item.permalink %}
<a href="{{ item.permalink|esc_url }}">{{ item.thumbnail|raw }}</a>
{% else %}
{{ item.thumbnail|raw }}
{% endif %}
</td>
<td class="product-name" data-title="{{ __('Product') }}">
{% if item.permalink %}
<a href="{{ item.permalink|esc_url }}" class="text-decoration-none">
{{ item.product_name|esc_html }}
</a>
{% else %}
{{ item.product_name|esc_html }}
{% endif %}
{{ item.item_data_html|default('')|raw }}
</td>
<td class="product-price" data-title="{{ __('Price') }}">
{{ item.price|raw }}
</td>
<td class="product-quantity" data-title="{{ __('Quantity') }}">
{% include 'global/quantity-input.html.twig' with {
input_id: 'quantity_' ~ item.key,
input_name: 'cart[' ~ item.key ~ '][qty]',
input_value: item.quantity,
min_value: 0,
max_value: item.product.get_max_purchase_quantity()|default(0),
step: 1,
placeholder: '',
inputmode: 'numeric',
classes: 'qty',
readonly: false,
type: 'number',
args: { product_name: item.product_name }
} %}
</td>
<td class="product-subtotal" data-title="{{ __('Subtotal') }}">
{{ item.subtotal|raw }}
</td>
<td class="product-remove">
<a href="{{ item.remove_url|esc_url }}"
class="btn btn-sm btn-outline-danger remove"
aria-label="{{ __('Remove this item') }}"
data-product_id="{{ item.product_id }}"
data-product_sku="{{ item.product.get_sku()|default('')|esc_attr }}">
<i class="bi bi-x-lg" aria-hidden="true"></i>
</a>
</td>
</tr>
{% endif %}
{% endfor %}
{% endif %}
{{ do_action('woocommerce_cart_contents') }}
<tr>
<td colspan="6" class="actions">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
{% if wc_coupons_enabled() %}
<div class="coupon input-group" style="max-width: 350px;">
<label class="visually-hidden" for="coupon_code">{{ __('Coupon:') }}</label>
<input type="text"
name="coupon_code"
class="form-control"
id="coupon_code"
placeholder="{{ __('Coupon code') }}" />
<button type="submit"
class="btn btn-outline-secondary"
name="apply_coupon"
value="{{ __('Apply coupon') }}">
{{ __('Apply coupon') }}
</button>
{{ do_action('woocommerce_cart_coupon') }}
</div>
{% endif %}
<button type="submit"
class="btn btn-outline-primary"
name="update_cart"
value="{{ __('Update cart') }}">
{{ __('Update cart') }}
</button>
{{ do_action('woocommerce_cart_actions') }}
{{ wp_nonce_field('woocommerce-cart', 'woocommerce-cart-nonce') }}
</div>
</td>
</tr>
{{ do_action('woocommerce_after_cart_contents') }}
</tbody>
</table>
</div>
{{ do_action('woocommerce_after_cart_table') }}
</form>
</div>
{# Cart totals sidebar #}
<div class="col-lg-4">
<div class="cart-collaterals">
{{ do_action('woocommerce_cart_collaterals') }}
</div>
</div>
</div>
{{ do_action('woocommerce_after_cart') }}

View File

@@ -0,0 +1,32 @@
{#
# Cross-sell Products (Bootstrap 5 Override)
#
# Renders cross-sell product recommendations below the cart.
#
# Expected context:
# cross_sells - Array of WC_Product objects
# columns - Number of columns
# heading - Section heading (filtered)
#
# WooCommerce PHP equivalent: cart/cross-sells.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{% if cross_sells is defined and cross_sells|length > 0 %}
<section class="cross-sells mt-5">
{% set heading = heading|default(__('You may be interested in&hellip;')) %}
{% if heading %}
<h2 class="h4 mb-4">{{ heading }}</h2>
{% endif %}
{{ woocommerce_product_loop_start() }}
{% for product in cross_sells %}
{% include 'content-product.html.twig' with { product: product } %}
{% endfor %}
{{ woocommerce_product_loop_end() }}
</section>
{% endif %}

View File

@@ -0,0 +1,87 @@
{#
# Mini Cart / Cart Widget (Bootstrap 5 Override)
#
# Renders the mini cart as an offcanvas slide-in panel.
#
# Expected context:
# cart_items - Array from WC()->cart->get_cart()
# cart_is_empty - Whether cart is empty
# cart_subtotal - Cart subtotal HTML
# args - Widget arguments (list_class)
#
# WooCommerce PHP equivalent: cart/mini-cart.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_mini_cart') }}
{% if not cart_is_empty|default(true) %}
<ul class="woocommerce-mini-cart list-unstyled {{ args.list_class|default('') }}">
{{ do_action('woocommerce_before_mini_cart_contents') }}
{% if cart_items is defined %}
{% for item in cart_items %}
<li class="woocommerce-mini-cart-item d-flex gap-3 py-2 border-bottom {{ item.css_class|default('') }}">
{# Remove link #}
<a href="{{ item.remove_url|esc_url }}"
class="remove remove_from_cart_button text-danger"
aria-label="{{ __('Remove this item') }}"
data-product_id="{{ item.product_id }}"
data-cart_item_key="{{ item.key }}">
<i class="bi bi-x" aria-hidden="true"></i>
</a>
{# Thumbnail #}
{% if item.permalink %}
<a href="{{ item.permalink|esc_url }}" class="flex-shrink-0" style="width: 50px;">
{{ item.thumbnail|raw }}
</a>
{% endif %}
{# Product info #}
<div class="flex-grow-1">
{% if item.permalink %}
<a href="{{ item.permalink|esc_url }}" class="text-decoration-none d-block small fw-semibold">
{{ item.product_name }}
</a>
{% else %}
<span class="d-block small fw-semibold">{{ item.product_name }}</span>
{% endif %}
{{ item.item_data_html|default('')|raw }}
<span class="quantity small text-body-secondary">
{{ item.quantity }} &times; {{ item.price|raw }}
</span>
</div>
</li>
{% endfor %}
{% endif %}
{{ do_action('woocommerce_mini_cart_contents') }}
</ul>
<div class="woocommerce-mini-cart__total border-top pt-3 mt-2">
{{ do_action('woocommerce_widget_shopping_cart_total') }}
<p class="total d-flex justify-content-between fw-bold mb-3">
<span>{{ __('Subtotal:') }}</span>
<span>{{ cart_subtotal|raw }}</span>
</p>
</div>
{{ do_action('woocommerce_widget_shopping_cart_before_buttons') }}
<div class="woocommerce-mini-cart__buttons d-grid gap-2">
{{ do_action('woocommerce_widget_shopping_cart_buttons') }}
</div>
{{ do_action('woocommerce_widget_shopping_cart_after_buttons') }}
{% else %}
<p class="woocommerce-mini-cart__empty-message text-body-secondary text-center py-3">
{{ __('No products in the cart.') }}
</p>
{% endif %}
{{ do_action('woocommerce_after_mini_cart') }}

View File

@@ -0,0 +1,12 @@
{#
# Proceed to Checkout Button (Bootstrap 5 Override)
#
# WooCommerce PHP equivalent: cart/proceed-to-checkout-button.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<a href="{{ wc_get_checkout_url()|esc_url }}" class="btn btn-primary btn-lg w-100 checkout-button">
{{ __('Proceed to checkout') }}
</a>

View File

@@ -0,0 +1,109 @@
{#
# Shipping Calculator (Bootstrap 5 Override)
#
# Collapsible form to estimate shipping costs.
#
# Expected context:
# button_text - Submit button text
# customer_country - Selected country code
# customer_state - Selected state code
# customer_city - City value
# customer_postcode - Postcode value
# countries - Array of country options
# states - Array of state options for selected country
# show_country - Whether to show country field
# show_state - Whether to show state field
# show_city - Whether to show city field
# show_postcode - Whether to show postcode field
#
# WooCommerce PHP equivalent: cart/shipping-calculator.php
#
# @package WcBootstrap
# @since 0.1.0
#}
{{ do_action('woocommerce_before_shipping_calculator') }}
<form class="woocommerce-shipping-calculator mt-3" action="{{ wc_get_cart_url()|esc_url }}" method="post">
<a href="#shippingCalcForm"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="collapse"
role="button"
aria-expanded="false"
aria-controls="shippingCalcForm">
{{ __('Calculate shipping') }}
</a>
<div class="collapse mt-3" id="shippingCalcForm">
{% if show_country|default(true) %}
<div class="mb-3">
<label for="calc_shipping_country" class="form-label">{{ __('Country / region') }}</label>
<select name="calc_shipping_country" id="calc_shipping_country" class="form-select country_to_state">
<option value="default">{{ __('Select a country / region&hellip;') }}</option>
{% if countries is defined %}
{% for code, name in countries %}
<option value="{{ code|esc_attr }}"{% if code == customer_country %} selected{% endif %}>
{{ name|esc_html }}
</option>
{% endfor %}
{% endif %}
</select>
</div>
{% endif %}
{% if show_state|default(true) %}
<div class="mb-3">
<label for="calc_shipping_state" class="form-label">{{ __('State / county') }}</label>
{% if states is defined and states|length > 0 %}
<select name="calc_shipping_state" id="calc_shipping_state" class="form-select">
<option value="">{{ __('Select an option&hellip;') }}</option>
{% for code, name in states %}
<option value="{{ code|esc_attr }}"{% if code == customer_state %} selected{% endif %}>
{{ name|esc_html }}
</option>
{% endfor %}
</select>
{% else %}
<input type="text"
class="form-control"
name="calc_shipping_state"
id="calc_shipping_state"
placeholder="{{ __('State / county') }}"
value="{{ customer_state|default('')|esc_attr }}" />
{% endif %}
</div>
{% endif %}
{% if show_city|default(false) %}
<div class="mb-3">
<label for="calc_shipping_city" class="form-label">{{ __('City') }}</label>
<input type="text"
class="form-control"
name="calc_shipping_city"
id="calc_shipping_city"
placeholder="{{ __('City') }}"
value="{{ customer_city|default('')|esc_attr }}" />
</div>
{% endif %}
{% if show_postcode|default(true) %}
<div class="mb-3">
<label for="calc_shipping_postcode" class="form-label">{{ __('Postcode / ZIP') }}</label>
<input type="text"
class="form-control"
name="calc_shipping_postcode"
id="calc_shipping_postcode"
placeholder="{{ __('Postcode / ZIP') }}"
value="{{ customer_postcode|default('')|esc_attr }}" />
</div>
{% endif %}
<button type="submit" name="calc_shipping" value="1" class="btn btn-outline-primary">
{{ button_text|default(__('Update')) }}
</button>
{{ wp_nonce_field('woocommerce-shipping-calculator', 'woocommerce-shipping-calculator-nonce') }}
</div>
</form>
{{ do_action('woocommerce_after_shipping_calculator') }}