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
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.
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.
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
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
This is the inner exception the tm.doom switch was meant to surface.
The PayPal complete-checkout flash now reads:
"Payment processing failed: Cart.remove_product() takes 2 positional
arguments but 3 were given"
Cart.remove_product(self, product) deletes the cart entry entirely;
it doesn't take a quantity. Two callsites in cart.py were passing
line_item.quantity as a second positional arg — both inside the
post-payment "drain the cart" loop that runs AFTER PayPal capture
succeeded:
views/cart.py:1094 — paypal_complete_checkout
views/cart.py:1345 — adyen_complete_checkout
PayPal got the buyer's money, the capture API succeeded, then the
cart-drain raised TypeError. Pre-tm.doom that bubbled into the
except block, hit tm.abort, blew up pyramid_tm.tm_tween →
uwsgi 500 → Caddy 502 — buyer charged, no invoice on our side.
Drop the quantity arg. cart.remove_product deletes the cart entry
unconditionally; the line item's full quantity is removed in one
shot, which is what the post-checkout drain wants anyway.
Stripe's user_cart_complete_checkout doesn't call remove_product at
all (it relies on cart.update_inventory + a session-clear elsewhere)
— that's why this only ever bit PayPal + Adyen.
Two issues fox flagged from prod observation:
1. PayPal capture succeeded but MPS returned 502 Bad Gateway —
buyer was charged, no invoice landed. Root cause traced via
tmux-hosts journalctl on mps-uwsgi1:
transaction.interfaces.NoTransaction
File "pyramid_tm/__init__.py", line 146, in tm_tween
if manager.isDoomed():
File "transaction/_manager.py", line 88, in get
raise NoTransaction()
All four payment-complete-checkout exception handlers in
views/cart.py called `request.tm.abort()` and returned
HTTPFound. abort() yanked the transaction out from under
pyramid_tm.tm_tween, whose post-view manager.isDoomed() check
then raised NoTransaction → uwsgi 500 → Caddy 502.
Fix: `request.tm.doom()` instead. Flags the txn for abort but
leaves it for pyramid_tm to clean up — the documented pattern.
Also added logging.getLogger(__name__).exception() at each
except so the original failure is captured in journalctl
instead of being swallowed into a flash message we can't see.
The flash + 302 redirect path still works for the user.
Four sites patched:
- user_cart_complete_checkout (Stripe) x2 (CardError + Exception)
- paypal_complete_checkout
- adyen_complete_checkout
The original inner exception in fox's PayPal case is still
unknown — the bug masked it. Next failed payment will surface
the real stack in journalctl.
2. Existing shops still showing 7-day (168h) seller-response
window even after DEFAULT_OFFER_EXPIRATION_HOURS dropped to 72.
The shop column default is for new rows only; rows already in
the DB kept 168. Migration 7d6af811b6a1 bumps any shop still
at the literal 168 down to 72; shops that explicitly customized
(any other value) are left alone.
Fox flagged this three times. Each pass I removed one or two of these
orphan stripes — green Accepted, blue can_act/is_open. Four were
still left in:
is_declined → red "automatically declined — try a higher amount"
is_withdrawn → yellow "This offer was withdrawn by the buyer."
is_expired → yellow "This offer expired before it was accepted."
is_paid → green "Paid — this offer is complete."
The red declined alert was also actively wrong: it asserted "below
the seller's minimum and was automatically declined" even when the
seller manually declined a perfectly reasonable offer. That copy
was hardcoded; the template didn't know whether the auto-decline
threshold fired or a seller hit the button.
Burn all of them. The offer-state-badge in the header well already
shows the state (Accepted / Declined / Withdrawn / Expired / Paid /
Cancelled by buyer) and the action wells below carry every actionable
detail. The orphan stripes were redundant at best and lying at worst.
Functional test test_offer_page_shows_declined_notice → renamed to
test_offer_page_shows_declined_state_in_badge. Asserts:
- offer-state-badge offer-state-3 + "Declined" text in header
- "offer-state-notice" string NOT in body
- "automatically declined" string NOT in body
A negotiated cart paid through Stripe's user_cart_complete_checkout
already fired _finalize_auction_offer_state(cart, request) — which
flips linked offers to PAID and linked auctions to SETTLED. But the
PayPal complete-checkout (cart.py:paypal_complete_checkout) and the
Adyen complete-checkout (cart.py:adyen_complete_checkout) paths
never called it.
Symptom: buyer pays a negotiated offer through PayPal or Adyen,
invoice is written, sale email fires — but offer.state stays at
ACCEPTED. The offer detail page keeps rendering the Pay $X / Cancel
buttons because `{% if is_accepted and not is_paid %}` is still True.
Even worse, the buyer could click Cancel after the payment had
processed (the cancel endpoint guarded on `state != ACCEPTED`, which
also still permitted it).
Fix: add the _finalize_auction_offer_state(cart, request) call to
both paypal_complete_checkout and adyen_complete_checkout, right
when successful_invoices is populated — before the cart line items
get drained. Each takes (cart, request) which both sites have in
scope. With the offer now in PAID:
- is_paid=True flips the offer.j2 gate, hiding Pay/Cancel.
- /o/{id}/cancel raises OfferRejected ("only accepted offers can
be cancelled by buyer").
- _user_party / shop_offers / buyer dashboards all read the right
terminal state.
Note: this only patches the cart.py callsites. Webhook fallbacks
(views/webhooks.py) and the crypto-watcher finalize path
(lib/crypto_watcher/__init__.py) still don't call mark_paid because
they only have `invoice`, not `cart`. Follow-up will route those
through a `_finalize_invoice_negotiation(invoice, session)` helper
once that's needed; the cart.py paths cover the common case.
Two coupled fixes the rendered sale email exposed:
<img src="None/<shop_id>/<product_id>/thumbnail1?ts=...">
1. ShopContextRequestWrapper (lib/crypto_watcher/__init__.py:1243)
wraps env_request for email-rendering inside the watcher loop. It
overrode domain / host_url / app and proxied everything else via
__getattr__. send_purchase_email + send_sale_email read
`request.shop_cdn_endpoint` — but that's a reified Pyramid request
method, not a static attr on env_request. __getattr__'s default
returned None, and the email's <img src> became `None/.../...`.
Fix: add explicit `shop_cdn_endpoint` (and `shop`) properties on
the wrapper, derived from the wrapper's `_shop`. Order:
- BYOB shop with primary_s3_cdn_endpoint set → that
- else `app["bucket.secure_uploads.get_endpoint"]` (MPS default)
- else None
The BYOB branch also defends against an enabled-but-blank
primary_s3_cdn_endpoint — drops to the default rather than
returning None.
2. Offer + auction-outbid emails carried no product thumbnail at
all — bummer, since the recipient can't visually identify which
item the negotiation is about. New _product_thumbnail_html(request,
product) helper renders a 184px-max <img> identical to the
purchase/sale shape (or empty string if the product has no
thumbnail1 extension / no CDN endpoint).
Wired into:
- send_offer_received_email
- send_offer_accepted_email
- send_offer_countered_email
- send_offer_declined_email
- send_offer_withdrawn_email
- send_offer_buyer_cancelled_email
- send_auction_outbid_email
Templates in lib/mail_messages.py gained a `{thumbnail}` slot
between the headline and the click-through link. Text variants are
unchanged (no inline images in text email).
Both fixes target the same surface: every transactional email now
renders the right image, regardless of whether it's sent from a
view (Pyramid request) or the crypto watcher loop (wrapped env_req).
The withdraw form on /o/<id> was nested inside the buyer's
`can_act` (your-turn) block, so a buyer who'd just opened an offer
or whose counter was awaiting the seller's response saw the
"Waiting on the other party" panel with no way to back out — they
either had to wait for auto-expiry or message the seller.
Add a symmetric withdraw form inside the `is_open` waiting block,
gated on actor_party == 0 (viewer is the buyer). The lib already
permits withdraw at any non-terminal pre-accept state
(lib/offer.py withdraw_offer); only the UI was the bottleneck.
Sellers don't get a symmetric "pull out" here — they decline
instead, which lives in their can_act block.
Regression test test_buyer_sees_withdraw_button_while_waiting_on_seller
asserts the form action and "Withdraw offer" copy render on a
PENDING offer from the buyer's view.
CI on commit 5f735ee broke two double-spend-protection unit tests:
TypeError: unsupported format string passed to MagicMock.__format__
The new notify_purchase_and_sale() call inside the crypto-watcher
finalization path runs `f"${invoice.total:.2f}"` to compose the
notification body. The double-spend tests pass a MagicMock as the
invoice — its .total is a MagicMock too — and the format-spec call
fails before _safe_add even runs.
Fix: wrap every public notify_* helper in a top-level
try/except-and-log. A notification persist failure must not
propagate up the payment-finalization stack (it didn't matter in
prod because real invoices format fine, but the unit-test mocks
exposed the contract gap).
All five helpers updated: notify_purchase_and_sale,
notify_auction_outbid, notify_auction_won,
notify_auction_ended_no_winner, notify_offer_expired. Inner
functions hold the actual logic; the outer wrapper is just the
swallow-and-log shield.
This also stops a transient DB or attribute error inside the
helper from rolling back the payment txn — same defense the
_safe_email wrapper provides for mail sends.
Two cleanups:
1. The orphaned blue "Waiting on the other party to respond" stripe
(alert-info-bg) appeared between the offer header and the
"Waiting on the other party · They have in X to respond" well
below — identical copy, twice. Same pattern as the previously
removed green "Offer accepted" banner. Dropping both the can_act
("It's your turn — accept, counter, or decline below") and
is_open branches of the state-notice. The "Your turn" section
heading and the waiting well right below already carry the
message, with the live countdown that the alert lacked.
Kept: DECLINED, WITHDRAWN, EXPIRED, PAID — those have no
follow-on action block, so the alert is the only signal.
2. DEFAULT_OFFER_EXPIRATION_HOURS 48 → 72 (3 days). Fox: 48h still
too tight for sellers checking shop mail intermittently. 7 days
was too generous, 48 hours was on the strict side. 72 hours
covers a long weekend.
Per-shop overrides are unchanged — operators tweak
offer_expiration_hours via offer-settings on /s/<shop>/settings.
Ported remarkbox's pattern over to MPS. views/version.py now tries
sources in order:
1. /opt/make_post_sell/commit-hash.txt (CI deploy artifact)
2. /opt/make_post_sell/env/commit-hash.txt (alt salt layout)
3. <package>/../commit-hash.txt (relative)
4. <package>/GIT_HASH (setup.py legacy)
5. git rev-parse --short HEAD (dev environment)
6. "unknown" (last resort)
MPS CI's build stage already writes commit-hash.txt to the artifact
tarball (.gitlab-ci.yml:38 `echo $CI_COMMIT_SHA >> commit-hash.txt`);
salt deploys it. setup.py's GIT_HASH rewrite still runs at install
time as a redundant fallback, so any environment that hasn't migrated
to commit-hash.txt yet keeps working.
Dev environments fall through to git rev-parse, which is faster and
more accurate than the previous setup.py-rewrites-source pattern that
required the file to be in the package at runtime. With this change,
/version returns the actual deployed commit hash everywhere — no
stale GIT_HASH file in git history (the file is .gitignored as of
c383c41).
Plug the remaining state-machine gaps the previous notification
batch left dark. With auction_tick and offer_tick running on cron,
buyers and sellers now learn about every passive transition:
- auction_tick ACTIVE → ENDED (with winner) drops
notify_auction_won for the winner — they see the pay CTA + 48h
payment_deadline before it lapses. Previously only the email
fired (and only if SMTP succeeded); now the in-app row also
exists with breadcrumbs Shop → Product → Auction.
- auction_tick ACTIVE → ENDED (without winner / reserve not met)
drops notify_auction_ended_no_winner for shop owners so they
can decide to relist.
- offer_tick PENDING/COUNTERED → EXPIRED and ACCEPTED → EXPIRED
both drop notify_offer_expired for buyer + shop owners. The
buyer was the most-affected silent case: they made an offer,
the seller never responded, the offer auto-expired, the buyer
never knew.
Four new notification kinds in models/notification.py:
auction_won, auction_ended_no_winner, auction_cancelled
(reserved — no call site yet), offer_expired.
lib/notifications.py orchestrator refactored: _resolve_session()
accepts either a Pyramid request or a SQLAlchemy session so the
same helpers work in views AND tick jobs.
Integration tests in TestAuctionTickIntegration and
TestOfferTickIntegration assert the right row exists with the
right kind + FK after each tick.
Three remaining notification surfaces wired:
1. Purchase (buyer) + sale (every shop owner) notifications drop on
every cart-completion site. Coverage:
- views/cart.py x3 (Stripe / PayPal-create / Adyen) — alongside the
existing send_purchase_email + send_sale_email pair
- views/webhooks.py x4 (PayPal capture / approved / Stripe / Adyen)
- lib/crypto_watcher/__init__.py x3 (Monero / Dogecoin / confirmed
duplicate-path) — gated on the existing sales_email_sent flag so
a rescan can't write duplicate rows
Each notification carries invoice_id, so MpsNotification.breadcrumbs
walks Shop → Product (first line item) → Invoice (/i/<id>).
2. Auction outbid: when a new bid lands and the prior bidder is
bumped, alongside send_auction_outbid_email the prior bidder gets
a row with auction_id set — breadcrumbs walk Shop → Product →
Auction (/a/<id>).
3. Read rows stay visible. Fox's clarification: mark-read must not
delete; just fade. The row stays in the list (still clickable,
breadcrumb still works); only the unread accent class and the
opacity differ. Tokens: .notification-row has opacity 0.65;
.notification-row-unread overrides to 1 + alert-info-bg +
left accent border. test_read_notifications_remain_visible_but_faded
asserts the row count stays the same in DB and the rendered
subject is still in the HTML after dismiss.
Shared orchestration in new lib/notifications.py — notify_purchase_and_sale
and notify_auction_outbid keep the call sites to one line each.
3 new functional tests, 108/108 in the offer/auction/cart sweep.
Every transactional email on the offer state machine now also drops a
row in a new mps_notification table — the user has a permanent in-app
inbox even if they never opened the email.
Schema (mps_notification, migration 5d01b163b805):
- user_id recipient
- shop_id which shop this is about (nullable)
- kind discriminator (offer_received, offer_accepted,
offer_countered, offer_declined, offer_withdrawn,
offer_buyer_cancelled, purchase, sale,
auction_outbid)
- subject, body denormalized text so deleting the source entity
doesn't blank the row
- link_url primary click-through (/o/<id>, /a/<id>, /i/<id>)
- offer_id, auction_id, invoice_id
optional FKs for breadcrumb rendering
- created/updated_timestamp
- read bool, drives the unread badge
- read_timestamp
Composite index on (user_id, read, created_timestamp) for cheap
unread-count queries.
Surfaces:
- request.unread_notification_count (reified) drives a navbar badge
next to the profile name and a duplicate badge on the /u/settings
"Notifications" button.
- /u/notifications lists rows newest-first with the kind label, time
delta (ago.human), subject/body, and a breadcrumb chain back to
source entities. MpsNotification.breadcrumbs walks shop → product
→ offer/auction/invoice for any combination of attached FKs.
- /u/notifications/{id}/read marks a single row read; bulk
"Mark all read" on the list page hits /u/notifications/read-all.
Wired into every offer state transition (open, counter, accept,
decline, withdraw, buyer-cancel) alongside the existing email sends.
_safe_email and notification persist are now decoupled — SMTP outages
no longer block notification creation. (This was the regression the
new TestNotifications suite caught: pre-fix, a refused SMTP swallowed
the notification persist call too.)
Tokenized CSS for the badge (.notification-badge pill, danger color)
and the list rows (.notification-row, .notification-row-unread with
left accent border, .notification-row-breadcrumbs trail). Grid only,
no flex, no inline styles.
Tests TestNotifications.test_offer_open_drops_received_notification_for_seller
and test_badge_count_and_mark_read drive the full flow: buyer opens
offer → seller's row exists with breadcrumbs (Shop → Product → Offer)
→ /u/settings badge renders → /u/notifications/read-all clears.
Previously the only emails on the offer state machine were:
- PENDING → seller (offer received)
- auto-accept / seller-accept → buyer (offer accepted)
- cart paid → buyer + seller (purchase / sale)
Every other transition was silent — buyer countered, seller
countered, seller manually declined, buyer withdrew before accept,
buyer cancelled after accept. The buyer-cancelled-after-accept case
stung most: the seller had accepted and was awaiting payment that
was never coming, with no notice except by polling /s/<shop>/offers.
Five gap-plugs:
1. Buyer counters back → seller(s) emailed (all shop owners).
2. Seller counters → buyer emailed.
3. Seller manually declines → buyer emailed. (auto-decline stays
silent — the submit flash already conveys it inline.)
4. Buyer withdraws (pre-accept) → seller(s) emailed.
5. Buyer cancels (post-accept) → seller(s) emailed with explicit
"buyer cancelled accepted offer" copy so the seller stops
expecting payment.
Templates: OFFER_COUNTERED_{TEXT,HTML}, OFFER_DECLINED_{TEXT,HTML},
OFFER_WITHDRAWN_{TEXT,HTML}, OFFER_BUYER_CANCELLED_{TEXT,HTML} in
lib/mail_messages.py.
Helpers: send_offer_countered_email, send_offer_declined_email,
send_offer_withdrawn_email, send_offer_buyer_cancelled_email in
lib/mail.py — same pattern as send_offer_received_email +
send_offer_accepted_email.
Each handler in views/offer.py snapshots the pre-action state, runs
the action, and only emails on the actual transition (so a retried
POST against an already-terminal offer doesn't re-fire the email).
All sends go through _safe_email which logs + swallows exceptions:
mail failure cannot 500 an offer-state HTTP response.
Crypto status-poll endpoints flashed "Payment received! Waiting for
confirmations…" on every refresh while a quote was in the
received-but-not-confirmed state. The flash queue is a list, so
identical alerts piled up — fox's screenshot showed the same line
repeated 18+ times stacked down the page.
Two-layer fix:
1. Render-level dedupe in templates/snippets/flash-alerts.j2.
Pop the queue once, walk it, render each unique (message, level)
pair only the first time it's seen. Safe regardless of how the
queue was populated — covers any other view that might flash
duplicates without the helper.
2. New request.flash_once(message, level) helper (request_methods.py)
that peeks the queue and skips the append if (message, level) is
already there. Switch the two crypto status-poll handlers
(views/crypto.py — Monero block ~820, Dogecoin block ~910) to use
it. Both endpoints are hit on every poll, so the queue-level
dedupe matters even with render-level dedupe (other consumers of
the queue would still see duplicates, and the queue would balloon).
The flash queue itself stays a list (order-preserving) — set would
lose insertion order, which matters when multiple distinct alerts
need to render top-to-bottom. The peek-then-skip pattern keeps the
list semantics while preventing dup growth.
CRITICAL financial defect. Cart UI showed the negotiated $21 total
for an accepted offer (list was $42), but EVERY payment pipeline —
Stripe (cart.py:848), PayPal (cart.py:1164), Monero quote
(crypto.py:269), Dogecoin quote (crypto.py:538) — pulled
invoice.total_in_cents, which summed line items and ignored the
cart_offer / cart_auction override entirely. Buyer's DOGE quote
asked for ~373 DOGE (≈$42 USD) for an offer they negotiated to $21.
Seller would have eaten $21 per accepted offer.
Fix:
- New column mps_invoice.negotiation_override_in_cents (nullable
BigInteger). Migration 27bc1bfc33dc adds it idempotently.
- Invoice.total_in_cents short-circuits to the override + handling
when the column is set. Coupons / discount math is bypassed (the
buyer already negotiated; we don't stack on top).
- Invoice.apply_cart_negotiation(cart) helper copies the cart's
override onto the invoice in one call. Idempotent. No-op for
non-negotiated carts.
- Wired into every invoice-from-cart construction site (7 total):
- views/crypto.py:161 (Monero quote)
- views/crypto.py:454 (Dogecoin quote — fox's screenshot)
- views/cart.py:827 (Stripe checkout)
- views/cart.py:974 (PayPal create-order)
- views/cart.py:1153 (Adyen sessions)
- views/cart.py:1258 (PayPal complete-checkout)
- views/cart.py:1388 (post-PayPal-approval finalize)
Two integration tests cover the model:
- test_negotiation_override_short_circuits_total walks the
invoice-from-negotiated-cart flow, asserts $42 → $21 transition
after apply_cart_negotiation, and confirms handling still adds
on top.
- test_apply_cart_negotiation_noop_when_not_negotiated proves the
helper is safe to call on plain carts (no negotiation_override
set, line items sum normally).
Legacy invoices already in the DB have NULL on the new column —
they keep their line-item-summed totals, untouched.
Three UI fixes batched (all touched adjacent templates):
1. /u/offers, /u/bids, and /s/{shop}/offers tables were cramped on
desktop: max-width was the .one-column 600px constraint, so the
Product column word-wrapped and timestamps wrapped onto multiple
lines. Widen .shop-offers-page to 1100px on desktop, give
Product the auto-flow column and tag the rest with .col-narrow
(nowrap) / .col-action (right-aligned). Replace strftime UTC
strings with ago.human() relative deltas ("2 hours ago",
"5 minutes ago") in the three views that feed those tables.
Mobile (<720px) collapses each row to a block list — the dense
table layout is desktop-only.
2. /u/cart/{id}/checkout had a visible gap between the
"Pay with Credit Card — Add Card" CTA and the PayPal button —
a redundant <br/> at the top of the PayPal block. Drop it; the
buttons' own margins now space them naturally.
3. Buyer's accepted-offer pay panel had Pay and Cancel stacked
vertically with Cancel rendered as a faint text link. Per fox:
put them on the same line, Cancel on the left. New
.offer-pay-cta-actions grid (auto 1fr) renders Cancel as a
neutral mps-button at start, Pay as the green primary at end.
Stacks on viewports under 600px.
All token-driven CSS, Grid only (no flexbox), no inline styles.