style: shop settings cards stay --surface-dim gray (drop white override)

Last batch promoted .shop-settings.well to a content-card and along
the way overrode the background to --surface-base (white) — fox
prefers the familiar light-gray slab. Drop the background-color +
border + dark-mode overrides; .well already sets --surface-dim and
its dark-mode rule, both of which I'm now letting through unchanged.

Kept: the rhythm + shape upgrades that actually fixed the "wells
butting together" problem — radius-lg, elevation-1 shadow,
var(--space-5) padding, var(--space-5) margin-bottom between
sections. Styleguide entry note updated to match.
This commit is contained in:
russell@unturf.com 2026-05-15 15:31:50 -04:00
parent 5aa6d1e756
commit 41d525aa1d
No known key found for this signature in database
9 changed files with 572 additions and 71 deletions

View file

@ -492,6 +492,21 @@ 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
`_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.
Phase 2 (shipped): deterministic title-plus-description auto-tagger
in `lib/tag_suggest.py`. Title tokens weight × 3, description × 1
(capped at 100 unique tokens per product). Pipeline: tokenize →

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.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
at ~200 chars (Google-snippet feel) but printableprompts product
descriptions are 4-8 sentences of classroom context that all matter to
the shopper. Also missing: a way to narrow within a tag (e.g. "math
products under $5") without going back to a flat grid.
| Surface | Change |
|---------|--------|
| `models/product.py` | New `_strip_markdown(text)` module helper. `excerpt()` now consumes it; new `excerpt_sentences(n=6, max_chars=1500)` splits on `.!?` and joins the first N — strips markdown first, caps at 1500 chars as a safety floor for terminator-free descriptions |
| `views/shop.py` | New `_price_range_from_request(request)``(min_cents, max_cents)`. New `_filter_by_price_range(products, min_cents, max_cents)` applies inclusive bounds. Wired into `shop_tag_detail` and `_build_home_layout_context` (filtered shop home / search). `shop_tag_detail` now passes `facet_tags = tags_by_popularity(...)` (all tags, no limit) for the sidebar |
| `templates/shop_tag.j2` | Layout split into `.tag-detail-layout` grid (sidebar 220px + content 1fr at ≥800px, single column below). Sidebar `<form method="get">` wraps three sections: Sort dropdown, Price min/max number inputs, full Categories list with `.facet-tag-active` highlighting. Top `.tag-chip-strip-mobile` retained for mobile (sidebar hidden <800px). SERP row now calls `product.excerpt_sentences(6)` |
| `static/css/common.css` | New `.tag-detail-layout` + `.facet-nav` + `.facet-section` + `.facet-tag-list` + `.facet-price-range` + dark-mode overrides. Grid-only per house style |
| `tests/test_models.py` | New `TestProductExcerpt` — 13 unit tests over `_strip_markdown`, `excerpt`, `excerpt_sentences` (sentence count, terminator variety, markdown stripping, safety cap) |
| `tests/test_functional.py` | `test_tag_detail_renders_facet_sidebar`, `test_tag_detail_price_filter_narrows_grid`, `test_tag_detail_excerpt_renders_six_sentences` — all green |
Capability-driven: sidebar is plain HTML + GET form. JS auto-submits the
sort `<select>` on change; without JS, the same Apply button submits
everything. No new JS file. No breaking change to existing chip filter
flow or the search route.
### Phase 2.5 — product page polish: description wrap + price-history toggle (shipped 2026-05-15)
Two product-page bugs surfaced while shopping printableprompts:

View file

@ -83,6 +83,20 @@ def get_media_type(extension):
return None
def _strip_markdown(text):
"""Strip common markdown markers and return a single-line plain string.
Order matters: handle images and links before stripping bare symbols.
Returns empty string for None or empty input."""
if not text:
return ""
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links
text = re.sub(r"[#>*_`~|]+", "", text) # md symbols
text = re.sub(r"^[\s-]+", "", text, flags=re.M) # leading -/space
text = re.sub(r"\s+", " ", text).strip()
return text
def sizeof_fmt(num, suffix="B"):
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
if abs(num) < 1024.0:
@ -304,18 +318,9 @@ class Product(RBase, Base):
don't have to import bleach or html.parser here — markdown's
symbol set is small and easy to strip cheaply.
"""
if not self.description:
text = _strip_markdown(self.description)
if not text:
return ""
# Strip the common markdown markers: # heading, * / _ emphasis,
# ` code, [text](url) → text, > blockquote, - bullet,
# newlines → space. Order matters: handle links before stars.
import re
text = self.description
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links
text = re.sub(r"[#>*_`~|]+", "", text) # md symbols
text = re.sub(r"^[\s-]+", "", text, flags=re.M) # leading -/space
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_chars:
return text
cut = text[:max_chars]
@ -330,6 +335,23 @@ class Product(RBase, Base):
return cut[:idx].rstrip() + ""
return cut.rstrip() + ""
def excerpt_sentences(self, n=6, max_chars=1500):
"""Plain-text snippet, first N sentences of description.
Splits on .!? terminators (keeping them with their sentence) and
joins the first N. max_chars is a safety cap so a description
with no terminators (one long blob) doesn't dump everything.
"""
text = _strip_markdown(self.description)
if not text:
return ""
parts = re.split(r"(?<=[.!?])\s+", text)
take = [p for p in parts[:n] if p]
out = " ".join(take)
if len(out) <= max_chars:
return out
return out[:max_chars].rstrip() + ""
def set_price(self, price):
self.updated_timestamp = now_timestamp()
try:

View file

@ -1797,6 +1797,125 @@ section.tag-detail-header {
}
}
/* ===================================================================
* Tag detail layout single column on mobile; 800px two-column
* grid with left facet nav (sort + price + categories). Capability-
* driven: sidebar is a plain <form method="get">. No JS required.
* ===================================================================*/
div.tag-detail-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4, 16px);
margin: var(--space-3, 12px) 0;
}
@media (min-width: 800px) {
div.tag-detail-layout {
grid-template-columns: 220px minmax(0, 1fr);
gap: var(--space-5, 20px);
}
/* The sidebar replaces the top chip strip on wide viewports. */
nav.tag-chip-strip-mobile {
display: none;
}
}
aside.facet-nav {
min-width: 0;
}
aside.facet-nav .facet-form {
display: grid;
gap: var(--space-4, 16px);
}
section.facet-section {
display: grid;
gap: var(--space-2, 8px);
}
h2.facet-title {
margin: 0;
font-size: var(--type-body-size, 1rem);
font-weight: 600;
color: var(--text-color, #333);
letter-spacing: 0.02em;
}
select.facet-select {
width: 100%;
padding: var(--space-2, 8px);
border: 1px solid var(--border-color, #d1d5db);
border-radius: var(--radius-sm, 4px);
background: var(--surface, #fff);
color: var(--text-color, #333);
}
div.facet-price-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2, 8px);
}
label.facet-price-label {
display: grid;
gap: var(--space-1, 4px);
}
span.facet-price-cap {
font-size: var(--type-body-sm-size, 0.875rem);
color: var(--text-muted, #6b7280);
}
input.facet-price-input {
width: 100%;
padding: var(--space-2, 8px);
border: 1px solid var(--border-color, #d1d5db);
border-radius: var(--radius-sm, 4px);
background: var(--surface, #fff);
color: var(--text-color, #333);
min-width: 0;
}
a.facet-clear-link {
font-size: var(--type-body-sm-size, 0.875rem);
color: var(--text-muted, #6b7280);
text-decoration: underline;
}
ul.facet-tag-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-1, 4px);
max-height: 60vh;
overflow-y: auto;
}
ul.facet-tag-list li { margin: 0; }
a.facet-tag {
display: block;
padding: var(--space-2, 8px) var(--space-3, 12px);
border-radius: var(--radius-sm, 4px);
color: var(--text-color, #333);
text-decoration: none;
transition: background-color 150ms ease;
word-break: break-word;
}
a.facet-tag:hover {
background: var(--surface-dim, #f3f4f6);
}
a.facet-tag-active {
background: var(--shop-theme-color, var(--color-primary, #4338ca));
color: var(--shop-theme-text-color, #fff);
font-weight: 600;
}
a.facet-tag-active:hover {
background: var(--shop-theme-color, var(--color-primary, #4338ca));
}
[data-theme="dark"] select.facet-select,
[data-theme="dark"] input.facet-price-input {
background: var(--dark-button-bg, #2d3748);
border-color: var(--dark-border-color, #4a5568);
color: var(--dark-text-color, #e2e8f0);
}
[data-theme="dark"] a.facet-tag:hover {
background: var(--dark-button-bg, #2d3748);
}
div.tag-detail-content {
min-width: 0;
}
div.edit-page {
display: grid;
@ -2179,26 +2298,19 @@ div.edit-page > section.edit-card-full {
.settings-form-actions .mps-submit { grid-column: 2; }
/* Shop settings page every section is wrapped in
<section class="shop-settings well">. Promote that combo to the
design-system content-card treatment (surface-base, light border,
elevation-1, generous padding) so the page reads as a stack of
clearly-separated cards instead of dim-gray slabs touching each
other. The legacy .well rule still sets surface-dim we override
it back to surface-base + a real shadow. Grid only no flex. */
<section class="shop-settings well">. The legacy .well already sets
--surface-dim (the familiar light-gray slab) and fox prefers that
look; this rule only adds the rhythm + shape pieces that were
missing proper outer margin between sections, a soft elevation-1
shadow so cards layer above the page background, generous padding,
rounded corners. Background stays --surface-dim via .well. */
.shop-settings.well,
.shop-settings {
background-color: var(--surface-base, #FFFFFF);
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-light, #eeeeee);
box-shadow: var(--elevation-1);
padding: var(--space-5, 20px);
margin-bottom: var(--space-5, 20px);
}
[data-theme="dark"] .shop-settings.well,
[data-theme="dark"] .shop-settings {
background-color: var(--surface-container, #161b22);
border-color: var(--border-default, #30363d);
}
.shop-settings > h3 {
margin: 0 0 var(--space-3, 12px) 0;
}

View file

@ -2,13 +2,20 @@
{% 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 %}
<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>
</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" data-tag-strip aria-label="Browse by category">
<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=""
@ -22,48 +29,103 @@
</nav>
{% endif %}
{# Sort dropdown — capability-driven: works as a plain GET form
without JS, auto-submits on change with JS. #}
<form method="get" action="" class="serp-sort-form">
<label for="serp-sort" class="serp-sort-label">Sort by</label>
<select id="serp-sort" name="sort" onchange="this.form.submit()">
{% for key, label in sort_options %}
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
<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>
<div class="tag-detail-content">
<section class="serp-list" data-tag-grid>
{% for product in products %}
{% if product.is_ready %}
<article class="serp-list-row">
{% 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 %}
</select>
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
</form>
</section>
<section class="serp-list" data-tag-grid>
{% for product in products %}
{% if product.is_ready %}
<article class="serp-list-row">
{% 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>
{% if not products %}
<section class="one-column well">
<p>No products match your filters in <b>{{ active_tag.name }}</b>.</p>
</section>
{% 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(200) %}
{% if snippet %}
<p class="serp-list-excerpt">{{ snippet }}</p>
{% endif %}
</div>
</article>
{% endif %}
{% endfor %}
</section>
{% if not products %}
<section class="one-column well">
<p>No products tagged <b>{{ active_tag.name }}</b> yet.</p>
</section>
{% endif %}
</div>
</div>
{%- endblock -%}

View file

@ -777,13 +777,13 @@ Dark mode: surface-container + border-default override (auto).</div>
<input type="submit" class="mps-submit" value="Save Settings" />
</section>
<div class="sg-code">section.shop-settings.well
same chrome as .content-card (surface-base, border-light,
elevation-1, radius-lg) but addressed by the existing
class combo so every shop-settings section on the
Settings page picks it up with no markup change.
— margin-bottom: var(--space-5) gives consistent gaps
between sections (the &lt;br&gt;&lt;br&gt; tags between sections
in the markup are visual whitespace, harmless).
keeps the familiar --surface-dim light-gray slab
(inherited from .well); the rule only adds the rhythm
+ shape that were missing: radius-lg corners,
elevation-1 shadow, var(--space-5) padding, and
var(--space-5) margin-bottom for consistent gaps
between adjacent sections (the &lt;br&gt;&lt;br&gt; tags inside
are harmless block whitespace).
— &gt; h3 reset to 0 top margin, var(--space-3) bottom.
— .mps-submit gets margin-top var(--space-3) so it
sits below the last field with breathing room.</div>

View file

@ -8507,6 +8507,125 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
)
self.assertEqual(res.status_int, 404)
def test_tag_detail_renders_facet_sidebar(self):
"""Left facet nav exposes sort, price, and a category list."""
shop, product = self._make_shop_with_product("facet-shop")
self.testapp.post(
f"/p/{product.id}/edit",
{
"title": product.title,
"description": product.description,
"price": str(product.price),
"visibility": "1",
"tags": "Math",
},
)
res = self.testapp.get(f"/s/{shop.id}/tag/math")
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)
# 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)
def test_tag_detail_price_filter_narrows_grid(self):
"""?price_min and ?price_max remove products outside the range."""
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "price-filter-shop"},
)
# Two content products (always is_ready) at different prices.
# Content products don't save price via the form, so we set
# price_in_cents directly on the model after creation.
cheap_params = dict(self.product1_params)
cheap_params["title"] = "cheap_widget"
cheap_params.pop("is_sellable", None)
self.testapp.post(f"/p/new?shop_id={shop.id}", cheap_params)
pricey_params = dict(self.product1_params)
pricey_params["title"] = "pricey_widget"
pricey_params.pop("is_sellable", None)
self.testapp.post(f"/p/new?shop_id={shop.id}", pricey_params)
from ..models.product import get_all_products
products = get_all_products(self.dbsession).all()
# Tag both products first via the edit handler.
for p in products:
self.testapp.post(
f"/p/{p.id}/edit",
{
"title": p.title,
"description": p.description,
"visibility": "1",
"tags": "Tools",
},
)
# Then set price_in_cents directly (form path skips it for content).
# Refresh products since the edit handler ran in its own transaction.
self.dbsession.expire_all()
for p in get_all_products(self.dbsession).all():
p.price_in_cents = 350 if "cheap" in p.title else 2500
# Snapshot the shop id as a plain string before we commit and detach.
shop_id = str(shop.id)
self.dbsession.flush()
import transaction
transaction.manager.commit()
# Upper bound — only cheap_widget passes.
res = self.testapp.get(f"/s/{shop_id}/tag/tools?price_max=10")
body = res.body.decode()
self.assertIn("cheap_widget", body)
self.assertNotIn("pricey_widget", body)
# Lower bound — only pricey_widget passes.
res = self.testapp.get(f"/s/{shop_id}/tag/tools?price_min=10")
body = res.body.decode()
self.assertIn("pricey_widget", body)
self.assertNotIn("cheap_widget", body)
# No bound — both visible.
res = self.testapp.get(f"/s/{shop_id}/tag/tools")
body = res.body.decode()
self.assertIn("cheap_widget", body)
self.assertIn("pricey_widget", 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
# uploading any files — the SERP row only renders for ready ones.
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "excerpt-shop"},
)
content_params = dict(self.product1_params)
content_params["title"] = "long_winded_widget"
content_params.pop("is_sellable", None)
self.testapp.post(f"/p/new?shop_id={shop.id}", content_params)
from ..models.product import get_all_products
product = get_all_products(self.dbsession).all()[-1]
long_desc = (
"One. Two. Three. Four. Five. Six. Seven. Eight."
)
self.testapp.post(
f"/p/{product.id}/edit",
{
"title": product.title,
"description": long_desc,
"price": str(product.price),
"visibility": "1",
"tags": "Reads",
},
)
res = self.testapp.get(f"/s/{shop.id}/tag/reads")
body = res.body.decode()
# Six sentences land; seventh + eighth are clipped.
self.assertIn("One. Two. Three. Four. Five. Six.", body)
self.assertNotIn("Seven.", body)
# --- MPS-24 Phase 2: suggest from title + description -----------------
def _make_shop_with_products(self, shop_name, products_meta):

View file

@ -4177,6 +4177,101 @@ class TestFeatureKillSwitches(unittest.TestCase):
self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False}))
class TestProductExcerpt(unittest.TestCase):
"""MPS-24 Phase 1 SERP rows: Product.excerpt + excerpt_sentences."""
def _make_product(self, description):
from types import SimpleNamespace
return SimpleNamespace(description=description)
def _strip(self, description):
from ..models.product import _strip_markdown
return _strip_markdown(description)
def _excerpt(self, description, max_chars=180):
from ..models.product import Product
return Product.excerpt(self._make_product(description), max_chars)
def _sentences(self, description, n=6, max_chars=1500):
from ..models.product import Product
return Product.excerpt_sentences(
self._make_product(description), n, max_chars
)
def test_strip_empty_returns_empty(self):
self.assertEqual(self._strip(""), "")
self.assertEqual(self._strip(None), "")
def test_strip_markdown_symbols_removed(self):
out = self._strip("# Heading\n\n**bold** and *italic* and `code`.")
self.assertEqual(out, "Heading bold and italic and code.")
def test_strip_links_keep_text_drop_url(self):
out = self._strip("See [our shop](https://example.com) for more.")
self.assertEqual(out, "See our shop for more.")
def test_strip_images_dropped_entirely(self):
out = self._strip("![alt](https://example.com/x.png) Buy now.")
self.assertEqual(out, "Buy now.")
def test_excerpt_under_limit_returns_full_text(self):
self.assertEqual(self._excerpt("Short.", 180), "Short.")
def test_excerpt_backtracks_to_sentence_boundary(self):
desc = (
"First sentence here. Second sentence here. "
"Third sentence here. Fourth sentence here. "
"Fifth sentence here."
)
out = self._excerpt(desc, 50)
self.assertTrue(out.endswith("."))
self.assertLessEqual(len(out), 50)
def test_sentences_empty_returns_empty(self):
self.assertEqual(self._sentences("", 6), "")
self.assertEqual(self._sentences(None, 6), "")
def test_sentences_takes_first_n(self):
desc = (
"One. Two. Three. Four. Five. Six. Seven. Eight."
)
out = self._sentences(desc, 3)
self.assertEqual(out, "One. Two. Three.")
def test_sentences_six_default(self):
desc = (
"One. Two. Three. Four. Five. Six. Seven. Eight."
)
out = self._sentences(desc, 6)
self.assertEqual(out, "One. Two. Three. Four. Five. Six.")
def test_sentences_handles_question_and_exclaim(self):
desc = "Why? Because! And then this. And that."
out = self._sentences(desc, 2)
self.assertEqual(out, "Why? Because!")
def test_sentences_fewer_available_returns_all(self):
desc = "Only one."
out = self._sentences(desc, 6)
self.assertEqual(out, "Only one.")
def test_sentences_strips_markdown_before_split(self):
desc = (
"# Title\n\nFirst **bold** sentence. "
"Second [linked](http://x.com) sentence. "
"Third one."
)
out = self._sentences(desc, 2)
self.assertEqual(out, "Title First bold sentence. Second linked sentence.")
def test_sentences_caps_at_max_chars_when_no_terminator(self):
# One long blob with no terminators — safety cap should kick in.
desc = "x" * 2000
out = self._sentences(desc, 6, max_chars=100)
self.assertEqual(len(out), 101) # 100 chars + ellipsis
self.assertTrue(out.endswith(""))
class TestProductPricingMode(unittest.TestCase):
"""MPS-20 + MPS-21: pricing_mode helper properties on Product."""

View file

@ -240,6 +240,44 @@ def _sort_key_from_request(request):
return raw if raw in SORT_KEYS else DEFAULT_SORT
def _price_range_from_request(request):
"""Read ?price_min= and ?price_max= (dollars) from the request and
return (min_cents, max_cents). Either side may be None (no bound).
Bad input is silently ignored so a malformed URL never 500s."""
def _to_cents(raw):
raw = (raw or "").strip()
if not raw:
return None
try:
dollars = float(raw)
except ValueError:
return None
if dollars < 0:
return None
return int(round(dollars * 100))
return (
_to_cents(request.params.get("price_min")),
_to_cents(request.params.get("price_max")),
)
def _filter_by_price_range(products, min_cents, max_cents):
"""Filter a list of Products by price_in_cents inclusive of both
bounds. Either bound may be None. No-op when both are None."""
if min_cents is None and max_cents is None:
return products
out = []
for p in products:
price = p.price_in_cents or 0
if min_cents is not None and price < min_cents:
continue
if max_cents is not None and price > max_cents:
continue
out.append(p)
return out
def _popular_view_counts(dbsession, shop_id, product_ids, days=28):
"""Return {product_id: view_count} over the last `days`. Used by the
'popular' sort option. Filters by visible_ms >= 7000 to match the
@ -318,13 +356,17 @@ def _build_home_layout_context(request, shop, products):
products_list = list(products)
sort_key = _sort_key_from_request(request)
min_cents, max_cents = _price_range_from_request(request)
ctx = {
"home_chips": [],
"home_lanes": [],
"active_tag": None,
"facet_tags": [],
"filtered_products": products_list,
"sort_key": sort_key,
"sort_options": SORT_OPTIONS,
"price_min": min_cents,
"price_max": max_cents,
}
if shop is None:
return ctx
@ -347,6 +389,9 @@ 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)
@ -357,6 +402,7 @@ def _build_home_layout_context(request, shop, products):
filtered = [
p for p in products_list if p.id in tagged_ids
]
filtered = _filter_by_price_range(filtered, min_cents, max_cents)
ctx["filtered_products"] = _sort_products(
filtered, sort_key,
dbsession=request.dbsession, shop_id=shop.id,
@ -2061,11 +2107,16 @@ def shop_tag_detail(request):
.all()
}
filtered = [p for p in products if p.id in tagged_ids]
min_cents, max_cents = _price_range_from_request(request)
filtered = _filter_by_price_range(filtered, min_cents, max_cents)
sort_key = _sort_key_from_request(request)
filtered = _sort_products(
filtered, sort_key,
dbsession=request.dbsession, shop_id=shop.id,
)
# Vertical tag list for the left facet nav — shop-wide, not just chips
# capped at home_layout_tag_limit. Same order as the chip strip.
facet_tags = tags_by_popularity(request.dbsession, shop)
ctx = {
"products": filtered,
"active_tag": tag,
@ -2074,10 +2125,13 @@ def shop_tag_detail(request):
shop,
limit=int(shop.home_layout_tag_limit or 8),
),
"facet_tags": facet_tags,
"home_lanes": [],
"filtered_products": filtered,
"sort_key": sort_key,
"sort_options": SORT_OPTIONS,
"price_min": min_cents,
"price_max": max_cents,
}
return ctx