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:
russell@unturf.com 2026-05-15 16:30:23 -04:00
parent 0f84e6871b
commit 8970e960fb
No known key found for this signature in database
10 changed files with 387 additions and 98 deletions

View file

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

View file

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

View file

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

View file

@ -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;
}

View 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">
&#x1F50D; Filter &amp; sort
{%- if price_min is not none or price_max is not none %} <span class="facet-details-active-mark">&bull;</span>{% endif -%}
</summary>
{{ facet_form(base_url, sort_options, sort_key, price_min, price_max, facet_tags, active_tag) }}
</details>
{% endmacro %}

View file

@ -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 &rarr;</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>

View file

@ -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 &rarr;</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">&larr; Back to all products</a></p>
<p><a href="{{ shop_url }}" class="shop-theme-link-color">&larr; 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 -%}

View file

@ -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">&larr; All products</a></p>
<p class="type-body-sm"><a href="{{ shop_url }}" class="shop-theme-link-color">&larr; 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">

View file

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

View file

@ -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).