diff --git a/CLAUDE.md b/CLAUDE.md index 4dbbe75..3120665 100644 --- a/CLAUDE.md +++ b/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 `
` 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 → diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 61accfd..5cd2f2f 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -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 `` 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 ` - {% for key, label in sort_options %} - +
+ + {# Left facet nav — wide-viewport only. Plain HTML, GET form, + no JS required. Single wraps sort + price so any submit + preserves both. #} + + +
+ +
+ {% for product in products %} + {% if product.is_ready %} + + {% endif %} {% endfor %} - - - +
-
-{% for product in products %} - {% if product.is_ready %} -
- {% if "thumbnail1" in product.extensions %} - - - + {% if not products %} +
+

No products match your filters in {{ active_tag.name }}.

+
{% endif %} -
-

- {{ product.title }} -

- {% if product.is_sellable %} -

${{ '{:,.2f}'.format(product.price) }}

- {% endif %} - {% set snippet = product.excerpt(200) %} - {% if snippet %} -

{{ snippet }}

- {% endif %} -
-
- {% endif %} -{% endfor %} -
-{% if not products %} -
-

No products tagged {{ active_tag.name }} yet.

-
-{% endif %} +
+ +
{%- endblock -%} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index 0380e9b..89e899e 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -777,13 +777,13 @@ Dark mode: surface-container + border-default override (auto).
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.
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index ce5039c..3b9f5a6 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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): diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 4f4c46d..bba93f3 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -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.""" diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 51fc472..b46eaa1 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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