Compare commits

...

2 commits

Author SHA1 Message Date
GitHub Copilot
5de7573fba [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>
2026-08-16 11:34:38 +02:00
GitHub Copilot
162ba0b570 [DOC] skills: update odoo-javascript skill documentation 2026-08-15 13:00:56 +02:00
40 changed files with 3054 additions and 2412 deletions

View file

@ -10,7 +10,7 @@ metadata:
## Style and conventions
- ES6+ (`let`/`const`, arrow functions). Linting with ESLint (`.eslintrc.js` at root).
- Odoo pattern: AMD modules (`odoo.define`).
- **No business logic in JS, only UI and events.** Business logic lives in Python.
- **No business logic in JS (pricing rules, stock/availability decisions, permission checks)** — these belong in Python. JS handles only rendering, DOM events, and calling Python endpoints via `ajax.jsonRpc`.
- Comments to explain hacks or workarounds.
## Key patterns

View file

@ -0,0 +1,182 @@
---
name: odoo-web-design
description: Professional finish for the Odoo frontend of this repo — visual polish, responsive layout and WCAG accessibility across CSS, QWeb/HTML and JS. Use when styling or reviewing website/portal pages (website_sale_aplicoop), adding UI components, or fixing responsive/a11y/design-consistency issues.
metadata:
type: reference
---
# Web design & finish (Odoo 18 frontend)
Covers the three layers together: `static/src/css/`, `static/src/js/`, and the QWeb templates in
`views/*.xml`. For language-level conventions see `odoo-qweb-html`, `odoo-javascript`,
`odoo-xml-views`; this skill is about the **finish**: does it look designed, does it work at 320 px,
does it work without a mouse.
## Inherited non-negotiables
- No logic in QWeb — prepare everything in the controller.
- No business logic in JS — UI and events only.
- **No inline `style="..."`** in templates. A style needs a class, and the class needs a token.
- Every user-visible string is translatable (see §4).
## 1. Work order
Never start by editing a component. Always:
1. **Audit** with the commands in §7. Write down what is inconsistent before touching anything.
2. **Tokens first** (`base/variables.css`). Every value a component needs must already exist as a token.
3. **Component by component**, one file each, media queries co-located at the bottom of that file.
4. **Accessibility pass** per template, top to bottom.
5. **Verify** — lint, contrast, keyboard-only, 320 px, 200 % zoom.
Doing 3 before 2 is how a codebase ends up with 61 `!important` and three shades of the same blue.
## 2. Design tokens
Odoo 18 ships Bootstrap 5.3 with `$variable-prefix: ''`
(`/media/snt/data/odoo/ocb/addons/web/static/src/scss/bootstrap_overridden.scss:51`), so Bootstrap's
root custom properties are emitted **unprefixed** into `web.assets_frontend` (via
`import_bootstrap.scss``@import "root"`). Available in plain CSS, no SCSS needed:
```
--primary --secondary --success --danger --warning --info --light --dark
--body-color --body-bg --emphasis-color --secondary-bg --tertiary-bg --border-color
--border-radius --border-radius-sm --border-radius-lg --border-radius-xl --border-radius-pill
--link-color --link-hover-color --focus-ring-color --focus-ring-width
--box-shadow --box-shadow-sm --box-shadow-lg --font-sans-serif --gray-100 … --gray-900
```
Rules:
- **Alias, don't copy.** `--ac-color-primary: var(--primary, #007bff);` keeps the website theme
editor in control of the palette. A hardcoded brand hex breaks theming silently.
- **Hex literals live only in `base/variables.css`.** A hex anywhere else is a defect.
- **Prefix new tokens** (`--ac-…`, aplicoop). Bare names like `--primary-color` sit in Bootstrap's
unprefixed namespace and will collide as Odoo adds tokens.
- **Never redefine a token inside a media query** to shrink it — use `clamp()` so it scales
continuously instead of jumping at one width.
- Space and radius come from the scale, never from a typed-in number. If you need a value the scale
doesn't have, the scale is wrong — fix the scale.
## 3. Responsive
- **Mobile-first, `min-width` only.** Bootstrap 5 breakpoints exclusively: **576 / 768 / 992 / 1200 /
1400**. No 480/720/1600 one-offs — a private breakpoint is a design decision nobody else knows about.
- **One range per rule.** Pairing `min-width` and `max-width` to carve a band is almost always a
symptom of desktop-first thinking leaking in.
- **Media queries belong with their component**, at the bottom of its file. A central
`responsive.css` drifts out of sync with the components it overrides and forces `!important`.
Don't add to it; empty it as you touch each component.
If a rule in `responsive.css` spans multiple components or has no clear owner, move it to the
relevant layout partial (e.g. `layout/grid.css`) rather than duplicating it per component; note
any such moves in the audit output.
- **Prefer intrinsic layout to breakpoints**: `repeat(auto-fill, minmax(min(100%, 16rem), 1fr))`,
`clamp()`, `flex-wrap`. Most grids need zero media queries.
- `rem` for everything; `px` only for hairlines, borders and shadow offsets.
- Touch targets: **24×24 px is the AA floor** (WCAG 2.2 §2.5.8); **44×44 px is §2.5.5, level AAA**,
and the size a thumb actually needs — apply it under `@media (pointer: coarse)`. Quantity steppers
and icon buttons are the usual offenders.
- Must survive **320 px wide** and **200 % zoom** with no horizontal scroll.
Details and recipes: `references/responsive.md`.
## 4. Accessibility — WCAG 2.2 AA baseline
- **Semantics before ARIA**: `<button>`, `<nav>`, `<ul>`, `<label for>`. ARIA only when no element
expresses the meaning. A `<div onclick>` is never acceptable.
- **Contrast**: 4.5:1 body text, 3:1 for ≥24 px (or bold ≥19 px), 3:1 for form-control borders,
icon-only buttons and focus rings.
- **Focus**: never `outline: none` without a replacement. Style `:focus-visible`, not `:focus` — the
latter also fires on mouse click and gets removed out of frustration.
- Every input has a real `<label>`. A placeholder is not a label.
- Icon-only control → translatable `aria-label`, or `.visually-hidden` text (Bootstrap ships the class).
- Dynamic updates (cart total, quantity, result count) → `aria-live="polite"` region.
- Images: meaningful `alt`, or `alt=""` when decorative. Never omit the attribute.
- Status is never conveyed by colour alone — pair it with text or an icon.
- Every animation you add needs a `@media (prefers-reduced-motion: reduce)` escape.
**Translatable QWeb attributes** (auto-extracted, `odoo/tools/translate.py:163`), plus their
`t-attf-` forms:
```
string help placeholder alt title label data-tooltip confirm
aria-label aria-keyshortcuts aria-placeholder aria-roledescription aria-valuetext
```
`aria-description` is **not** translated — use `aria-label` or visually-hidden text instead.
Strings injected from JS go through `window.i18nManager.get(key)` (served by `/eskaera/i18n`), with
the key added server-side — never a hardcoded literal, including in live-region announcements.
Per-component patterns: `references/accessibility.md`.
## 5. JS for finish (UI only)
- **Delegated events** — content is lazy-loaded and infinite-scrolled, direct listeners die.
- After injecting nodes: move or restore focus deliberately, announce the new count in the live
region, and set `aria-busy` on the container while fetching.
- Check `matchMedia("(prefers-reduced-motion: reduce)")` before animating or smooth-scrolling.
- Toggle **classes**, don't write `element.style.*` — inline styles from JS are the same defect as
inline styles in templates, just later.
- Debounce search input ≥ 200 ms and announce "N results" politely.
- Batch DOM reads and writes; never read `offsetHeight` inside a loop that also writes.
## 6. Asset delivery
`website_sale.css` pulls its 17 partials with `@import`. Odoo rewrites those to absolute URLs and
hoists them to the top of the bundle (`odoo/addons/base/models/assetsbundle.py:909` and `:525`), so
they ship as ~17 extra render-blocking requests **outside** the minified bundle. For a production
finish, prefer listing each file in the manifest's `web.assets_frontend` (order: variables →
utilities → layout → components → sections). Only rename them to `.scss` if the maintainer
explicitly requests SCSS compilation so Odoo inlines the imports at compile time.
## 7. Audit & verify
If `/media/snt/data/addons-cm/website_sale_aplicoop` does not exist, run the audit commands from the
repository root of the current workspace instead, and report which path was used.
```bash
cd /media/snt/data/addons-cm/website_sale_aplicoop
# Breakpoint sprawl — should only ever list 576/768/992/1200/1400, all min-width
grep -rho "@media[^{]*" static/src/css | sort | uniq -c | sort -rn
# Hardcoded colours outside the token file (expect: none)
grep -rn "#[0-9a-fA-F]\{3,8\}\b" static/src/css --include="*.css" | grep -v "base/variables.css"
# Specificity debt
grep -rc "!important" static/src/css/*/*.css | grep -v ':0'
# Focus handling: :focus-visible should dominate, outline:none must be paired with a replacement
grep -rn ":focus-visible\|outline: *none\|outline: *0" static/src/css
# Motion safety (expect at least one per animating component)
grep -rn "prefers-reduced-motion" static/src/css
# Inline styles in templates (expect: none)
grep -rn 'style="' views/*.xml
# Images without alt
grep -o "<img[^>]*" views/*.xml | grep -v "alt="
# Interactive divs/spans (expect: none)
grep -rn "<div[^>]*t-on-click\|<span[^>]*t-on-click\|<div[^>]*onclick" views/*.xml
```
Then, always:
```bash
pre-commit run --all-files # prettier (width 100, indent 4) over css/js/xml + eslint
docker-compose up -d # http://localhost:8070 — check at 320/768/1280 and 200% zoom
```
Manual passes that no grep replaces: **Tab through the whole page** (visible focus everywhere, order
matches visual order, no trap), and check contrast on the actual rendered colours.
## 8. References
- `references/accessibility.md` — WCAG AA patterns as QWeb snippets, per component.
- `references/responsive.md` — breakpoints, intrinsic layout, images, test matrix.
- `references/css-architecture.md` — file layout, naming, specificity, killing `!important`.
- `references/finish-checklist.md` — the polish pass: states, rhythm, empty/loading/error.
Repo docs: `docs/QWEB_BEST_PRACTICES.md`, `docs/LAZY_LOADING.md`.

View file

@ -0,0 +1,245 @@
# Accessibility patterns (QWeb + CSS + JS)
WCAG 2.2 AA. Snippets use this repo's conventions: 4-space indent, Bootstrap 5.3 utilities,
translatable attributes, `window.i18nManager.get(key)` for JS strings.
## Page skeleton
```xml
<t t-call="website.layout">
<a href="#eskaera-main" class="visually-hidden-focusable btn btn-primary m-2">
Skip to main content
</a>
<div id="wrap" class="eskaera-shop-page">
<main id="eskaera-main" tabindex="-1">
<h1 class="eskaera-page-title" t-esc="page_title"/>
...
</main>
</div>
</t>
```
- Exactly one `<h1>` per page; never skip heading levels to get a font size — that's what classes are for.
- `<main>`, `<nav aria-label="...">`, `<aside>` instead of anonymous `<div>`s.
- `tabindex="-1"` on the skip-link target so focus actually lands there.
## Buttons vs links
| Intent | Element |
| --- | --- |
| Navigates to a URL | `<a href="...">` |
| Changes state on this page | `<button type="button">` |
| Submits a form | `<button type="submit">` |
Never `<div t-on-click>` / `<span onclick>` — no keyboard, no role, no focus.
Icon-only:
```xml
<button type="button" class="btn btn-outline-secondary qty-decrease"
aria-label="Decrease quantity">
<i class="fa fa-minus" aria-hidden="true"/>
</button>
```
`aria-label` is auto-translated; `aria-hidden` on the icon stops the screen reader announcing
"minus icon" twice. Alternative when the label is long: visible `<span class="visually-hidden">`.
## Quantity stepper (live-announced)
```xml
<div class="qty-control" role="group" aria-label="Quantity">
<button type="button" class="btn qty-decrease" aria-label="Decrease quantity">
<i class="fa fa-minus" aria-hidden="true"/>
</button>
<input type="number" class="form-control qty-input"
t-att-value="line.qty" min="0" step="1"
aria-label="Quantity" inputmode="numeric"/>
<button type="button" class="btn qty-increase" aria-label="Increase quantity">
<i class="fa fa-plus" aria-hidden="true"/>
</button>
</div>
<p class="visually-hidden" aria-live="polite" id="qty_status"/>
```
```js
// after the server confirms the new qty
const status = document.getElementById("qty_status");
status.textContent = window.i18nManager.get("qty_updated_announcement");
```
Buttons must stay ≥ 24×24 px (WCAG 2.2 AA §2.5.8) everywhere, and ≥ 44×44 px under
`@media (pointer: coarse)` — including at 320 px width.
## Forms and errors
```xml
<div class="mb-3">
<label class="form-label" for="delivery_note">Delivery note</label>
<textarea id="delivery_note" name="delivery_note" class="form-control"
t-att-aria-invalid="'true' if errors.get('delivery_note') else None"
t-att-aria-describedby="'delivery_note_help delivery_note_error'
if errors.get('delivery_note')
else 'delivery_note_help'"/>
<small id="delivery_note_help" class="form-text">Optional, visible to the group manager.</small>
<p t-if="errors.get('delivery_note')" id="delivery_note_error"
class="invalid-feedback d-block" t-esc="errors['delivery_note']"/>
</div>
```
- The `for`/`id` pair is mandatory; wrapping alone is not enough for every AT.
- Error text is referenced by `aria-describedby`, and never colour-only — keep the message.
- The error summary at the top of a failed form should be focused on submit
(`container.focus()` on a `tabindex="-1"` heading).
- Required fields: `required` + a visible "(required)" or `*` with a legend explaining it.
## Product card — one link, no duplicates
```xml
<article class="product-card">
<img class="product-card-img" t-att-src="product['image_url']"
t-att-alt="product['name']" loading="lazy" width="400" height="400"/>
<h3 class="product-card-title">
<a t-att-href="product['url']" class="stretched-link" t-esc="product['name']"/>
</h3>
<p class="product-card-price">
<span class="visually-hidden">Price</span>
<span t-esc="product['price_formatted']"/>
</p>
</article>
```
- `stretched-link` makes the whole card clickable with a **single** link in the a11y tree — do not
also wrap the image in an `<a>`, that produces two identical stops.
- `width`/`height` reserve space (no layout shift); CSS `object-fit: cover` handles the ratio.
- Decorative product placeholder → `alt=""`.
## Filters as toggle buttons
```xml
<div class="tag-filter" role="group" aria-label="Filter by category">
<button t-foreach="categories" t-as="cat" t-key="cat['id']"
type="button" class="tag-filter-badge"
t-att-aria-pressed="'true' if cat['active'] else 'false'"
t-att-data-category-id="cat['id']">
<span t-esc="cat['name']"/>
</button>
</div>
```
JS flips `aria-pressed` together with the active class — the class alone announces nothing.
If the filters are a true multi-select set, `<fieldset><legend>` + checkboxes is better still.
## Search with results announcement
```xml
<label class="visually-hidden" for="product_search">Search products</label>
<input id="product_search" type="search" class="form-control"
placeholder="Search products…" autocomplete="off"
aria-describedby="search_results_count"/>
<p id="search_results_count" class="visually-hidden" aria-live="polite" aria-atomic="true"/>
```
Debounce ≥ 200 ms, then set the text once per settled query — announcing on every keystroke makes
the page unusable with a screen reader.
## Loading, infinite scroll, empty states
```xml
<div id="products_grid" class="products-grid" aria-busy="false"></div>
<p class="visually-hidden" aria-live="polite" id="load_status"/>
<button type="button" id="load_more" class="btn btn-outline-primary">Load more products</button>
```
- Set `aria-busy="true"` on the grid while fetching, back to `false` when done.
- Announce `"N more products loaded"` via `load_status`; don't move focus mid-scroll.
- **Always keep a real "Load more" button** alongside scroll-triggered loading — infinite scroll
alone is unreachable by keyboard and traps users away from the footer.
- Empty state is content, not a blank div: heading + explanation + the action that fixes it.
## Modals
Use Bootstrap's modal (focus trap, Esc, `aria-modal` come free) and give it a label:
```xml
<div class="modal fade" id="confirm_order_modal" tabindex="-1"
aria-labelledby="confirm_order_title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="confirm_order_title">Confirm your order</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
</div>
</div>
</div>
</div>
```
Return focus to the trigger on close (Bootstrap does this if the trigger is a real button).
## Order tables
```xml
<table class="table checkout-summary-table">
<caption class="visually-hidden">Order lines</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col" class="text-end">Quantity</th>
<th scope="col" class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="lines" t-as="line" t-key="line['id']">
<th scope="row" t-esc="line['name']"/>
<td class="text-end" t-esc="line['qty']"/>
<td class="text-end" t-esc="line['subtotal_formatted']"/>
</tr>
</tbody>
</table>
```
Never use a table for layout, and never drop `<thead>`/`scope` when stacking to cards on mobile —
restyle, don't restructure.
## Focus and motion in CSS
```css
/* One ring, everywhere, on keyboard focus only. */
.eskaera-page :focus-visible {
outline: 2px solid var(--ac-color-focus, var(--focus-ring-color, #0d6efd));
outline-offset: 2px;
border-radius: var(--ac-radius-sm);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
This is the one legitimate `!important` in the codebase — a user preference must beat every component.
## Multilingual
The addon runs es / eu / ca. `website.layout` sets `<html lang>`; when a string stays in another
language inside a translated page (a supplier name, a product origin), mark it: `<span lang="eu">`.
Never build a sentence by concatenating translated fragments — pass the whole string with `%s`
placeholders so translators can reorder.
## Verification
1. **Keyboard only**: Tab through the page. Visible focus at every stop, order matches the visual
order, no trap, every action reachable, Esc closes overlays.
2. **Zoom 200 %** and **320 px width**: no horizontal scroll, nothing clipped.
3. **Contrast**: check rendered colours, including disabled states and placeholder text.
4. **Screen reader** smoke test on the cart flow (Orca on Linux, NVDA on Windows).
5. Lighthouse/axe in DevTools catches the mechanical half — it does not catch focus order,
announcement quality, or a label that lies.

View file

@ -0,0 +1,163 @@
# CSS architecture
## File layout
```
static/src/css/
├── website_sale.css # index only: @import list, no rules of its own
├── base/
│ ├── variables.css # :root tokens — the ONLY file with hex literals
│ └── utilities.css # tiny single-purpose helpers, when Bootstrap lacks one
├── layout/
│ ├── pages.css # page shells, containers, backgrounds
│ └── header.css
├── components/ # one file per reusable component
│ ├── product-card.css
│ ├── order-card.css
│ ├── cart.css
│ ├── buttons.css
│ ├── quantity-control.css
│ ├── forms.css
│ ├── alerts.css
│ └── tag-filter.css
└── sections/ # page-specific composition only
├── products-grid.css
├── order-list.css
├── checkout.css
└── info-cards.css
```
Rules of placement:
- A rule that could appear on two pages is a **component**, never a section.
- A **section** file may only position and space components — if it restyles a component's internals,
the component is missing a modifier.
- Each component file owns its own media queries, at the bottom. There is no global responsive file
in a healthy tree; a legacy `layout/responsive.css` should shrink to zero as components are touched.
- `website_sale.css` contains imports and comments only.
## Naming
Prefix everything with `eskaera-` (the product name already used for page-level classes). Unprefixed
names like `.product-card`, `.cart-header`, `.qty-control` sit in the same namespace as Bootstrap,
Odoo core and every other installed addon — that collision is what forces `!important` later.
```
.eskaera-product-card /* block */
.eskaera-product-card__title /* element */
.eskaera-product-card--out-of-stock /* modifier */
.is-loading .is-active .has-error /* state, always paired with a block class */
```
`js-` prefix for hooks JS queries, and never style a `js-` class — that way markup can be restyled
without breaking behaviour, and behaviour can move without breaking styles.
When renaming existing classes, do it component by component: template + CSS + JS selectors + tests
in the same commit, then grep for the old name across `views/`, `static/src/`, and `README*`.
## Specificity — how to stop writing `!important`
Overriding Bootstrap globally (`.list-group-item { … }`, `.alert-warning { … }`) is a fight you win
only with `!important`, and it leaks into every other addon. Scope the override under the page root
instead: equal specificity plus one class always wins, no escape hatch needed.
```css
/* ❌ global override, needs !important to beat Bootstrap's own later rules */
.list-group-item {
border-radius: 0 !important;
}
/* ✅ scoped: higher specificity, no !important, no leakage */
.eskaera-checkout-page .list-group-item {
border-radius: 0;
}
```
Other rules:
- Max **3** levels of nesting in a selector. Deeper means the markup is doing the work the class
should do.
- No ID selectors for styling; IDs are for anchors and `aria-*` references.
- No element selectors outside `base/` (`div.product-card` locks the markup).
- `!important` is allowed in exactly three places, each with a comment saying why: the
`prefers-reduced-motion` reset; a genuine **utility** that must beat whatever component it lands on
(`.hidden-product`, Bootstrap's own utility layer works this way); and a documented override of a
core rule you cannot scope. Anything else means the selector is wrong.
## Ordering inside a file
```css
.eskaera-product-card {
/* 1. layout */
display: flex;
flex-direction: column;
/* 2. box */
padding: var(--ac-space-md);
border: 1px solid var(--ac-border-light);
border-radius: var(--ac-radius-lg);
/* 3. typography */
font-size: var(--ac-text-base);
color: var(--ac-text-primary);
/* 4. paint & motion */
background: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
transition: box-shadow var(--ac-transition-fast);
}
```
Then, in order: `:hover` (inside `@media (hover: hover)`), `:focus-visible`, `:active`, `:disabled`,
state classes, modifiers, media queries.
## Tokens
Full rules in `SKILL.md` §2. In short:
```css
:root {
/* alias Odoo/Bootstrap's unprefixed custom properties so theming still works */
--ac-color-primary: var(--primary, #007bff);
--ac-color-danger: var(--danger, #dc3545);
--ac-surface: var(--body-bg, #fff);
--ac-text-primary: var(--body-color, #1a202c);
--ac-border-light: var(--border-color, #e2e8f0);
/* own scales */
--ac-space-xs: 0.25rem;
--ac-space-sm: 0.5rem;
--ac-space-md: 1rem;
--ac-space-lg: 1.5rem;
--ac-space-xl: 2rem;
--ac-radius-sm: 0.25rem;
--ac-radius-md: 0.5rem;
--ac-radius-lg: 0.75rem;
--ac-shadow-sm: 0 2px 8px rgb(0 0 0 / 8%);
--ac-shadow-md: 0 4px 12px rgb(0 0 0 / 10%);
--ac-transition-fast: 200ms ease;
}
```
Migrating a legacy token set: add the `--ac-` token aliased to the old name, move consumers file by
file, delete the old token when `grep -rn "\-\-old-name" static/src/` is empty. Don't rename all at once.
## Anti-patterns seen in Odoo addons
| Smell | Fix |
| --- | --- |
| `!important` to beat Bootstrap | scope under the page root class |
| Same colour as `#28a745` in six files | one token in `variables.css` |
| `responsive.css` overriding twelve components | move each query into its component |
| `style="margin-top:20px"` in QWeb | utility class or component spacing |
| `.card .body .row .col span` | give the span a class |
| `px` for font sizes | `rem`, so browser zoom and user font size work |
| Colour hardcoded instead of `var(--primary)` | breaks the website theme editor silently |
| Both `@import` chain **and** manifest entries | double download; pick one (see SKILL.md §6) |
## Formatting
Prettier handles `.css` via pre-commit: `printWidth: 100`, `tabWidth: 4`. Run
`pre-commit run --all-files` (or `make format`) before committing; never hand-align properties, it
will be reformatted.

View file

@ -0,0 +1,96 @@
# The finish pass
What separates "it works" from "it looks designed". Go through this per screen, not per file.
## 1. Every interactive element has all its states
For each button, link, card, input, filter chip:
| State | Must be | Common miss |
| --- | --- | --- |
| default | on the token scale | — |
| `:hover` | inside `@media (hover: hover)` | sticks after tap on touch |
| `:focus-visible` | visible ring, 3:1 contrast | removed via `outline: none` |
| `:active` | perceptible press | missing entirely |
| `:disabled` | dimmed **and** `cursor: not-allowed`, still 4.5:1 if it carries text | unreadable grey |
| loading | spinner + `aria-busy`, size unchanged | layout jumps |
The element must not resize between states — animate `box-shadow`, `background`, `transform`, never
`padding`, `border-width` or `font-size`.
## 2. Vertical rhythm and spacing
- All spacing from `--ac-space-*`. A `margin: 13px` anywhere is a defect.
- Space belongs to **one** side consistently (block-end margins, or `gap` — pick one per container).
Prefer `gap` in flex/grid; it doesn't collapse and doesn't need `:last-child` cleanups.
- Related things sit closer than unrelated things. If the label is as far from its input as from the
previous field, the form reads as noise.
- Cards in a grid must be equal height (`align-items: stretch` + the price pinned with `margin-top: auto`).
## 3. Typography
- One scale (`--ac-text-*`), max 3 weights, max 2 sizes per card.
- Headings are structural (`h1``h2``h3`); size comes from a class, never from picking `h4` because
it looks right.
- `max-width: 65ch` on paragraphs of real prose.
- Numbers in tables: `font-variant-numeric: tabular-nums` and right-aligned so columns line up.
- Long product names truncate predictably: `text-overflow: ellipsis` with `title` attribute, or
`-webkit-line-clamp: 2` with a fixed `min-height` so cards don't stagger.
## 4. Colour
- Semantic, not decorative: success/danger/warning always the same hue for the same meaning.
- Status never colour-only — icon or text alongside (colour blindness, and grayscale printing).
- Max ~2 accent colours per screen. Everything else is surface, border, text.
- Check the rendered contrast, including: placeholder text, disabled buttons, badges on coloured
backgrounds, white text on `--warning` (this one almost always fails).
## 5. The states people forget
Every list, grid and total needs four designs, not one:
1. **Loading** — skeletons matched to the real layout (not a centred spinner that collapses the page).
2. **Empty** — heading, one sentence explaining why it's empty, and the button that fixes it.
"No products found" alone is a dead end; offer "Clear filters".
3. **Error** — what failed, in the user's language, and what to do now. Never a raw traceback or code.
4. **Partial / stale** — an order past its cutoff, an out-of-stock line, a price that changed. Say it
inline, next to the affected row, not only in a toast that disappears.
## 6. Feedback and motion
- Any action taken by the user gets a response within 100 ms — disable the button, show the spinner,
optimistic-update the quantity. Silence reads as "broken", and users double-submit.
- Toasts: `aria-live="polite"`, dismissible, never the only place an error is shown.
- Motion is 150250 ms and eases out. Anything above 400 ms feels slow; anything that moves the page
under the cursor is a bug.
- Everything animated respects `prefers-reduced-motion`.
## 7. Content and copy
- Sentence case for buttons and labels; imperative verbs ("Add to cart", not "Adding products").
- Prices always formatted server-side with the currency and locale (`i18nManager.formatCurrency`),
never string-concatenated in the template.
- Dates: locale-formatted, and relative only when the absolute date is also available (`title`).
- Nothing user-visible is a hardcoded English literal — templates get translatable text, JS gets
`window.i18nManager.get(key)`.
## 8. Cross-cutting checks before "done"
```
[ ] 320 px: no horizontal scroll on any page
[ ] 200 % zoom: nothing clipped or overlapping
[ ] Tab through the whole flow: visible focus, logical order, no trap
[ ] All actions reachable without a mouse, Esc closes overlays
[ ] No hex outside base/variables.css
[ ] No inline style="" in views/*.xml
[ ] No new !important (except the reduced-motion reset)
[ ] Every <img> has alt and width/height
[ ] Every icon-only control has aria-label
[ ] Empty / loading / error states exist for each list
[ ] es, eu and ca render without overflow (German-length strings break tight buttons)
[ ] pre-commit run --all-files is clean
[ ] Lighthouse a11y ≥ 95 on shop, cart, checkout
```
The last three matter as much as the rest: a layout tuned only against Spanish copy usually breaks in
Basque, and a11y regressions land silently.

View file

@ -0,0 +1,191 @@
# Responsive layout
## Breakpoints — Bootstrap 5, and only these
| Name | `min-width` | Typical device |
| --- | --- | --- |
| (base) | — | phones, 320575 px |
| `sm` | 576px | large phones |
| `md` | 768px | tablets |
| `lg` | 992px | small laptops |
| `xl` | 1200px | desktops |
| `xxl` | 1400px | large desktops |
Mobile-first, `min-width` only:
```css
/* ✅ base = mobile, enhance upwards */
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.products-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1200px) {
.products-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
```css
/* ❌ desktop-first bands: overlapping ranges, unclear cascade, private breakpoints */
@media (max-width: 1599px) and (min-width: 1400px) { … }
@media (max-width: 720px) { … }
```
## Prefer intrinsic layout — most grids need no media query at all
```css
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
}
```
`min(100%, 16rem)` is what keeps it from overflowing at 320 px. Reach for a media query only when the
layout must genuinely *change* (sidebar moves, table becomes cards), not merely resize.
## Fluid type and space
```css
:root {
--ac-text-sm: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
--ac-text-base: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--ac-text-lg: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
--ac-text-xl: clamp(1.5rem, 1.3rem + 1vw, 2.25rem);
--ac-space-md: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
}
```
One continuous scale beats three step-changes at breakpoints. Never redefine a token inside a media
query to make it smaller.
Body copy: 1rem minimum, line-height ≥ 1.5, measure 4575 characters (`max-width: 65ch`).
## Touch targets and pointer
WCAG 2.2 AA (§2.5.8 Target Size, Minimum) asks for **24×24 px**. The familiar **44×44 px** is §2.5.5,
level **AAA** — treat it as the house rule for anything a thumb hits, applied by pointer type rather
than by viewport width: a 1024 px tablet needs the big target, a 1024 px laptop doesn't.
```css
.qty-control .btn,
.tag-filter-badge {
min-width: 2.25rem; /* 36px — comfortable with a mouse */
min-height: 2.25rem;
}
@media (pointer: coarse) {
.qty-control .btn,
.tag-filter-badge {
min-width: 2.75rem; /* 44px */
min-height: 2.75rem;
}
}
@media (hover: hover) and (pointer: fine) {
.product-card:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
}
}
```
Hover effects belong inside `@media (hover: hover)` — on touch they stick after a tap and look broken.
## Tables → cards, without restructuring
```css
@media (max-width: 767.98px) {
.checkout-summary-table thead {
/* keep it in the a11y tree; hide visually only */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.checkout-summary-table tr {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--ac-space-xs);
padding: var(--ac-space-sm) 0;
border-bottom: 1px solid var(--ac-border-light);
}
}
```
This is the one place a `max-width` query is honest: it's an override of the desktop table. Keep the
markup semantic — never emit different HTML per viewport.
## Sticky bars (cart / checkout actions on mobile)
```css
.checkout-actions {
position: sticky;
bottom: 0;
z-index: var(--ac-z-sticky);
padding: var(--ac-space-sm);
padding-bottom: calc(var(--ac-space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--ac-surface, #fff);
box-shadow: 0 -2px 8px rgb(0 0 0 / 8%);
}
```
`env(safe-area-inset-bottom)` keeps the button above the iOS home indicator. Sticky elements must not
eat more than ~20 % of a 568 px-tall viewport.
## Images
```xml
<img class="product-card-img" t-att-src="product['image_url']" t-att-alt="product['name']"
loading="lazy" decoding="async" width="400" height="400"/>
```
- `width`/`height` always — they reserve the box and prevent layout shift (CLS).
- `loading="lazy"` for everything below the fold; **never** on the LCP image (first product row,
hero) — that delays the largest paint.
- CSS: `max-width: 100%; height: auto;` plus `object-fit: cover` with a fixed `aspect-ratio` when
cards must align.
- Odoo serves resized variants: `/web/image/product.product/<id>/image_256` etc. Ask for the size you
render, not `image_1920` scaled down in CSS.
## Container and page rhythm
```css
.eskaera-page {
width: 100%;
max-width: 75rem;
margin-inline: auto;
padding-inline: clamp(1rem, 4vw, 2rem);
}
```
Use logical properties (`margin-inline`, `padding-block`, `inset-inline-start`) — Odoo auto-generates
an RTL stylesheet, and logical properties survive the flip without a second rule.
## Test matrix
| Width | Why |
| --- | --- |
| 320 px | smallest supported; the one that breaks first |
| 360 / 390 px | real Android / iPhone |
| 768 px | tablet portrait, first breakpoint |
| 1024 px | tablet landscape / small laptop |
| 1280 / 1440 px | desktop |
| 1280 px @ 200 % zoom | WCAG 1.4.4 reflow — equivalent to 640 px |
Run the stack (`docker-compose up -d`, http://localhost:8070) and check, per viewport: no horizontal
scroll, no clipped text, touch targets still ≥ 44 px on coarse pointers, sticky bars not covering
content, images not stretched.
DevTools device toolbar is enough for layout; check touch targets by actually tapping on a phone once
before calling it done.

View file

@ -40,7 +40,6 @@ Los siguientes addons custom han sido actualizados con la estructura OCA:
Todos los addons incluyen correctamente:
- **Autor**: Criptomart (en `__manifest__.py` y `README.rst`)
- **Financiador**: Elika Bilbo (en `readme/CREDITS.rst`)
## Logo de CriptoMart

View file

@ -106,10 +106,51 @@ PEDIR AL USUARIO QUE GENERE EL POT DESDE LA UI DE ODOO
docker-compose cp odoo:/tmp/addon_name.pot ./addon_name/i18n/
```
#### El `.pot` no es opcional: Odoo fusiona los `.po` contra él
El `.pot` es un artefacto **generado** y está en `.gitignore` (`*.pot`), pero no es
prescindible: al importar, Odoo fusiona cada `.po` contra el `.pot` del módulo antes
de leerlo.
```python
# odoo/tools/translate.py, PoFileReader.__init__
if pot_path:
self.pofile.merge(polib.pofile(pot_path))
```
`merge()` de polib **sustituye las ocurrencias de cada entrada por las del POT**, y
marca como obsoletas las entradas que el POT no tiene (el lector se salta las
obsoletas). Consecuencias prácticas:
- Añadir una ocurrencia `model_terms:ir.ui.view,...` solo al `.po` **no sirve de
nada**: el merge la descarta y la traducción nunca llega a la vista.
- Una cadena nueva que no esté en el POT se ignora entera, por muy traducida que
esté en los tres idiomas.
- Por eso, tras mover una cadena a un atributo traducible o añadir una nueva, hay
que **regenerar el POT** antes de que la traducción surta efecto.
⚠️ Al exportar contra una BD **con datos demo**, el POT se lleva los nombres de los
registros demo (productos, impuestos, nombres de pedidos). Genera el POT contra una
BD limpia.
### 2. Actualizar Archivos .po Existentes
no usar msmerge, corrompe el po. Usa polib.
Al crear entradas con polib hay dos detalles que fallan **en silencio**:
```python
entry = polib.POEntry(
msgid="products found",
msgstr="produktu aurkitu dira",
# 1. Sin `module:` el lector revienta; sin `odoo-python` la cadena se lee
# del .po y se descarta acto seguido (ver Troubleshooting).
comment="module: website_sale_aplicoop\nodoo-python",
# 2. El número de línea es obligatorio en ocurrencias `code:`.
occurrences=[("code:addons/website_sale_aplicoop/models/js_translations.py", "0")],
)
```
### 3. Traducir Términos Nuevos
Editar los archivos `.po` y completar las traducciones:
@ -245,6 +286,58 @@ name = fields.Char(string="Name")
4. **Usuario con idioma incorrecto**: Verificar preferencias de usuario
5. **Cache**: Reiniciar Odoo: `docker-compose restart odoo`
### La cadena SÍ está traducida en el .po y sigue saliendo en inglés
Los tres casos siguientes fallan sin dar ningún error, ni en el log ni al importar.
**a) El atributo no es traducible.** QWeb solo traduce los de esta lista
(`odoo/tools/translate.py`, `TRANSLATED_ATTRS`), más sus variantes `t-attf-`:
```text
string help placeholder alt title label data-tooltip confirm
aria-label aria-keyshortcuts aria-placeholder aria-roledescription aria-valuetext
```
`data-bs-title` **no está**: un tooltip escrito ahí nunca se traduce. Usa `title`
(Bootstrap lo respeta igual) o pasa el texto desde el endpoint i18n. Tampoco están
`aria-description` ni `data-bs-*` en general.
**b) Falta la referencia a la vista en el POT.** Para que una traducción se aplique
a una vista, su entrada necesita una ocurrencia
`model_terms:ir.ui.view,arch_db:modulo.xmlid` **en el POT** (ver el apartado del
merge más arriba). Síntoma típico: dos botones contiguos, uno traducido y el otro
no, con las dos cadenas presentes en el `.po`. Se comprueba así:
```bash
grep -B 4 '^msgid "Save Cart"$' addon/i18n/addon.pot # ¿tiene model_terms?
```
**c) Falta el comentario `#. odoo-python`.** Las cadenas de `_()` se leen del `.po`
directamente (no de la BD), y `CodeTranslations._load_python_translations` filtra
por ese comentario:
```python
def filter_func(row):
return row.get('value') and PYTHON_TRANSLATION_COMMENT in row['comments']
```
Sin él la entrada se lee y se descarta. La entrada tiene que quedar así:
```po
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "products found"
msgstr "produktu aurkitu dira"
```
Y ojo con el `:0` final: el lector hace `int(line_number)` sobre las ocurrencias
`code:`, así que una sin número de línea lanza `ValueError` y **tumba la carga del
catálogo entero**, no solo la de esa entrada.
Estas cadenas se cachean por proceso, así que después de tocarlas hay que
reiniciar Odoo (`docker-compose restart odoo`), no basta con `-u`.
### Caracteres Especiales
Usar escape correcto en .po:

View file

@ -2249,6 +2249,9 @@ class AplicoopWebsiteSale(WebsiteSale):
"website_sale_aplicoop.eskaera_load_from_history",
{
"group_order_id": group_order_id,
# This template renders a bare HTML document instead of
# website.layout, so it has to declare its own language.
"page_lang": (request.env.lang or "en_US").replace("_", "-"),
# sessionStorage keys stay keyed by id; the redirect at
# the end of the template needs the public URL instead.
"group_order_url": self._eskaera_url(group_order),

View file

@ -129,6 +129,11 @@ def _get_translated_labels(self, lang=None, request_obj=None):
"delivery_info_template": tr(
"Delivery Information: Your order will be delivered at {pickup_day} {pickup_date}"
),
# Announced in the search results live region (realtime_search.js)
"no_results": tr("No products found"),
"products_found": tr("products found"),
# Accessible name of the notification dismiss button (website_sale.js)
"close": tr("Close"),
"important": tr("Important"),
"confirm_order_warning": tr(
"Once you confirm this order, you will not be able to modify it. Please review carefully before confirming."

View file

@ -12,7 +12,7 @@ This module has complete translation support for **3 languages**:
## Translated Content
Each `.po` file contains **378 translations** for:
Each `.po` file contains **388 translations** for:
- **Selection Field Options** (Days of week, Recurrence periods)
- Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
@ -41,11 +41,26 @@ When users switch their Odoo interface language to any of the supported language
To add or update translations:
1. Edit the corresponding `.po` file
1. Edit the corresponding `.po` file **with `polib`** (never `msgmerge`, it corrupts them)
2. Update the `msgstr` values (keep `msgid` unchanged)
3. Save and reload the module in Odoo
4. Translations apply immediately
### Adding a *new* string is not just a `.po` edit
`website_sale_aplicoop.pot` is generated by Odoo and is gitignored, but it is not
optional: Odoo merges every `.po` against it before importing, and polib's `merge()`
drops entries the POT does not have and replaces their occurrences with the POT's.
A brand new string, or an existing one that just became a view term, needs the POT
regenerated (from the Odoo UI, against a database **without demo data**) before its
translation applies anywhere.
Three failure modes that produce no error at all — an untranslatable attribute
(`data-bs-title` is not on QWeb's list, `title` is), a missing
`model_terms:ir.ui.view` reference in the POT, and a missing `#. odoo-python`
comment on `_()` strings — are written up in
[`docs/TRANSLATIONS.md`](../../docs/TRANSLATIONS.md), under *Troubleshooting*.
Example entry in a `.po` file:
```po
#. module: website_sale_aplicoop
@ -57,7 +72,7 @@ msgstr "Pedido de Grupo" # Spanish translation
- All `.po` files were generated and tested: **40/40 tests passing**
- Translation coverage: **100%** for all supported languages
- Last updated: August 13, 2026
- Last updated: August 16, 2026
---

View file

@ -288,6 +288,7 @@ msgstr "Nombre de productes disponibles"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout
msgid "Back to Cart"
msgstr "Torna al carret"
@ -416,7 +417,9 @@ msgid "Clear search"
msgstr "Neteja la cerca"
#. module: website_sale_aplicoop
#. odoo-python
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Close"
msgstr "Tanca"
@ -1899,6 +1902,7 @@ msgstr "Dissabte"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Save Cart"
msgstr "Desa el Carret"
@ -2298,3 +2302,63 @@ msgstr "Desa l'Esborrany"
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Save order as draft"
msgstr "Desa la comanda com a esborrany"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Search products"
msgstr "Cerca productes"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Order summary"
msgstr "Resum de la comanda"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Products in this order, with quantity, price and subtotal"
msgstr "Productes d'aquesta comanda, amb quantitat, preu i subtotal"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Loading your order…"
msgstr "Carregant la teva comanda…"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "This page needs JavaScript to load the order into your cart."
msgstr ""
"Aquesta pàgina necessita JavaScript per carregar la comanda al teu carret."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Continue to the group order"
msgstr "Continua a la comanda de grup"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "products found"
msgstr "productes trobats"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Load More Products"
msgstr "<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Carregar més productes"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Special Order</span>"
msgstr ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Comanda especial</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Home Delivery</span>"
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Lliurament a domicili</span>"

View file

@ -290,6 +290,7 @@ msgstr "Cantidad de Productos Disponibles"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout
msgid "Back to Cart"
msgstr "Volver al Carrito"
@ -418,7 +419,9 @@ msgid "Clear search"
msgstr "Limpiar búsqueda"
#. module: website_sale_aplicoop
#. odoo-python
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Close"
msgstr "Cerrar"
@ -1899,6 +1902,7 @@ msgstr "Sábado"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Save Cart"
msgstr "Guardar Carrito"
@ -2295,3 +2299,62 @@ msgstr "Grabar pedido"
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Save order as draft"
msgstr "Grabar pedido"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Search products"
msgstr "Buscar productos"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Order summary"
msgstr "Resumen del pedido"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Products in this order, with quantity, price and subtotal"
msgstr "Productos de este pedido, con cantidad, precio y subtotal"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Loading your order…"
msgstr "Cargando tu pedido…"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "This page needs JavaScript to load the order into your cart."
msgstr "Esta página necesita JavaScript para cargar el pedido en tu carrito."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Continue to the group order"
msgstr "Continuar al pedido de grupo"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "products found"
msgstr "productos encontrados"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Load More Products"
msgstr "<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Cargar más productos"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Special Order</span>"
msgstr ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Pedido Especial</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Home Delivery</span>"
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Entrega a Casa</span>"

View file

@ -291,6 +291,7 @@ msgstr "Eskuragarri Dauden Produktuen Kopurua"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout
msgid "Back to Cart"
msgstr "Sarera Itzuli"
@ -419,7 +420,9 @@ msgid "Clear search"
msgstr "Bilaketa garbitu"
#. module: website_sale_aplicoop
#. odoo-python
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Close"
msgstr "Itxi"
@ -1895,6 +1898,7 @@ msgstr "Larunbata"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Save Cart"
msgstr "Saskia Gorde"
@ -2293,3 +2297,64 @@ msgstr "Eskaera gorde"
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "Save order as draft"
msgstr "Eskaera gorde"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "Search products"
msgstr "Produktuak bilatu"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Order summary"
msgstr "Eskaera laburpena"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_checkout_summary
msgid "Products in this order, with quantity, price and subtotal"
msgstr "Eskaera honetako produktuak, kantitatea, prezioa eta azpitotala"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Loading your order…"
msgstr "Zure eskaera kargatzen…"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "This page needs JavaScript to load the order into your cart."
msgstr "Orri honek JavaScript behar du eskaera zure saskira kargatzeko."
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_load_from_history
msgid "Continue to the group order"
msgstr "Jarraitu taldeko eskaerara"
#. module: website_sale_aplicoop
#. odoo-python
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
msgid "products found"
msgstr "produktu aurkitu dira"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Load More Products"
msgstr ""
"<i class=\"fa fa-download me-2\" aria-hidden=\"true\"/>Produktu Gehiago "
"Kargatu"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Special Order</span>"
msgstr ""
"<span class=\"badge special-order-badge\"><i class=\"fa fa-star\" aria-"
"hidden=\"true\"/> Eskaera Berezia</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_card_meta
msgid ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Home Delivery</span>"
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Etxerako Entrega</span>"

View file

@ -140,9 +140,17 @@ def _register_translations():
_("Search")
_("Search products...")
_("No products found")
# Announced with a count in the search results live region, e.g. "7 products found"
_("products found")
_("Categories")
_("All categories")
# ========================
# Accessible Names
# ========================
# Dismiss button of the floating notification (website_sale.js)
_("Close")
# ========================
# Category Labels
# ========================

View file

@ -16,7 +16,7 @@ website_sale_aplicoop/static/src/css/
├── base/
│ ├── variables.css ← Variables CSS globales (colores, tipografía, espaciados)
│ └── utilities.css ← Clases utilitarias (.sr-only, .text-muted, etc)
│ └── utilities.css ← Clases utilitarias (.text-muted, .word-wrap-break, etc)
├── layout/
│ ├── pages.css ← Fondos y layouts de páginas (eskaera-page, etc)
@ -62,7 +62,8 @@ website_sale_aplicoop/static/src/css/
- **variables.css** (~80 líneas)
Colores, tipografía, espaciados, sombras, transiciones, z-index
- **utilities.css** (~15 líneas)
Clases utilitarias reutilizables (.sr-only, .text-muted, etc)
Clases utilitarias reutilizables (.text-muted, .word-wrap-break, etc).
Para texto solo-lector-de-pantalla usa `.visually-hidden` de Bootstrap.
### **layout/** - Estructura Global
- **pages.css** (~70 líneas)

View file

@ -1,34 +1,26 @@
/* filepath: website_sale_aplicoop/static/src/css/base/utilities.css */
/**
* Utility classes used throughout the project
* Utility classes
*
* Screen-reader-only text: use Bootstrap's own `.visually-hidden` /
* `.visually-hidden-focusable`, shipped in web.assets_frontend. The local
* `.sr-only` copy (the Bootstrap 4 name, dropped in 5.x) is gone, as are
* `.word-wrap-break` and `.text-xs`, which no template ever used.
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.text-muted {
color: var(--text-secondary) !important;
}
.word-wrap-break {
word-wrap: break-word;
overflow-wrap: break-word;
}
.text-xs {
font-size: 0.85rem;
/* Scoped to the addon's pages: unscoped this redefined Bootstrap's .text-muted
for the whole frontend, and needed !important to do it. Two classes beat
Bootstrap's one, so the flag is no longer necessary. */
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page) .text-muted {
color: var(--ac-text-secondary);
}
/* Set by realtime_search.js to filter the grid. A utility has to beat the
component it lands on .product-card declares display:flex at the same
specificity and loads later so this is the one place the flag is the
mechanism rather than a workaround. Bootstrap's own utility layer does the
same for every one of its classes. */
.hidden-product {
display: none !important;
}

View file

@ -1,72 +1,228 @@
/* filepath: website_sale_aplicoop/static/src/css/base/variables.css */
/**
* CSS Custom Properties (Variables)
* Colores, tipografía, espaciados centralizados
* Design tokens (CSS custom properties)
*
* The ONLY file in this addon allowed to contain colour literals.
*
* Odoo 18 ships Bootstrap 5.3 with `$variable-prefix: ''`
* (web/static/src/scss/bootstrap_overridden.scss), so Bootstrap's root custom
* properties are exposed UNPREFIXED: --primary, --body-bg, --border-color,
* Brand tokens alias them so the website theme editor keeps control of the palette;
* the literal is only the fallback when the bundle is loaded outside a themed page.
*
* Contrast ratios noted below are against --ac-surface (#fff) and are the reason
* a given literal was chosen. Do not "tidy" them without re-checking WCAG AA.
*/
:root {
/* ========== COLORS ========== */
--primary-color: var(--primary, #007bff);
--primary-dark: var(--primary-dark, #0056b3);
--secondary-color: var(--secondary, #6c757d);
--success-color: var(--success, #28a745);
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #17a2b8;
--light-color: #f8f9fa;
/* ========== BRAND ========== */
--ac-color-primary: var(--primary, #007bff);
/* White text on --ac-color-primary is only 3.98:1 (fails AA for body text).
Use -strong whenever text sits ON the colour: 7.04:1. */
--ac-color-primary-strong: #0056b3;
--ac-color-secondary: var(--secondary, #6c757d);
--ac-color-accent: #7c3aed; /* white on it: 5.70:1 */
--ac-color-accent-strong: #6d28d9; /* white on it: 7.10:1 */
/* Order cards carry their own indigo accent, and special orders an amber one. */
--ac-color-order: #5a67d8; /* white on it: 4.81:1 */
--ac-color-order-strong: #4c57bd; /* 6.17:1 */
--ac-color-order-deep: #3d4898; /* 8.14:1 */
--ac-color-order-border: rgb(90 103 216 / 12%);
--ac-color-order-border-hover: rgb(90 103 216 / 25%);
--ac-surface-order: linear-gradient(180deg, #fff 0%, #fbfdff 100%);
--ac-surface-order-hover: linear-gradient(180deg, #fff 0%, #f7faff 100%);
--ac-color-special: #f59e0b;
--ac-color-special-strong: #d97706;
--ac-color-special-border: rgb(245 158 11 / 35%);
--ac-color-special-border-hover: rgb(245 158 11 / 50%);
--ac-surface-special: linear-gradient(180deg, #fffdf0 0%, #fffbea 100%);
--ac-surface-special-hover: linear-gradient(180deg, #fffdf0 0%, #fff8db 100%);
/* White on amber is 2.15:1. Amber always takes dark text: 7.60:1. */
--ac-text-on-special: #1a202c;
/* ========== FEEDBACK ========== */
--ac-color-success: var(--success, #28a745);
/* #28a745 is only 3.13:1 on white — fine as a fill, not as text. */
--ac-color-success-strong: #1e7e34; /* 5.14:1 */
--ac-color-success-deep: #1a6e2e; /* hover/active on -strong */
/* Warning alert: amber surface with its own readable foreground (6.30:1). */
--ac-surface-warning: #fef3c7;
--ac-border-warning: #fcd34d;
--ac-text-warning: #92400e;
--ac-color-danger: var(--danger, #dc3545);
--ac-color-warning: var(--warning, #ffc107);
--ac-color-info: var(--info, #17a2b8);
/* ========== SURFACES ========== */
--ac-surface: var(--body-bg, #fff);
--ac-surface-sunken: var(--light, #f8f9fa);
--ac-surface-muted: #f3f3f3;
--ac-surface-tint: rgb(0 123 255 / 6%);
--ac-surface-tint-hover: rgb(108 117 125 / 10%);
/* ========== TEXT (all ≥ 4.5:1 on --ac-surface) ========== */
--ac-text-primary: var(--body-color, #1a202c); /* 16.32:1 */
--ac-text-secondary: #4a5568; /* 7.53:1 */
--ac-text-muted: #6b7280; /* 4.83:1 */
--ac-text-on-fill: #fff;
/* ========== BORDERS ========== */
/* Decorative separators and card outlines. */
--ac-border: var(--border-color, #e2e8f0);
--ac-border-subtle: #e0e0e0;
/* Boundaries of interactive controls (inputs, steppers): WCAG 1.4.11 needs
3:1 against the adjacent surface. #8a919a is 3.18:1. */
--ac-border-strong: #8a919a;
/* ========== FOCUS RING ========== */
--ac-focus-ring-color: var(--primary, #007bff); /* 3.98:1 — ok for a focus indicator */
--ac-focus-ring-width: 2px;
--ac-focus-ring-offset: 2px;
/* ========== TYPOGRAPHY ========== */
--ac-font-family: var(
--font-sans-serif,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif
);
/* Fluid scale: one continuous ramp instead of per-breakpoint overrides.
Always rem + vw, never pure vw, so browser zoom keeps working. */
--ac-text-2xs: clamp(0.6875rem, 0.66rem + 0.1vw, 0.75rem);
--ac-text-xs: clamp(0.75rem, 0.72rem + 0.12vw, 0.8125rem);
--ac-text-sm: clamp(0.8125rem, 0.79rem + 0.14vw, 0.875rem);
--ac-text-base: clamp(0.875rem, 0.85rem + 0.18vw, 1rem);
--ac-text-md: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--ac-text-lg: clamp(1.125rem, 1.06rem + 0.3vw, 1.375rem);
--ac-text-xl: clamp(1.375rem, 1.25rem + 0.6vw, 1.75rem);
--ac-text-2xl: clamp(1.75rem, 1.5rem + 1vw, 2.25rem);
--ac-leading-tight: 1.2;
--ac-leading-snug: 1.35;
--ac-leading-normal: 1.5;
--ac-weight-normal: 400;
--ac-weight-medium: 500;
--ac-weight-semibold: 600;
--ac-weight-bold: 700;
/* ========== SPACING ========== */
--ac-space-3xs: 0.125rem;
--ac-space-2xs: 0.25rem;
--ac-space-xs: 0.375rem;
--ac-space-sm: 0.5rem;
--ac-space-md: 1rem;
--ac-space-lg: 1.5rem;
--ac-space-xl: 2rem;
--ac-space-2xl: 3rem;
/* ========== RADIUS ========== */
--ac-radius-sm: 0.25rem;
--ac-radius-md: 0.5rem;
--ac-radius-lg: 0.75rem;
--ac-radius-xl: 1rem;
--ac-radius-pill: 50rem;
/* ========== ELEVATION ========== */
--ac-shadow-sm: 0 2px 8px rgb(0 0 0 / 8%);
--ac-shadow-md: 0 4px 12px rgb(0 0 0 / 10%);
--ac-shadow-lg: 0 10px 25px rgb(0 0 0 / 12%);
/* ========== MOTION ========== */
--ac-transition-fast: 150ms ease-out;
--ac-transition-normal: 220ms cubic-bezier(0.2, 0.9, 0.2, 1);
--ac-transition-slow: 400ms ease;
/* ========== CONTROLS ========== */
/* Fluid control size: replaces six per-breakpoint width/height overrides. */
--ac-control-size: clamp(2.25rem, 2.05rem + 0.7vw, 2.5rem);
/* 44px. WCAG 2.2 AA (2.5.8) only requires 24px, but 44px is the AAA figure
(2.5.5) and the practical minimum for touch. Applied on coarse pointers. */
--ac-touch-target: 2.75rem;
--ac-control-border-width: 1px;
/* ========== 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;
--ac-z-sticky: 1020;
--ac-z-fixed: 1030;
--ac-z-modal: 1040;
--ac-z-popover: 1050;
--ac-z-tooltip: 1060;
--ac-z-notification: 9999;
/* ==========================================================================
LEGACY ALIASES do not use in new code.
Kept so the not-yet-migrated partials keep rendering. Delete each one once
`grep -rn "var(--<name>)" static/src/` comes back empty.
========================================================================== */
--primary-color: var(--ac-color-primary);
/* Was `var(--primary-dark, #0056b3)`: a custom property cannot reference
itself that made the declaration invalid at computed-value time, so the
five consumers in buttons.css / order-card.css silently rendered with no
colour at all. */
--primary-dark: var(--ac-color-primary-strong);
--secondary-color: var(--ac-color-secondary);
--success-color: var(--ac-color-success);
--danger-color: var(--ac-color-danger);
--warning-color: var(--ac-color-warning);
--info-color: var(--ac-color-info);
--light-color: var(--ac-surface-sunken);
--dark-color: #2d3748;
/* Text colors */
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-muted: #6b7280;
--text-primary: var(--ac-text-primary);
--text-secondary: var(--ac-text-secondary);
--text-muted: var(--ac-text-muted);
/* Border colors */
--border-light: #e2e8f0;
--border-light: var(--ac-border);
--border-medium: #cbd5e0;
--border-dark: #718096;
/* ========== TYPOGRAPHY ========== */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
--font-family-base: var(--ac-font-family);
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-normal: var(--ac-weight-normal);
--font-weight-medium: var(--ac-weight-medium);
--font-weight-semibold: var(--ac-weight-semibold);
--font-weight-bold: var(--ac-weight-bold);
--font-weight-extrabold: 800;
/* ========== SPACING ========== */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-xs: var(--ac-space-2xs);
--spacing-sm: var(--ac-space-sm);
--spacing-md: var(--ac-space-md);
--spacing-lg: var(--ac-space-lg);
--spacing-xl: var(--ac-space-xl);
--spacing-2xl: var(--ac-space-2xl);
/* ========== BORDER RADIUS ========== */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-sm: var(--ac-radius-sm);
--radius-md: var(--ac-radius-md);
--radius-lg: var(--ac-radius-lg);
--radius-xl: var(--ac-radius-xl);
/* ========== SHADOWS ========== */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.12);
--shadow-sm: var(--ac-shadow-sm);
--shadow-md: var(--ac-shadow-md);
--shadow-lg: var(--ac-shadow-lg);
/* ========== TRANSITIONS ========== */
--transition-fast: 200ms ease;
--transition-normal: 320ms cubic-bezier(0.2, 0.9, 0.2, 1);
--transition-normal: var(--ac-transition-normal);
--transition-slow: 500ms ease;
/* ========== Z-INDEX ========== */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal: 1040;
--z-popover: 1050;
--z-tooltip: 1060;
--z-notification: 9999;
--z-dropdown: var(--ac-z-dropdown);
--z-sticky: var(--ac-z-sticky);
--z-fixed: var(--ac-z-fixed);
--z-modal: var(--ac-z-modal);
--z-popover: var(--ac-z-popover);
--z-tooltip: var(--ac-z-tooltip);
--z-notification: var(--ac-z-notification);
}

View file

@ -1,77 +1,68 @@
/* filepath: website_sale_aplicoop/static/src/css/components/alerts.css */
/**
* Alert and notification component styles
* Alerts, toasts and notifications
*
* `.alert-warning` is scoped to the addon's pages: unscoped it redefined
* Bootstrap's own alert for the whole frontend. `.group-order-alert` and its
* .info/.warning variants were dead no template emits them.
*/
.group-order-alert {
background-color: #e7f3ff;
border-left: 4px solid var(--primary-color);
color: #004085;
padding: 1rem;
border-radius: 0.375rem;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
.alert-warning {
--alert-bg: var(--ac-surface-warning);
--alert-border-color: var(--ac-border-warning);
--alert-color: var(--ac-text-warning);
margin-bottom: var(--ac-space-xl);
padding: var(--ac-space-md);
border-radius: var(--ac-radius-md);
}
.group-order-alert.info {
background-color: #d1ecf1;
border-left-color: #17a2b8;
color: #0c5460;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
.alert-warning
i {
margin-right: var(--ac-space-xs);
font-size: var(--ac-text-md);
}
.group-order-alert.warning {
background-color: #fff3cd;
border-left-color: #ffc107;
color: #856404;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
.alert-warning
strong {
font-weight: var(--ac-weight-bold);
}
.alert-warning {
background-color: #fef3c7;
border-color: #fcd34d;
color: #92400e;
margin-bottom: 2rem;
border-radius: 0.5rem;
padding: 1.25rem;
.eskaera-delivery-notice {
margin-top: var(--ac-space-md);
border-left: 4px solid var(--ac-color-info);
}
.alert-warning i {
margin-right: 0.75rem;
font-size: 1.1rem;
.eskaera-delivery-notice .fa-truck {
margin-right: var(--ac-space-sm);
color: var(--ac-color-info);
}
.alert-warning strong {
font-weight: 700;
}
/* ---------- Toast (added to cart) ---------- */
#delivery-info-alert {
margin-top: 1rem;
border-left: 4px solid #0dcaf0;
}
#delivery-info-alert .fa-truck {
margin-right: 0.5rem;
color: #0dcaf0;
}
/* Toast Notification Animation */
.toast-notification {
position: fixed;
bottom: 30px;
right: 30px;
background-color: #28a745;
color: white;
padding: 1rem 1.5rem;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
inset: auto var(--ac-space-lg) var(--ac-space-lg) auto;
z-index: var(--ac-z-notification);
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
z-index: 9999;
gap: var(--ac-space-xs);
max-width: 25rem;
padding: var(--ac-space-md) var(--ac-space-lg);
padding-bottom: calc(var(--ac-space-md) + env(safe-area-inset-bottom, 0px));
border-radius: var(--ac-radius-sm);
/* White on --ac-color-success is 3.13:1; the strong tone is 5.14:1. */
background-color: var(--ac-color-success-strong);
color: var(--ac-text-on-fill);
box-shadow: var(--ac-shadow-lg);
font-weight: var(--ac-weight-medium);
word-break: break-word;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
max-width: 400px;
word-break: break-word;
transition: opacity var(--ac-transition-normal), transform var(--ac-transition-normal);
}
.toast-notification.show {
@ -80,6 +71,41 @@
}
.toast-notification i {
font-size: 1.2rem;
min-width: 20px;
min-width: 1.25rem;
font-size: var(--ac-text-md);
}
/* ---------- Floating notification (_showNotification) ---------- */
/* Was a 200-character inline style written from JS on every call. */
.eskaera-notification {
position: fixed;
top: 5rem;
right: var(--ac-space-md);
z-index: var(--ac-z-notification);
min-width: 18.75rem;
max-width: 31.25rem;
padding: var(--ac-space-lg);
font-size: var(--ac-text-md);
box-shadow: var(--ac-shadow-lg);
transition: opacity var(--ac-transition-normal);
}
@media (max-width: 576px) {
.eskaera-notification {
left: var(--ac-space-md);
right: var(--ac-space-md);
min-width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast-notification,
.eskaera-notification {
transition: none;
}
.toast-notification {
transform: none;
}
}

View file

@ -1,103 +1,102 @@
/* filepath: website_sale_aplicoop/static/src/css/components/buttons.css */
/**
* Button and action component styles
* Buttons
*
* Colours are set through Bootstrap 5.3's own --btn-* custom properties, which
* Odoo exposes unprefixed. That wins on the cascade without !important and
* keeps every state (hover, active, disabled) consistent, instead of
* overriding background-color and leaving the others behind.
*
* `.btn-add-to-cart` and `.btn-checkout` used to live here; no template or
* script emits either name the real ones are `.add-to-cart-btn` (see
* components/quantity-control.css) and `.checkout-btn-lg`.
*/
.btn-add-to-cart {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
:is(.btn-primary, .btn-success) {
font-weight: var(--ac-weight-semibold);
}
.btn-add-to-cart:focus {
outline: 3px solid var(--primary-color);
outline-offset: 2px;
}
/* ---------- Checkout actions ---------- */
.btn-add-to-cart:hover {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
}
.btn-checkout {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
border: none;
color: white;
font-weight: 600;
}
.btn-checkout:focus {
outline: 3px solid #667eea;
outline-offset: 2px;
}
.btn-checkout:hover {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-dark) 100%);
color: white;
}
.btn-primary,
.btn-success {
font-weight: 600;
}
.btn-primary:disabled,
.btn-success:disabled {
opacity: 0.65;
}
/* Checkout action buttons */
.checkout-actions {
margin-top: 2rem;
margin-top: var(--ac-space-xl);
}
.checkout-actions .btn {
font-size: 1.1rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
--btn-border-radius: var(--ac-radius-md);
--btn-font-size: var(--ac-text-md);
--btn-font-weight: var(--ac-weight-semibold);
--btn-padding-y: var(--ac-space-xs);
--btn-padding-x: var(--ac-space-lg);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.checkout-actions .btn-success {
background-color: var(--success-color);
border-color: var(--success-color);
}
.checkout-actions .btn-success:hover {
background-color: #218838;
border-color: #218838;
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
transform: translateY(-2px);
}
.checkout-actions .btn-outline-secondary {
color: #ebeef0;
border-color: #cad2d8;
}
.checkout-actions .btn-outline-secondary:hover {
color: white;
background-color: #6c757d;
border-color: #6c757d;
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
letter-spacing: 0.05em;
transition: background-color var(--ac-transition-fast), border-color var(--ac-transition-fast),
box-shadow var(--ac-transition-fast), transform var(--ac-transition-fast);
}
.checkout-actions .btn i {
margin-right: 0.5rem;
margin-right: var(--ac-space-sm);
}
.checkout-actions .btn-success {
--btn-bg: var(--ac-color-success-strong);
--btn-border-color: var(--ac-color-success-strong);
--btn-color: var(--ac-text-on-fill);
--btn-hover-bg: var(--ac-color-success-deep);
--btn-hover-border-color: var(--ac-color-success-deep);
--btn-hover-color: var(--ac-text-on-fill);
}
/* "Back to cart". The previous #ebeef0 text sat at 1.17:1 on the white
checkout card the label was invisible until hover. */
.checkout-actions .btn-outline-secondary {
--btn-color: var(--ac-text-secondary);
--btn-border-color: var(--ac-border-strong);
--btn-hover-color: var(--ac-text-on-fill);
--btn-hover-bg: var(--ac-color-secondary);
--btn-hover-border-color: var(--ac-color-secondary);
}
/* ---------- Long-label buttons ---------- */
.save-order-btn,
.save-order-btn-styled,
.checkout-btn-lg {
padding: var(--ac-space-sm) var(--ac-space-md);
font-size: var(--ac-text-md);
white-space: nowrap;
font-size: 1.1rem;
padding: 0.6rem 1.2rem;
}
.save-icon-size {
font-size: 1.2rem;
/* ---------- States ---------- */
@media (hover: hover) and (pointer: fine) {
.checkout-actions .btn-success:hover,
.checkout-actions .btn-outline-secondary:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
}
}
.checkout-actions .btn:focus-visible,
.save-order-btn:focus-visible,
.checkout-btn-lg:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
@media (pointer: coarse) {
.checkout-actions .btn,
.save-order-btn,
.checkout-btn-lg {
min-height: var(--ac-touch-target);
}
}
@media (prefers-reduced-motion: reduce) {
.checkout-actions .btn,
.checkout-actions .btn:hover {
transform: none;
transition: none;
}
}

View file

@ -1,236 +1,177 @@
/* filepath: website_sale_aplicoop/static/src/css/components/cart.css */
/**
* Shopping cart component styles
* Cart sidebar
*
* Markup (eskaera_shop):
* .card.sticky-top.cart-sticky-position
* .card-header #cart-title + .btn-group.cart-btn-group (4 icon buttons)
* .card-body.eskaera-cart-items[aria-live=polite]
* injected by website_sale.js: .list-group > .list-group-item, .cart-total
* .card-footer clear cart + checkout
*
* The .list-group-item rules are scoped to the container: unscoped, they hit
* every Bootstrap list group on the site, which is what forced three rounds of
* re-declaration in layout/responsive.css.
*/
.sticky-cart {
top: 20px;
.cart-sticky-position {
top: var(--ac-space-md);
}
.cart-header {
background-color: var(--primary-color);
color: white;
padding: 0.25rem;
border-radius: 0.25rem 0.25rem 0 0;
}
.cart-header h5 {
color: white;
margin: 0;
}
.cart-header .btn {
color: white;
}
.cart-items {
max-height: 400px;
/* Cap the scroll area against the viewport, not a fixed pixel height, so it
still fits when text is enlarged. */
.cart-items,
.eskaera-cart-items {
max-height: min(60vh, 25rem);
overflow-y: auto;
}
#cart-items-container {
padding: 0.2rem 0.4rem;
.eskaera-cart-items {
padding: var(--ac-space-2xs) var(--ac-space-xs);
font-size: var(--ac-text-base);
}
#cart-items-container .list-group {
.eskaera-cart-items .list-group {
margin-bottom: 0;
}
#cart-items-container p.text-muted {
margin: 0.4rem 0;
.eskaera-cart-items p.text-muted {
margin: var(--ac-space-2xs) 0;
}
.list-group-item {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #e0e0e0;
.cart-empty-message {
font-size: var(--ac-text-base);
}
.list-group-item.d-flex {
gap: 0.5rem;
/* ---------- Cart lines ---------- */
.eskaera-cart-items .list-group-item {
display: flex;
flex-direction: column;
gap: var(--ac-space-2xs);
padding: var(--ac-space-xs) var(--ac-space-sm);
border-bottom: 1px solid var(--ac-border);
font-size: var(--ac-text-base);
}
.list-group-item h6 {
margin: 0;
margin-bottom: 0.2rem;
font-weight: 600;
.eskaera-cart-items .list-group-item h6 {
margin: 0 0 var(--ac-space-3xs);
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-snug);
overflow-wrap: break-word;
word-break: break-word;
font-size: 0.875rem;
line-height: 1.3;
}
.list-group-item small {
.eskaera-cart-items .list-group-item small {
display: block;
font-size: 0.75rem;
color: #6c757d;
font-size: var(--ac-text-xs);
color: var(--ac-text-muted);
}
.list-group-item .d-flex {
min-width: auto;
.eskaera-cart-items .list-group-item .d-flex {
justify-content: space-between;
align-items: center;
gap: 0.5rem;
gap: var(--ac-space-sm);
flex-wrap: wrap;
}
.list-group-item .remove-from-cart {
flex-shrink: 0;
min-width: 24px;
min-height: 24px;
/* Line subtotal. The previous #667eea was 3.42:1 at this size — under AA. */
.eskaera-cart-items .list-group-item strong {
min-width: 3.75rem;
font-size: var(--ac-text-base);
text-align: right;
white-space: nowrap;
color: var(--ac-color-primary-strong);
}
.eskaera-cart-items .list-group-item > div:first-child {
flex: 1 1 auto;
min-width: 0;
}
/* Colours come from Bootstrap's .btn-danger, already on the element. */
.remove-from-cart {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.25rem;
font-size: 0.7rem;
flex-shrink: 0;
/* 24px is the WCAG 2.2 AA floor (2.5.8); touch gets the full target below. */
min-width: 1.5rem;
min-height: 1.5rem;
padding: var(--ac-space-3xs) var(--ac-space-2xs);
font-size: var(--ac-text-2xs);
cursor: pointer;
background-color: #dc3545;
color: #ffffff;
border: 1px solid #dc3545;
border-radius: 3px;
transition: all 0.2s ease;
}
.list-group-item .remove-from-cart:hover {
background-color: #bb2d3b;
border-color: #bb2d3b;
transform: scale(1.05);
}
.list-group-item .remove-from-cart i {
font-size: 0.85rem;
color: #ffffff;
}
.list-group-item strong {
min-width: 60px;
text-align: right;
white-space: nowrap;
font-size: 0.875rem;
color: #667eea;
}
.list-group-item.d-flex > div:first-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.list-group-item.d-flex > div:first-child h6 {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
transition: background-color var(--ac-transition-fast), transform var(--ac-transition-fast);
}
.cart-total {
padding: 1rem;
background-color: #f8f9fa;
font-size: 1.5rem;
font-weight: 700;
border-top: 2px solid var(--primary-color);
padding: var(--ac-space-md);
border-top: 2px solid var(--ac-color-primary);
background-color: var(--ac-surface-sunken);
font-size: var(--ac-text-lg);
font-weight: var(--ac-weight-bold);
text-align: right;
color: var(--primary-color);
color: var(--ac-color-primary-strong);
}
.cart-item-remove {
cursor: pointer;
color: #dc3545;
font-size: 0.7rem;
}
/* ---------- Header actions ---------- */
.cart-item-remove:hover {
text-decoration: underline;
}
/* Cart header buttons styling */
#save-cart-btn,
#reload-cart-btn {
font-weight: 600;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-right: 0.25rem;
color: white;
}
#save-cart-btn {
background-color: #007bff;
border-color: #0056b3;
}
#save-cart-btn:hover {
background-color: #0056b3;
border-color: #004085;
box-shadow: 0 4px 8px rgba(0, 86, 179, 0.3);
}
#save-cart-btn:active {
transform: scale(0.95);
box-shadow: 0 2px 4px rgba(0, 86, 179, 0.3);
}
#reload-cart-btn {
background-color: #17a2b8;
border-color: #117a8b;
}
#reload-cart-btn:hover {
background-color: #117a8b;
border-color: #0c5460;
box-shadow: 0 4px 8px rgba(17, 122, 139, 0.3);
}
#reload-cart-btn:active {
transform: scale(0.95);
box-shadow: 0 2px 4px rgba(17, 122, 139, 0.3);
}
.card-header .btn-group {
flex-shrink: 0;
}
#cart-title {
font-size: 1.2rem;
}
.cart-btn-group-nowrap,
.card-header .btn-group,
.cart-btn-group {
flex-shrink: 0;
flex-wrap: nowrap;
}
.cart-header-btn {
padding: 0.25rem;
/* These four icon buttons were 0.5rem type with 0.1rem padding roughly an
18px target, under the 24px AA minimum, for the cart's primary actions. */
.cart-btn-compact {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
min-height: 2rem;
padding: var(--ac-space-2xs);
line-height: 1;
}
.cart-icon-size {
font-size: 0.9rem;
}
.cart-body-text {
font-size: 1.1rem;
}
.cart-body-lg {
font-size: 1.1rem;
}
.cart-title-lg {
font-size: 1.5rem;
font-size: var(--ac-text-base);
}
.cart-title-sm {
font-size: 1.1rem;
font-size: var(--ac-text-md);
}
.cart-btn-compact {
padding: 0.1rem;
min-width: auto;
font-size: 0.5rem;
line-height: 0.5;
height: auto;
/* ---------- States ---------- */
@media (hover: hover) and (pointer: fine) {
.remove-from-cart:hover {
transform: scale(1.05);
}
}
.cart-sticky-position {
top: 20px;
.remove-from-cart:focus-visible,
.cart-btn-compact:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
position: relative;
z-index: 1;
}
@media (pointer: coarse) {
.cart-btn-compact,
.remove-from-cart {
min-width: var(--ac-touch-target);
min-height: var(--ac-touch-target);
}
}
@media (prefers-reduced-motion: reduce) {
.remove-from-cart,
.remove-from-cart:hover {
transform: none;
transition: none;
}
}

View file

@ -1,99 +1,129 @@
/* filepath: website_sale_aplicoop/static/src/css/components/forms.css */
/**
* Form and input component styles
* Form controls
*
* Everything here is scoped to an Eskaera page root. The previous version
* styled bare `label`, `input[type="text"]`, `input[type="number"]` and
* `select`, which reaches every form in web.assets_frontend the website
* login, the contact form, any other module's frontend widgets not just this
* addon's pages.
*/
label {
font-weight: 600;
color: #2d3748;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page) label {
font-weight: var(--ac-weight-semibold);
color: var(--ac-text-secondary);
}
.form-control,
.form-select {
border-color: #cbd5e0;
font-size: 1rem;
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
:is(.form-control, .form-select, input[type="text"], input[type="number"], select) {
/* WCAG 1.4.11: an input's border is what identifies it, so ≥ 3:1. */
border: 1px solid var(--ac-border-strong);
font-size: var(--ac-text-base);
color: var(--ac-text-primary);
background-color: var(--ac-surface);
}
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
/* One focus treatment for every control on these pages, keyboard only. */
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
:is(.form-control, .form-select, .form-check-input, input, select, textarea):focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
border-color: var(--ac-color-primary);
}
input[type="number"],
input[type="text"],
select {
background-color: #ffffff;
color: #212529;
border: 1px solid #ced4da;
/* Bootstrap draws its own ring through a box-shadow on :focus; keep it in
token colours so mouse focus still reads, without a second outline. */
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
:is(.form-control, .form-select):focus {
border-color: var(--ac-color-primary);
box-shadow: 0 0 0 0.2rem rgb(0 123 255 / 20%);
}
input[type="number"]:focus,
input[type="text"]:focus,
select:focus {
outline: 2px solid #667eea;
outline-offset: 0;
border-color: var(--primary-color);
/* ---------- Search and category filter ---------- */
.eskaera-filters :is(.form-control, .form-select) {
height: auto;
padding: var(--ac-space-xs) var(--ac-space-sm);
font-size: var(--ac-text-base);
}
.form-check {
/* The clear-search button sits inside the input; give it a real target. */
.search-clear-btn {
position: absolute;
inset-block-start: 50%;
inset-inline-end: var(--ac-space-2xs);
transform: translateY(-50%);
display: none;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: none;
font-size: var(--ac-text-lg);
line-height: 1;
color: var(--ac-text-secondary);
text-decoration: none;
}
.search-clear-btn.is-visible {
display: flex;
}
.search-input-wrapper {
position: relative;
}
.search-input-wrapper .form-control {
padding-inline-end: 2.5rem;
}
/* ---------- Home delivery checkbox ---------- */
.eskaera-delivery-check {
display: flex;
align-items: center;
padding: 1rem 1.25rem;
margin-left: 0;
gap: var(--ac-space-sm);
padding: var(--ac-space-md);
}
.form-check-input {
margin-right: 0.75rem;
.eskaera-delivery-check .form-check-input {
flex-shrink: 0;
}
/* Compact search and filter inputs */
#realtime-search-input,
#realtime-category-select {
font-size: 0.95rem;
padding: 0.375rem 0.75rem;
height: auto;
}
#realtimeSearch-filters .form-control,
#realtimeSearch-filters .form-select {
padding: 0.375rem 0.75rem;
font-size: 0.95rem;
}
.form-check-label {
margin: 0;
}
#home-delivery-checkbox {
width: 1.5rem;
height: 1.5rem;
margin: 0;
border: 2px solid var(--ac-color-primary);
border-radius: var(--ac-radius-sm);
cursor: pointer;
border: 2px solid #0d6efd;
border-radius: 0.25rem;
margin-right: 0.75rem;
}
#home-delivery-checkbox:checked {
background-color: #0d6efd;
border-color: #0d6efd;
.eskaera-delivery-check .form-check-input:checked {
border-color: var(--ac-color-primary);
background-color: var(--ac-color-primary);
}
#home-delivery-checkbox:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.form-check-label[for="home-delivery-checkbox"] {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
cursor: pointer;
.eskaera-delivery-check .form-check-label {
display: flex;
align-items: center;
margin: 0;
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-semibold);
color: var(--ac-text-primary);
cursor: pointer;
}
.help-text-sm {
font-size: 0.85rem;
/* ---------- Touch ---------- */
@media (pointer: coarse) {
:is(.eskaera-page, .eskaera-shop-page, .eskaera-generic-page, .eskaera-checkout-page)
:is(.form-control, .form-select) {
min-height: var(--ac-touch-target);
}
.search-clear-btn {
width: var(--ac-touch-target);
height: var(--ac-touch-target);
}
}

View file

@ -1,30 +1,43 @@
/* filepath: website_sale_aplicoop/static/src/css/components/order-card.css */
/**
* Order card (Eskaera) component styles
* Order card (Eskaera)
*
* Markup (eskaera_page):
* .eskaera-order-card-wrapper
* .eskaera-order-card[.eskaera-order-card--special]
* .card-body
* .order-badge-position (product count)
* a.eskaera-order-card-link .card-header-top (image, title, description)
* .card-meta-compact > .meta-grid (dates, badges)
* a.btn (Browse products)
*
* Note the link only wraps the header, so the whole card is NOT clickable
* the hover/focus treatment below is applied to the card while the pointer is
* anywhere over it, but `cursor: pointer` is left to the two real links.
*/
.eskaera-order-card-link {
text-decoration: none;
color: inherit;
display: block;
width: 100%;
color: inherit;
text-decoration: none;
}
.eskaera-order-card {
position: relative;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
border: 1px solid rgba(90, 103, 216, 0.12);
border-radius: 0.75rem;
box-shadow: 0 6px 18px rgba(28, 37, 80, 0.06);
transition: transform 320ms cubic-bezier(0.2, 0.9, 0.2, 1), box-shadow 320ms, border-color 320ms,
background 320ms;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 290px;
/* min-height in rem, not px: at 200% zoom a px floor clips the content. */
min-height: 18rem;
height: 100%;
cursor: pointer;
border: 1px solid var(--ac-color-order-border);
border-radius: var(--ac-radius-lg);
background: var(--ac-surface-order);
box-shadow: var(--ac-shadow-md);
overflow: hidden;
transition: transform var(--ac-transition-normal), box-shadow var(--ac-transition-normal),
border-color var(--ac-transition-normal), background var(--ac-transition-normal);
animation: slideInUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@ -33,371 +46,281 @@
opacity: 0;
transform: translateY(30px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Accent bar along the top edge. */
.eskaera-order-card::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
inset: 0 0 auto;
height: 6px;
background: linear-gradient(90deg, var(--primary-color), var(--primary-dark));
background: linear-gradient(90deg, var(--ac-color-order), var(--ac-color-order-strong));
}
.eskaera-order-card-link:hover .eskaera-order-card {
transform: translateY(-8px) scale(1.01);
box-shadow: 0 20px 50px rgba(90, 103, 216, 0.15), 0 0 30px rgba(90, 103, 216, 0.1);
border: 1px solid rgba(90, 103, 216, 0.25);
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
}
.eskaera-order-card-link:hover .eskaera-order-card::before {
animation: shimmer 0.6s ease-in-out;
}
@keyframes shimmer {
0% {
left: 0;
}
50% {
left: 100%;
}
100% {
left: 0;
}
}
.eskaera-order-card .card-body {
padding: 0.6rem 0.8rem 0 0.8rem;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.eskaera-order-card .card-title {
font-size: 0.95rem;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.15;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.order-desc-text {
font-size: 0.8rem;
color: #6b7280;
margin: 0;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.order-desc-sm {
font-size: 0.875rem;
line-height: 1.4;
}
.order-desc-md {
font-size: 0.95rem;
line-height: 1.5;
}
.eskaera-order-card .btn {
margin-top: auto;
margin-left: auto;
margin-right: auto;
margin-bottom: 0.6rem;
padding: 0.6rem 1.2rem;
font-weight: 600;
border-radius: 6px;
border: none !important;
font-size: 0.85rem;
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #5a67d8, #4c57bd) !important;
color: white !important;
box-shadow: 0 4px 12px rgba(90, 103, 216, 0.2) !important;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none !important;
position: relative;
overflow: hidden;
text-align: center;
width: auto;
}
.eskaera-order-card .btn::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.5s, height 0.5s;
}
.eskaera-order-card .btn:active::before {
width: 300px;
height: 300px;
}
.eskaera-order-card .btn:hover {
box-shadow: 0 8px 24px rgba(90, 103, 216, 0.4), inset 0 0 20px rgba(255, 255, 255, 0.2) !important;
transform: translateY(-3px) scale(1.03);
background: linear-gradient(135deg, #4c57bd, #3d4898) !important;
}
/* Center button within card body */
.eskaera-order-card .card-body > .btn {
margin-left: auto;
margin-right: auto;
}
/* Order card header spacing */
.order-card-header-spacing {
margin-top: 0.9rem;
}
/* Order thumbnail small */
.order-thumbnail-sm {
width: 90px;
height: 90px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 2px solid rgba(90, 103, 216, 0.1);
}
/* Order thumbnail medium */
.order-thumbnail-md {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Order thumbnail checkout */
.order-thumbnail-checkout,
.checkout-thumbnail {
width: 90px;
height: 90px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* ---------- Header ---------- */
.card-header-top {
display: flex;
gap: 0.6rem;
gap: var(--ac-space-sm);
align-items: flex-start;
margin-bottom: 0.3rem;
height: 100px;
overflow: hidden;
margin-bottom: var(--ac-space-2xs);
}
.card-header-top > div:last-child {
text-align: left;
flex: 1;
text-align: left;
}
.card-header-top .card-title {
margin: 0;
}
.card-badges .badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
.eskaera-order-card .card-body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: var(--ac-space-2xs);
padding: var(--ac-space-sm) var(--ac-space-sm) 0;
}
.eskaera-order-card .card-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
margin: 0;
overflow: hidden;
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-bold);
line-height: var(--ac-leading-tight);
color: var(--ac-text-primary);
text-overflow: ellipsis;
}
.order-desc-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
margin: 0;
overflow: hidden;
font-size: var(--ac-text-xs);
line-height: var(--ac-leading-snug);
color: var(--ac-text-muted);
text-overflow: ellipsis;
}
/* ---------- Thumbnails ---------- */
.order-thumbnail-sm {
flex-shrink: 0;
width: 5.625rem;
height: 5.625rem;
border: 2px solid var(--ac-color-order-border);
border-radius: var(--ac-radius-md);
object-fit: cover;
}
.order-thumbnail-md {
flex-shrink: 0;
width: 7.5rem;
height: 7.5rem;
border-radius: var(--ac-radius-md);
box-shadow: var(--ac-shadow-sm);
object-fit: cover;
}
/* ---------- Product-count badge ---------- */
.order-badge-position {
position: absolute;
top: var(--ac-space-md);
right: var(--ac-space-md);
z-index: 1;
display: flex;
}
.order-badge-custom {
display: flex;
align-items: center;
gap: var(--ac-space-sm);
padding: var(--ac-space-sm) var(--ac-space-xs);
border-radius: var(--ac-radius-sm);
font-size: var(--ac-text-sm);
font-weight: var(--ac-weight-bold);
box-shadow: var(--ac-shadow-md);
}
/* ---------- Meta block ---------- */
.card-meta-compact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
margin: 0.2rem auto 0 auto;
padding: 0.6rem 0.8rem;
border-top: 1px solid rgba(90, 103, 216, 0.08);
background: rgba(245, 247, 255, 0.4);
border-radius: 0 0 0.75rem 0.75rem;
width: 100%;
margin: var(--ac-space-2xs) auto 0;
padding: var(--ac-space-sm);
border-top: 1px solid var(--ac-color-order-border);
border-radius: 0 0 var(--ac-radius-lg) var(--ac-radius-lg);
background: var(--ac-surface-sunken);
}
.card-meta-compact .card-meta-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Meta table styles for clean date display */
.meta-table {
width: auto;
border-collapse: collapse;
font-size: 0.8rem;
margin: 0 auto;
text-align: left;
min-height: 160px;
display: table;
}
.meta-table tbody {
display: table-row-group;
}
.meta-row {
display: table-row;
font-size: 0.8rem;
margin-bottom: 0;
}
.meta-label-cell {
display: table-cell;
padding: 0.25rem 0.6rem 0.25rem 0;
font-weight: 600;
color: #374151;
text-align: right;
vertical-align: top;
white-space: nowrap;
}
.meta-label-cell span {
display: inline-block;
}
.meta-value-cell {
display: table-cell;
padding: 0.25rem 0;
color: #6b7280;
text-align: left;
vertical-align: middle;
}
.meta-value-cell .badge {
display: inline-block;
vertical-align: middle;
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
}
.meta-label {
font-weight: 600;
color: #374151;
min-width: 85px;
}
.meta-value {
color: #6b7280;
flex: 1;
}
.order-badge-position {
position: absolute;
top: 1.25rem;
right: 1.25rem;
z-index: 10;
display: flex;
}
.order-badge-custom {
font-weight: 700;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(90, 103, 216, 0.3);
display: flex !important;
align-items: center;
gap: 0.5rem;
}
/* Meta grid - compact horizontal layout for order card metadata */
.meta-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
justify-content: center;
gap: var(--ac-space-sm) var(--ac-space-md);
align-items: center;
justify-content: center;
}
.meta-grid .meta-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
gap: var(--ac-space-2xs);
font-size: var(--ac-text-xs);
}
.meta-grid .meta-label {
font-weight: 600;
color: #374151;
min-width: auto;
font-weight: var(--ac-weight-semibold);
color: var(--ac-text-secondary);
}
.meta-grid .meta-value {
color: #6b7280;
color: var(--ac-text-muted);
}
.meta-grid .badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.meta-grid .badge i {
font-size: 0.7rem;
}
/* Special order card */
.eskaera-order-card--special {
background: linear-gradient(180deg, #fffdf0 0%, #fffbea 100%);
border: 1px solid rgba(245, 158, 11, 0.35);
box-shadow: 0 6px 18px rgba(245, 158, 11, 0.12);
}
.eskaera-order-card--special::before {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.eskaera-order-card-link:hover .eskaera-order-card--special {
box-shadow: 0 20px 50px rgba(245, 158, 11, 0.2), 0 0 30px rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.5);
background: linear-gradient(180deg, #fffdf0 0%, #fff8db 100%);
}
.special-order-badge {
background: linear-gradient(135deg, #f59e0b, #d97706) !important;
color: #fff !important;
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 4px;
font-weight: 600;
box-shadow: 0 2px 6px rgba(245, 158, 11, 0.3);
gap: var(--ac-space-2xs);
padding: var(--ac-space-2xs) var(--ac-space-xs);
font-size: var(--ac-text-2xs);
}
.meta-item--full {
width: 100%;
justify-content: center;
}
/* Home delivery: its own row, badge then date, centred as a pair so it stays
centred whether or not the date is there. */
.meta-grid .meta-item--delivery {
width: 100%;
justify-content: center;
gap: var(--ac-space-sm);
}
/* ---------- Call to action ---------- */
/* Bootstrap 5.3 drives .btn from unprefixed custom properties, so the card's
own colours are set by redefining those instead of piling up !important. */
.eskaera-order-card .btn {
--btn-color: var(--ac-text-on-fill);
--btn-bg: var(--ac-color-order);
--btn-border-color: transparent;
--btn-hover-color: var(--ac-text-on-fill);
--btn-hover-bg: var(--ac-color-order-strong);
--btn-hover-border-color: transparent;
--btn-active-color: var(--ac-text-on-fill);
--btn-active-bg: var(--ac-color-order-deep);
--btn-active-border-color: transparent;
--btn-border-radius: var(--ac-radius-sm);
--btn-font-size: var(--ac-text-sm);
--btn-font-weight: var(--ac-weight-semibold);
--btn-padding-y: var(--ac-space-sm);
--btn-padding-x: var(--ac-space-md);
display: inline-flex;
align-items: center;
gap: var(--ac-space-sm);
margin: auto auto var(--ac-space-sm);
box-shadow: var(--ac-shadow-sm);
text-align: center;
text-decoration: none;
transition: background-color var(--ac-transition-fast), box-shadow var(--ac-transition-fast),
transform var(--ac-transition-fast);
}
/* ---------- Special orders ---------- */
.eskaera-order-card--special {
border-color: var(--ac-color-special-border);
background: var(--ac-surface-special);
}
.eskaera-order-card--special::before {
background: linear-gradient(90deg, var(--ac-color-special), var(--ac-color-special-strong));
}
.special-order-badge {
/* Amber needs dark text — white on it is 2.15:1. */
--badge-color: var(--ac-text-on-special);
--badge-font-size: var(--ac-text-2xs);
--badge-border-radius: var(--ac-radius-sm);
--badge-padding-x: var(--ac-space-xs);
--badge-padding-y: var(--ac-space-2xs);
display: inline-flex;
align-items: center;
gap: var(--ac-space-2xs);
background: linear-gradient(135deg, var(--ac-color-special), var(--ac-color-special-strong));
font-weight: var(--ac-weight-semibold);
box-shadow: var(--ac-shadow-sm);
}
/* ---------- States ---------- */
@media (hover: hover) and (pointer: fine) {
.eskaera-order-card:hover {
transform: translateY(-8px);
border-color: var(--ac-color-order-border-hover);
background: var(--ac-surface-order-hover);
box-shadow: var(--ac-shadow-lg);
}
.eskaera-order-card--special:hover {
border-color: var(--ac-color-special-border-hover);
background: var(--ac-surface-special-hover);
}
.eskaera-order-card .btn:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
}
}
/* Keyboard users get the same emphasis as a hover, plus a ring on the card
that holds the focused link. */
.eskaera-order-card:focus-within {
border-color: var(--ac-color-order-border-hover);
background: var(--ac-surface-order-hover);
box-shadow: var(--ac-shadow-lg);
}
.eskaera-order-card-link:focus-visible,
.eskaera-order-card .btn:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
border-radius: var(--ac-radius-sm);
}
/* The entry animation is decorative; motion sensitivity outranks it, and the
hover lift has to stop moving too. */
@media (prefers-reduced-motion: reduce) {
.eskaera-order-card {
animation: none;
transition: none;
}
.eskaera-order-card:hover,
.eskaera-order-card .btn,
.eskaera-order-card .btn:hover {
transform: none;
transition: none;
}
}

View file

@ -1,238 +1,204 @@
/* filepath: website_sale_aplicoop/static/src/css/components/product-card.css */
/**
* Product card component styles
* Product card
*
* Markup (eskaera_shop_products):
* .product-card-wrapper.product-card
* .card
* img.product-img-cover | .product-img-placeholder
* .card-body (title, tags, supplier, origin, 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.
*/
.product-card {
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s, transform 0.2s;
display: flex;
flex-direction: column;
padding: 0.5rem;
height: 100%;
padding: var(--ac-space-2xs);
border: 1px solid var(--ac-border-subtle);
border-radius: var(--ac-radius-lg);
background-color: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
overflow: hidden;
outline: none;
transition: box-shadow var(--ac-transition-normal), transform var(--ac-transition-normal);
}
.product-card:hover,
.product-card:focus-within {
transform: translateY(-4px) scale(1.01);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13);
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 2px;
/* Bootstrap 5.3 drives .card through unprefixed custom properties, so the inner
card can be flattened without !important and without touching the markup. */
.product-card > .card {
--card-bg: transparent;
--card-border-width: 0;
--card-border-radius: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.product-card .product-image {
height: 120px;
width: 100%;
object-fit: cover;
border-radius: 8px 8px 0 0;
background: #f3f3f3;
display: block;
}
/* ---------- Media ---------- */
.product-img-cover {
height: 120px;
width: 100%;
object-fit: cover;
border-radius: 8px 8px 0 0;
box-shadow: 0 2px 8px rgba(40, 39, 39, 0.09);
background: #f3f3f3;
.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);
border-radius: var(--ac-radius-md) var(--ac-radius-md) 0 0;
object-fit: cover;
background-color: var(--ac-surface-muted);
}
.product-img-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--ac-text-muted);
}
/* ---------- Body ---------- */
.product-card .card-body {
display: flex;
flex-direction: column;
height: 100%;
flex-grow: 1;
padding: 0.4rem 0.55rem 0.5rem;
position: relative;
background: linear-gradient(135deg, rgba(0, 123, 255, 0.07) 0%, rgba(0, 123, 255, 0.04) 100%);
transition: background 0.3s;
}
.product-card:hover .card-body,
.product-card:focus-within .card-body {
background: linear-gradient(
135deg,
rgba(108, 117, 125, 0.13) 0%,
rgba(108, 117, 125, 0.09) 100%
);
/* Sized to its own content, not stretched: the add-to-cart form below is
what aligns across the row (see quantity-control.css), so the text/price
block stays compact instead of ballooning to match a taller sibling. */
gap: var(--ac-space-3xs);
padding: var(--ac-space-2xs) var(--ac-space-xs) var(--ac-space-2xs);
background: linear-gradient(135deg, var(--ac-surface-tint) 0%, transparent 100%);
transition: background var(--ac-transition-normal);
}
.product-card .card-title {
flex-grow: 1;
margin: 0 0 0.1rem 0;
min-height: auto;
display: block;
word-wrap: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
/* Reserve both lines so single-line titles don't stagger the grid. */
min-height: calc(2em * var(--ac-leading-tight));
margin: 0;
overflow: hidden;
overflow-wrap: break-word;
font-size: 1rem !important;
line-height: 1.15;
text-align: center;
font-weight: 600;
color: #1a202c;
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-tight);
letter-spacing: 0.01em;
text-align: center;
color: var(--ac-text-primary);
}
.product-card .card-text {
margin-bottom: 0.08rem;
margin-bottom: 0;
font-size: var(--ac-text-sm);
text-align: center;
font-size: 0.95rem;
}
.product-card .card-text strong {
display: block;
margin-bottom: 0.1rem;
font-size: 1.15rem;
color: #3b82f6;
}
.product-card .product-supplier {
text-align: center;
color: #4a5568;
font-weight: 400;
margin-bottom: 0.08rem;
font-size: 0.85rem !important;
}
/* ---------- Tags ---------- */
.product-tags {
text-align: center;
display: flex;
flex-wrap: wrap;
gap: 0.12rem;
gap: var(--ac-space-3xs);
justify-content: center;
font-weight: 400;
font-size: 1rem !important;
margin: 0 0 0.08rem 0;
margin: 0;
padding: 0;
}
/* The per-tag colour is a record value, so it arrives as the --ac-tag-color
custom property set inline on the element (see eskaera_shop_products). */
.badge-km {
background-color: var(--primary-color, #007bff) !important;
color: #fff !important;
font-weight: 600 !important;
padding: 0.18rem 0.32rem !important;
font-size: 0.68rem !important;
border-radius: 0.22rem;
display: inline-block;
border: 1px solid #007bff;
--badge-padding-x: var(--ac-space-2xs);
--badge-padding-y: var(--ac-space-3xs);
--badge-font-size: var(--ac-text-2xs);
--badge-font-weight: var(--ac-weight-semibold);
--badge-color: var(--ac-text-on-fill);
--badge-border-radius: var(--ac-radius-sm);
border: 1px solid var(--ac-tag-color, var(--ac-color-primary-strong));
background-color: var(--ac-tag-color, var(--ac-color-primary-strong));
white-space: nowrap;
margin-right: 0.08rem;
margin-bottom: 0.08rem;
}
.card-body p.card-text {
/* ---------- Supplier & origin ---------- */
.product-card .product-supplier,
.product-card .product-origin {
margin: 0;
font-size: var(--ac-text-xs);
font-weight: var(--ac-weight-normal);
text-align: center;
margin-bottom: 0.35rem;
min-height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-color, #007bff);
color: #fff;
border-radius: 0.18rem;
font-size: 0.98rem;
color: var(--ac-text-secondary);
}
.card-body p.card-text strong {
display: inline;
font-size: 1.18rem !important;
color: var(--primary-color, #007bff);
margin-bottom: 0;
.product-card .product-origin .fa {
margin-right: var(--ac-space-3xs);
}
/* ---------- Price ---------- */
.product-card .product-price-display {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: var(--ac-space-2xs);
margin: 0;
}
.product-card .product-price-main {
font-size: var(--ac-text-lg);
font-weight: var(--ac-weight-bold);
line-height: var(--ac-leading-tight);
white-space: nowrap;
color: var(--ac-color-primary-strong);
}
.product-img-fixed {
object-fit: cover;
height: 120px;
width: 100%;
border-radius: 8px 8px 0 0;
background: #f3f3f3;
display: block;
.product-card .product-unit-price {
margin: 0;
font-size: var(--ac-text-xs);
text-align: center;
color: var(--ac-text-secondary);
}
.product-img-placeholder {
height: 120px;
width: 100%;
border-radius: 8px 8px 0 0;
background: #f3f3f3;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #ccc;
}
/* ---------- States ---------- */
/* Responsive: tablet (≤768px) — compresión moderada */
@media (max-width: 768px) {
.product-card .product-image,
.product-img-cover,
.product-img-fixed,
.product-img-placeholder {
height: 90px;
/* Hover effects only where a pointer can actually hover on touch they stick
after a tap and read as a stuck selection. */
@media (hover: hover) and (pointer: fine) {
.product-card:hover {
transform: translateY(-4px);
box-shadow: var(--ac-shadow-lg);
}
.product-card:hover .card-body {
background: linear-gradient(135deg, var(--ac-surface-tint-hover) 0%, transparent 100%);
}
}
/* Keyboard focus only: a ring, and no transform moving the card under the
caret while tabbing is disorienting. */
.product-card:focus-visible,
.product-card:focus-within {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
.product-card:focus-within .card-body {
background: linear-gradient(135deg, var(--ac-surface-tint-hover) 0%, transparent 100%);
}
@media (prefers-reduced-motion: reduce) {
.product-card,
.product-card .card-body {
padding: 0.35rem 0.45rem 0.4rem;
transition: none;
}
.card-body p.card-text {
margin-bottom: 0.25rem;
min-height: 1.4rem;
}
}
/* Responsive: móvil (≤480px) — compresión máxima */
@media (max-width: 480px) {
.product-card {
padding: 0.15rem;
border-radius: 6px;
}
.product-card .product-image,
.product-img-cover,
.product-img-fixed,
.product-img-placeholder {
height: 60px;
border-radius: 5px 5px 0 0;
}
.product-card .card-body {
padding: 0.25rem 0.3rem 0.3rem;
}
.product-card .card-title {
font-size: 0.82rem !important;
line-height: 1.15;
margin-bottom: 0.05rem;
}
.product-card .card-text {
font-size: 0.8rem;
}
.badge-km {
font-size: 0.55rem !important;
padding: 0.1rem 0.18rem !important;
}
.product-tags {
font-size: 0.82rem !important;
gap: 0.08rem;
margin-bottom: 0.05rem;
}
.card-body p.card-text {
min-height: 1rem;
font-size: 0.85rem;
margin-bottom: 0.2rem;
}
.product-card .product-supplier {
font-size: 0.72rem !important;
margin-bottom: 0.05rem;
.product-card:hover {
transform: none;
}
}
/* Accesibilidad: focus visible */
.product-card:focus-visible {
outline: 2.5px solid var(--primary-color, #007bff);
outline-offset: 2px;
}

View file

@ -1,405 +1,220 @@
/* filepath: website_sale_aplicoop/static/src/css/components/quantity-control.css */
/**
* Quantity control (+ - input) component styles
* Quantity control stepper ( input +) plus the add-to-cart button.
*
* Markup (eskaera_shop_products):
* form.add-to-cart-form
* .qty-control
* label.visually-hidden
* button.qty-decrease
* input.product-qty
* button.qty-increase
* button.add-to-cart-btn
*
* Sizing is fluid (--ac-control-size), which replaces the six per-breakpoint
* bands this component used to carry. The only query left is a pointer query:
* what actually changes between a phone and a desktop is the input device, not
* the viewport width.
*/
/* Formulario agregar al carrito */
/* margin-top: auto pins this to the bottom of .product-card > .card (which is
stretched to the grid row's tallest sibling) so every card's button ends up
at the same height, without ballooning the text/price block above it see
.product-card .card-body in product-card.css. */
.add-to-cart-form {
width: 100%;
gap: 0;
padding: 0.5rem;
margin-top: 0.25rem;
background-color: #f8f9fa;
border-radius: 4px;
display: flex;
border: 1px solid #e9ecef;
align-content: center;
justify-content: center;
width: 100%;
margin-top: auto;
padding: var(--ac-space-3xs);
border: 1px solid var(--ac-border);
border-radius: var(--ac-radius-sm);
background-color: var(--ac-surface-sunken);
}
.add-to-cart-form .input-group {
/* A grid, not a wrapping flex row: four 44px controls never fit across a card
that is ~150px wide, so the row used to wrap and drop "+" onto its own line
next to the cart button. Two deliberate rows instead the stepper across the
top, the primary action full width underneath, where it is easier to hit. */
.qty-control {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
width: 100%;
gap: 0;
padding: 0;
}
/* ---------- Stepper ---------- */
.qty-control .qty-decrease,
.qty-control .qty-increase {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
flex-shrink: 0;
width: var(--ac-control-size);
height: var(--ac-control-size);
padding: 0;
/* WCAG 1.4.11: the boundary is what identifies the control, so it needs
3:1 against the surface behind it. */
border: var(--ac-control-border-width) solid var(--ac-border-strong);
font-size: var(--ac-text-sm);
color: var(--ac-text-secondary);
background-color: var(--ac-surface);
cursor: pointer;
transition: color var(--ac-transition-fast), border-color var(--ac-transition-fast),
background-color var(--ac-transition-fast);
}
.qty-control .qty-decrease {
border-start-start-radius: var(--ac-radius-sm);
border-end-start-radius: var(--ac-radius-sm);
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.qty-control .qty-increase {
border-start-end-radius: var(--ac-radius-sm);
border-end-end-radius: var(--ac-radius-sm);
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.add-to-cart-form .product-qty {
flex: 1 1 auto;
text-align: center;
font-weight: 700;
font-size: 1rem;
padding: 0.3rem;
min-width: 32px;
max-width: 70px;
border: 2px solid #dee2e6;
/* Fills the middle grid column; min-width 0 lets it shrink on a narrow card
instead of pushing the "+" button out of the row. */
width: 100%;
min-width: 0;
height: var(--ac-control-size);
padding: 0 var(--ac-space-2xs);
/* Collapse the shared edge with the two stepper buttons. */
border: var(--ac-control-border-width) solid var(--ac-border-strong);
border-inline-width: 0;
border-radius: 0;
background-color: #ffffff;
transition: all 0.2s ease;
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-bold);
text-align: center;
color: var(--ac-text-primary);
background-color: var(--ac-surface);
appearance: textfield;
-moz-appearance: textfield;
height: 36px;
line-height: 30px;
transition: border-color var(--ac-transition-fast), box-shadow var(--ac-transition-fast);
}
.add-to-cart-form .product-qty::-webkit-outer-spin-button,
.add-to-cart-form .product-qty::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
-webkit-appearance: none;
}
.add-to-cart-form .product-qty:focus {
outline: none;
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
background-color: #ffffff;
}
/* ---------- Add to cart ---------- */
/* Contenedor de control de cantidad */
.qty-control {
.add-to-cart-form .add-to-cart-btn {
/* Full width on its own row, under the stepper. */
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 0;
width: 100%;
height: 44px;
}
/* Botones de cantidad + y - */
.qty-control .qty-decrease,
.qty-control .qty-increase {
min-width: 36px;
width: 36px;
height: 36px;
height: var(--ac-control-size);
margin-top: var(--ac-space-3xs);
padding: 0;
gap: 0;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dee2e6;
background-color: #ffffff;
color: #495057;
transition: all 0.2s ease;
border: none;
border-radius: var(--ac-radius-sm);
font-size: var(--ac-text-sm);
color: var(--ac-text-on-fill);
background-color: var(--ac-color-accent);
cursor: pointer;
flex-shrink: 0;
transition: background-color var(--ac-transition-fast), transform var(--ac-transition-fast);
}
.qty-control .qty-decrease:hover,
.qty-control .qty-increase:hover {
border-color: var(--primary-color, #007bff);
color: var(--primary-color, #007bff);
background-color: #f8f9fa;
.add-to-cart-form .add-to-cart-btn .fa {
font-size: var(--ac-text-md);
}
/* Out of stock stays focusable and clickable on purpose: the handler answers
with an explanation instead of the control silently doing nothing. It is
marked aria-disabled in the template, so the styling has to read as
unavailable without relying on colour alone (the icon changes to fa-ban). */
.add-to-cart-form .add-to-cart-btn[data-out-of-stock="true"] {
color: var(--ac-text-secondary);
background-color: var(--ac-surface-muted);
cursor: not-allowed;
}
/* ---------- States ---------- */
@media (hover: hover) and (pointer: fine) {
.qty-control .qty-decrease:hover,
.qty-control .qty-increase:hover {
border-color: var(--ac-color-primary);
color: var(--ac-color-primary-strong);
background-color: var(--ac-surface-sunken);
}
.add-to-cart-form .add-to-cart-btn:hover {
background-color: var(--ac-color-accent-strong);
transform: scale(1.05);
}
.add-to-cart-form .add-to-cart-btn[data-out-of-stock="true"]:hover {
background-color: var(--ac-surface-muted);
transform: none;
}
}
.qty-control .qty-decrease:active,
.qty-control .qty-increase:active {
background-color: #e9ecef;
}
.add-to-cart-form .add-to-cart-btn {
white-space: nowrap;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin: 0;
font-size: 0.8rem;
height: 36px;
width: 36px;
min-width: 36px;
max-width: 36px;
min-height: 36px;
max-height: 36px;
flex-shrink: 0;
border: none;
background-color: #7c3aed !important;
color: #ffffff !important;
cursor: pointer;
transition: all 0.2s ease;
}
.add-to-cart-form .add-to-cart-btn:hover {
background-color: #6d28d9 !important;
transform: scale(1.05);
background-color: var(--ac-surface-sunken);
}
.add-to-cart-form .add-to-cart-btn:active {
background-color: #5b21b6 !important;
background-color: var(--ac-color-accent-strong);
transform: scale(0.98);
}
.add-to-cart-form .add-to-cart-btn i {
font-size: 1rem;
.qty-control .qty-decrease:focus-visible,
.qty-control .qty-increase:focus-visible,
.add-to-cart-form .add-to-cart-btn:focus-visible,
.add-to-cart-form .product-qty:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
/* Raise the focused control so the ring is not clipped by its neighbours. */
position: relative;
z-index: 1;
}
/* Pantallas muy grandes: 1600px+ (6 columnas) */
@media (min-width: 1600px) {
.add-to-cart-form {
padding: 0.35rem;
gap: 0.08rem;
}
.add-to-cart-form .product-qty:focus-visible {
border-color: var(--ac-color-primary);
}
.add-to-cart-form .input-group {
height: 32px;
gap: 0.06rem;
}
.add-to-cart-form .product-qty {
font-size: 1rem;
min-width: 28px;
max-width: 60px;
height: 32px;
padding: 0.2rem;
}
/* ---------- Input device ---------- */
/* Touch: grow every target to 44px. WCAG 2.2 AA (2.5.8) only asks for 24px,
but 44px is the AAA figure (2.5.5) and what a thumb actually needs. */
@media (pointer: coarse) {
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 32px;
height: 32px;
font-size: 0.9rem;
width: var(--ac-touch-target);
height: var(--ac-touch-target);
}
.add-to-cart-form .product-qty,
.add-to-cart-form .add-to-cart-btn {
height: 32px;
width: 32px;
min-width: 32px;
max-width: 32px;
font-size: 0.75rem;
height: var(--ac-touch-target);
}
}
/* Pantallas grandes: 1400-1599px (5 columnas) */
@media (max-width: 1599px) and (min-width: 1400px) {
.add-to-cart-form {
padding: 0.36rem;
gap: 0.07rem;
}
.add-to-cart-form .input-group {
height: 32px;
gap: 0.05rem;
}
.add-to-cart-form .product-qty {
font-size: 1rem;
min-width: 28px;
max-width: 62px;
height: 32px;
padding: 0.2rem;
}
@media (prefers-reduced-motion: reduce) {
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 32px;
height: 32px;
font-size: 0.9rem;
.qty-control .qty-increase,
.add-to-cart-form .product-qty,
.add-to-cart-form .add-to-cart-btn {
transition: none;
}
.add-to-cart-form .add-to-cart-btn {
height: 32px;
width: 32px;
min-width: 32px;
font-size: 0.75rem;
}
}
/* Pantallas medianas: 1200-1399px (4 columnas) */
@media (max-width: 1399px) and (min-width: 1200px) {
.add-to-cart-form {
padding: 0.34rem;
gap: 0.06rem;
}
.add-to-cart-form .input-group {
height: 32px;
gap: 0.05rem;
}
.add-to-cart-form .product-qty {
font-size: 0.95rem;
min-width: 28px;
max-width: 60px;
height: 32px;
padding: 0.2rem;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 32px;
height: 32px;
font-size: 0.88rem;
}
.add-to-cart-form .add-to-cart-btn {
height: 32px;
width: 32px;
min-width: 32px;
font-size: 0.74rem;
}
}
/* Pantallas tablet grandes: 992-1199px (3 columnas) */
@media (max-width: 1199px) and (min-width: 992px) {
.add-to-cart-form {
padding: 0.32rem;
gap: 0.04rem;
}
.add-to-cart-form .input-group {
height: 32px;
gap: 0.04rem;
}
.add-to-cart-form .product-qty {
font-size: 0.9rem;
min-width: 28px;
max-width: 58px;
height: 32px;
padding: 0.2rem;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 32px;
height: 32px;
font-size: 0.85rem;
border-width: 1px;
}
.add-to-cart-form .add-to-cart-btn {
height: 32px;
width: 32px;
min-width: 32px;
font-size: 0.72rem;
}
}
/* Pantallas tablet: 768-991px (2-3 columnas) */
@media (max-width: 991px) and (min-width: 768px) {
.add-to-cart-form {
padding: 0.42rem;
gap: 0.03rem;
}
.add-to-cart-form .input-group {
height: 36px;
gap: 0.04rem;
}
.add-to-cart-form .product-qty {
font-size: 1rem;
min-width: 32px;
max-width: 68px;
height: 36px;
padding: 0.3rem;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 36px;
height: 36px;
font-size: 0.95rem;
border-width: 1px;
}
.add-to-cart-form .add-to-cart-btn {
height: 36px;
width: 36px;
min-width: 36px;
font-size: 0.82rem;
}
}
/* Móvil grande: 576-767px (2 columnas) */
@media (max-width: 767px) and (min-width: 576px) {
.add-to-cart-form {
padding: 0.35rem;
gap: 0.1rem;
}
.add-to-cart-form .input-group {
height: 34px;
gap: 0.08rem;
}
.add-to-cart-form .product-qty {
font-size: 0.9rem;
min-width: 30px;
max-width: 64px;
height: 34px;
padding: 0.25rem;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 34px;
height: 34px;
font-size: 0.9rem;
border-width: 1px;
}
.add-to-cart-form .add-to-cart-btn {
height: 34px;
width: 34px;
min-width: 34px;
font-size: 0.8rem;
}
}
/* Móvil pequeño: < 576px (1 columna) */
@media (max-width: 575px) {
.add-to-cart-form {
padding: 0.3rem;
gap: 0.08rem;
flex-wrap: wrap;
width: 100%;
}
.add-to-cart-form .input-group {
height: 32px;
gap: 0.06rem;
flex: 1 1 100%;
}
.add-to-cart-form .product-qty {
font-size: 0.8rem;
min-width: 28px;
max-width: 60px;
height: 32px;
padding: 0.2rem;
line-height: 28px;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
width: 32px;
height: 32px;
font-size: 0.8rem;
border-width: 1px;
min-width: 32px;
}
.qty-control {
width: auto;
gap: 0;
}
.add-to-cart-form .add-to-cart-btn {
height: 32px;
width: 32px;
min-width: 32px;
max-width: 32px;
font-size: 0.75rem;
flex: 0 0 auto;
}
.add-to-cart-form .add-to-cart-btn i {
font-size: 0.8rem;
.add-to-cart-form .add-to-cart-btn:hover,
.add-to-cart-form .add-to-cart-btn:active {
transform: none;
}
}

View file

@ -4,70 +4,103 @@
*/
/**
* Tag Filter Badges Component
* Tag filter badges
*
* Styles for interactive tag filter badges in the product search/filter bar.
* Badges toggle between secondary (unselected) and primary (selected) states.
* Toggle buttons above the product grid. Three states:
* (default) nothing selected each badge wears its own tag colour
* .is-selected this tag is filtering
* .is-dimmed another tag is filtering, this one is not
*
* realtime_search.js used to paint all three by writing nine
* style.setProperty(..., "important") calls per click. It now only toggles
* these classes and aria-pressed; the appearance lives here.
*
* The per-tag colour arrives as --ac-tag-color, set inline from the record.
*/
/* Container for all tag filter badges */
.tag-filter-badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0;
justify-content: center;
gap: var(--ac-space-sm);
padding: var(--ac-space-xs) 0;
}
/* Individual tag filter badge button */
.tag-filter-badge {
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
display: inline-flex;
align-items: center;
/* 24px is the AA floor (WCAG 2.2 §2.5.8); coarse pointers get 44 below. */
min-height: 1.5rem;
padding: var(--ac-space-sm) var(--ac-space-xs);
border: 1px solid var(--ac-tag-color, var(--ac-color-secondary));
border-radius: var(--ac-radius-sm);
background-color: var(--ac-tag-color, var(--ac-color-secondary));
font-size: var(--ac-text-sm);
font-weight: var(--ac-weight-medium);
color: var(--ac-text-on-fill);
white-space: nowrap;
transition: all 0.2s ease-in-out;
cursor: pointer;
user-select: none;
display: inline-block;
border: 1px solid;
color: #ffffff !important;
transition: background-color var(--ac-transition-fast), border-color var(--ac-transition-fast),
box-shadow var(--ac-transition-fast), transform var(--ac-transition-fast);
}
/* Tags sin color definido en Odoo: usar color secundario del tema */
.tag-filter-badge.tag-use-theme-color {
background-color: var(--bs-secondary, #6c757d);
border-color: var(--bs-secondary, #6c757d);
}
/* Product card tags (badge-km) sin color definido: usar color del tema */
/* Tags with no colour of their own fall back to the theme's secondary. The old
var(--bs-secondary) never resolved: Odoo emits Bootstrap's custom properties
unprefixed, so that lookup always landed on its hardcoded fallback. */
.tag-filter-badge.tag-use-theme-color,
.badge-km.tag-use-theme-color {
background-color: var(--bs-secondary, #6c757d);
border-color: var(--bs-secondary, #6c757d);
color: #ffffff !important;
border-color: var(--ac-color-secondary);
background-color: var(--ac-color-secondary);
color: var(--ac-text-on-fill);
}
.tag-filter-badge:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
filter: brightness(0.9);
/* Active filter: the strong primary, which also carries white text at 7.04:1. */
.tag-filter-badge.is-selected {
border-color: var(--ac-color-primary-strong);
background-color: var(--ac-color-primary-strong);
box-shadow: var(--ac-shadow-sm);
}
/* Not part of the current filter while others are. */
.tag-filter-badge.is-dimmed {
border-color: var(--ac-color-secondary);
background-color: var(--ac-color-secondary);
}
/* Counter text inside badge */
.tag-filter-badge .tag-count {
font-weight: 600;
margin-left: 0.25rem;
margin-left: var(--ac-space-2xs);
font-weight: var(--ac-weight-semibold);
font-variant-numeric: tabular-nums;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.tag-filter-badges {
gap: 0.375rem;
}
/* ---------- States ---------- */
@media (hover: hover) and (pointer: fine) {
.tag-filter-badge:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
filter: brightness(0.9);
}
}
.tag-filter-badge:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
@media (pointer: coarse) {
.tag-filter-badge {
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
min-height: var(--ac-touch-target);
padding-inline: var(--ac-space-md);
}
}
@media (prefers-reduced-motion: reduce) {
.tag-filter-badge,
.tag-filter-badge:hover {
transform: none;
transition: none;
}
}

View file

@ -7,83 +7,142 @@
/* Unified header for both shop and checkout pages */
.eskaera-order-header,
.checkout-header {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: clamp(var(--ac-space-md), 2vw, var(--ac-space-xl));
border: 1px solid var(--ac-border-subtle);
border-radius: var(--ac-radius-md);
background: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
}
.eskaera-order-header h1,
.checkout-header h1 {
color: #2d3748;
font-weight: 600;
margin-bottom: 1.5rem;
border-bottom: 3px solid var(--primary-color, #007bff);
padding-bottom: 1rem;
margin-bottom: var(--ac-space-md);
padding-bottom: var(--ac-space-md);
border-bottom: 3px solid var(--ac-color-primary);
font-size: var(--ac-text-2xl);
font-weight: var(--ac-weight-bold);
color: var(--ac-text-primary);
}
.order-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.info-item:last-child {
margin-bottom: 0;
}
.info-label {
font-weight: 700;
color: #2d3748;
font-size: 1.05rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value,
.info-date {
font-size: 1.35rem;
color: #1a202c;
font-weight: 600;
}
.info-date {
display: block;
font-size: 1.25rem;
color: #2d3748;
font-weight: 500;
margin-top: 0.25rem;
}
/* Title styling for both pages */
.checkout-title,
.eskaera-order-header h1 {
font-size: 2rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 1rem;
.checkout-title {
margin-bottom: var(--ac-space-md);
font-size: var(--ac-text-2xl);
font-weight: var(--ac-weight-bold);
color: var(--ac-text-primary);
}
.checkout-order-name {
font-size: 1.5rem;
color: #2d3748;
font-weight: 600;
font-size: var(--ac-text-lg);
font-weight: var(--ac-weight-semibold);
color: var(--ac-text-secondary);
}
/* Info value styling */
.info-value {
font-size: 1.1rem;
/* ==========================================================================
Order info: label / value pairs
Rendered as <dl><dt class="info-label"><dd class="info-value">.
Column count follows the viewport: 4 (label, value, label, value) on
desktop, 2 (one pair per row) from sm, stacked below that. Cells carry the
rules themselves and column-gap stays 0, so each row reads as one
uninterrupted line instead of a dashed one.
========================================================================== */
.order-info-grid {
display: grid;
grid-template-columns: 1fr;
align-items: baseline;
margin: 0;
}
.order-info-grid > .info-label {
padding-block: var(--ac-space-sm) 0;
/* Small, muted and spaced out, so the label never competes with its value.
--ac-text-secondary keeps it at 7.53:1 despite the smaller size. */
font-size: var(--ac-text-xs);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-snug);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ac-text-secondary);
}
.order-info-grid > .info-value {
margin: 0;
padding-block: var(--ac-space-2xs) var(--ac-space-sm);
border-bottom: 1px solid var(--ac-border);
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-snug);
color: var(--ac-text-primary);
overflow-wrap: break-word;
}
/* Multi-line values (pickup slots) keep their own rhythm. */
.order-info-grid .info-value-line + .info-value-line {
margin-top: var(--ac-space-2xs);
}
/* Two columns: label and value side by side, one pair per row. */
@media (min-width: 576px) {
.order-info-grid {
grid-template-columns: auto 1fr;
}
.order-info-grid > .info-label {
padding-block: var(--ac-space-sm);
padding-inline-end: var(--ac-space-md);
border-bottom: 1px solid var(--ac-border);
}
.order-info-grid > .info-value {
padding-block: var(--ac-space-sm);
padding-inline-end: var(--ac-space-md);
}
}
/* Four columns: two pairs per row, split by a vertical rule. The second pair
always starts on an even <dt>, because grid places the cells in source
order. */
@media (min-width: 992px) {
.order-info-grid {
grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1fr);
}
.order-info-grid > .info-label:nth-of-type(even) {
padding-inline-start: var(--ac-space-lg);
border-inline-start: 1px solid var(--ac-border);
}
}
/* Single label/value pair, used in the checkout info row where each pair sits in
its own Bootstrap column. */
.info-pair {
margin: 0;
}
.info-pair > .info-label {
margin-bottom: var(--ac-space-3xs);
font-size: var(--ac-text-xs);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-snug);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ac-text-secondary);
}
.info-pair > .info-value {
margin: 0;
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-semibold);
line-height: var(--ac-leading-snug);
color: var(--ac-text-primary);
overflow-wrap: break-word;
}
/* Standalone date line used by the checkout header. */
.info-date {
font-size: 1rem;
display: block;
margin-top: var(--ac-space-2xs);
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-medium);
color: var(--ac-text-secondary);
}

View file

@ -1,106 +1,67 @@
/* filepath: website_sale_aplicoop/static/src/css/layout/pages.css */
/**
* Page backgrounds and main layout structures
* Page shells: backgrounds and the container stacking above them.
*
* These styles used to reach far outside the addon:
* - `html, body { background: transparent !important }` stripped the
* background from EVERY page of the website, addon or not.
* - `body.website_published { !important }` was meant to put it back, but
* no page carries that class checked against the rendered HTML of the
* orders, shop and checkout pages, whose <body> has no class at all. Both
* rules were therefore dead weight on top of a site-wide side effect.
* - `#wrap { padding: 2rem 0 }` styled Odoo's own page wrapper everywhere.
* On these pages #wrap *is* the Eskaera root, so the padding moved onto the
* root classes and the ID selector is gone.
*
* The body reset now applies only to pages that actually contain an Eskaera
* root, which is enough specificity to win without !important.
*/
html,
body {
background-color: transparent !important;
background: transparent !important;
body:has(.eskaera-page),
body:has(.eskaera-shop-page),
body:has(.eskaera-generic-page),
body:has(.eskaera-checkout-page) {
background: transparent;
}
body.website_published {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color) 30%, white),
color-mix(in srgb, var(--primary-color) 60%, black)
) !important;
}
body.website_published .eskaera-shop-page,
body.website_published .eskaera-checkout-page {
background: transparent !important;
}
/* Generic page background mixin */
.eskaera-page,
.eskaera-shop-page,
.eskaera-generic-page,
.eskaera-checkout-page {
min-height: 100vh;
position: relative;
/* dvh tracks the mobile browser chrome; vh stays as the fallback. */
min-height: 100vh;
min-height: 100dvh;
padding: var(--ac-space-xl) 0;
}
.eskaera-page,
.eskaera-generic-page {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary-color) 10%, white),
color-mix(in srgb, var(--primary-color) 70%, black)
) !important;
color-mix(in srgb, var(--ac-color-primary) 10%, white),
color-mix(in srgb, var(--ac-color-primary) 70%, black)
);
}
.eskaera-shop-page {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color) 10%, white),
color-mix(in srgb, var(--primary-color) 10%, rgb(135, 135, 135))
) !important;
color-mix(in srgb, var(--ac-color-primary) 10%, white),
color-mix(in srgb, var(--ac-color-primary) 10%, rgb(135, 135, 135))
);
}
.eskaera-checkout-page {
background: linear-gradient(
-135deg,
color-mix(in srgb, var(--primary-color) 0%, white),
color-mix(in srgb, var(--primary-color) 60%, black)
) !important;
white,
color-mix(in srgb, var(--ac-color-primary) 60%, black)
);
}
.eskaera-page::before,
.eskaera-generic-page::before {
background-image: radial-gradient(
circle at 20% 50%,
color-mix(in srgb, var(--primary-color, white) 20%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 80%,
color-mix(in srgb, var(--primary-color) 25%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 20%,
color-mix(in srgb, var(--primary-color, white) 15%, transparent) 0%,
transparent 50%
);
}
.eskaera-shop-page::before {
background-image: radial-gradient(
circle at 15% 30%,
color-mix(in srgb, var(--primary-color, white) 18%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 85% 70%,
color-mix(in srgb, var(--primary-color) 22%, transparent) 0%,
transparent 50%
);
}
.eskaera-checkout-page::before {
background-image: radial-gradient(
circle at 20% 50%,
color-mix(in srgb, var(--primary-color, white) 20%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 80%,
color-mix(in srgb, var(--primary-color) 25%, transparent) 0%,
transparent 50%
);
}
/* ---------- Decorative glow overlay ---------- */
.eskaera-page::before,
.eskaera-shop-page::before,
@ -108,28 +69,61 @@ body.website_published .eskaera-checkout-page {
.eskaera-checkout-page::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
inset: 0;
z-index: 0;
pointer-events: none;
}
.eskaera-page::before,
.eskaera-generic-page::before {
background-image: radial-gradient(
circle at 20% 50%,
color-mix(in srgb, var(--ac-color-primary) 20%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 80%,
color-mix(in srgb, var(--ac-color-primary) 25%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 20%,
color-mix(in srgb, var(--ac-color-primary) 15%, transparent) 0%,
transparent 50%
);
}
.eskaera-shop-page::before {
background-image: radial-gradient(
circle at 15% 30%,
color-mix(in srgb, var(--ac-color-primary) 18%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 85% 70%,
color-mix(in srgb, var(--ac-color-primary) 22%, transparent) 0%,
transparent 50%
);
}
.eskaera-checkout-page::before {
background-image: radial-gradient(
circle at 20% 50%,
color-mix(in srgb, var(--ac-color-primary) 20%, transparent) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 80%,
color-mix(in srgb, var(--ac-color-primary) 25%, transparent) 0%,
transparent 50%
);
}
/* Content sits above the overlay. */
.eskaera-page > .container,
.eskaera-shop-page > .container,
.eskaera-generic-page > div,
.eskaera-checkout-page > .container {
position: relative;
z-index: 1;
padding-top: 2rem;
padding-bottom: 2rem;
}
#wrap {
padding: 2rem 0;
}
.group-order-shop {
background-color: transparent;
}

View file

@ -1,103 +1,21 @@
/* filepath: website_sale_aplicoop/static/src/css/layout/responsive.css */
/**
* Responsive design breakpoints and adjustments
* All media queries centralized here for easier maintenance
* NOTE: products-grid.css has its own breakpoints and should NOT be overridden here
* Responsive adjustments do not add to it.
*
* Centralising every media query here was the original plan; in practice this
* file drifted out of sync with the components it overrode and needed
* !important to win twenty of them at its peak. Each component now owns its
* queries at the bottom of its own file, and most need none at all once sizing
* is fluid. What is left here is thumbnail geometry and table density, which
* belong to no single component.
*/
@media (max-width: 992px) {
/* Cart sidebar */
.sticky-cart {
position: static !important;
margin-top: 2rem;
width: 100%;
}
.cart-items {
max-height: 400px;
}
#cart-items-container {
width: 100%;
padding: 0.75rem;
}
.list-group-item {
padding: 0.75rem;
}
.list-group-item h6 {
font-size: 0.95rem;
}
.list-group-item strong {
min-width: 70px;
}
.cart-header {
padding: 1rem;
width: 100%;
}
.cart-header h5 {
font-size: 1.25rem;
}
.cart-title-lg {
font-size: 1.25rem;
}
#save-cart-btn,
#reload-cart-btn {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
margin-right: 0.25rem;
}
.card-header .btn-group {
gap: 0.5rem !important;
}
/* Order list grid */
.eskaera-orders,
.eskaera-orders-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
}
@media (max-width: 768px) {
/* Header (shared between eskaera and checkout) */
.eskaera-order-header,
.checkout-header {
padding: 1rem;
}
.eskaera-order-header h1,
.checkout-header h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
word-wrap: break-word;
overflow-wrap: break-word;
}
.checkout-title,
.eskaera-order-header h1 {
font-size: 1.5rem;
}
.checkout-order-name {
font-size: 1.2rem;
}
/* Fix header flex layout for mobile */
.eskaera-order-header .d-flex,
.checkout-header .d-flex {
flex-direction: column !important;
gap: 1rem !important;
align-items: flex-start !important;
}
/* Header type and the info grid scale from layout/header.css; the title row
stacks through Bootstrap's flex-column/flex-md-row utilities. Order cards,
order list, product card and quantity control all size themselves from
their own files. */
.order-thumbnail-md {
max-width: 100%;
@ -105,413 +23,54 @@
height: auto;
}
/* Order info grid */
.order-info-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
/* Eskaera page headings */
.eskaera-page h1 {
font-size: 2rem;
}
.eskaera-page > .container > .row:first-child p {
font-size: 1.1rem !important;
}
/* Order cards */
.eskaera-order-card {
min-height: 250px;
}
.eskaera-order-card .card-body {
padding: 0.5rem 0.6rem 0 0.6rem;
gap: 0.15rem;
}
.eskaera-order-card .card-title {
font-size: 0.9rem;
-webkit-line-clamp: 3;
}
.order-desc-text {
font-size: 0.75rem;
-webkit-line-clamp: 2;
}
.eskaera-order-card .btn {
padding: 0.5rem 1rem;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.order-thumbnail-sm {
width: 70px;
height: 70px;
}
.order-thumbnail-md {
width: 100px;
height: 100px;
}
/* Header with image and text */
.eskaera-order-card .d-flex {
gap: 0.75rem !important;
}
.eskaera-order-card .card-footer {
padding: 0.75rem 1rem;
}
/* Order list */
.eskaera-orders,
.eskaera-orders-grid {
grid-template-columns: 1fr !important;
gap: 1.25rem;
}
/* Product grid */
.card-grid {
grid-template-columns: 1fr;
}
/* Product quantity controls */
.add-to-cart-form .product-qty {
font-size: 1rem;
min-width: 50px;
height: 32px;
padding: 0.25rem 0.35rem;
}
.qty-control .qty-decrease,
.qty-control .qty-increase {
height: 32px;
width: 32px;
font-size: 0.9rem;
}
.add-to-cart-form .add-to-cart-btn {
height: 36px;
min-height: 36px;
padding: 0.35rem 0.5rem;
font-size: 1rem;
min-width: 44px;
}
.add-to-cart-form .input-group {
gap: 0.25rem;
}
/* Checkout summary */
.checkout-summary-container {
padding: 1rem;
overflow-x: auto;
}
.checkout-summary-table {
font-size: 0.95rem;
min-width: 500px; /* Prevent table collapse */
}
.checkout-summary-table thead th {
padding: 0.75rem 0.5rem;
font-size: 0.85rem;
}
.checkout-summary-table tbody td {
padding: 0.75rem 0.5rem;
}
.total-amount {
font-size: 1.5rem;
}
.summary-heading {
font-size: 1.5rem;
}
.checkout-actions .btn {
font-size: 1rem;
padding: 0.6rem 1.2rem;
width: 4.375rem;
height: 4.375rem;
}
.group-order-header {
padding: 1rem 0;
margin-bottom: 1rem;
}
.product-card {
margin-bottom: 1rem;
}
.product-card .product-image {
height: 150px;
}
.checkout-container {
padding: 1rem;
padding: var(--ac-space-md) 0;
margin-bottom: var(--ac-space-md);
}
}
@media (max-width: 576px) {
/* Cart items */
.list-group-item.d-flex {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.list-group-item h6 {
font-size: 0.85rem;
word-break: break-word;
}
.list-group-item small {
font-size: 0.75rem;
}
.list-group-item .d-flex {
width: 100%;
justify-content: space-between;
align-items: center;
}
.list-group-item strong {
min-width: auto;
font-size: 0.95rem;
}
.list-group-item .remove-from-cart {
min-width: 28px;
min-height: 28px;
}
.cart-items {
max-height: 250px;
}
.sticky-cart {
margin-top: 1.5rem;
}
/* Checkout type scales fluidly from sections/checkout.css; only the table's
density genuinely changes here, and the container's padding with it. */
.toast-notification {
bottom: 20px;
right: 20px;
left: 20px;
inset: auto var(--ac-space-md) var(--ac-space-md);
max-width: none;
}
#save-cart-btn,
#reload-cart-btn {
padding: 0.3rem 0.4rem;
font-size: 0.8rem;
}
.card-header .d-flex {
flex-wrap: wrap;
gap: 0.5rem !important;
}
.card-header .btn-group {
gap: 0.25rem !important;
}
.card-header .btn-group .btn {
padding: 0.3rem 0.4rem;
font-size: 0.8rem;
}
.form-check {
padding: 1rem 1.5rem !important;
margin-left: 0 !important;
}
.form-check-input {
margin-left: 0 !important;
margin-right: 1rem !important;
flex-shrink: 0;
}
.form-check-label[for="home-delivery-checkbox"] {
font-size: 1rem !important;
margin-left: 0 !important;
}
/* Header */
.eskaera-order-header,
.checkout-header {
padding: 0.75rem;
}
.eskaera-order-header h1,
.checkout-header h1 {
font-size: 1.25rem;
padding-bottom: 0.5rem;
}
.checkout-title,
.eskaera-order-header h1 {
font-size: 1.25rem;
}
.checkout-order-name {
font-size: 1rem;
}
.info-label {
font-size: 0.9rem;
}
/* Eskaera page */
.eskaera-page h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.eskaera-page > .container > .row:first-child {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
}
.eskaera-page > .container > .row:first-child p {
font-size: 1rem !important;
}
/* Order cards */
.eskaera-order-card {
min-height: 220px;
}
.eskaera-order-card .card-body {
padding: 0.4rem 0.5rem 0 0.5rem;
}
.eskaera-order-card .card-title {
font-size: 0.85rem;
}
.order-desc-text {
font-size: 0.7rem;
}
.eskaera-order-card .btn {
padding: 0.45rem 0.85rem;
font-size: 0.75rem;
margin-bottom: 0.4rem;
}
.order-thumbnail-sm {
width: 60px;
height: 60px;
width: 3.75rem;
height: 3.75rem;
}
.order-thumbnail-md {
width: 80px;
height: 80px;
}
/* Order list */
.eskaera-orders,
.eskaera-orders-grid {
grid-template-columns: 1fr !important;
gap: 1rem;
width: 5rem;
height: 5rem;
}
.eskaera-empty-state {
padding: 2rem 0.5rem;
padding: var(--ac-space-xl) var(--ac-space-sm);
}
.eskaera-empty-state .alert {
font-size: 0.95rem;
padding: 1rem;
}
/* Checkout */
.checkout-summary-container {
padding: 0.75rem;
padding: var(--ac-space-sm);
}
.checkout-summary-table {
font-size: 0.85rem;
min-width: 450px;
}
.checkout-summary-table thead th {
padding: 0.5rem 0.35rem;
font-size: 0.75rem;
min-width: 28rem;
}
.checkout-summary-table thead th,
.checkout-summary-table tbody td {
padding: 0.5rem 0.35rem;
font-size: 0.85rem;
}
.total-amount {
font-size: 1.3rem;
}
.total-label {
font-size: 1rem;
}
.currency {
font-size: 1rem;
}
.summary-heading {
font-size: 1.25rem;
padding-left: 0.75rem;
}
.checkout-actions .btn {
font-size: 0.9rem;
padding: 0.6rem 1rem;
}
}
/* Product card typography responsive scaling */
@media screen and (min-width: 1600px) {
.product-tags {
font-size: 1.1rem !important;
}
/* Scale down quantity input for 6-column layout */
.add-to-cart-form .product-qty {
font-size: 0.85rem;
max-width: 55px;
}
.add-to-cart-form .qty-decrease,
.add-to-cart-form .qty-increase {
font-size: 0.75rem;
min-width: 28px;
min-height: 28px;
}
}
@media screen and (min-width: 1400px) and (max-width: 1599px) {
.product-tags {
font-size: 1.25rem !important;
}
/* Scale down quantity input for 5-column layout */
.add-to-cart-form .product-qty {
font-size: 0.9rem;
max-width: 60px;
}
.add-to-cart-form .qty-decrease,
.add-to-cart-form .qty-increase {
font-size: 0.8rem;
min-width: 30px;
min-height: 30px;
}
}
@media (max-width: 720px) {
.card-grid {
grid-template-columns: 1fr;
padding: var(--ac-space-sm) var(--ac-space-2xs);
}
}

View file

@ -1,64 +1,67 @@
/* filepath: website_sale_aplicoop/static/src/css/sections/checkout.css */
/**
* Checkout page section styles
* Checkout page
*
* The summary table keeps a min-width and scrolls inside its own container
* rather than reflowing into cards: the four columns (product, qty, price,
* subtotal) are read across, and stacking them loses that. The container is
* focusable so the scroll is reachable without a pointer.
*/
.checkout-container {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.checkout-summary {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
margin-bottom: var(--ac-space-xl);
padding: var(--ac-space-lg);
border-radius: var(--ac-radius-md);
background-color: var(--ac-surface-sunken);
}
.checkout-summary-container {
background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
margin-bottom: 2rem;
margin-bottom: var(--ac-space-xl);
padding: var(--ac-space-lg);
border-radius: var(--ac-radius-lg);
background: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
overflow-x: auto;
}
.checkout-summary-container:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
.checkout-summary-table {
margin-bottom: 0;
font-size: 1.15rem;
}
.checkout-summary-table thead {
background-color: #2d3748;
color: white;
min-width: 30rem;
font-size: var(--ac-text-md);
}
.checkout-summary-table thead th {
font-weight: 700;
padding: 1rem 0.75rem;
text-transform: uppercase;
font-size: 1rem;
letter-spacing: 0.5px;
padding: var(--ac-space-md) var(--ac-space-xs);
border: none;
font-size: var(--ac-text-base);
font-weight: var(--ac-weight-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.checkout-summary-table tbody tr {
border-bottom: 1px solid #e2e8f0;
transition: background-color 0.2s ease;
}
.checkout-summary-table tbody tr:hover {
background-color: #f7fafc;
border-bottom: 1px solid var(--ac-border);
transition: background-color var(--ac-transition-fast);
}
.checkout-summary-table tbody td {
padding: 1rem 0.75rem;
padding: var(--ac-space-md) var(--ac-space-xs);
vertical-align: middle;
}
/* Figures line up column-wise instead of drifting with glyph widths. */
.checkout-summary-table .col-price,
.checkout-summary-table .col-subtotal,
.checkout-summary-table td.text-end {
font-variant-numeric: tabular-nums;
}
.checkout-summary-table .col-name {
width: 50%;
}
@ -76,52 +79,68 @@
}
.checkout-summary-table .empty-message {
background-color: #f7fafc;
background-color: var(--ac-surface-sunken);
}
/* ---------- Total ---------- */
.checkout-total-section {
border-top: 2px solid #e2e8f0;
padding-top: 1.5rem;
margin-top: 1rem;
margin-top: var(--ac-space-md);
padding-top: var(--ac-space-lg);
border-top: 2px solid var(--ac-border);
}
.total-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1rem;
align-items: baseline;
gap: var(--ac-space-sm);
}
.total-label {
font-weight: 700;
font-size: 1.2rem;
color: #1a202c;
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-bold);
color: var(--ac-text-primary);
}
.total-amount {
font-size: 2rem;
font-weight: 700;
color: var(--success-color);
min-width: 120px;
min-width: 7.5rem;
font-size: var(--ac-text-2xl);
font-weight: var(--ac-weight-bold);
font-variant-numeric: tabular-nums;
text-align: right;
/* --ac-color-success is 3.13:1 on white: fine for the 2xl total, but the
darker tone keeps it readable if the type scale ever shrinks. */
color: var(--ac-color-success-strong);
}
.currency {
font-size: 1.2rem;
color: #2d3748;
font-weight: 600;
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-semibold);
color: var(--ac-text-secondary);
}
.summary-heading {
font-size: 1.75rem;
font-weight: 700;
color: #1a202c;
border-left: 4px solid var(--success-color);
padding-left: 1rem;
padding-left: var(--ac-space-md);
border-left: 4px solid var(--ac-color-success-strong);
font-size: var(--ac-text-xl);
font-weight: var(--ac-weight-bold);
color: var(--ac-text-primary);
}
.checkout-order-desc {
word-wrap: break-word;
color: var(--ac-text-secondary);
overflow-wrap: break-word;
color: #666;
}
@media (hover: hover) and (pointer: fine) {
.checkout-summary-table tbody tr:hover {
background-color: var(--ac-surface-sunken);
}
}
@media (prefers-reduced-motion: reduce) {
.checkout-summary-table tbody tr {
transition: none;
}
}

View file

@ -1,63 +1,21 @@
/* filepath: website_sale_aplicoop/static/src/css/sections/info-cards.css */
/**
* Info cards and grid section styles
* Info cards
*
* The meta block that used to be duplicated here now lives with its component
* (components/order-card.css): `.card-meta-compact`, `.meta-label` and
* `.meta-value` were declared in both files, and this one loading last won,
* silently overriding the component. `.card-grid`, `.card-col`, `.card-meta-list`
* and `.meta-row` were dead: no template ever emitted them.
*/
.order-info-card {
background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-radius: var(--ac-radius-lg);
background: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
}
.order-info-card .card-body {
padding: 1rem;
}
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
align-items: start;
margin-top: 0.5rem;
}
.card-meta-compact {
padding: 0.5rem 0;
}
.card-meta-compact .card-meta-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
}
.meta-label {
font-weight: 700;
color: #2d3748;
min-width: fit-content;
flex-shrink: 0;
}
.meta-value {
font-weight: 400;
color: #27292c;
word-break: break-word;
flex-grow: 1;
}
.card-col {
min-width: 0;
}
.card-col .card-text strong {
font-weight: 700;
color: #111827;
padding: var(--ac-space-md);
}

View file

@ -1,48 +1,54 @@
/* filepath: website_sale_aplicoop/static/src/css/sections/order-list.css */
/**
* Order list and Eskaera page section styles
* Order list (Eskaera index page)
*
* The grid derived its column count from three breakpoints (3 / 2 / 1) that
* each had to out-specify the previous one the mobile rule needed
* !important. Deriving it from the available width removes all three.
*/
.eskaera-page h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2d3748;
margin-bottom: 0.5rem;
margin-bottom: var(--ac-space-sm);
font-size: var(--ac-text-2xl);
font-weight: var(--ac-weight-bold);
text-align: center;
color: var(--ac-text-primary);
}
.eskaera-page > .container > .row:first-child {
margin-bottom: 2rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 1.5rem;
margin-bottom: var(--ac-space-xl);
padding-bottom: var(--ac-space-lg);
border-bottom: 2px solid var(--ac-border);
}
.eskaera-page > .container > .row:first-child p {
font-size: 1.3rem !important;
color: #4a5568;
text-align: center;
margin: 0;
font-weight: 500;
font-size: var(--ac-text-md);
font-weight: var(--ac-weight-medium);
text-align: center;
color: var(--ac-text-secondary);
}
.eskaera-orders,
.eskaera-orders-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
/* 20rem keeps three across a full-width container and drops to one on a
phone, without any breakpoint doing the counting. */
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: var(--ac-space-lg);
margin: 0 auto;
max-width: 100%;
}
.eskaera-empty-state {
padding: var(--ac-space-2xl) var(--ac-space-md);
text-align: center;
padding: 3rem 1rem;
}
.eskaera-empty-state .alert {
max-width: 500px;
max-width: 31.25rem;
margin: 0 auto;
font-size: 1.05rem;
border-radius: 0.5rem;
border-radius: var(--ac-radius-md);
font-size: var(--ac-text-base);
}

View file

@ -1,14 +1,29 @@
/* filepath: website_sale_aplicoop/static/src/css/sections/products-grid.css */
/**
* Products grid section styles
* Products grid
*
* 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.
*
* 9rem is the card's minimum width; at the widest container (1320px minus the
* category sidebar) it still resolves to six columns, as before.
*/
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1.2rem;
margin-bottom: 2rem;
grid-template-columns: 1fr;
gap: var(--ac-space-md);
margin-bottom: var(--ac-space-xl);
}
@media (min-width: 576px) {
.products-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 9rem), 1fr));
}
}
.product-card-wrapper {
@ -17,62 +32,7 @@
}
.product-search-highlight {
border: 2px solid #007bff;
padding: 10px;
}
/* Pantallas muy grandes: 1600px+ (6 columnas) */
@media screen and (min-width: 1600px) {
.products-grid {
grid-template-columns: repeat(6, 1fr);
gap: 1.2rem;
}
}
/* Pantallas grandes: 1400px-1599px (5 columnas) */
@media screen and (min-width: 1400px) and (max-width: 1599px) {
.products-grid {
grid-template-columns: repeat(5, 1fr);
gap: 1.15rem;
}
}
/* Pantallas medianas: 1200px-1399px (4 columnas) */
@media screen and (min-width: 1200px) and (max-width: 1399px) {
.products-grid {
grid-template-columns: repeat(4, 1fr);
gap: 1.1rem;
}
}
/* Pantallas tablet grandes: 992px-1199px (3 columnas) */
@media screen and (min-width: 992px) and (max-width: 1199px) {
.products-grid {
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
}
/* Pantallas tablet: 768px-991px (3 columnas en tablet grande, 2 en tablet pequeña) */
@media screen and (min-width: 768px) and (max-width: 991px) {
.products-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.95rem;
}
}
/* Pantallas móvil grande: 576px-767px (2 columnas) */
@media screen and (min-width: 576px) and (max-width: 767px) {
.products-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.8rem;
}
}
/* Pantallas móvil pequeño: 1 columna */
@media (max-width: 575px) {
.products-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
padding: var(--ac-space-sm);
border: 2px solid var(--ac-color-primary);
border-radius: var(--ac-radius-md);
}

View file

@ -194,10 +194,10 @@
'<th scope="col" class="col-qty text-center">' +
escapeHtml(labels.quantity) +
"</th>" +
'<th scope="col" class="col-price text-right">' +
'<th scope="col" class="col-price text-end">' +
escapeHtml(labels.price) +
"</th>" +
'<th scope="col" class="col-subtotal text-right">' +
'<th scope="col" class="col-subtotal text-end">' +
escapeHtml(labels.subtotal) +
"</th>" +
'</tr></thead><tbody id="checkout-summary-tbody"></tbody></table>' +
@ -280,10 +280,10 @@
'<td class="text-center">' +
qty.toFixed(2).replace(/\.?0+$/, "") +
"</td>" +
'<td class="text-right">€' +
'<td class="text-end">€' +
price.toFixed(2) +
"</td>" +
'<td class="text-right">€' +
'<td class="text-end">€' +
subtotal.toFixed(2) +
"</td>";
tbody.appendChild(row);
@ -307,10 +307,10 @@
'<td class="text-center">' +
qty.toFixed(2).replace(/\.?0+$/, "") +
"</td>" +
'<td class="text-right">€' +
'<td class="text-end">€' +
price.toFixed(2) +
"</td>" +
'<td class="text-right">€' +
'<td class="text-end">€' +
subtotal.toFixed(2) +
"</td>";
tbody.appendChild(row);

View file

@ -459,8 +459,9 @@ console.log("[INFINITE_SCROLL] Script loaded!");
// Show fallback button
var btn = document.getElementById("load-more-btn");
if (btn) {
// d-none is the only thing hiding it; the button no longer
// carries an inline display:none to clear.
btn.classList.remove("d-none");
btn.style.display = "";
}
});
},

View file

@ -141,9 +141,6 @@
// Initialize available tags from DOM
self._initializeAvailableTags();
// Store original colors for each tag badge
self.originalTagColors = {}; // Maps tag ID to original color
// Store last values at instance level so polling can access them
// Initialize to current values to avoid triggering reset on first poll
self.lastSearchValue = self.searchInput.value.trim();
@ -165,9 +162,9 @@
// This listener is separate from the filtering listener
self.searchInput.addEventListener("input", function () {
if (self.searchInput.value.trim().length > 0) {
self.clearSearchBtn.style.display = "block";
self.clearSearchBtn.classList.add("is-visible");
} else {
self.clearSearchBtn.style.display = "none";
self.clearSearchBtn.classList.remove("is-visible");
}
});
@ -179,7 +176,7 @@
// Clear the input
self.searchInput.value = "";
self.clearSearchBtn.style.display = "none";
self.clearSearchBtn.classList.remove("is-visible");
// Update last stored value to prevent polling from detecting "change"
self.lastSearchValue = "";
@ -204,7 +201,7 @@
// Initial check - don't show if empty
if (self.searchInput.value.trim().length > 0) {
self.clearSearchBtn.style.display = "block";
self.clearSearchBtn.classList.add("is-visible");
}
}
@ -283,114 +280,21 @@
var tagBadges = document.querySelectorAll('[data-toggle="tag-filter"]');
console.log("[realtimeSearch] Found " + tagBadges.length + " tag filter badges");
// Get theme colors from CSS variables
var rootStyles = getComputedStyle(document.documentElement);
var primaryColor =
rootStyles.getPropertyValue("--bs-primary").trim() ||
rootStyles.getPropertyValue("--primary").trim() ||
"#0d6efd";
var secondaryColor =
rootStyles.getPropertyValue("--bs-secondary").trim() ||
rootStyles.getPropertyValue("--secondary").trim() ||
"#6c757d";
console.log(
"[realtimeSearch] Theme colors - Primary:",
primaryColor,
"Secondary:",
secondaryColor
);
// Store original colors for each badge BEFORE adding event listeners
tagBadges.forEach(function (badge) {
var tagId = parseInt(badge.getAttribute("data-tag-id"), 10);
var tagColor = badge.getAttribute("data-tag-color");
// Store the original color (either from data-tag-color or use secondary for tags without color)
if (tagColor) {
self.originalTagColors[tagId] = tagColor;
console.log(
"[realtimeSearch] Stored original color for tag " + tagId + ": " + tagColor
);
} else {
self.originalTagColors[tagId] = "var(--bs-secondary, " + secondaryColor + ")";
console.log("[realtimeSearch] Tag " + tagId + " has no color, using secondary");
}
});
tagBadges.forEach(function (badge) {
badge.addEventListener("click", function (e) {
e.preventDefault();
var tagId = parseInt(badge.getAttribute("data-tag-id"), 10);
var originalColor = self.originalTagColors[tagId];
console.log(
"[realtimeSearch] Tag badge clicked: " +
tagId +
" (original color: " +
originalColor +
")"
);
// Toggle tag selection
if (self.selectedTags.has(tagId)) {
// Deselect
self.selectedTags.delete(tagId);
console.log("[realtimeSearch] Tag " + tagId + " deselected");
} else {
// Select
self.selectedTags.add(tagId);
console.log("[realtimeSearch] Tag " + tagId + " selected");
}
// Update colors for ALL badges based on selection state
tagBadges.forEach(function (badge) {
var id = parseInt(badge.getAttribute("data-tag-id"), 10);
if (self.selectedTags.size === 0) {
// No tags selected: restore all to original colors
var originalColor = self.originalTagColors[id];
badge.style.setProperty("background-color", originalColor, "important");
badge.style.setProperty("border-color", originalColor, "important");
badge.style.setProperty("color", "#ffffff", "important");
console.log(
"[realtimeSearch] Badge " +
id +
" reset to original color (no selection)"
);
} else if (self.selectedTags.has(id)) {
// Selected: primary color
badge.style.setProperty(
"background-color",
"var(--bs-primary, " + primaryColor + ")",
"important"
);
badge.style.setProperty(
"border-color",
"var(--bs-primary, " + primaryColor + ")",
"important"
);
badge.style.setProperty("color", "#ffffff", "important");
console.log(
"[realtimeSearch] Badge " + id + " set to primary (selected)"
);
} else {
// Not selected but others are: secondary color
badge.style.setProperty(
"background-color",
"var(--bs-secondary, " + secondaryColor + ")",
"important"
);
badge.style.setProperty(
"border-color",
"var(--bs-secondary, " + secondaryColor + ")",
"important"
);
badge.style.setProperty("color", "#ffffff", "important");
console.log(
"[realtimeSearch] Badge " + id + " set to secondary (not selected)"
);
}
});
self._syncTagBadgeStates(tagBadges);
// Filter products (independent of search/category state)
// Skip during initialization
@ -647,6 +551,10 @@
")"
);
// Announce the outcome to screen readers, which otherwise get no
// signal that the grid changed under them.
self._announceResults(visibleCount);
// Auto-load more products if too few are visible
self._autoLoadMoreIfNeeded(visibleCount);
} catch (error) {
@ -655,6 +563,49 @@
}
},
/**
* Reflect the current selection on every tag badge: aria-pressed for
* assistive tech, two state classes for the eye. Colours belong to
* components/tag-filter.css this only says which state each badge is in.
*
* @param {NodeList} tagBadges - All badges in the filter bar
*/
_syncTagBadgeStates: function (tagBadges) {
var self = this;
var hasSelection = self.selectedTags.size > 0;
tagBadges.forEach(function (badge) {
var id = parseInt(badge.getAttribute("data-tag-id"), 10);
var isSelected = self.selectedTags.has(id);
badge.classList.toggle("is-selected", isSelected);
badge.classList.toggle("is-dimmed", hasSelection && !isSelected);
badge.setAttribute("aria-pressed", isSelected ? "true" : "false");
});
},
/**
* Write the result count into the polite live region next to the search
* box. Labels come from i18nManager, never from a hardcoded literal.
*
* @param {number} visibleCount - Number of currently visible products
*/
_announceResults: function (visibleCount) {
var status = document.getElementById("realtime-search-status");
if (!status) {
return;
}
var i18n = window.i18nManager;
var message;
if (visibleCount === 0) {
message = (i18n && i18n.get("no_results")) || "No products found";
} else {
message =
visibleCount + " " + ((i18n && i18n.get("products_found")) || "products found");
}
status.textContent = message;
},
_autoLoadMoreIfNeeded: function (visibleCount) {
/**
* Automatically load more products if there are too few visible on screen.

View file

@ -691,12 +691,16 @@
var notification = document.createElement("div");
notification.className =
"alert alert-" + type + " alert-dismissible fade show position-fixed";
notification.style.cssText =
"top: 80px; right: 20px; z-index: 9999; min-width: 300px; max-width: 500px; font-size: 18px; padding: 1.5rem; transition: opacity 0.3s ease-out;";
"alert alert-" + type + " alert-dismissible fade show eskaera-notification";
// Announce it: errors and warnings are the whole point of this helper,
// and without a live role a screen reader never hears them.
notification.setAttribute("role", type === "error" ? "alert" : "status");
notification.setAttribute("aria-live", type === "error" ? "assertive" : "polite");
notification.innerHTML =
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>';
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="' +
((window.i18nManager && window.i18nManager.get("close")) || "Close") +
'"></button>';
document.body.appendChild(notification);
@ -1253,13 +1257,17 @@
// Create toast element
var toast = document.createElement("div");
toast.className = "toast-notification";
toast.setAttribute("role", "status");
// Get translated "added to cart" label from server
var labels = this._getLabels();
var addedMsg = labels.added_to_cart || "added to cart";
toast.innerHTML =
'<i class="fa fa-check"></i> <strong>' + productName + "</strong> " + addedMsg;
'<i class="fa fa-check" aria-hidden="true"></i> <strong>' +
this.escapeHtml(productName) +
"</strong> " +
addedMsg;
document.body.appendChild(toast);
@ -1551,7 +1559,7 @@
// Simple approach: if response is very short, assume no more products
if (html.trim().length < 100) {
console.log("[LAZY_LOADING] No more products, hiding load-more button");
btn.style.display = "none";
btn.classList.add("d-none");
} else {
// Re-enable button for next page
btn.disabled = false;
@ -1595,7 +1603,7 @@
if (Object.keys(this.cart).length === 0) {
cartContainer.innerHTML =
'<p class="text-muted" style="font-size: 1.1rem;">' +
'<p class="text-muted cart-empty-message">' +
(labels.empty_cart || "This order's cart is empty.") +
"</p>";
return;
@ -1612,20 +1620,20 @@
total += subtotal;
html +=
'<div class="list-group-item" style="font-size: 1.05rem;">' +
'<div class="list-group-item">' +
'<div class="mb-2">' +
'<h6 class="mb-0" style="font-size: 1.1rem;">' +
'<h6 class="mb-0">' +
self.escapeHtml(item.name) +
"</h6>" +
"</div>" +
'<div class="d-flex justify-content-between align-items-center">' +
'<small class="text-muted" style="font-size: 1rem;">' +
'<small class="text-muted">' +
parseFloat(item.qty).toFixed(1) +
" x €" +
item.price.toFixed(2) +
"</small>" +
'<div class="d-flex align-items-center gap-2">' +
'<strong style="font-size: 1.1rem;">€' +
"<strong>€" +
subtotal.toFixed(2) +
"</strong>" +
'<button class="btn btn-sm btn-danger remove-from-cart" ' +
@ -1644,11 +1652,7 @@
});
html += "</div>";
html +=
'<div class="cart-total mt-3 pt-3" style="font-size: 1.2rem; font-weight: bold;">' +
"Total: €" +
total.toFixed(2) +
"</div>";
html += '<div class="cart-total mt-3 pt-3">' + "Total: €" + total.toFixed(2) + "</div>";
cartContainer.innerHTML = html;

View file

@ -3,12 +3,45 @@
<data>
<!-- Template to load items from history and redirect to group order -->
<template id="eskaera_load_from_history" name="Load Order from History">
<html>
<html t-att-lang="page_lang or 'en-US'">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Loading Order...</title>
<!-- Standalone document: it does not go through website.layout, so
web.assets_frontend is not loaded and there is no design token to
reach for. Hence the local style block. -->
<style>
.eskaera-redirect-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem 1rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
text-align: center;
color: #1a202c;
}
.eskaera-redirect-message {
font-size: 1.125rem;
}
</style>
</head>
<body>
<!-- This page only stages the cart in sessionStorage and redirects, but it is
still a page a person can land on: give it something to read while that
happens, announced politely, and a way out if the script never runs. -->
<main class="eskaera-redirect-page">
<p class="eskaera-redirect-message" role="status">Loading your order…</p>
<noscript>
<p>This page needs JavaScript to load the order into your cart.</p>
<p>
<a t-att-href="group_order_url">Continue to the group order</a>
</p>
</noscript>
</main>
<script type="text/javascript">
// Items are embedded directly in the script (pre-serialized JSON from controller)
var itemsJson = <t t-raw="items_json"/>; // This is a JSON array/string

View file

@ -3,11 +3,13 @@
<data>
<!-- Extend portal_my_orders to add group order name and pickup day columns -->
<template id="portal_my_orders_extend" inherit_id="sale.portal_my_orders" name="My Orders - Add Group Order Info">
<!-- Add column headers for new info -->
<!-- Add column headers for new info.
There is no "Actions" header here: the matching <td> below is
commented out, so declaring it left every row one cell short of
its header row and pushed the columns out of alignment. -->
<xpath expr="//tr[hasclass('active')]//th[last()]" position="after">
<th>Group Order</th>
<th>Pickup Day</th>
<th class="text-center">Actions</th>
<th scope="col">Group Order</th>
<th scope="col">Pickup Day</th>
</xpath>
<!-- Add data cells for each order row -->
@ -75,25 +77,30 @@
<!-- Group Order Info -->
<div class="col-lg-12">
<div class="card">
<!-- text-bg-primary, not bg-primary: a badge's default white on
the primary blue is 3.98:1, under AA. The utility picks a
contrasting foreground for whatever the theme colour is.
d-block on the notes so their mt-2 actually applies — margins
do nothing on an inline span. -->
<div class="card-header bg-light">
<strong><t t-esc="sale_order.group_order_id.name"/></strong>
<t t-if="sale_order.home_delivery">
<span class="badge bg-primary">
<span class="badge text-bg-primary">
<t t-set="day_idx" t-value="(int(sale_order.group_order_id.pickup_day) + 1) % 7 if sale_order.group_order_id.pickup_day else 0"/>
<t t-esc="day_names[day_idx] if day_names else ''"/>,
<t t-esc="sale_order.group_order_id.delivery_date.strftime('%d/%m/%Y') if sale_order.group_order_id.delivery_date else ''"/>
</span>
<span class="mt-2 text-muted small">
<span class="d-block mt-2 text-muted small">
<t t-esc="sale_order.group_order_id.delivery_notice"/>
</span>
</t>
<t t-else="">
<span class="badge bg-primary">
<span class="badge text-bg-primary">
<t t-set="day_idx" t-value="int(sale_order.group_order_id.pickup_day) % 7 if sale_order.group_order_id.pickup_day else 0"/>
<t t-esc="day_names[day_idx] if day_names else ''"/>,
<t t-esc="sale_order.pickup_date.strftime('%d/%m/%Y')"/>
</span>
<span class="mt-2 small">
<span class="d-block mt-2 small">
<t t-foreach="sale_order.group_order_id.group_ids" t-as="group">
<t t-esc="group.name"/>
<t t-if="group.street">

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"
>