[IMP] website_sale_aplicoop: compact mobile layout for the Eskaera shop

Reorder cart/tags/category/search/products into one flex row with
Bootstrap order utilities, so mobile shows cart, tags, category, search,
then products, while desktop keeps its current layout. Product cards
become a two-column grid of compact tiles from the smallest phones, with
a 4:3 photo instead of a tall rectangle. Origin, supplier and tags move
behind a per-product "i" toggle instead of always reserving their own
row, which also drops the now-unused any_product_has_tags plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-27 15:24:34 +02:00
parent ef1283be7c
commit 6a59d9f6ad
6 changed files with 217 additions and 152 deletions

View file

@ -890,13 +890,6 @@ class AplicoopWebsiteSale(WebsiteSale):
product_display_info,
filtered_products_dict,
) = self._prepare_products_maps(products, pricelist)
# Whether the tags row needs to be reserved on every card: if nothing in
# this batch has a published tag, the row is skipped entirely instead of
# leaving an empty aria-hidden gap on every product (see product-card.css).
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
# Inject draft sale demand context so ribbons/is_out_of_stock use forecasted net qty
products, product_max_qty = self._prepare_draft_stock_data(products)
@ -941,7 +934,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"placed_order_url": placed_order_url,
"products": products,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"cart": cart,
"available_categories": available_categories,
"category_hierarchy": category_hierarchy,
@ -1053,11 +1045,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"product": product,
"published_tags": published_tags,
}
# See eskaera_shop for why this decides whether to reserve the tags row.
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
product_display_info = {}
for product in products_page:
product_display_info[product.id] = self._prepare_product_display_info(
@ -1075,7 +1062,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,
@ -1185,11 +1171,6 @@ class AplicoopWebsiteSale(WebsiteSale):
}
for product in products_page
}
# See eskaera_shop for why this decides whether to reserve the tags row.
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
# Inject draft demand context for ribbons/stock flags
products_page, product_max_qty = self._prepare_draft_stock_data(products_page)
@ -1203,7 +1184,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,

View file

@ -152,7 +152,6 @@
/* ========== PRODUCT CARD MEDIA ========== */
--ac-card-media-ratio: 4 / 3;
--ac-card-media-height: clamp(5rem, 4rem + 6vw, 8.5rem);
/* ========== Z-INDEX ========== */
--ac-z-dropdown: 1000;

View file

@ -6,13 +6,23 @@
* Markup (eskaera_shop_products):
* .product-card-wrapper.product-card
* .card
* img.product-img-cover | .product-img-placeholder
* .card-body (title, tags, supplier, origin, price)
* .product-media
* img.product-img-cover | .product-img-placeholder
* button.product-info-toggle (only when the product has a
* tag, a supplier or an origin to show)
* .card-body (title, .product-info-panel, price)
* form.add-to-cart-form components/quantity-control.css
*
* The wrapper carries the chrome (border, radius, elevation); the inner
* Bootstrap .card is neutralised through its own custom properties so the two
* boxes stop drawing a double frame.
*
* Tags/supplier/origin used to sit unconditionally in the card body so every
* card's price row lined up regardless of which fields a product had. The
* compact grid has no room for that: they now live in .product-info-panel,
* collapsed by default and revealed by .product-info-toggle (see
* website_sale.js for the click handler, delegated on #products-grid same as
* the quantity stepper).
*/
.product-card {
@ -41,14 +51,23 @@
/* ---------- Media ---------- */
.product-media {
position: relative;
flex-shrink: 0;
}
.product-card .product-image,
.product-img-cover,
.product-img-fixed,
.product-img-placeholder {
display: block;
flex-shrink: 0;
width: 100%;
height: var(--ac-card-media-height);
/* 4:3, not square: a square photo ate too much vertical space on a
two-up mobile grid. aspect-ratio keeps it fluid across card widths
instead of a fixed height that over- or under-crops depending on how
many columns fit. */
aspect-ratio: var(--ac-card-media-ratio);
height: auto;
border-radius: var(--ac-radius-md) var(--ac-radius-md) 0 0;
object-fit: cover;
background-color: var(--ac-surface-muted);
@ -61,6 +80,68 @@
color: var(--ac-text-muted);
}
/* ---------- Info toggle & panel ---------- */
.product-info-toggle {
position: absolute;
top: var(--ac-space-2xs);
right: var(--ac-space-2xs);
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
padding: 0;
border: 1px solid var(--ac-border-strong);
border-radius: var(--ac-radius-pill);
font-size: var(--ac-text-2xs);
color: var(--ac-text-secondary);
background-color: rgb(255 255 255 / 90%);
cursor: pointer;
transition: background-color var(--ac-transition-fast), color var(--ac-transition-fast);
}
.product-info-toggle[aria-expanded="true"] {
color: var(--ac-text-on-fill);
background-color: var(--ac-color-primary-strong);
border-color: var(--ac-color-primary-strong);
}
.product-info-panel {
display: flex;
flex-direction: column;
gap: var(--ac-space-3xs);
margin-bottom: var(--ac-space-3xs);
padding: var(--ac-space-2xs);
border: 1px solid var(--ac-border);
border-radius: var(--ac-radius-sm);
background-color: var(--ac-surface-sunken);
}
.product-info-panel[hidden] {
display: none;
}
@media (hover: hover) and (pointer: fine) {
.product-info-toggle:hover {
color: var(--ac-text-on-fill);
background-color: var(--ac-color-primary);
border-color: var(--ac-color-primary);
}
}
.product-info-toggle:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
@media (pointer: coarse) {
.product-info-toggle {
width: var(--ac-touch-target);
height: var(--ac-touch-target);
}
}
/* ---------- Body ---------- */
.product-card .card-body {
@ -107,10 +188,6 @@
align-content: center;
gap: var(--ac-space-3xs);
justify-content: center;
/* Reserved even when a product has no tags (rendered empty, aria-hidden
see eskaera_shop_products), so the supplier/origin/price rows below line
up across every card in the grid row. Matches one row of .badge-km. */
min-height: calc(var(--ac-text-2xs) * var(--ac-leading-tight) + 2 * var(--ac-space-3xs) + 2px);
margin: 0;
padding: 0;
}
@ -131,14 +208,12 @@
/* ---------- Supplier & origin ---------- */
/* Both rows are reserved even when empty (rendered aria-hidden see
eskaera_shop_products) and clamped to a fixed line count, so the price row
below lines up across every card in the grid row regardless of which
products actually have a supplier name or an origin. */
/* Only ever rendered inside .product-info-panel now (see
eskaera_shop_products), so there is no longer a cross-card alignment to
protect no reserved space, no forced line clamp. */
.product-card .product-supplier,
.product-card .product-origin {
margin: 0;
overflow: hidden;
font-size: var(--ac-text-xs);
font-weight: var(--ac-weight-normal);
text-align: center;
@ -146,17 +221,11 @@
}
.product-card .product-supplier {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
min-height: calc(2em * var(--ac-leading-tight));
overflow-wrap: break-word;
}
.product-card .product-origin {
min-height: calc(1em * var(--ac-leading-tight));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -5,17 +5,18 @@
*
* The grid used to pin an exact column count at seven breakpoints (1 6). It
* now derives the count from the space available, which keeps the card width
* stable no matter how wide the sidebar or the container is. The single query
* left is the deliberate design decision: one card per row on phones, where a
* two-up grid would shrink names and controls below comfortable size.
* stable no matter how wide the sidebar or the container is.
*
* 9rem is the card's minimum width; at the widest container (1320px minus the
* category sidebar) it still resolves to six columns, as before.
* Two columns from the smallest phones: with the compact "cromo" card (4:3
* photo, no inline tags/supplier/origin see product-card.css) a single
* full-width column read as a tall row rather than a browsable grid. 9rem is
* the card's minimum width from 576px up; at the widest container (1320px
* minus the category sidebar) it still resolves to six columns, as before.
*/
.products-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
gap: var(--ac-space-md);
margin-bottom: var(--ac-space-xl);
}

View file

@ -1246,6 +1246,23 @@
}
});
// Product info toggle: shows/hides the origin/supplier/tags panel
// that the compact card no longer displays inline (via event
// delegation, same reasoning as the stepper above).
productsGrid.addEventListener("click", function (e) {
var infoBtn = e.target.closest(".product-info-toggle");
if (!infoBtn) return;
e.preventDefault();
var panelId = infoBtn.getAttribute("aria-controls");
var panel = panelId ? document.getElementById(panelId) : null;
if (!panel) return;
var isOpen = infoBtn.getAttribute("aria-expanded") === "true";
infoBtn.setAttribute("aria-expanded", isOpen ? "false" : "true");
panel.hidden = isOpen;
});
// Add to cart button (via event delegation on grid)
productsGrid.addEventListener("click", handleAddToCart);

View file

@ -274,56 +274,15 @@
</div>
</div>
</div>
<div class="mb-3 eskaera-filters" id="realtimeSearch-filters">
<div class="row g-2">
<div class="col-md-7">
<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">
<option value="">Browse Product Categories</option>
<t t-call="website_sale_aplicoop.category_hierarchy_options">
<t t-set="categories" t-value="category_hierarchy" />
<t t-set="depth" t-value="0" />
</t>
</select>
</div>
</div>
<t t-if="available_tags">
<div class="row mt-3">
<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="--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" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
</t>
</div>
</div>
</div>
</t>
</div>
<div class="row g-2">
<div class="col-lg-3 mb-4 mb-lg-0">
<!-- One flex row for cart/tags/category/search/products: Bootstrap's
order utilities need every reordered item to be a sibling of the
same flex container, so the four blocks that used to live in two
separate rows (filters above, cart+products below) are now columns
here. Unprefixed order-* puts the cart first on mobile (right below
the header, above the filters); order-lg-* restores the desktop
layout: search+category as one row, then tags, then cart+products. -->
<div class="row g-2 eskaera-filters" id="realtimeSearch-filters">
<div class="col-12 col-lg-3 order-1 order-lg-4 mb-4 mb-lg-0">
<div class="card sticky-top cart-sticky-position" aria-label="Cart Summary">
<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>
@ -362,7 +321,49 @@
</div>
</div>
</div>
<div class="col-lg-9">
<t t-if="available_tags">
<div class="col-12 order-2 order-lg-3">
<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="--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" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
</t>
</div>
</div>
</t>
<div class="col-12 col-lg-5 order-3 order-lg-2">
<select name="category" id="realtime-category-select" class="form-select">
<option value="">Browse Product Categories</option>
<t t-call="website_sale_aplicoop.category_hierarchy_options">
<t t-set="categories" t-value="category_hierarchy" />
<t t-set="depth" t-value="0" />
</t>
</select>
</div>
<div class="col-12 col-lg-7 order-4 order-lg-1">
<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-12 col-lg-9 order-5 order-lg-5">
<div class="oe_structure oe_empty" data-name="Before Products Filter" />
<t t-if="products">
<div class="products-grid" id="products-grid">
@ -763,16 +764,31 @@
</template>
<template id="eskaera_shop_products" name="Eskaera Shop Products">
<t t-foreach="products" t-as="product">
<t t-set="has_tags" t-value="bool(product.product_tag_ids)" />
<t t-set="supplier_text" t-value="product_supplier_info.get(product.id)" />
<t t-set="has_origin" t-value="bool(product.origin_text)" />
<t t-set="has_info" t-value="has_tags or bool(supplier_text) or has_origin" />
<div class="product-card-wrapper product-card" t-attf-data-product-name="{{ product.name }}" t-attf-data-category-id="{{ product.categ_id.id if product.categ_id else '' }}" t-attf-data-product-tags="{{ ','.join(str(t.id) for t in product.product_tag_ids) if product.product_tag_ids else '' }}">
<div class="card h-100">
<t t-if="product.image_128">
<img t-attf-src="data:image/png;base64,{{ product.image_128.decode() }}" class="card-img-top product-img-cover" t-attf-alt="{{ product.name }}" />
</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" aria-hidden="true" />
</div>
</t>
<div class="product-media">
<t t-if="product.image_128">
<img t-attf-src="data:image/png;base64,{{ product.image_128.decode() }}" class="card-img-top product-img-cover" t-attf-alt="{{ product.name }}" />
</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" aria-hidden="true" />
</div>
</t>
<t t-if="has_info">
<!-- Origin/supplier/tags used to sit unconditionally in the card
body; on the compact "cromo" tile there is no room to show
them inline, so they move behind this toggle (see
.product-info-panel below). -->
<button type="button" class="product-info-toggle" aria-expanded="false" t-attf-aria-controls="product-info-{{ product.id }}" aria-label="Show origin, supplier and tags" title="Origin, supplier and tags">
<i class="fa fa-info" aria-hidden="true" t-translation="off" />
</button>
</t>
</div>
<t t-if="product.dynamic_ribbon_id">
<t t-set="ribbon" t-value="product.dynamic_ribbon_id" />
<span t-attf-class="o_ribbon {{ ribbon._get_position_class() }} z-1" t-attf-style="color: {{ ribbon.text_color }}; background-color: {{ ribbon.bg_color }};" t-esc="ribbon.name" />
@ -783,53 +799,36 @@
</t>
<div class="card-body d-flex flex-column">
<h6 class="card-title" t-esc="product.name" />
<!-- Tags/supplier/origin always occupy their slot (empty and
aria-hidden when the product has no value) so that the price
row below lines up across every card in the grid row,
regardless of which optional fields a given product has. The
tags row itself is only reserved when at least one product in
this batch actually has a tag (any_product_has_tags, computed
controller-side) — otherwise it would add a permanent empty
gap to every card for a feature nobody in the list uses. -->
<t t-if="any_product_has_tags">
<t t-if="product.product_tag_ids">
<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">
<!-- 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-if="has_info">
<div t-attf-id="product-info-{{ product.id }}" class="product-info-panel" hidden="hidden">
<t t-if="has_tags">
<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">
<!-- 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" />
</t>
</t>
<t t-else="">
<span class="badge badge-km tag-use-theme-color" t-esc="tag.name" />
</t>
</t>
</div>
</t>
<t t-else="">
<div class="product-tags" aria-hidden="true" />
</t>
</t>
<t t-if="product_supplier_info.get(product.id)">
<p class="product-supplier">
<small>
<t t-esc="product_supplier_info[product.id]" />
</small>
</p>
</t>
<t t-else="">
<p class="product-supplier" aria-hidden="true" />
</t>
<t t-if="product.origin_text">
<p class="product-origin">
<small>
<i class="fa fa-map-marker" aria-hidden="true" />
<t t-out="product.origin_text" />
</small>
</p>
</t>
<t t-else="">
<p class="product-origin" aria-hidden="true" />
</div>
</t>
<t t-if="supplier_text">
<p class="product-supplier">
<small t-esc="supplier_text" />
</p>
</t>
<t t-if="has_origin">
<p class="product-origin">
<small>
<i class="fa fa-map-marker" aria-hidden="true" />
<t t-out="product.origin_text" />
</small>
</p>
</t>
</div>
</t>
<t t-set="price_info" t-value="product_price_info.get(product.id, {})" />
<t t-set="display_price" t-value="product_display_info.get(product.id, {}).get('display_price', 0.0)" />