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:
parent
5aa6d1e756
commit
41d525aa1d
9 changed files with 572 additions and 71 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">← 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 -%}
|
||||
|
|
|
|||
|
|
@ -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 <br><br> 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 <br><br> tags inside
|
||||
are harmless block whitespace).
|
||||
— > 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>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(" 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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue