[IMP] website_sale_aplicoop: accessibility and responsive polish — phase 8

Quantity control layout: changed from wrapping flex to deliberate two-row
grid so the add-to-cart button sits full-width underneath the stepper,
avoiding accidental line breaks and giving the primary action more pulsing
area. Also removed the /Kg suffix's redundant font rules.

Order card delivery row: reordered to badge-then-date (visually centred),
removed the "Delivery" label, and swapped bg-primary to text-bg-primary for
proper contrast on the home-delivery badge (3.13:1 minimum).

Tooltip translations: moved hardcoded static tooltips from data-bs-title
(untranslatable) to title (QWeb-translatable). Handled the edge case of
"Save Cart" and "Back to Cart" which had translations but no model_terms
reference in the POT, causing the merge to discard them — added the view
reference so translations now apply.

Load-from-history page: added accessibility: a visible status message
("Loading your order…"), a <noscript> fallback with link to the group
order, lang and viewport meta tags, and localised strings for all three
languages. Gave the page a minimal inline style block since it does not
inherit the token system (no website.layout).

Translations: 11 new entries (es/eu/ca) for new/reworded UI strings, plus
two code catalogue entries (products found, Close) that needed the
#. odoo-python comment for _() resolution. Documented the silent-failure
pattern in docs/TRANSLATIONS.md: the POT merge, untranslatable attributes,
and missing code comments.

Tests: 246 passing. Pre-commit: clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-16 11:34:38 +02:00
parent 162ba0b570
commit 5de7573fba
35 changed files with 2181 additions and 2413 deletions

View file

@ -21,8 +21,8 @@
<div t-attf-class="eskaera-order-card{{ ' eskaera-order-card--special' if order.type == 'special' else '' }}">
<div class="card-body">
<div class="position-absolute order-badge-position">
<span class="badge bg-primary d-flex align-items-center gap-2 order-badge-custom">
<i class="fa fa-shopping-bag" />
<span class="badge text-bg-primary d-flex align-items-center gap-2 order-badge-custom">
<i class="fa fa-shopping-bag" aria-hidden="true" />
<strong>
<t t-esc="order.available_products_count" />
</strong>
@ -147,22 +147,24 @@
</span>
</div>
</t>
<t t-if="order.home_delivery and order.delivery_date">
<div class="meta-item">
<span class="meta-label">Delivery</span>
<span class="meta-value">
<t t-esc="day_names[order.delivery_date.weekday()]" /> <t t-esc="order.delivery_date.strftime('%d/%m')" />
</span>
</div>
</t>
<t t-if="order.home_delivery">
<div class="meta-item">
<span class="badge bg-success"><i class="fa fa-truck" /> Home Delivery</span>
<!-- Badge first, then the date, centred as a pair. Source order
matches reading order, so no CSS reordering is involved.
No "Delivery" label: the badge already names the thing.
text-bg-* instead of bg-*: Bootstrap picks a contrasting
foreground. A badge's default white on green is 3.13:1. -->
<div class="meta-item meta-item--delivery">
<span class="badge text-bg-success"><i class="fa fa-truck" aria-hidden="true" /> Home Delivery</span>
<t t-if="order.delivery_date">
<span class="meta-value">
<t t-esc="day_names[order.delivery_date.weekday()]" /> <t t-esc="order.delivery_date.strftime('%d/%m')" />
</span>
</t>
</div>
</t>
<t t-if="order.type == 'special'">
<div class="meta-item meta-item--full">
<span class="badge special-order-badge"><i class="fa fa-star" /> Special Order</span>
<span class="badge special-order-badge"><i class="fa fa-star" aria-hidden="true" /> Special Order</span>
</div>
<t t-if="order.start_date">
<div class="meta-item">
@ -182,7 +184,10 @@
</template>
<template id="order_header" name="Order Header">
<div t-att-class="header_class or 'eskaera-order-header'">
<div class="d-flex gap-5 align-items-center mb-4">
<!-- Responsive Bootstrap utilities rather than a CSS override: the
spacing/alignment utilities are !important, so fighting them from
a stylesheet would need !important too. -->
<div class="d-flex flex-column flex-md-row gap-3 gap-md-5 align-items-start align-items-md-center mb-4">
<t t-set="image_to_show" t-value="group_order.image or (group_order.group_ids[0].image_1920 if group_order.group_ids else False)" />
<t t-if="image_to_show">
<img t-att-src="image_data_uri(image_to_show)" alt="Order image" class="order-thumbnail-md" />
@ -210,79 +215,70 @@
<t t-set="header_class" t-value="'eskaera-order-header'" />
</t>
<div class="eskaera-order-header">
<div class="order-info-grid">
<div class="info-item">
<span t-att-class="'info-label'">Consumer Groups</span>
<span class="info-value">
<t t-esc="', '.join(group_order.group_ids.mapped('name'))" />
</span>
</div>
<!-- Name/value pairs: a description list, so assistive tech
announces each value with its label. The 4 → 2 → 1 column
layout lives in layout/header.css. -->
<dl class="order-info-grid">
<dt class="info-label">Consumer Groups</dt>
<dd class="info-value">
<t t-esc="', '.join(group_order.group_ids.mapped('name'))" />
</dd>
<t t-if="group_order.cutoff_day">
<div class="info-item">
<span t-att-class="'info-label'">Cutoff Day</span>
<span class="info-value">
<t t-esc="day_names[int(group_order.cutoff_day) % 7]" /> (<t t-esc="group_order.cutoff_date.strftime('%d/%m/%Y')" />)</span>
</div>
<dt class="info-label">Cutoff Day</dt>
<dd class="info-value">
<t t-esc="day_names[int(group_order.cutoff_day) % 7]" /> (<t t-esc="group_order.cutoff_date.strftime('%d/%m/%Y')" />)</dd>
</t>
<t t-if="group_order.pickup_slot_ids">
<div class="info-item">
<span t-att-class="'info-label'">Store Pickup Slots</span>
<span class="info-value">
<t t-foreach="group_order.pickup_slot_ids" t-as="slot">
<div>
<t t-esc="day_names[int(slot.weekday) % 7]" />
&#160;
<t t-esc="('%02d:%02d%02d:%02d' % (int(slot.start_hour or 0), int(((slot.start_hour or 0) % 1) * 60), int(slot.end_hour or 0), int(((slot.end_hour or 0) % 1) * 60)))" />
<t t-if="slot.label">&#160;(<t t-esc="slot.label" />)</t>
</div>
</t>
</span>
</div>
<dt class="info-label">Store Pickup Slots</dt>
<dd class="info-value">
<t t-foreach="group_order.pickup_slot_ids" t-as="slot">
<div class="info-value-line">
<t t-esc="day_names[int(slot.weekday) % 7]" />
&#160;
<t t-esc="('%02d:%02d%02d:%02d' % (int(slot.start_hour or 0), int(((slot.start_hour or 0) % 1) * 60), int(slot.end_hour or 0), int(((slot.end_hour or 0) % 1) * 60)))" />
<t t-if="slot.label">&#160;(<t t-esc="slot.label" />)</t>
</div>
</t>
</dd>
</t>
<t t-elif="group_order.pickup_day">
<div class="info-item">
<span t-att-class="'info-label'">Store Pickup Day</span>
<span class="info-value">
<t t-esc="day_names[int(group_order.pickup_day) % 7]" /> (<t t-esc="group_order.pickup_date.strftime('%d/%m/%Y')" />)
</span>
</div>
<dt class="info-label">Store Pickup Day</dt>
<dd class="info-value">
<t t-esc="day_names[int(group_order.pickup_day) % 7]" /> (<t t-esc="group_order.pickup_date.strftime('%d/%m/%Y')" />)
</dd>
</t>
<t t-if="group_order.delivery_date and group_order.home_delivery">
<div class="info-item">
<span t-att-class="'info-label'">Home Delivery Day</span>
<span class="info-value">
<t t-esc="day_names[group_order.delivery_date.weekday()]" /> (<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />)</span>
</div>
<dt class="info-label">Home Delivery Day</dt>
<dd class="info-value">
<t t-esc="day_names[group_order.delivery_date.weekday()]" /> (<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />)</dd>
</t>
<t t-if="group_order.start_date">
<div class="info-item">
<span t-att-class="'info-label'">Open From</span>
<span class="info-value">
<t t-esc="group_order.start_date.strftime('%d/%m/%Y')" />
</span>
</div>
<dt class="info-label">Open From</dt>
<dd class="info-value">
<t t-esc="group_order.start_date.strftime('%d/%m/%Y')" />
</dd>
</t>
<t t-if="group_order.end_date">
<div class="info-item">
<span t-att-class="'info-label'">Open Until</span>
<span class="info-value">
<t t-esc="group_order.end_date.strftime('%d/%m/%Y')" />
</span>
</div>
<dt class="info-label">Open Until</dt>
<dd class="info-value">
<t t-esc="group_order.end_date.strftime('%d/%m/%Y')" />
</dd>
</t>
</div>
</dl>
</div>
</div>
</div>
<div class="mb-3" id="realtimeSearch-filters">
<div class="mb-3 eskaera-filters" id="realtimeSearch-filters">
<div class="row g-2">
<div class="col-md-7">
<div style="position: relative;">
<input type="text" id="realtime-search-input" class="form-control realtime-search-box search-input-styled" placeholder="Search products..." autocomplete="off" style="padding-right: 40px;" />
<button type="button" id="clear-search-btn" class="btn btn-link" style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%); padding: 0; width: 30px; height: 30px; display: none; color: #6c757d; text-decoration: none; font-size: 1.5rem; line-height: 1;" aria-label="Clear search" title="Clear search">
×
</button>
<div class="search-input-wrapper">
<label class="visually-hidden" for="realtime-search-input">Search products</label>
<input type="search" id="realtime-search-input" class="form-control eskaera-search-input" placeholder="Search products..." autocomplete="off" aria-describedby="realtime-search-status" />
<button type="button" id="clear-search-btn" class="search-clear-btn" aria-label="Clear search" title="Clear search">
×
</button>
</div>
<p id="realtime-search-status" class="visually-hidden" aria-live="polite" aria-atomic="true" />
</div>
<div class="col-md-5">
<select name="category" id="realtime-category-select" class="form-select">
@ -299,13 +295,18 @@
<div class="col-12">
<div id="tag-filter-container" class="tag-filter-badges">
<t t-foreach="available_tags" t-as="tag">
<!-- Toggle buttons: aria-pressed carries the filter
state, which realtime_search.js keeps in sync
with the .is-selected / .is-dimmed classes.
The record colour rides in a custom property so
the stylesheet keeps owning the badge. -->
<t t-if="tag['color']">
<button type="button" class="badge tag-filter-badge" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" t-att-data-tag-color="tag['color']" t-attf-style="background-color: {{ tag['color'] }} !important; border-color: {{ tag['color'] }} !important; color: #ffffff !important;" data-toggle="tag-filter">
<button type="button" class="badge tag-filter-badge" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" t-att-data-tag-color="tag['color']" t-attf-style="--ac-tag-color: {{ tag['color'] }};" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
<t t-else="">
<button type="button" class="badge tag-filter-badge tag-use-theme-color" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" data-tag-color="" data-toggle="tag-filter">
<button type="button" class="badge tag-filter-badge tag-use-theme-color" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" data-tag-color="" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
@ -321,30 +322,32 @@
<div class="card-header d-flex justify-content-between align-items-center gap-1">
<h6 class="mb-0 cart-title-sm" id="cart-title">My Cart</h6>
<div class="btn-group cart-btn-group gap-0" role="group">
<button type="button" class="btn btn-primary cart-btn-compact" id="save-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Save Cart" data-bs-toggle="tooltip">
<i class="fa fa-save cart-icon-size" />
<!-- data-bs-title feeds the tooltip, it is not an
accessible name: this icon-only button had none. -->
<button type="button" class="btn btn-primary cart-btn-compact" id="save-cart-btn" t-attf-data-order-id="{{ group_order.id }}" aria-label="Save Cart" title="Save Cart" data-bs-toggle="tooltip">
<i class="fa fa-save cart-icon-size" aria-hidden="true" />
</button>
<t t-if="group_order.home_delivery and delivery_product_id">
<button type="button" class="btn btn-outline-warning cart-btn-compact" id="home-delivery-btn" t-att-aria-label="labels.get('toggle_home_delivery', 'Toggle home delivery')" t-att-data-bs-title="labels.get('home_delivery', 'Home Delivery')" data-bs-toggle="tooltip">
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
</button>
</t>
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to checkout" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to Checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
</a>
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
<i class="fa fa-trash cart-icon-size" aria-hidden="true" />
</button>
</div>
</div>
<div class="card-body cart-body-lg" id="cart-items-container" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-build-version="{{ module_version }}" aria-labelledby="cart-title" aria-live="polite" aria-relevant="additions removals">
<div class="card-body eskaera-cart-items" id="cart-items-container" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-build-version="{{ module_version }}" aria-labelledby="cart-title" aria-live="polite" aria-relevant="additions removals">
<p class="text-muted">This order's cart is empty</p>
</div>
<div class="card-footer bg-white d-flex justify-content-between align-items-center gap-2">
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
<i class="fa fa-trash me-1" aria-hidden="true" />Clear Cart
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg" title="Proceed to Checkout" data-bs-toggle="tooltip">
Proceed to Checkout
</a>
</div>
@ -361,14 +364,14 @@
<t t-if="lazy_loading_enabled and has_next">
<div id="infinite-scroll-container" class="row mt-4">
<div class="col-12 text-center">
<div id="loading-spinner" class="d-none" style="padding: 20px;">
<div id="loading-spinner" class="d-none p-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading more products...</p>
</div>
<button id="load-more-btn" class="btn btn-primary btn-lg d-none" t-attf-data-page="{{ current_page + 1 }}" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-search="{{ search_query }}" t-attf-data-category="{{ selected_category }}" t-attf-data-per-page="{{ per_page }}" aria-label="Load more products" style="display: none;">
<i class="fa fa-download me-2" />Load More Products
<button id="load-more-btn" class="btn btn-primary btn-lg d-none" t-attf-data-page="{{ current_page + 1 }}" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-search="{{ search_query }}" t-attf-data-category="{{ selected_category }}" t-attf-data-per-page="{{ per_page }}" aria-label="Load more products">
<i class="fa fa-download me-2" aria-hidden="true" />Load More Products
</button>
</div>
</div>
@ -396,14 +399,13 @@
var successCount = 0;
tooltipElements.forEach(function(element) {
// Static labels now live in the title attribute, which QWeb
// translates; data-bs-title (which it does not translate) is
// only left where the text comes from the i18n endpoint.
var title = element.getAttribute('data-bs-title');
if (title) {
// Set native title attribute for browser-native tooltip
element.setAttribute('title', title);
successCount++;
console.log('[TOOLTIP] ✅ Set title for', element.id || element.className, ':', title);
} else {
console.warn('[TOOLTIP] ⚠️ No data-bs-title found for element:', element.id || element.className);
}
});
console.log('[TOOLTIP] Tooltip initialization complete:', successCount, 'elements updated');
@ -421,20 +423,23 @@
</t>
</template>
<template id="eskaera_checkout_summary" name="Checkout Order Summary">
<div class="checkout-summary-container">
<!-- The table keeps its width and scrolls inside this container; tabindex
makes that scroll reachable from the keyboard (WCAG 2.1.1). -->
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
<table class="table table-hover checkout-summary-table" id="checkout-summary-table">
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
<thead class="table-dark">
<tr>
<th class="col-name">Product</th>
<th class="col-qty text-center">Quantity</th>
<th class="col-price text-right">Price</th>
<th class="col-subtotal text-right">Subtotal</th>
<th scope="col" class="col-name">Product</th>
<th scope="col" class="col-qty text-center">Quantity</th>
<th scope="col" class="col-price text-end">Price</th>
<th scope="col" class="col-subtotal text-end">Subtotal</th>
</tr>
</thead>
<tbody id="checkout-summary-tbody">
<tr id="checkout-empty-row" class="empty-message">
<td colspan="4" class="text-center text-muted py-4">
<i class="fa fa-inbox fa-2x mb-2" />
<i class="fa fa-inbox fa-2x mb-2" aria-hidden="true" />
<p>This order's cart is empty</p>
</td>
</tr>
@ -464,55 +469,60 @@
</div>
<div class="order-info-card card border-0 shadow-sm mb-4">
<div class="card-body">
<!-- Name/value pairs, same treatment as the shop header:
a <label> without a form control to point at labels
nothing, while <dt>/<dd> ties value to name. -->
<div class="row mb-4">
<div class="col-md-4">
<div class="info-item">
<label t-att-class="'info-label'">Cutoff Day</label>
<dl class="info-pair">
<dt class="info-label">Cutoff Day</dt>
<t t-if="group_order.cutoff_day and group_order.cutoff_date">
<span class="info-value">
<dd class="info-value">
<t t-esc="day_names[int(group_order.cutoff_day) % 7]" />
<span class="info-date">(<t t-esc="group_order.cutoff_date.strftime('%d/%m/%Y')" />)</span>
</span>
</dd>
</t>
<t t-else="">
<span t-att-class="'text-muted small'">Not configured</span>
<dd class="info-value text-muted small">Not configured</dd>
</t>
</div>
</dl>
</div>
<div class="col-md-4">
<div class="info-item">
<t t-if="group_order.pickup_slot_ids">
<label t-att-class="'info-label'">Store Pickup Slots</label>
<span class="info-value">
<t t-if="group_order.pickup_slot_ids">
<dl class="info-pair">
<dt class="info-label">Store Pickup Slots</dt>
<dd class="info-value">
<t t-foreach="group_order.pickup_slot_ids" t-as="slot">
<div>
<div class="info-value-line">
<t t-esc="day_names[int(slot.weekday) % 7]" />
&#160;
<t t-esc="('%02d:%02d%02d:%02d' % (int(slot.start_hour or 0), int(((slot.start_hour or 0) % 1) * 60), int(slot.end_hour or 0), int(((slot.end_hour or 0) % 1) * 60)))" />
<t t-if="slot.label">&#160;(<t t-esc="slot.label" />)</t>
</div>
</t>
</span>
</t>
<t t-elif="group_order.pickup_day and group_order.pickup_date">
<label t-att-class="'info-label'">Store Pickup Day</label>
<span class="info-value" t-attf-data-pickup-date="{{ group_order.pickup_date }}">
</dd>
</dl>
</t>
<t t-elif="group_order.pickup_day and group_order.pickup_date">
<dl class="info-pair">
<dt class="info-label">Store Pickup Day</dt>
<dd class="info-value" t-attf-data-pickup-date="{{ group_order.pickup_date }}">
<t t-esc="day_names[int(group_order.pickup_day) % 7]" />
<span class="info-date">(<t t-esc="group_order.pickup_date.strftime('%d/%m/%Y')" />)</span>
</span>
</t>
</div>
</dd>
</dl>
</t>
</div>
<div class="col-md-4">
<div class="info-item">
<t t-if="group_order.delivery_date and group_order.home_delivery">
<label t-att-class="'info-label'">Home Delivery Day</label>
<span class="info-value">
<t t-if="group_order.delivery_date and group_order.home_delivery">
<dl class="info-pair">
<dt class="info-label">Home Delivery Day</dt>
<dd class="info-value">
<t t-esc="day_names[group_order.delivery_date.weekday()]" />
<span class="info-date">(<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />)</span>
</span>
</t>
</div>
</dd>
</dl>
</t>
</div>
</div>
<hr class="my-2" />
@ -529,11 +539,11 @@
<t t-if="group_order.home_delivery">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="form-check">
<div class="form-check eskaera-delivery-check">
<input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" />
<label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
</div>
<div id="delivery-info-alert" class="alert alert-info mt-3 d-none">
<div id="delivery-info-alert" class="alert alert-info mt-3 d-none eskaera-delivery-notice">
<p class="mb-2">
<i class="fa fa-truck" aria-hidden="true" t-translation="off" />
<t t-if="group_order.delivery_date">
@ -555,7 +565,7 @@
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
<span t-esc="labels.get('save_draft', 'Save Draft')" />
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" data-bs-title="Back to Cart" data-bs-toggle="tooltip">
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
@ -635,7 +645,7 @@
</t>
<t t-else="">
<div class="card-img-top bg-light d-flex align-items-center justify-content-center product-img-placeholder">
<i class="fa fa-image fa-3x text-muted" />
<i class="fa fa-image fa-3x text-muted" aria-hidden="true" />
</div>
</t>
<t t-if="product.dynamic_ribbon_id">
@ -649,10 +659,12 @@
<div class="card-body d-flex flex-column">
<h6 class="card-title" t-esc="product.name" />
<t t-if="product.product_tag_ids">
<div class="product-tags mb-2">
<div class="product-tags">
<t t-foreach="filtered_product_tags.get(product.id, {}).get('published_tags', product.product_tag_ids)" t-as="tag">
<t t-if="tag.color">
<span class="badge badge-km" t-attf-style="background-color: {{ tag.color }} !important; border-color: {{ tag.color }} !important; color: #ffffff !important;" t-esc="tag.name" />
<!-- Per-record colour: passed as a custom property so the
stylesheet keeps owning the badge, no !important needed. -->
<span class="badge badge-km" t-attf-style="--ac-tag-color: {{ tag.color }};" t-esc="tag.name" />
</t>
<t t-else="">
<span class="badge badge-km tag-use-theme-color" t-esc="tag.name" />
@ -661,14 +673,14 @@
</div>
</t>
<t t-if="product_supplier_info.get(product.id)">
<p class="product-supplier mb-2">
<p class="product-supplier">
<small>
<t t-esc="product_supplier_info[product.id]" />
</small>
</p>
</t>
<t t-if="product.origin_text">
<p class="product-origin mb-2">
<p class="product-origin">
<small>
<i class="fa fa-map-marker" aria-hidden="true" />
<t t-out="product.origin_text" />
@ -695,7 +707,7 @@
</h6>
<t t-set="tax_incl_base_unit_price" t-value="product_display_info.get(product.id, {}).get('base_unit_price', 0.0)" />
<t t-if="tax_incl_base_unit_price and product.base_unit_name">
<p class="product-unit-price text-muted" style="font-size: 0.85rem; margin-top: 0.25rem; margin-bottom: 0;">
<p class="product-unit-price text-muted">
<t t-esc="'%.2f' % tax_incl_base_unit_price" /> € / <t t-esc="product.base_unit_name" />
</p>
</t>
@ -716,19 +728,24 @@
t-att-data-out-of-stock="'true' if product.is_out_of_stock else 'false'"
>
<div class="qty-control">
<label t-attf-for="qty_{{ product.id }}" class="sr-only">Quantity of <t t-esc="product.name" />
<label t-attf-for="qty_{{ product.id }}" class="visually-hidden">Quantity of <t t-esc="product.name" />
</label>
<button class="qty-decrease" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Decrease quantity">
<i class="fa fa-minus" />
<i class="fa fa-minus" aria-hidden="true" />
</button>
<input type="number" t-attf-id="qty_{{ product.id }}" class="product-qty" name="quantity" t-attf-value="1" t-attf-min="{{ quantity_step }}" t-attf-step="{{ quantity_step }}" t-att-max="product_max_qty.get(product.id) if product_max_qty else None" t-att-data-max-qty="product_max_qty.get(product.id) if product_max_qty else None" />
<button class="qty-increase" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Increase quantity">
<i class="fa fa-plus" />
<i class="fa fa-plus" aria-hidden="true" />
</button>
<!-- Out of stock stays focusable and clickable on purpose: the handler
explains why instead of the button silently doing nothing.
aria-disabled announces the state without removing it from the
tab order (a real `disabled` would suppress the click event). -->
<button
class="add-to-cart-btn"
type="button"
t-att-data-out-of-stock="'true' if product.is_out_of_stock else 'false'"
t-att-aria-disabled="'true' if product.is_out_of_stock else None"
t-att-aria-label="out_of_stock_label if product.is_out_of_stock else add_to_cart_label"
t-att-title="out_of_stock_label if product.is_out_of_stock else add_to_cart_label"
>