feat: MPS-24 Phase 2.6b — facet nav on shop home + mobile SERP lanes
Operator review on tablet showed two gaps in the 2.6 ship: - Shop home (layout 2 lanes) had no facet sidebar — only tag detail did - Mobile lanes were horizontal Netflix-style tile rows with no description visible at all This batch extends the facet experience across every page where the operator opted into categorization (home_layout >= 1): - New templates/_facet_nav.j2 with three macros (facet_form, sidebar, details). One source of truth for the controls, three variants of the wrapper. shop_tag.j2 refactored to import the macro. - home.j2 + shop.j2 now wrap content in .tag-detail-layout when home_layout >= 1, rendering both the desktop sidebar and the mobile <details> accordion. CSS toggles visibility per viewport. - Each lane in layout 2 now emits BOTH horizontal tiles AND vertical .serp-list-row markup with 6-sentence excerpts. CSS shows tiles >=800px, SERP rows <800px. Tablet / phone shoppers see image + title + price + description excerpt under each tag heading. - views/shop.py: facet_tags is populated for any home_layout >= 1 (was only on ?tag= filter); sort + price now also filter the non-tag-filtered home grid when the shopper applies them. Native HTML. No JS dependency. Same controls everywhere. Test: test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows. Docs: CLAUDE.md MPS-24 section, architecture matrix, ticket Phase 2.6b.
This commit is contained in:
parent
0f84e6871b
commit
8970e960fb
10 changed files with 387 additions and 98 deletions
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -492,20 +492,38 @@ filters on no-JS. With JS, `static/js/tag_filter.js` intercepts clicks
|
|||
and filters the grid in place via `data-tag-slugs` attribute on
|
||||
`.serp-item`, zero network cost, fewer clicks to purchase.
|
||||
|
||||
**Tag-detail facet sidebar.** `shop_tag.j2` renders a left
|
||||
`.facet-nav` (220px column ≥800px viewport, hidden below) wrapping a
|
||||
single `<form method="get">` with three sections: Sort dropdown, Price
|
||||
range (`?price_min=` / `?price_max=`, parsed by
|
||||
`_price_range_from_request()` → cents, filtered by
|
||||
**Facet nav (Phase 2.6).** All facet controls live in one Jinja macro
|
||||
file: `templates/_facet_nav.j2` exports `sidebar()` (desktop `<aside>`)
|
||||
and `details()` (mobile/tablet `<details>` accordion). Both call the
|
||||
same internal `facet_form()` so the controls stay identical across
|
||||
viewports — only the wrapper differs.
|
||||
|
||||
Every page that opts into categorization (operator picks `home_layout
|
||||
>= 1`, or tag detail page) includes BOTH variants in the markup. CSS
|
||||
hides one per viewport: `details.facet-details` hidden ≥800px, `aside
|
||||
.facet-nav` hidden <800px. Native HTML, no JS.
|
||||
|
||||
Surfaces wired:
|
||||
- `shop_tag.j2` — tag detail (always shows facets)
|
||||
- `home.j2` + `shop.j2` — shop home / search when `shop.home_layout >= 1`
|
||||
|
||||
Form anatomy: Sort dropdown, Price range (`?price_min=` / `?price_max=`,
|
||||
parsed by `_price_range_from_request()` → cents, filtered by
|
||||
`_filter_by_price_range()`), and full Categories list
|
||||
(`ctx["facet_tags"]` = `tags_by_popularity(...)` with no limit).
|
||||
Mobile (<800px) hides the sidebar and keeps the horizontal
|
||||
`.tag-chip-strip-mobile` so phones still have one-tap tag switching.
|
||||
SERP rows render `product.excerpt_sentences(6)` instead of the
|
||||
char-based excerpt — six sentences, markdown-stripped, with a
|
||||
1500-char safety cap for descriptions that lack terminators.
|
||||
`Product.excerpt()` and `Product.excerpt_sentences()` both consume
|
||||
the module-level `_strip_markdown()` helper for one source of truth.
|
||||
|
||||
**Mobile lanes (Phase 2.6).** When `home_layout == 2` (sectioned lanes),
|
||||
each `.tag-lane` renders BOTH a horizontal `.tag-lane-grid` of tiles AND
|
||||
a vertical `.serp-list.tag-lane-rows` of `.serp-list-row` items with
|
||||
6-sentence excerpts. CSS toggles: tiles ≥800px, SERP rows <800px. The
|
||||
mobile/tablet experience now shows description + title + price per
|
||||
product instead of a Netflix-style swipe row that hides the description.
|
||||
|
||||
**SERP excerpt.** `Product.excerpt_sentences(n=6, max_chars=1500)`
|
||||
returns six sentences, markdown-stripped, with a 1500-char safety cap
|
||||
for descriptions without terminators. `Product.excerpt()` (char-based)
|
||||
and `Product.excerpt_sentences()` both consume the module-level
|
||||
`_strip_markdown()` helper for one source of truth.
|
||||
|
||||
Phase 2 (shipped): deterministic title-plus-description auto-tagger
|
||||
in `lib/tag_suggest.py`. Title tokens weight × 3, description × 1
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ mps_page_session (raw rows)
|
|||
| Tag auto-suggest (MPS-24 Phase 2) | `lib/tag_suggest.py` over `Product.title` + `Product.description` | `?show_suggestions=1` on `/s/{id}/tags` + `scripts/backfill_tags.py` | Never auto-applies |
|
||||
| Tag-detail facet sidebar (MPS-24 Phase 2.6) | `shop_tag.j2` + `views/shop.py` `_price_range_from_request` / `_filter_by_price_range` | Sort + price range + categories list, GET form, sidebar ≥800px, top chip strip <800px | Always on for tag detail pages |
|
||||
| 6-sentence SERP excerpt (MPS-24 Phase 2.6) | `Product.excerpt_sentences(n=6, max_chars=1500)` via shared `_strip_markdown()` helper | `shop_tag.j2` row description | Always on |
|
||||
| Facet nav on shop home (MPS-24 Phase 2.6b) | `_facet_nav.j2` macros + `home.j2` / `shop.j2` wrappers | Desktop sidebar + mobile `<details>` accordion when `shop.home_layout >= 1` | Opt-in via home_layout |
|
||||
| Mobile SERP rows under each lane (MPS-24 Phase 2.6b) | `home.j2` / `shop.j2` `.tag-lane-rows` markup + CSS visibility swap | Horizontal tiles ≥800px; vertical SERP rows with 6-sentence excerpts <800px | On for layout 2 |
|
||||
|
||||
## Ticket Index
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,28 @@ picker is Phase 2).
|
|||
| `tests/test_models.py` | `TestTagSuggestPureFunctions` — 11 unit tests over tokenize / stem / cluster |
|
||||
| `tests/test_functional.py` | `test_suggest_clusters_renders_candidates`, `test_apply_suggestion_creates_tag_and_attaches_products`, `test_dismiss_suggestion_adds_to_stopwords`, `test_apply_suggestion_rejects_empty_input` |
|
||||
|
||||
### Phase 2.6b — facet nav on shop home (layout 2) + mobile SERP rows under each lane (shipped 2026-05-15)
|
||||
|
||||
Follow-up to 2.6 after operator review on tablet: layout 2 (sectioned
|
||||
lanes) had no facet sidebar and mobile lanes were horizontal tile rows
|
||||
with no description (image attached in chat shows the issue on
|
||||
`shop.printableprompts.com` rendered on a tablet in Firefox).
|
||||
|
||||
| Surface | Change |
|
||||
|---------|--------|
|
||||
| `templates/_facet_nav.j2` (new) | Reusable Jinja macros: `facet_form(...)` shared body, `sidebar(...)` desktop wrapper, `details(...)` mobile `<details>` accordion. Single source of truth for the controls |
|
||||
| `templates/shop_tag.j2` | Switched to the macro; both sidebar + details now render |
|
||||
| `templates/home.j2` + `shop.j2` | Wrapped lanes content + flat/filtered grid in `.tag-detail-layout` when `shop.home_layout >= 1`; both facet variants render. Each lane now emits BOTH horizontal `.tag-lane-grid` (desktop) AND vertical `.serp-list.tag-lane-rows` with 6-sentence excerpts (mobile/tablet) |
|
||||
| `views/shop.py` | `_build_home_layout_context()` populates `facet_tags` for any `layout >= 1` (was only on `?tag=` filter). Also wires sort + price filter on non-tag-filtered home when shopper applies them |
|
||||
| `static/css/common.css` | New `.facet-details` styles (mobile accordion); `aside.facet-nav` hidden <800px; `.tag-lane-grid` hidden <800px; `.serp-list.tag-lane-rows` hidden ≥800px |
|
||||
| `tests/test_functional.py` | `test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows` |
|
||||
|
||||
Mobile rule: shopper opens `shop.foo.com` on phone, taps "Filter & sort"
|
||||
to open the `<details>` accordion (sort, price, every category), then
|
||||
scrolls a vertical SERP list with description excerpts under each tag
|
||||
heading. Desktop rule: 220px left sidebar + Netflix-style horizontal
|
||||
tile lanes. Same controls, same data, viewport-driven presentation.
|
||||
|
||||
### Phase 2.6 — tag-detail facet sidebar + 6-sentence SERP excerpt (shipped 2026-05-15)
|
||||
|
||||
Operator feedback after Phase 2.5: tag-detail SERP rows were truncating
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,25 @@ div.tag-lane-tile img.serp-thumbnail {
|
|||
}
|
||||
}
|
||||
|
||||
/* MPS-24 Phase 2.6: mobile/tablet lanes show SERP rows
|
||||
(.tag-lane-rows) instead of horizontal tiles (.tag-lane-grid).
|
||||
Each lane includes BOTH markups; CSS shows the right one. */
|
||||
section.tag-lane-rows {
|
||||
/* Hidden on desktop — horizontal tiles take over. */
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 799.99px) {
|
||||
div.tag-lane-grid {
|
||||
display: none;
|
||||
}
|
||||
section.tag-lane-rows {
|
||||
display: grid;
|
||||
gap: var(--space-3, 12px);
|
||||
container-type: inline-size;
|
||||
margin: var(--space-2, 8px) 0 var(--space-3, 12px) 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tag editor bulk list */
|
||||
ul.tag-list {
|
||||
list-style: none;
|
||||
|
|
@ -1819,6 +1838,46 @@ div.tag-detail-layout {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
* Mobile/tablet <details> facet accordion — hidden on desktop where
|
||||
* the sidebar takes over. Native HTML, no JS required.
|
||||
* ===================================================================*/
|
||||
details.facet-details {
|
||||
margin: var(--space-3, 12px) 0;
|
||||
padding: var(--space-3, 12px);
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
details.facet-details summary.facet-details-summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #333);
|
||||
padding: var(--space-1, 4px) 0;
|
||||
}
|
||||
span.facet-details-active-mark {
|
||||
color: var(--color-primary, #4338ca);
|
||||
font-weight: 700;
|
||||
margin-left: var(--space-1, 4px);
|
||||
}
|
||||
details.facet-details[open] .facet-form {
|
||||
margin-top: var(--space-3, 12px);
|
||||
}
|
||||
[data-theme="dark"] details.facet-details {
|
||||
background: var(--dark-surface, #1f2937);
|
||||
border-color: var(--dark-border-color, #4a5568);
|
||||
}
|
||||
@media (min-width: 800px) {
|
||||
details.facet-details {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 799.99px) {
|
||||
aside.facet-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
aside.facet-nav {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
98
make_post_sell/templates/_facet_nav.j2
Normal file
98
make_post_sell/templates/_facet_nav.j2
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{# Reusable facet nav (sort + price + categories). MPS-24 Phase 2.6.
|
||||
|
||||
Used by:
|
||||
- shop_tag.j2 (tag detail page)
|
||||
- home.j2 / shop.j2 (shop home with layout 2 lanes, or any ?tag= filter)
|
||||
|
||||
Container variant is passed in so the same controls render in either
|
||||
a desktop sidebar (.facet-nav) or a mobile <details> accordion. The
|
||||
`variant` arg drives the wrapper element only — controls inside are
|
||||
identical so the user sees the same options on every device.
|
||||
|
||||
Args:
|
||||
variant — "sidebar" (desktop <aside>) or "details" (mobile <details>)
|
||||
base_url — shop absolute URL for "All" + tag links (no trailing slash)
|
||||
sort_options — list of (key, label) tuples for the sort <select>
|
||||
sort_key — currently selected sort key
|
||||
price_min — current min in cents (int or None)
|
||||
price_max — current max in cents (int or None)
|
||||
facet_tags — list of Tag rows for the categories list
|
||||
active_tag — currently active Tag (or None)
|
||||
#}
|
||||
|
||||
{% macro fmt_cents(cents) -%}
|
||||
{%- if cents is not none -%}{{ (cents / 100)|round(2) }}{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro facet_form(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) %}
|
||||
<form method="get" action="" class="facet-form">
|
||||
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Sort by</h2>
|
||||
<select name="sort" class="facet-select" onchange="this.form.submit()">
|
||||
{% for key, label in sort_options %}
|
||||
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Price ($)</h2>
|
||||
<div class="facet-price-range">
|
||||
<label class="facet-price-label">
|
||||
<span class="facet-price-cap">Min</span>
|
||||
<input type="number" name="price_min" min="0" step="0.01"
|
||||
class="facet-price-input"
|
||||
value="{{ fmt_cents(price_min) }}" />
|
||||
</label>
|
||||
<label class="facet-price-label">
|
||||
<span class="facet-price-cap">Max</span>
|
||||
<input type="number" name="price_max" min="0" step="0.01"
|
||||
class="facet-price-input"
|
||||
value="{{ fmt_cents(price_max) }}" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="mps-button mps-button-small">Apply</button>
|
||||
{% if price_min is not none or price_max is not none %}
|
||||
<a href="?sort={{ sort_key }}" class="facet-clear-link" rel="nofollow">Clear price</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if facet_tags %}
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Categories</h2>
|
||||
<ul class="facet-tag-list">
|
||||
<li>
|
||||
<a href="{{ base_url }}"
|
||||
class="facet-tag{% if active_tag is none %} facet-tag-active{% endif %}"
|
||||
rel="nofollow">All</a>
|
||||
</li>
|
||||
{% for t in facet_tags %}
|
||||
<li>
|
||||
<a href="{{ base_url }}/tag/{{ t.slug }}"
|
||||
class="facet-tag{% if active_tag and active_tag.id == t.id %} facet-tag-active{% endif %}"
|
||||
rel="nofollow">{{ t.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sidebar(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) %}
|
||||
<aside class="facet-nav" aria-label="Filter and sort">
|
||||
{{ facet_form(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro details(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) %}
|
||||
<details class="facet-details">
|
||||
<summary class="facet-details-summary">
|
||||
🔍 Filter & sort
|
||||
{%- if price_min is not none or price_max is not none %} <span class="facet-details-active-mark">•</span>{% endif -%}
|
||||
</summary>
|
||||
{{ facet_form(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
</details>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.j2" -%}
|
||||
{% import "_facet_nav.j2" as facet %}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
|
|
@ -79,10 +80,17 @@
|
|||
|
||||
{% else %}
|
||||
|
||||
{% set shop_url = request.shop.absolute_url(request) %}
|
||||
{# MPS-24 Phase 2.6: when the operator opted into categorization
|
||||
(home_layout 1 chips or 2 lanes), give shoppers the full facet
|
||||
nav — sidebar on desktop, <details> accordion on mobile/tablet.
|
||||
Layout 0 (flat) keeps its existing simple grid with no nav. #}
|
||||
{% set show_facet_nav = request.shop.home_layout and request.shop.home_layout >= 1 %}
|
||||
|
||||
{# MPS-24: tag chip strip — shown on layout 1 (chips) and 2 (lanes). #}
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
<a href="{{ shop_url }}"
|
||||
class="tag-chip{% if not active_tag %} tag-chip-active{% endif %}"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
|
|
@ -95,6 +103,17 @@
|
|||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if show_facet_nav %}
|
||||
{# Mobile/tablet: collapsed <details> facets above the grid. #}
|
||||
{{ facet.details(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
<div class="tag-detail-layout">
|
||||
|
||||
{# Desktop sidebar. #}
|
||||
{{ facet.sidebar(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
|
||||
<div class="tag-detail-content">
|
||||
{% endif %}
|
||||
|
||||
{# Sectioned lanes (layout == 2 and no active tag filter). #}
|
||||
{% if home_lanes %}
|
||||
{% for lane in home_lanes %}
|
||||
|
|
@ -103,6 +122,8 @@
|
|||
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
|
||||
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all →</a>
|
||||
</header>
|
||||
|
||||
{# Desktop: horizontal tile scroll (Netflix-style). Hidden <800px. #}
|
||||
<div class="tag-lane-grid" role="list">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
|
|
@ -121,6 +142,34 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Mobile/tablet: SERP-style rows with description excerpt.
|
||||
Hidden ≥800px in favor of the horizontal lane above. #}
|
||||
<section class="serp-list tag-lane-rows" role="list">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
<article class="serp-list-row" role="listitem">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow" class="serp-list-thumb-link">
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-list-thumb" loading="lazy" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="serp-list-body">
|
||||
<h3 class="serp-list-title">
|
||||
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a>
|
||||
</h3>
|
||||
{% if product.is_sellable %}
|
||||
<p class="serp-list-price"><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a></p>
|
||||
{% endif %}
|
||||
{% set snippet = product.excerpt_sentences(6) %}
|
||||
{% if snippet %}
|
||||
<p class="serp-list-excerpt">{{ snippet }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
|
@ -180,6 +229,11 @@
|
|||
|
||||
{% endif %} {# end home_lanes else #}
|
||||
|
||||
{% if show_facet_nav %}
|
||||
</div> {# /.tag-detail-content #}
|
||||
</div> {# /.tag-detail-layout #}
|
||||
{% endif %}
|
||||
|
||||
{% if request.shop and request.shop.subscriptions_enabled %}
|
||||
<section class="one-column subscribe-cta">
|
||||
<a href="/subscribe" class="shop-theme-link-color">Stay in the Loop — get email updates</a>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.j2" -%}
|
||||
{% import "_facet_nav.j2" as facet %}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
|
|
@ -10,10 +11,13 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% set shop_url = request.shop.absolute_url(request) %}
|
||||
{% set show_facet_nav = request.shop.home_layout and request.shop.home_layout >= 1 %}
|
||||
|
||||
{# MPS-24: tag chip strip — shown on layout 1 (chips) and 2 (lanes). #}
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
<a href="{{ shop_url }}"
|
||||
class="tag-chip{% if not active_tag %} tag-chip-active{% endif %}"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
|
|
@ -26,6 +30,17 @@
|
|||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if show_facet_nav %}
|
||||
{# Mobile/tablet: collapsed <details> facets above the grid. #}
|
||||
{{ facet.details(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
<div class="tag-detail-layout">
|
||||
|
||||
{# Desktop sidebar. #}
|
||||
{{ facet.sidebar(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
|
||||
<div class="tag-detail-content">
|
||||
{% endif %}
|
||||
|
||||
{% if home_lanes %}
|
||||
{% for lane in home_lanes %}
|
||||
<section class="tag-lane" data-tag-lane="{{ lane.tag.slug }}">
|
||||
|
|
@ -33,6 +48,8 @@
|
|||
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
|
||||
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all →</a>
|
||||
</header>
|
||||
|
||||
{# Desktop: horizontal tile scroll. Hidden <800px. #}
|
||||
<div class="tag-lane-grid" role="list">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
|
|
@ -51,6 +68,34 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Mobile/tablet: SERP-style rows with description excerpt.
|
||||
Hidden ≥800px. #}
|
||||
<section class="serp-list tag-lane-rows" role="list">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
<article class="serp-list-row" role="listitem">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow" class="serp-list-thumb-link">
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-list-thumb" loading="lazy" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="serp-list-body">
|
||||
<h3 class="serp-list-title">
|
||||
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a>
|
||||
</h3>
|
||||
{% if product.is_sellable %}
|
||||
<p class="serp-list-price"><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a></p>
|
||||
{% endif %}
|
||||
{% set snippet = product.excerpt_sentences(6) %}
|
||||
{% if snippet %}
|
||||
<p class="serp-list-excerpt">{{ snippet }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
|
@ -84,12 +129,17 @@
|
|||
{% if (not grid_products or grid_products|length == 0) and active_tag %}
|
||||
<section class="one-column well">
|
||||
<p>No products tagged <b>{{ active_tag.name }}</b> yet.</p>
|
||||
<p><a href="{{ request.shop.absolute_url(request) }}" class="shop-theme-link-color">← Back to all products</a></p>
|
||||
<p><a href="{{ shop_url }}" class="shop-theme-link-color">← Back to all products</a></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endif %} {# end home_lanes else #}
|
||||
|
||||
{% if show_facet_nav %}
|
||||
</div> {# /.tag-detail-content #}
|
||||
</div> {# /.tag-detail-layout #}
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/js/tag_filter.js" defer></script>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,23 @@
|
|||
{% extends "base.j2" -%}
|
||||
{% import "_facet_nav.j2" as facet %}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
{# Format dollar bounds back into the inputs without trailing ".00" when whole. #}
|
||||
{% macro fmt_cents(cents) -%}
|
||||
{%- if cents is not none -%}{{ (cents / 100)|round(2) }}{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% set shop_url = request.shop.absolute_url(request) %}
|
||||
|
||||
<section class="one-column tag-detail-header">
|
||||
<h1 class="type-headline-3">{{ active_tag.name }}</h1>
|
||||
<p class="type-body-sm"><a href="{{ request.shop.absolute_url(request) }}" class="shop-theme-link-color">← All products</a></p>
|
||||
<p class="type-body-sm"><a href="{{ shop_url }}" class="shop-theme-link-color">← All products</a></p>
|
||||
</section>
|
||||
|
||||
{# Top chip strip — mobile-only on wide viewports (sidebar facet nav
|
||||
takes over below 800px the chips remain since the sidebar is hidden). #}
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip tag-chip-strip-mobile" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
class="tag-chip"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
{% for chip in home_chips %}
|
||||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ chip.slug }}"
|
||||
class="tag-chip{% if active_tag.id == chip.id %} tag-chip-active{% endif %}"
|
||||
data-tag-slug="{{ chip.slug }}"
|
||||
rel="nofollow">{{ chip.name }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{# Mobile/tablet: collapsed <details> facets above the grid (CSS hides
|
||||
it ≥800px where the sidebar takes over). #}
|
||||
{{ facet.details(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
|
||||
<div class="tag-detail-layout">
|
||||
|
||||
{# Left facet nav — wide-viewport only. Plain HTML, GET form,
|
||||
no JS required. Single <form> wraps sort + price so any submit
|
||||
preserves both. #}
|
||||
<aside class="facet-nav" aria-label="Filter and sort">
|
||||
<form method="get" action="" class="facet-form">
|
||||
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Sort by</h2>
|
||||
<select id="serp-sort" name="sort" class="facet-select" onchange="this.form.submit()">
|
||||
{% for key, label in sort_options %}
|
||||
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Price ($)</h2>
|
||||
<div class="facet-price-range">
|
||||
<label class="facet-price-label">
|
||||
<span class="facet-price-cap">Min</span>
|
||||
<input type="number" name="price_min" min="0" step="0.01"
|
||||
class="facet-price-input"
|
||||
value="{{ fmt_cents(price_min) }}" />
|
||||
</label>
|
||||
<label class="facet-price-label">
|
||||
<span class="facet-price-cap">Max</span>
|
||||
<input type="number" name="price_max" min="0" step="0.01"
|
||||
class="facet-price-input"
|
||||
value="{{ fmt_cents(price_max) }}" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="mps-button mps-button-small">Apply</button>
|
||||
{% if price_min is not none or price_max is not none %}
|
||||
<a href="?sort={{ sort_key }}" class="facet-clear-link" rel="nofollow">Clear price</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if facet_tags %}
|
||||
<section class="facet-section">
|
||||
<h2 class="facet-title">Categories</h2>
|
||||
<ul class="facet-tag-list">
|
||||
<li>
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
class="facet-tag" rel="nofollow">All</a>
|
||||
</li>
|
||||
{% for t in facet_tags %}
|
||||
<li>
|
||||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ t.slug }}"
|
||||
class="facet-tag{% if active_tag.id == t.id %} facet-tag-active{% endif %}"
|
||||
rel="nofollow">{{ t.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</form>
|
||||
</aside>
|
||||
{# Desktop sidebar — hidden <800px via CSS. #}
|
||||
{{ facet.sidebar(shop_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
|
||||
|
||||
<div class="tag-detail-content">
|
||||
|
||||
|
|
|
|||
|
|
@ -8524,13 +8524,16 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
|
|||
body = res.body.decode()
|
||||
# Sort dropdown lives inside the facet sidebar form now
|
||||
self.assertIn('class="facet-form"', body)
|
||||
self.assertIn('id="serp-sort"', body)
|
||||
self.assertIn('name="sort"', body)
|
||||
# Price min/max inputs rendered as a plain GET form
|
||||
self.assertIn('name="price_min"', body)
|
||||
self.assertIn('name="price_max"', body)
|
||||
# Category list with the active tag highlighted
|
||||
self.assertIn('facet-tag-list', body)
|
||||
self.assertIn('facet-tag-active', body)
|
||||
# Both sidebar (desktop) and details accordion (mobile) render.
|
||||
self.assertIn('facet-nav', body)
|
||||
self.assertIn('facet-details', body)
|
||||
|
||||
def test_tag_detail_price_filter_narrows_grid(self):
|
||||
"""?price_min and ?price_max remove products outside the range."""
|
||||
|
|
@ -8593,6 +8596,49 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
|
|||
self.assertIn("cheap_widget", body)
|
||||
self.assertIn("pricey_widget", body)
|
||||
|
||||
def test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows(self):
|
||||
"""Layout 2 (lanes) shop home gets facet sidebar + SERP rows for
|
||||
mobile/tablet. Phase 2.6 multi-surface rollout."""
|
||||
shop, product = self._make_shop_with_product("lanes-facet-shop")
|
||||
# Flip the shop into layout 2.
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "2",
|
||||
"home_layout_tag_limit": "5",
|
||||
"home_layout_per_lane_limit": "10",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
# Tag the product so a lane has content.
|
||||
long_desc = (
|
||||
"One. Two. Three. Four. Five. Six. Seven."
|
||||
)
|
||||
self.testapp.post(
|
||||
f"/p/{product.id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": long_desc,
|
||||
"price": str(product.price),
|
||||
"visibility": "1",
|
||||
"tags": "Math",
|
||||
},
|
||||
)
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}")
|
||||
body = res.body.decode()
|
||||
# Facet sidebar (desktop) and details accordion (mobile) both
|
||||
# render in markup — CSS toggles visibility.
|
||||
self.assertIn('facet-nav', body)
|
||||
self.assertIn('facet-details', body)
|
||||
self.assertIn('facet-form', body)
|
||||
# Layout wrapper present.
|
||||
self.assertIn('tag-detail-layout', body)
|
||||
# Lane has tile markup (.tag-lane-grid) AND SERP rows
|
||||
# (.tag-lane-rows) — CSS swaps them per viewport.
|
||||
self.assertIn('tag-lane-grid', body)
|
||||
self.assertIn('tag-lane-rows', body)
|
||||
|
||||
def test_tag_detail_excerpt_renders_six_sentences(self):
|
||||
"""SERP rows render up to six sentences of the description."""
|
||||
# Use a non-sellable (content) product so is_ready is True without
|
||||
|
|
|
|||
|
|
@ -381,6 +381,10 @@ def _build_home_layout_context(request, shop, products):
|
|||
ctx["home_chips"] = tags_by_popularity(
|
||||
request.dbsession, shop, limit=tag_limit
|
||||
)
|
||||
# Phase 2.6: facet sidebar follows the chip strip — same opt-in
|
||||
# gate (layout >= 1). All tags, in display order, so the sidebar
|
||||
# shows everything the chip strip caps off.
|
||||
ctx["facet_tags"] = tags_by_popularity(request.dbsession, shop)
|
||||
|
||||
# Active tag from ?tag= param — applied to filtered_products regardless
|
||||
# of layout so /search?tag=X and /s/{id}?tag=X both filter in place.
|
||||
|
|
@ -389,9 +393,6 @@ def _build_home_layout_context(request, shop, products):
|
|||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
if tag is not None:
|
||||
ctx["active_tag"] = tag
|
||||
# Filtered grid view also shows the facet nav with every tag
|
||||
# in the shop, so a shopper can switch tag without going home.
|
||||
ctx["facet_tags"] = tags_by_popularity(request.dbsession, shop)
|
||||
tagged_ids = {
|
||||
row.product_id
|
||||
for row in request.dbsession.query(ProductTag.product_id)
|
||||
|
|
@ -407,6 +408,16 @@ def _build_home_layout_context(request, shop, products):
|
|||
filtered, sort_key,
|
||||
dbsession=request.dbsession, shop_id=shop.id,
|
||||
)
|
||||
elif layout in (1, 2) and products_list is not None and (
|
||||
min_cents is not None or max_cents is not None or sort_key != DEFAULT_SORT
|
||||
):
|
||||
# No tag but operator opted into facets and shopper applied
|
||||
# price/sort — filter the grid without forcing a tag selection.
|
||||
filtered = _filter_by_price_range(products_list, min_cents, max_cents)
|
||||
ctx["filtered_products"] = _sort_products(
|
||||
filtered, sort_key,
|
||||
dbsession=request.dbsession, shop_id=shop.id,
|
||||
)
|
||||
|
||||
# Sectioned lanes only when layout == 2 and the shopper hasn't already
|
||||
# filtered to one tag (filtering trumps lanes — single grid in that case).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue