Commit graph

1210 commits

Author SHA1 Message Date
2301ab33cc
fix: MPS-24 auto-suggest — way more clusters, stop missing 'holiday'
Operator: '100 suggested tags is not enough, we need way more —
missing holiday holidays'. Two separate 100 caps in lib/tag_suggest.py:

- DESCRIPTION_TOKEN_CAP 100 -> 400: long teaching-resource
  descriptions truncated cross-cutting words like holiday/holidays/
  seasonal before they were ever counted, so those clusters never
  surfaced (verified: neither word is a stopword; season/seasonal/
  valentine only appear in comments, not ENGLISH_STOPWORDS).
- DEFAULT_TOP_N 100 -> 500: a 481-product catalogue has valid niche
  groups ranking past the old cut. The min_products / max_share /
  min_title_share filters already strip noise, so a high ceiling
  surfaces the long tail without resurfacing junk.
- views/shop.py ?top_n= clamp 500 -> 5000 for operator headroom.

Both caps stay bounded (deduped unique tokens / no unbounded query —
CWE-407-safe). Test: +test_deep_description_word_surfaces_after_cap_raise
(word past the old 100-token cap now clusters). 1137 passed.

Docs: CLAUDE.md Phase 2, mps-24.md Phase 2.8k.
2026-05-16 16:19:31 -04:00
68a1309c83
style: MPS-24 — drop redundant tag-detail header from the SERP top
Operator: remove the tag title + '← All products' from the top of the
tag SERP. With the always-on chip strip (active category highlighted +
an 'All' chip) the tag-detail-header h1/back-link was redundant.

- Removed the <section class=tag-detail-header> from shop_tag.j2 and
  the now-dead section.tag-detail-header CSS rule.
- Document <title> (in <head>) still carries the tag name for SEO.
- Tests discriminate the tag SERP via tag-detail-content instead of
  tag-detail-header, and assert the header is gone. 1136 passed.

Docs: mps-24.md Phase 2.8j.
2026-05-16 15:53:11 -04:00
09f5a68428
feat: MPS-24 — chip strip stays on every SERP page
Operator: 'leave the chits on screen for all serp pages.' The
horizontal tag-chip-strip only rendered on the shop home; drilling
into a category (tag-detail SERP) dropped it, so hopping categories
meant going back.

- Extracted the chip strip (duplicated verbatim in home.j2 + shop.j2)
  into a single _facet_nav.j2 chip_strip(...) macro — DRY, one source
  of truth — and added it to shop_tag.j2 under the header.
- shop_tag_detail view already supplied home_chips / active_tag / sort
  / price, so this was a template-only gap. Active category chip
  highlights on the SERP and carries facet_qs (sort/price compose).
- Search SERP renders home.j2 so it gets the macro for free.

Test: +test_chip_strip_stays_on_tag_detail_serp. 1136 passed.
Docs: mps-24.md Phase 2.8i.
2026-05-16 11:46:16 -04:00
3bb05e4b5d
fix: MPS-24 — facets compose; sort+price survive switching category
Operator: 'switching one breaks it' — picking a category reset the
active Sort + Price. Cause: facet category links / 'All' link / top
chips / lane 'See all' all pointed at a bare {tag_base}/tag/{slug}
with NO query string, so a click dropped ?sort= / ?price_*. (The
Sort select / Price form already preserved the tag via action='' +
path and each other as sibling fields — only category nav lost state.)

Fix: one shared facet_qs(sort_key, price_min, price_max) macro in
_facet_nav.j2 returning the ?sort=...&price_min=...&price_max=...
suffix, appended to every category/All/chip/See-all href in
_facet_nav.j2, home.j2, shop.j2. URL state, NOT localStorage
(operator's suggestion): shareable, no-JS, back-button correct, and
the destination SERP already reads those params. The & is HTML-escaped
to &amp; in hrefs (Jinja autoescape) — browsers decode it fine.

Tests: +test_facet_links_preserve_sort_and_price; updated
test_tag_detail_renders_facet_sidebar +
test_facet_category_link_renders_tag_detail_not_home for the new
(correct) query-carrying behavior. 1135 passed.

Docs: mps-24.md Phases 2.8e–2.8h.
2026-05-16 11:22:23 -04:00
bcd8c2471b
fix: MPS-24 — top chips navigate to the tag SERP like the left nav
Operator: the top chips should do what the new left-nav category links
do (navigate to the per-tag SERP rendered in the shop's configured
home_layout), not the in-place 'default cards' hide/show.

Root cause: tag_filter.js decided whether to intercept by checking
chips[0].href for '/tag/'. chips[0] is the 'All' chip, which points at
the shop home (shop_url, no '/tag/'), so the category chips' real
{tag_base}/tag/{slug} hrefs were never detected → tag_filter.js always
intercepted → in-place card filter. Now scan ALL chips: if any links
to /tag/<slug>, bail and let full navigation happen, so a chip behaves
exactly like its matching left-nav category link. JS-only defect fix;
tag_filter.js is ?v={{ request.git_hash }} cache-busted.
2026-05-16 11:02:30 -04:00
98939fbc80
style: MPS-24 — remove nested scrollbar on facet sidebar category list
ul.facet-tag-list had max-height:60vh + overflow-y:auto, producing an
ugly inner scrollbar on the shop-home facet sidebar (and the mobile
details accordion). Drop the constraint so the category list flows at
full height and the PAGE scrolls — no nested scrollbar. CSS-only;
common.css is already ?v={{ request.git_hash }} cache-busted.
2026-05-16 10:54:46 -04:00
7836035d1a
fix: MPS-24 Phase 2.8e — form.action DOM-clobbered by <input name=action>
With 2.8d live the operator's Network panel proved the proxy-proof
ajax=1 signal works (real fetch to /tags -> 200, 0.7kB JSON) but also
showed 4 requests to a URL literally named [object HTMLInputElement],
with a CORRECT payload (action=delete, tag_slug=..., ajax=1).

Cause: every tag form contains <input type=hidden name=action>. A
named form control clobbers the built-in HTMLFormElement.action
property (DOM clobbering), so fetch(form.action) fetched that <input>
element -> 'String([object HTMLInputElement])' -> resolved to the shop
page (200 HTML, 25.9kB) -> reportFailure, no DOM change ('closer but
nothing changes on screen').

Fix: read form.getAttribute('action') (content attribute, never
clobbered) in submitForm + doReorder; build the programmatic toggle
form with setAttribute('action', ...) instead of form.action =.
No bare form.action reads remain. product_tags.js unaffected (posts
to data-product-tags-url). node --check clean; JS-only defect fix,
no Python/template/test impact.

This closes the chain: 2.8c stale cache -> 2.8d proxy-stripped
X-Requested-With -> 2.8e clobbered form.action. Docs: mps-24.md
Phase 2.8e, CLAUDE.md DOM-clobbering note.
2026-05-16 10:32:50 -04:00
8e31124ca8
fix: MPS-24 Phase 2.8d — proxy-proof AJAX signal (the actual root cause)
Operator DevTools (custom domain shop.printableprompts.com) showed the
tell: bulk-tagger actions did a DOCUMENT POST -> 302 -> 200 and the
page rendered the SERVER-SIDE flash banner. That banner only survives
if the view took the non-AJAX HTTPFound branch — i.e. is_ajax() was
False: the app never saw X-Requested-With. Custom-domain shops sit
behind a Caddy reverse proxy that was not forwarding that request
header to uWSGI, so the capability-driven split ALWAYS chose 302 and
the page full-reloaded. Canonical host worked, so it looked fine.

- views/__init__.py:is_ajax() now returns True for
  X-Requested-With == XMLHttpRequest OR request param ajax=1. The param
  rides in the URL/body — no proxy strips it. Header kept for back-compat.
- tag_bulk.js (submitForm/doReorder/persistOrder FormData, fetchFocus
  URL) and product_tags.js (post helper) now send ajax=1.
- Hardened tag_bulk.js: ZERO code paths full-reload on failure anymore.
  reportFailure() surfaces HTTP status + content-type + body snippet as
  a visible banner (the old form.submit()/location fallbacks turned
  every server hiccup into 'the screen keeps refreshing' and hid the
  cause). safeInit() + window 'error' handler make a dead script
  visible (transient '✓ Tag editor interactive' proof-of-life banner)
  instead of failing silently.
- Tests: +test_ajax_param_signals_ajax_without_header,
  +test_no_ajax_signal_still_redirects,
  +test_ajax_focus_via_param_returns_json. 1134 passed.

Docs: mps-24.md Phase 2.8d, CLAUDE.md (is_ajax dual signal).
2026-05-16 10:16:37 -04:00
cd5ea68fe3
fix: MPS-24 Phase 2.8c — cache-bust ALL static JS (THE root cause)
THE root cause of the entire 'still reloads / still not working' saga
across 2.7 -> 2.8 -> 2.8b: shop_tags.j2 (tag_bulk.js) and
product_edit.j2 (product_tags.js) loaded their <script> WITHOUT the
?v={{ request.git_hash }} cache-bust. routes.py serves /static with
cache_max_age=3600, so the operator's browser kept the STALE JS for up
to an hour after every deploy — the new SPA code never executed, forms
fell back to native submit = full page reload, every time. Server-side
functional tests passed throughout because they have no browser cache.

Fix: append ?v={{ request.git_hash }} to EVERY static <script> include
(the established base.j2 / offer.js / pay-countdown.js convention) —
not just the two at fault but the whole latent class: tag_bulk,
product_tags, tag_filter, auction, player, sandbox, watch, signals,
comments, shop-settings. request.git_hash shifts every deploy -> URL
changes -> fresh fetch, no hard-refresh ever needed again.

Gate (must be empty):
  grep -rnE '<script src="/static/js/[^"?]+\.js"' make_post_sell/templates/

The 2.8/2.8b JS (onTagFormClick unified click handler, AJAX focus,
drag-to-reorder) stands — it just was never being fetched by the
browser. 1131 tests pass. Docs: mps-24.md Phase 2.8c, CLAUDE.md
(new mandatory cache-bust convention section).
2026-05-16 09:39:17 -04:00
b15c0a0f38
fix: MPS-24 Phase 2.8b — one unified AJAX click handler for all bulk-tagger actions
Operator: 'same with the delete button. and add' — i.e. Add / Delete
(and reorder) still full-reloaded. The generic data-tag-form
submit-EVENT interception is unreliable in the field; the explicit
click handlers (focus/drag) work. Root fix instead of patching each
button: one capture-phase CLICK handler (onTagFormClick) on every
submit control inside form[data-tag-form].

- onTagFormClick preventDefault()s so the native submit never starts
  (no reload, no double-handling), runs the delete confirm via
  data-confirm, routes reorder -> doReorder (in-place swap), everything
  else (create/add, delete, attach/detach, apply/dismiss suggestion)
  -> submitForm.
- Removed inline onclick="return confirm()" from shop_tags.j2 AND the
  JS appendTagRow builder — it fought the interception; now data-confirm.
- submit listener kept only as the Enter-key fallback. Standalone
  wireReorderButtons folded into onTagFormClick. Dead escapeJs removed.
- Tests: +test_ajax_delete_tag_returns_json,
  +test_bulk_tagger_delete_uses_data_confirm_not_inline_onclick,
  +test_ajax_reorder_arrow_returns_json_and_moves. 1131 passed.

Docs: mps-24.md Phase 2.8b.
2026-05-16 09:05:14 -04:00
155f7ff66f
fix: MPS-24 Phase 2.8 — bulk tagger AJAX tag-focus + real drag-to-reorder
Operator (printableprompts.com, 481 products) reported the bulk tagger
'still refreshing the whole screen' and 'dragging tags doesn't work'
after 2.7. Two real defects the 2.7 static audit missed:

1. Tag-focus was a full-page navigation: clicking a tag chip is
   <a href=?focus=slug>, and the view loaded+rendered ALL products on
   EVERY GET. On a 481-product catalog every tag click reloaded a
   multi-MB page. The forms were AJAX; the dominant workflow was not.
2. Drag-to-reorder never existed: shop_tags.j2 shipped draggable=true +
   a handle + help text, but tag_bulk.js had ZERO drag handlers.

Fix:
- shop.py:shop_tags — all_products loads only when focus_tag or
  show_suggestions (bare GET is light). New AJAX branch: is_ajax +
  ?focus=slug -> JSON {focus, products:[{id,title,url,attached}]}.
- shop_tags.j2 — stable [data-focus-section] (always in DOM, hidden
  until focused); ?focus= chips carry data-tag-focus-link. No-JS
  unchanged (real navigation, server renders the section).
- tag_bulk.js — wireFocusLinks() intercepts chip clicks, fetchFocus()
  + renderFocus() swap the list in place, active-chip + history
  pushState/popstate, real-navigation fallback. wireDragAndDrop()
  HTML5 DnD -> persistOrder() POSTs action=set_order&tag_slugs=…
  (view already supported it) + re-syncs up/down disabled states.
  .tag-list-dragging CSS added.
- Tests: TestProductTagsSpa +4 (ajax focus json, unknown-slug null,
  set_order persists positions, bare GET no catalog). 1128 passed.

Docs: mps-24.md Phase 2.8, architecture.md, design-system.md, CLAUDE.md.
Deferred: AJAX 'Suggest categories' link (occasional click, not hot path).
2026-05-16 08:31:03 -04:00
f591620424
feat: MPS-24 Phase 2.7 — per-product SPA tag chips on product edit
Operator report: adding/removing a tag on the product edit page
refreshed the whole screen. Tags lived only as a comma-separated
<input name=tags> inside the big product form, so any tag change
needed a full Save Settings POST + page reload.

- New route/view: product_tags -> /p/{id}/tags (before product_slug
  catch-all), @shop_editor_required + @trial_active_required.
  action=add (get_or_create_tag + attach) / action=remove (detach).
  AJAX (X-Requested-With) -> JSON, no reload; plain POST -> 302 back
  to edit (no-JS still works). Rebuilds discovery ring like product_edit.
- Shared is_ajax() in views/__init__.py (single source of truth;
  shop.py:_is_ajax delegates — bulk tagger behaviour unchanged).
- product_edit.j2: comma field kept as no-JS path; js-only chip
  editor added. product_tags.js reveals chips, demotes raw input to
  hidden, keeps it in lock-step so a later full Save is a no-op.
- .tag-chip-removable family in common.css (tokens-only, Grid-only,
  always-visible remove button) + /styleguide#tagchips.
- Harden tag_bulk.js: init() binds the delegated submit listener
  unconditionally (no early-return that could strand the bulk-tagger
  SPA into full reloads).
- Tests: unit (slug dedupe invariant), integration
  (TestProductTagAddRemoveIntegration), functional (TestProductTagsSpa
  incl. bulk-tagger-AJAX-returns-JSON regression guard). 1124 passed.

Docs: architecture.md, design-system.md, CLAUDE.md, mps-24.md.
2026-05-16 07:58:25 -04:00
48a5eb715d
fix: link tag chip strip to the facet left-nav (same targets)
The chip strip linked to ?tag=<slug> (in-place filtered shop home)
while the facet sidebar/details Categories list linked to
{tag_base}/tag/<slug> (the canonical tag detail page). Clicking the
same category in the two navs took you to two different pages with
independently-computed active states — they were never in sync.

Point the chip strip at the same targets the facet nav uses:
  - "All"      -> shop home ({{ shop_url }})  (unchanged)
  - category   -> {{ tag_base }}/tag/{{ slug }}  (was ?tag={{ slug }})
Lane "See all ->" links moved the same way for consistency.

tag_filter.js already keeps full navigation for /tag/ hrefs (it bails
on init when the chip href contains /tag/), so this needs no JS
change — chips and the left nav now land on the identical page with
the identical active highlight. Server still resolves active_tag from
both /tag/<slug> and any legacy ?tag= param, so old links keep working.

33 tests in the home-layout / chip / facet / tag-detail / lane slice
pass.
2026-05-16 05:33:58 -04:00
2329dc8f4a
style: SERP list uses full width, bigger thumbs, category hit counts
Three fixes from fox's screenshot of the deployed tag detail page:

1. Dead right-hand space — .serp-list-excerpt had max-width: 70ch, so
   the snippet capped at ~600px while the row was full width, leaving
   ~40% of the viewport empty next to the facet sidebar. Removed the
   cap; the excerpt now fills the row body. Product.excerpt_sentences
   already bounds the block at 6 sentences so it can't run unbounded.

2. Bigger thumbnails — .serp-list-row thumbnail column goes 80→140px
   base, and the container-query steps go 120→200 (≥600), 160→260
   (≥900), plus a new 320px step at ≥1200. The facet sidebar makes
   the content container wide, so the larger steps actually fire.

3. Category hit counts — facet sidebar each category now shows a
   muted tabular-nums pill with Tag.product_count next to the name.
   a.facet-tag becomes a 1fr/auto grid (name | count); the count
   chip inverts on the active row so it stays legible on the themed
   background. No view change — Tag.product_count is an existing
   on-demand property, ~one indexed COUNT per sidebar row.

6-sentence excerpt was already wired (excerpt_sentences(6) via the
facet-nav work). 42 tag/facet/serp functional tests pass.
2026-05-15 21:02:38 -04:00
2640b035bc
fix: MPS-24 2.6c — sidebar category links hit tag route, not home catch-all
Operator review of 2.6b: clicking any sidebar category landed on a
page that looked exactly like the shop home (lanes), ignoring the
tag filter.

Root cause: _facet_nav.j2 built category links as
{absolute_url}/tag/{slug}. absolute_url() includes the shop slug
(/s/{id}/{shop_slug}), so the link became
/s/{id}/{shop_slug}/tag/{slug}. The tag detail route is
/s/{shop_id}/tag/{slug} — no shop-slug segment — so that path missed
shop_tag_detail and fell through to the shop_slug catch-all
(/s/{shop_id}/{slug:.*}), rendering the shop home.

Fix: macros now take a tag_base arg =
request.shop.absolute_url(request, slug=False) (= /s/{id}).
Category links build {tag_base}/tag/{slug} — matches
shop_tag_detail exactly. The All link keeps the slugged base_url
(shop home). All three callers (shop_tag.j2, home.j2, shop.j2)
pass both.

Regression coverage:
- test_facet_category_link_renders_tag_detail_not_home (new)
- test_tag_detail_renders_facet_sidebar (asserts slug-less link,
  asserts NOT slugged link)

Docs: CLAUDE.md facet-nav note, ticket Phase 2.6c.
2026-05-15 20:00:02 -04:00
8970e960fb
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.
2026-05-15 16:30:23 -04:00
0f84e6871b
fix: split Subscription / Comments / Gift Cards into separate cards
Three sections shared a single <section class="shop-settings well">
wrapper — Subscription and Comments were nested *inside the same
card*, and Gift Cards lived in a bare <section class="well"> right
after Comments inside the same outer .one-column. So:

  - Subscription's body bled directly into Comment System Settings,
    no margin, no card break (fox's screenshot).
  - Gift Cards picked up only the legacy flat .well treatment — no
    elevation, no margin-bottom — because it lacked .shop-settings.

Split each into its own <section class="one-column">→<section
class="shop-settings well"> so the margin-bottom + elevation rules
from common.css land on every one. Gift Cards now also carries
.shop-settings so it matches the rest of the page.

Markup is otherwise unchanged. 18 tests in the shop_settings /
gift_card / styleguide / subscription / comment_settings slice green.
2026-05-15 15:58:16 -04:00
91c0854a1f
docs: MPS-24 Phase 2.6 — facet sidebar + 6-sentence excerpt entries
Surface the new tag-detail facet nav (sort + price + categories) and
6-sentence SERP excerpt in two places future readers will look:
- docs/architecture.md feature toggle matrix gets two rows
- styleguide.j2 gets a live demo of the facet sidebar so the pattern
  is documented in the single source of truth for components
2026-05-15 15:54:50 -04:00
41d525aa1d
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.
2026-05-15 15:31:50 -04:00
5aa6d1e756
style: shop settings — promote every section to a content-card
The settings page was visibly butting disparate sections together
(dim-gray .well + .well, no real margin between them) because each
section was wrapped in section.shop-settings.well, which only ever
got the legacy .well treatment (surface-dim, padding 10px) — none of
the elevation/border/rhythm of the design-system content-card.

This is a CSS-only upgrade: section.shop-settings.well now gets the
same chrome as .content-card (surface-base, --border-light, elevation
-1, --radius-lg, --space-5 padding, --space-5 margin-bottom). The 18
shop-settings sections in shop_settings.j2 pick this up with zero
markup churn. The legacy <br/><br/> intra-section spacers stay
(harmless block whitespace inside the card).

Dark mode override added (--surface-container / --border-default).
.mps-submit gets a top margin so the Save Settings button isn't
glued to the last field. Styleguide entry under #wells documents the
pattern so future setting forms (or refactors) stay consistent.

57 tests in the shop_settings/styleguide slice still green.
2026-05-15 14:26:21 -04:00
56649db952
feat: Google-SERP-style list rows on tag detail (Phase 1 redesign)
Phase 1 of fox's "drop the desktop-card framing, build SERP-style
list view that works at every resolution" redesign. Scope: tag detail
page only (/s/{shop_id}/tag/{slug}). If this shape lands well it
extends to filtered shop home + search + "See all" routes next.

  templates/shop_tag.j2 — replaces .serp card grid with .serp-list
  rows. Each row: thumbnail | (title + price + excerpt). Article
  semantics (<article class="serp-list-row"> + <h3>) so the page
  reads correctly to crawlers + screen readers.

  models/product.py — new Product.excerpt(max_chars=180) method.
  Strips common markdown markers (headings, emphasis, links, images,
  blockquotes, code, list bullets) and backtracks to the nearest
  sentence terminator or word boundary so the snippet doesn't end
  mid-word. ~Google-snippet feel (default 180 chars).

  static/css/common.css — mobile-first SERP-list layout. Default
  grid-template-columns is 80px minmax(0, 1fr) — small thumbnail on
  the left, text on the right, comfortable on phones. Container
  queries (container-type: inline-size on .serp-list) bump the
  thumbnail to 120px at 600px container width and 160px at 900px,
  so the layout uses as much horizontal real estate as it gets
  without ever wrapping. Excerpt has max-width: 70ch so the reading
  band stays comfortable on wide monitors instead of stretching to
  one-line-per-row.

Still TODO (next phases):
  - left sidebar with categories on wide viewports
  - apply this same SERP-list to filtered shop home + search
  - convert lanes to either list rows or keep Netflix-style cards
    based on fox's call after seeing this phase live
2026-05-15 14:18:30 -04:00
095d91bc68
feat: sort dropdown on tag detail + filtered shop home (MPS-24)
Six sort options on category/tag pages, controlled by ?sort=:

  newest       Newest first        (default — created_timestamp desc)
  oldest       Oldest first        (created_timestamp asc)
  price_asc    Price low → high    (price_in_cents asc)
  price_desc   Price high → low    (price_in_cents desc)
  title        Alphabetical A→Z    (lowercased title)
  popular      Most popular (28d)  (PageSession views with visible_ms ≥ 7s)

Rendered as a <select> right-aligned above the grid. Capability-driven:
plain GET form works without JS (button appears via <noscript>), JS-on
auto-submits on change. Both shop_tag.j2 and home.j2 (filtered branch
only — lanes keep their editorial order) get the dropdown.

views/shop.py — new SORT_OPTIONS list + _sort_key_from_request +
_sort_products + _popular_view_counts helpers. Sort runs in Python on
the already-filtered list, so it's O(N log N) on the tagged subset
not the whole shop catalog. The 'popular' option fires one extra
PageSession aggregate query scoped to the filtered product_ids;
~tens of ms on indexed (shop_id, product_id, created_timestamp).

Doesn't address fox's bigger ask: Netflix-style truly-responsive
SERP layout with left nav + description-excerpt rows that works at
every resolution. That's a real redesign — proposing it next.
2026-05-15 14:14:17 -04:00
18a8566023
feat: MPS-24 — operator-controlled tag order (no-JS + drag-and-drop ready)
The chip strip and lane sequence on the shop home page were locked to
tags_by_popularity (product_count desc, name asc). Fox wants shop
operators to set the order manually.

- New mps_tag.position column (Integer NOT NULL, server_default 0,
  idempotent migration 7f2a91c4d810). Smaller = earlier on the home
  chip strip / lanes / bulk-tagger list.
- tags_by_popularity now orders by (position asc, count desc, name
  asc). Fresh shops still get the popularity ordering — every row
  starts at position=0 so the next two clauses do the real work — but
  once an operator reorders, position wins.
- Two new POST actions on /s/{shop_id}/tags:
    action=reorder, tag_slug=<s>, direction=up|down  — no-JS path,
      swaps with the adjacent tag (positions normalised to dense
      0..N-1 first so a swap is always meaningful).
    action=set_order, tag_slugs=a,b,c,…              — single-POST
      "commit the whole order", for drag-and-drop. Any slug missing
      from the explicit list tails the order; we never silently drop
      a tag from the rendering.
- shop_tags.j2 grew up/down arrow forms per row (disabled on the
  first/last) plus a drag handle marked js-only. The grid template was
  bumped to seven columns (handle | chip | count | view | up | down |
  delete).
- tag_bulk.js gains onReorder: the form is intercepted via the
  existing maybeIntercept submit-capture; on a successful AJAX reorder
  we swap the row in the DOM and re-disable the up/down on whichever
  row is now first/last — no full page reload.

Tests (TestHomeLayoutAndTags): up, down, edge-no-op, set_order
drag-and-drop, and a render check confirming the new order surfaces on
the home chip strip. 28 in class pass (5 new).
2026-05-15 14:02:43 -04:00
dc79d13358
feat: MPS-24 Phase 2.5 — product page polish (description wrap + price-history toggle)
Two product-page issues surfaced while shopping printableprompts on
mobile. Both fixed in one commit since they're tightly scoped to the
product page experience.

Description text clipping the right edge on mobile:

- .content-card uses CSS Grid but its grid items had default
  min-width: auto, so they expanded to their content's intrinsic
  width — long unbreakable tokens (URLs, etc.) pushed the card
  wider than the viewport. Then .content's overflow-x: clip
  silently hid the right side instead of wrapping the text.
- Add min-width: 0 + overflow-wrap: break-word to .content-card,
  .content-card-header, .content-card-body. Add word-break:
  break-word to inner <a> / <p> so URLs hyphenate at any character.

Price history shown by default:

- The price history table (commit 1e5fe27, 2026-02-11) was always
  visible to anyone who could edit the shop. Operator feedback:
  "wait for a sale" psychology hurts conversions; shoppers
  shouldn't see a timeline of past prices.
- New Shop.show_price_history Boolean (default False, server-default
  "0") with idempotent Alembic migration c792642911e2.
- Toggle lives in the existing ribbon-settings form section.
- views/product.py (public view) + views/watch.py JSON gate the
  price_history list on the toggle. Template product.j2 also gates
  rendering as belt-and-suspenders.
- Edit page (also views/product.py:product_edit) intentionally
  remains always-on — the operator needs price audit access from
  their own admin surface regardless of the shopper-facing toggle.
- New shop matrix entry in docs/architecture.md.
- 2 new functional tests (default-off + toggle round-trip).

1090 tests passing.
2026-05-15 13:54:24 -04:00
7fe6b56cdb
seo: stop the /join-or-log-in?next= crawl recursion
Search Console reported 72 nested /join-or-log-in URLs on media.unturf.com,
each one /join-or-log-in?next=https://.../join-or-log-in?next=... a level
deeper. Each got crawled and 401'd — Google's not indexing them (good),
but it's burning crawl budget on infinite nesting (yuck).

Root cause: base.j2's navbar "My Account" link for anonymous visitors
built the next= param from request.url (the FULL current URL with query
string). When the user lands on /join-or-log-in?next=/c/foo, that link
becomes /join-or-log-in?next=https://.../join-or-log-in?next=/c/foo —
which Google then crawls, and on THAT page the navbar link recurses
deeper, and so on.

Fix:
- base.j2 — suppress the navbar link entirely when already on
  /join-or-log-in (user is on the page; no point in pointing back).
  Also switch from request.url to request.path so any stray emission
  can't recurse via query string.
- All internal /join-or-log-in links get rel="nofollow" (base, home,
  product, comments, auction, verification-challenge). Defense in
  depth: even if a future template forgets the guard, Googlebot
  won't follow the link to discover deeper permutations.

Pairs with prior ba8fc45 (noindex + no-referrer + robots.txt disallow).
Already-crawled deep URLs will fall out of Search Console as Googlebot
re-fetches them, sees the 401 + no internal links pointing at them,
and ages the entries out.
2026-05-15 13:44:17 -04:00
ba8fc451c0
seo: mark /join-or-log-in as crawler-unfriendly + no Referer leakage
Google Search Console flagged six /join-or-log-in?next=... URLs as
"Duplicate without user-selected canonical" — every ?next= permutation
serves identical content, so Google groups them as duplicates and
indexes none. Auth pages have no business in the index anyway, and
the ?next= query string can carry private cart/product paths that
shouldn't end up in crawl logs or Referer headers.

Two-pronged fix:

  templates/join-or-log-in.j2
    - <meta name="robots" content="noindex,nofollow"> drops the page
      and its outbound links from the index entirely.
    - <meta name="referrer" content="no-referrer"> stops the URL —
      with its ?next= cart-uuid / product-uuid contents — from leaking
      to any third party when the user clicks an outbound link.
    - Explicit <title>Log in or join</title> via the head block so
      the default {{ request.domain }} title isn't shown either.

  views/misc.py — DEFAULT_ROBOTS_DOT_TXT
    - Disallow: /join-or-log-in so crawlers don't fetch the URL in
      the first place. Pages already in the index will drop out once
      Google re-crawls and sees the noindex header.

Operator shops can still override robots_dot_txt in pillar config;
they get the same default unless they explicitly opt out.
2026-05-15 13:29:32 -04:00
adf145a7e8
fix: tag chip "All" navigates fully from a filtered URL
When the page loads at ?tag=<slug>, the server has rendered a SUBSET
of products (the tag-filtered grid). The DOM only contains that
subset, so clicking "All" via the in-place filter just unhides what's
already visible — it can't synthesize the rest of the shop, and on
the lanes home_layout it can't rebuild the lane sections at all.

tag_filter.js now bails on init when the URL carries a ?tag= param.
Chips on filtered pages do normal navigation, so clicking "All" hits
the server and gets back the correct unfiltered home (flat grid for
home_layout 0/1, sectioned lanes for home_layout 2). The fast
in-place path still works on the unfiltered home, which is when it's
correct anyway (full set of products is in the DOM).
2026-05-15 13:16:14 -04:00
7c227ab467
feat: MPS-24 Phase 2.4 — SPA bulk tagger + Netflix-style lanes
Two operator-workflow improvements that compound. With Phase 1+2+2.3
shipped the categorisation works; with this phase iterating on tag
suggestions feels like one fluid screen instead of a stack of POST/
redirect cycles, and the lanes home actually looks like categorised
shelves.

SPA progressive enhancement on /s/{shop_id}/tags:

- Every form (create / delete / attach / detach / apply_suggestion /
  dismiss_suggestion) still POSTs and 302-redirects without JS — the
  no-JS user path is unchanged. With JS, static/js/tag_bulk.js
  intercepts submits, POSTs via fetch with X-Requested-With:
  XMLHttpRequest, and the server returns JSON describing what
  changed. Capability-driven per CLAUDE.md.
- New _is_ajax() + _tag_ajax_response() helpers in views/shop.py
  pop the Pyramid flash queue into the JSON payload so JS can render
  toasts (.tag-flash / .tag-flash-toast / .tag-flash-{success,error,
  info}). Falls back to a full form submit if fetch() errors.
- Template gained data-tag-form="<action>" attributes for delegation
  and data-tag-row / data-suggest-row / data-product-row hooks for
  DOM mutation. Re-attach pass on inserted rows.
- 6 new functional tests cover each AJAX action plus the no-JS
  fallback (POST without the header still 302-redirects).

Netflix-style horizontal-scroll lanes:

- .tag-lane-grid is now display: grid + grid-auto-flow: column +
  grid-auto-columns: minmax(160px, 200px) + overflow-x: auto +
  scroll-snap-type: x mandatory. Each lane is visually bounded as
  a category, tiles snap on swipe.
- Tiles drop the .serp class (the auto-fit grid was fighting the
  new horizontal flow) but keep .serp-item for hover styles.
- Thumbnails inside lane tiles use width:auto + max-width:100% +
  max-height:200px per CLAUDE.md media-sizing rule.
- Mobile (≤ 800px): tiles narrow to 140-160px, swipe-friendly.
- /styleguide updated with a 5-tile lane example so future operators
  see the new pattern.

1088 tests passing.
2026-05-15 12:55:36 -04:00
660b577d32
fix: extend mobile order rules to tablet — close the 800-959 gap
8b25285 hoisted the buy CTA above the title on mobile, but the
@media (max-width: 800px) breakpoint left a 160px gap (800-959) where
neither mobile order rules nor the desktop two-column grid applied.
In that range the page fell to source order and the buy zone sank back
below comments — fox's tablet screenshot.

Bump the order/single-column block to (max-width: 959px) so portrait
tablet + small-laptop widths get the same hoisted-CTA stack as phone.
Pull the mobile-only max-width: 600px readability cap out into its
own (max-width: 800px) block — tablet keeps a wider centered column
instead of being pinned to a phone-width strip.
2026-05-15 12:30:28 -04:00
e284f88923
fix: serp thumbnails shrink to fit grid cell (MPS-24 lane layout)
shop.printableprompts.com's lanes layout looked broken: each lane
rendered a single giant product card spanning the entire viewport.
Cause: img.serp-thumbnail had no width constraint, so a 1080px-natural
thumbnail expanded its grid cell to 1080px, collapsing the auto-fit
'repeat(auto-fit, minmax(160px, 1fr))' grid to a one-column layout.

Per CLAUDE.md CSS Media Sizing rule, use width: auto + max-width: 100%
(never width: 100% with max-height). Adds height: auto + display: block
to kill inline-image whitespace.

Affects every page that renders .serp-thumbnail (home, shop, search,
tag detail) — printableprompts had pixel-large thumbnails so it
surfaced there first; other shops with smaller thumbs were getting
away with the lack of constraint.
2026-05-15 12:30:08 -04:00
28ffee6c8c
test: explicit tag delete-cascade cleanup test (MPS-24)
Operator-facing concern: 'can I experiment with auto-suggested tags
and undo cleanly without DB cruft?' Yes — Phase 1 already wires the
SQLAlchemy cascade via ProductTag.tag's backref
(cascade='all, delete-orphan'), but the existing functional test only
verified the Tag row went away. This adds an end-to-end test that:

1. Applies a tag to 3 products via action=apply_suggestion (creating
   3 ProductTag rows).
2. Deletes the tag via action=delete.
3. Asserts: Tag row gone, all 3 ProductTag rows gone, products survive
   with empty .tags.

Proves reversibility for an operator testing categorizations on the
suggest-then-approve loop.
2026-05-15 11:55:40 -04:00
8b25285647
style: mobile — hoist buy CTA above the title, split product-right
The right column was a single .product-right that wrapped the buy zone
(price + Add to Cart + preview + auction/offer) AND the related content
(comments link, price history, watch queue, related products) in one
node. Hoisting it to top of the mobile stack would put the related grid
above the title — too much chrome before the title even appears.

Split into two sibling sections, both still carrying .product-right
so existing button/cinema/centering rules apply unchanged:
  - section.product-purchase — buy zone
  - section.product-related  — comments link, price history, watch
                                queue, related products

Mobile (≤800px) order:
  0 product-purchase   ← above the title
  1 product-images     (title + cover + thumbnails)
  2 product-description
  3 product-comments
  4 product-related    (no longer crowding the title)

Desktop (≥960px) grid-template-areas:
  "images       purchase"
  "description  related"
  "comments     related"

So purchase sits at the top of the right column (where the price has
always lived) and related spans the two rows below — the page hierarchy
stays identical to before on desktop, but mobile finally gets the CTA
to the top of fold.

Cinema-mode grid + watch-mode mobile/desktop rules updated to address
both new grid areas. CLAUDE.md Mobile Layout section rewritten.

Product/watch/cinema test slice (37 tests) still green.
2026-05-15 11:53:03 -04:00
81c051e3fc
feat: MPS-24 Phase 2.3 — multi-bigram supersession, apostrophe labels, top_n 100
Phase 2.2 surfaced real categories but left noise:
- Color (119), Number (100), Day (63), Room (36) — unigrams fully covered
  by multiple bigrams, but the prior supersession only considered one
  bigram at a time so "Day" stayed even though "Valentine's Day" +
  "Patrick's Day" + … collectively cover all its products.
- "Valentine Day" / "Patrick Day" labels read as typo-broken because
  apostrophes were stripped during cleaning.
- 50 candidates wasn't long-tail enough on a 481-product catalog.

Three fixes:

- Multi-bigram supersession: a unigram drops when the UNION of bigrams
  containing it covers ≥ 80% of its product set. Iterates all bigrams
  for the unigram's stem, unions their product sets, computes coverage
  once.
- Apostrophe-preserving tokeniser + stemmer: `_MD_PUNCT` no longer
  strips `'`; `_WORD` regex accepts a trailing `(?:'[a-z]+)?` so
  "valentine's" and "patrick's" survive as surface forms.
  `simple_stem` drops the apostrophe tail before suffix-stripping so
  "valentine's" stems to "valentine" — the cluster groups correctly
  while the label vote wins with the readable surface form. Stopword
  check uses the apostrophe-less base so possessives can't slip past
  the list.
- top_n default 50 → 100. CLI default also bumped.

Tested with a Valentine's/Patrick's-heavy sample: bigrams render as
"Valentine's Day", "Patrick's Day" with proper apostrophes; the bare
"Day" unigram drops because the bigrams together cover all its
products. 1080 tests passing.
2026-05-15 11:41:46 -04:00
4e203c4001
feat: product thumbnail hover/click swap — capability-driven enhancement
New /static/js/product-thumbnail-swap.js (loaded with `defer` only when
thumbnail1 is present in product.extensions): hovering a thumbnail
swaps the .product-main preview image to that thumb's source; clicking
sticks the swap and preventDefault()s the anchor so the customer stays
on the product page. Mouseleave on the .product-images container
restores the cover image unless a click has pinned it.

The no-JS path is unchanged — each thumbnail stays wrapped in an
<a href target="_blank"> so clicking still opens the full image in a
new tab when the script isn't loaded. The script self-bails on
non-img main elements (watch-mode video / audio art) and on
.audio-cover so album-art swap doesn't fire on audio products.

CSS: .product-thumbnail gains a brief border-color transition + a
:hover navy outline so the affordance reads without changing markup.

Test: asset-served check for the new JS file. The template wiring is a
single guarded line; if it regresses, the visual smoke test on the
product page catches it.
2026-05-15 11:40:46 -04:00
873cb375a5
style: empty-cart buttons now match the right column's full width
Previous attempt capped .cart-empty-actions at 320px, which made the
"Let's go shopping!" / "View Saved Carts" pair visibly narrower than
the "Make Cart Active" / "Delete Cart" pair in the right column —
still inconsistent.

Drop the cap and centering so the buttons fill the well exactly like
the right column's cart-action buttons. Both wells now produce the
same button width, one cohesive rhythm down the page.

Styleguide entry updated to match.
2026-05-15 11:25:28 -04:00
df65536c17
feat: MPS-24 Phase 2.2 — bigrams + title-required + supersession dedup
Phase 2.1's max_share=0.4 filter only caught Students (53%); the other
four printableprompts generics (Resource / Activities / Writing /
Practice, each 30-32%) slipped through. And single-word "First" was
collapsing the real phrase "First Grade" into noise. Three compounding
fixes plus a dedup pass:

- Bigram detection: adjacent non-stopword tokens cluster as phrases.
  "Write the Room" → bigram "write room"; "First Grade Math" →
  "first grade"; "Valentine's Day Color" → "valentine day"; "Novel
  Study" → "novel study". Bigrams get 2× unigram weight per product —
  phrases out-rank single words when both cluster equally well.
- Title-required filter (min_title_share, default 0.3): candidate must
  appear in title of at least 30% of carrier products. Kills
  description-only marketing noise like "versions", "engaged",
  "offered", "during", "these", "check", "right", "well", "help",
  "time" — words that live in body copy but never in product titles.
- Expanded English stopword list (~80 → ~200): adds generic verbs
  ("see", "ask", "give", "tell", "show"), marketing fluff ("perfect",
  "best", "lovely", "amazing", "favorite"), content-medium nouns
  ("version", "sheet", "page", "draw", "line", "color", "theme",
  "graphic", "answer", "picture"), and their inflections.
- Bigram supersession: when a bigram and one of its component
  unigrams overlap ≥ 80% of products, drop the unigram. Operator sees
  "Write Room" once, not "Write" + "Room" + "Write Room" three times.

URL knobs: ?max_share=0.3 / ?max_share=1 / ?min_title=0.5 /
?min_title=0 / ?bigrams=0 / ?top_n=200. CLI: --min-title-share,
--no-bigrams flags on scripts/backfill_tags.py.

On a printableprompts-shaped fixture the new defaults surface
Write Room, Novel Study, Valentine Day as bigram phrases plus Math,
Counting, Addition, Literacy, Fall — 13 clean candidates instead of
the original 50 noisy ones.

1078 total tests passing; 5 new pure-function tests cover bigrams,
title-required filter, and supersession dedup.
2026-05-15 10:49:54 -04:00
1c43f467fc
fix: replace stale _cutoffs test — analytics now uses RANGE_SPECS
bb54152 retired the _cutoffs() helper when the time-range dropdown
landed (1d/7d/14d/28d/6mo/1yr/lifetime), but TestAnalyticsHelpers still
imported it and asserted against the old "21d"/"365d" keys — the
breakage blocked CI for that commit and stalled the master deploy
queue. Rewrites the test to bind against the new RANGE_SPECS /
RANGE_KEYS / RANGE_LABELS surface that the dropdown actually reads.
2026-05-15 10:27:06 -04:00
95d297ae8a
style: mobile — promote buy CTA above description + comments
Tablet/phone buyers had to scroll past the entire description and
comment thread to find Add to Cart. Desktop two-column has always kept
product-right visible as a side panel; this mobile rule mirrors that
intent.

New order on `@media (max-width: 800px)`:

  1. product-images      (sticky in watch mode)
  2. product-right       (price, Add to Cart, Preview, Up Next)  ← was 4
  3. product-description (was 2)
  4. product-comments    (was 3)

Also tightens product-right .well top margin from 30px → space-3 since
it no longer needs to separate from a comment thread above.
CLAUDE.md Mobile Layout doc updated to match.
2026-05-15 10:25:45 -04:00
d86372c288
style: cohesive empty-cart layout — single button rhythm, no duplicates
The empty-cart view rendered three different button sizes side-by-side:
two compact `mps-button-small` chips in the left well ("View Saved
Carts", "Let's go shopping!") and three full-width primaries in the
right column ("Make Cart Active", "Delete Cart", "Continue shopping").
"Let's go shopping!" and "Continue shopping" pointed at the same `/`
link — a literal duplicate split across two columns.

Fix:

  templates/cart.j2
    - Replace the centered `<br>`-stacked left block with a proper
      `.cart-empty-state` well that uses full-width buttons sized to
      match the right column.
    - Drop unrelated cosmetic class aliases (product-edit-button,
      cart-checkout-button) inherited from other contexts.
    - Hide the right column's "Continue shopping" when the cart is
      empty — same intent as "Let's go shopping!" above it.

  static/css/common.css
    - New .cart-empty-state / .cart-empty-hint / .cart-empty-actions
      Grid-only rules: stacked, centered, max-width 320 so the buttons
      don't stretch awkwardly wide. Reuses var(--space-N) tokens.

  templates/styleguide.j2
    - New "Empty Cart State" subsection under Cart Action Buttons so
      the pattern lives where future empty-state work can find it.

All 25 cart-related functional tests pass.
2026-05-15 10:19:04 -04:00
55137af986
feat: /u/carts surfaces product list, checked-out marker, Activate button
- New mps_invoice.cart_id (nullable FK to mps_cart.id, idempotent
  migration 2dbdb8c89e66). Invoice.apply_cart_negotiation(cart) now also
  tags the source cart, so we have a one→many Cart.invoices back-ref —
  every checkout flow already routes through that method.
- /u/carts list: each row now renders the cart's product titles as
  links (so the user can re-open them). When a cart is empty but has
  invoices (the json_cart was cleared / replaced after checkout), the
  row falls back to listing the line items from those invoices plus a
  "View receipt" button, so the user can repurchase without digging
  through their invoice history.
- Status indicators: a green "active" tag on the current cart, a navy
  "checked out" tag on rows with linked invoices.
- Activate button alongside Delete on every non-active row (POSTs to
  the existing /u/cart/{id}/activate route).
- Layout uses grid-template-areas (summary | actions / products span
  both) and collapses to a single column at ≤600px.

Tests: TestUserCartsList grows three new cases — Activate flips the
active flag, non-empty rows show product titles + links, checked-out
empty carts surface the invoice line items + receipt link.
6 in class pass; full Checkout/Cart slice (42 tests) still green.
2026-05-15 10:01:35 -04:00
bb54152d47
feat: time-range dropdown on analytics — 1d / 7d / 14d / 28d / 6mo / 1yr / lifetime
A `?range=` query param threads through every shop + product analytics
query. The page renders a <select> in the header that re-loads with the
chosen range; default stays 28d so existing bookmarks behave the same.

Bucket coarsening keeps every chart ~24-30 bars regardless of range:

  range      bucket     ~bars  label format
  ───────────────────────────────────────────
  1d         hourly     24     HH:00
  7d         daily      7      Mon
  14d        daily      14     Mar 5
  28d        daily      28     Mar 5
  6mo        weekly     26     Mar 5
  1yr        biweekly   26     Mar
  lifetime   monthly+   ~24    Mar '25

Lifetime sizes its bucket dynamically from the shop's first PageSession
so old shops widen past monthly. RANGE_SPECS in views/analytics.py owns
all of it; `label_step` thins x-axis labels per range so they don't
collide.

Every section that used to be hardcoded 7d / 14d / 21d / 28d now reads
the selected range: overview strip, top products (now total + newer-half
+ older-half + trend arrow), ring entries, engagement / attention /
learning / passive boards, video metrics, traffic, devices, sentiment,
keywords, referrer domains, search queries, referrer trend chart.

Ring Consumed (its own multi-range card) and Views Over Time on the
product page stay fixed — they're permanent comparison views.

Test coverage: TestAnalytics.test_analytics_shows_overview_with_data
now asserts the default-range label, that the range <select> renders,
and that switching to ?range=7d re-labels the overview.
2026-05-15 09:56:16 -04:00
546e85416e
feat: MPS-24 Phase 2.1 — drop shop-vocabulary stems, surface more candidates
First Phase 2 deploy surfaced the wrong candidates on
shop.printableprompts.com: Students (53%), Resource (32%), Activities
(32%), Writing (31%), Practice (30%). These are shop vocabulary —
words that describe the whole shop, not categories within it. A stem
in 53% of products gives a shopper almost no information about which
subset a product belongs to.

- lib/tag_suggest.py: new max_share filter (default 0.4). Stems whose
  product share exceeds this fraction auto-drop as shop vocabulary.
  suggest_clusters now returns (clusters, filtered_count) so the UI
  can show how many stems were filtered.
- top_n default 20 → 50 so the long tail of niche categories surfaces.
- views/shop.py: ?max_share=0.3 (stricter), ?max_share=1 (disable),
  ?top_n=200 URL knobs on the suggestions endpoint — power users tune
  in the browser without redeploying. Floats over 1.0 are interpreted
  as percentages (40 → 0.4) so the URL accepts either form.
- templates/shop_tags.j2: filtered-count hint with copy-paste tuning
  knobs ("?max_share=0.3 stricter, ?max_share=1 to disable").
- scripts/backfill_tags.py: --max-share=0.4 CLI flag.
- Tests: test_suggest_clusters_filters_shop_vocabulary +
  test_suggest_clusters_max_share_one_disables_filter. Existing pure-
  function tests pass max_share=1.0 since their tiny fixtures would
  otherwise be penalised for being small. 1067 total passing.
2026-05-15 09:51:16 -04:00
60a6f02cbc
feat: delete buttons on /u/carts + on empty saved carts, with confirm
The saved-carts list (/u/carts) had no delete affordance, and the cart
detail page's Delete Cart button was hidden the moment a cart went empty
(the entire .cart-right column was gated on `not cart.is_empty`), so
abandoned empty saved carts couldn't be cleaned up.

- /u/carts now renders each row in a .content-card with a Delete form
  on every non-active cart (the active cart is marked "active" and
  cannot be deleted — the existing view rejects it anyway). The form
  carries onsubmit="return confirm(...)" so a stray click can't nuke a
  saved cart by accident.
- The cart detail page's right column now also renders when the cart is
  empty AND the viewer owns it AND it's not active — so the Delete Cart
  button (already gated on non-active) becomes reachable from there too.
  Added the same confirm prompt on that form.
- carts.j2 rewritten to use the design system (.content-card,
  --surface-dim cart-rows, --color-green active accent, --color-danger
  delete button) instead of the prior bare <h2> + <section> + <a> stack.

Tests: TestUserCartsList — 3 functional tests covering inactive cart
shows delete (active doesn't), POST deletes and redirects + row gone,
POST against active cart is rejected (flash + redirect). 3 pass.
2026-05-15 09:45:11 -04:00
14ebda23a0
fix: analytics charts now show y-axis units & tick labels
5 tick labels (0/25/50/75/100% of max) appear on every analytics
chart's y-axis — line_chart macro accepts unit ("s") or as_pct=True
for percentage charts, plus an optional axis_label rendered top-left
above the highest tick. Daily-views bar chart gets the same treatment
inline (unit "views").

Per chart:
  Daily views        bar  → "views" axis, integer ticks
  Session Duration   line → "s" unit, "seconds" axis label
  Engagement         line → 0-100% formatting
  Bounce Rate        line → 0-100% formatting
  External Referrer  line → "visits" axis, integer ticks

viewBox widens from 560→610 to reserve ~50px on the left for the
y-tick labels; SVG scales to container so no CSS change is needed.
2026-05-15 09:35:01 -04:00
4cbd7e4b20
fix: scope /u/notifications + navbar badge to the current shop
Operator users may own multiple custom-domain shops (e.g. shop.unturf.com
and media.unturf.com). Notifications carried a shop_id at insert time
already (lib/notifications.py, views/offer.py:_drop_offer_notification),
but the listing + the navbar unread count queried by user_id only, so
each shop's site surfaced every other shop's offers / sales / auctions.

Both count_unread_notifications and get_notifications_for_user now take
an optional `shop` kwarg. When supplied, they filter to that shop_id
plus shop_id IS NULL (so account-level rows — logins, etc. — still
follow the user regardless of which shop's site they're on). The
notifications view passes request.shop; the navbar badge does too via
add_unread_notification_count.

Tests: TestNotifications gains test_notifications_isolated_per_shop —
two shops owned by the same user, three rows (one per shop + one
shop-less); shop_a context shows shop_a + global, shop_b context shows
shop_b + global, no-shop call still returns all three. 4 in class pass.
2026-05-15 09:25:17 -04:00
1075004419
chore: one-off data fix — paid-but-stuck offers + orphan cart_offers
Two ACCEPTED offers on shop.unturf.com were paid via PayPal but never
transitioned ACCEPTED → PAID because the cart-drain TypeError 502'd
the complete-checkout flow before mark_paid could fire (fixed by
ef91469 + 31e06ff + 6e44027). The /u/offers page still lists them
as Accepted with active Pay $X buttons that would re-charge.

Migration 73c5cb973915:
  1. Flips the two specific offer IDs to PAID, stamps
     paid_timestamp, writes a synthetic OFFER_EVENT_PAY entry in
     the offer-event log so the History panel reflects reality.
  2. Sweeps every cart_offer row whose offer is now in PAID state.
     These are orphans from pre-6e44027 drained carts — the
     symptom is an empty cart still rendering the green
     "Offer accepted" banner with a $X.XX total in the navbar
     even though the offer is settled.
  3. Same sweep for cart_auction → SETTLED auctions, for parity.

The flip-to-PAID is guarded — it only runs on offers still in
ACCEPTED state, so a re-run is a no-op. The cart_offer / cart_auction
sweep joins to terminal-state offers/auctions, so it's idempotent.
2026-05-15 09:20:36 -04:00
5dbbe697b6
feat: MPS-24 Phase 2 — auto-suggest tags from title + description
Operator with 481 untagged products (printableprompts.com) gets a
one-click path to a usable categorization without hand-tagging each
product. Strictly suggest-then-approve — nothing writes Tag or
ProductTag rows until the operator clicks Apply on a cluster.

- lib/tag_suggest.py: pure-function clusterer. Tokenize title (weight 3)
  + description (weight 1, capped at 100 unique tokens per product),
  strip markdown / URLs / HTML, English + per-shop stopwords, simple
  suffix-strip stemmer, group by stem, drop stems matching existing
  tag slugs, rank by product count, label each cluster with the most
  frequent original word for its stem. No new deps, no ML.
- scripts/backfill_tags.py: CLI preview + --apply for a single shop.
- views/shop.py: shop_tags gains action=apply_suggestion (creates tag +
  bulk-attaches every product in cluster) and action=dismiss_suggestion
  (adds the cluster's words to shop.tag_stopwords_json so it never
  resurfaces). ?show_suggestions=1 triggers the cluster compute.
- templates/shop_tags.j2: "Suggest categories from titles + descriptions"
  button + suggestions well with per-cluster sample titles, Apply, and
  Dismiss buttons.
- 15 new tests (11 unit over tokenize / stem / cluster + 4 functional
  over the suggest/apply/dismiss flow). 1064 total passing.

On a printableprompts-style sample the clusterer surfaces Math, Reading,
Literacy, Seasonal, Novel, Activities, Comprehension — matching what an
operator would manually pick.
2026-05-15 09:09:40 -04:00
c03ca53fb4
feat: MPS-24 — shop home page categorization (tags + chips + sectioned lanes)
Operator feedback on shop.printableprompts.com flagged our flat default
home page as the reason for considering a move to Shopify. This adds an
opt-in home_layout selector with the navigation primitives shoppers expect
from a modern catalog — fewer clicks to a relevant product.

Phase 1 shipped (default unchanged for every existing shop):

- New Tag + ProductTag models, shop-scoped, many-per-product, flat (no tree)
- Shop.home_layout (0=flat / 1=chips / 2=lanes) plus tag/lane caps, optional
  featured strip, and per-shop tag stopwords for the Phase 2 auto-tagger
- home-layout-settings form section in shop_settings.j2
- Bulk tag editor at /s/{shop_id}/tags with apply/remove per product
- Public tag detail page at /s/{shop_id}/tag/{slug} (works without JS)
- Comma-separated tag input on the product edit form
- home.j2 / shop.j2 branch on layout — chip strip for layout 1, sectioned
  lanes for layout 2, flat unchanged for layout 0
- /search results page also receives the chip strip so shoppers can narrow
  keyword results by tag
- static/js/tag_filter.js progressively enhances chip clicks into in-place
  grid filtering via data-tag-slugs — zero navigation cost, capability-driven
  fallback to ?tag= URL nav with no JS
- New chip / lane CSS in common.css — tokens only, Grid only (no flexbox)
- Live tag-chip + tag-lane examples in /styleguide under #cards
- Idempotent Alembic migration creates 2 tables + 5 shop columns with
  server_default + _table_exists / _column_exists guards
- 24 new tests across unit + functional layers (1049 total passing)
- New "Ticket Scoping — One Feature, One Ticket" rule in CLAUDE.md;
  Phase 2 (deterministic auto-tag from titles) and Phase 3 (uncloseai-
  backed ML categorization behind a kill switch) stay under this ticket
2026-05-15 08:48:25 -04:00
3e4e663498
style: product page polish — content-card layering, comment form fix
Wrap the product page's Description, File info, and Comments sections in
a new .content-card pattern: white surface, --border-light, --elevation-1
shadow, --radius-lg, --space-5 padding. Replaces the loose <br/><br/>
separators each section used to rely on with proper visual chunks layered
above the page surface (material-style).

Comment form fixes:
- Add input[type=email] to the global input style block (was unstyled,
  picking up browser default narrow width — the inline style="width:100%"
  on .comment-form-field's <input> got swamped by browser default).
- Replace ad-hoc <div>/<br/> markup with .comment-form-field rows; inputs
  and textarea fill width via box-sizing:border-box; submit button is
  justify-self:start instead of stretching across the form.
- Individual comments now sit on a --surface-dim soft inset card with
  --color-navy left accent on .comment-reply; .comment-header is a
  baseline-aligned 3-col grid (name / date / title).

Back-to-shop link is now a small secondary anchor (.mps-button-small),
left-aligned, instead of a full-width button.

Styleguide gains three subsections: Content card (under #wells), Comment
form, and a sample rendered comment with reply (under #comments).

Tests: targeted product/comment/styleguide slice — 51 passed.
2026-05-15 08:27:34 -04:00
6e44027354
fix: delete cart_offer / cart_auction rows after payment finalizes
The previous fix drained line items from a paid cart but left the
cart_offer (or cart_auction) association row alive. The cart then
reads as is_negotiated=True with zero products — the cart page
renders the green "Offer accepted" card on top of an empty cart,
the cart total shows the override amount, and the navbar reads
"Cart $1.00 (0)" — 0 items, $1.00 total.

_finalize_auction_offer_state now also dbsession.delete()s the
association row immediately after flipping offer.state=PAID and
auction.state=SETTLED. The negotiation is single-use; once the
offer is PAID, the cart should be a plain empty cart.

The Cart.auction_offer_override_in_cents property short-circuits
on cart_auctions/cart_offers truthiness, so removing the row
makes is_negotiated return False, the negotiation card disappears,
and total_in_cents stops returning the override.
2026-05-15 07:55:17 -04:00