Two coupled fixes on the accepted-offer flow:
1. Single redemption. offer_checkout was creating a fresh cart on
every POST. While offer.state == ACCEPTED, a buyer could spawn N
parallel carts on one offer; mark_paid is idempotent on the offer
but the *other* carts still carried the override and could each
complete checkout, double-charging the buyer. Now: if any
cart_offer already exists for the offer, reuse that cart (and
re-activate it). Only one cart_offer row can ever exist per offer.
2. Seller copy on the "Awaiting payment" panel said "they need to
sign in and pay from this same page — send them the link", which
implied the seller had to manually deliver the link. The system
already emails the buyer on accept (send_offer_accepted_email is
wired in views/offer.py for both auto-accept and manual paths).
The copy now reflects that: "We emailed them a one-time checkout
link — this offer can be redeemed only once." The shareable link
stays as a fallback for if the buyer asks for it again.
Functional test test_offer_checkout_is_single_redemption asserts
three consecutive POSTs to /o/{id}/checkout redirect to the same
cart URL and produce exactly one cart_offer row. Existing
test_accepted_offer_seller_sees_pay_link_to_share extended to
assert the new copy ("emailed", "one-time checkout link").
Two new buyer-side pages mirror /u/purchases — scoped to request.shop,
listing every offer or bid the user has placed within the current shop:
- /u/offers — open offers (pending/countered) on top, terminal below.
404s when the shop has offer_enabled=False. Wired to user_offers.j2.
- /u/bids — latest bid per auction in this shop. 404s when the shop
has zero products in auction pricing_mode. Wired to user_bids.j2.
Gating exposed on /u/settings:
- "My Offers" button: visible iff request.shop.offer_enabled is True.
- "My Bids" button: visible iff request.shop.has_auction_products.
New property Shop.has_auction_products: returns True when the shop
has at least one product with pricing_mode in (1, 2). Auctions have
no shop-level toggle — they're enabled per product — so the buyer
gate is derived. Unit-test coverage in test_integration.py.
Tests:
- TestAuctionFoundation.test_shop_has_auction_products_property
covers all three pricing-mode transitions on the same shop.
- TestUserOffersBidsDashboards (7 tests) covers 404 paths, page
renders, and button visibility on plain / offer / auction shops.
Cart polish for offer/auction carts:
- Negotiation card at top of cart-left: eyebrow ("Offer accepted" /
"Auction won"), list price (strike) vs agreed price (bold green),
savings line, link to /o/{id} or /a/{id}.
- Shop subtotal honors the override: strikethrough list price, bold
negotiated price. (is_discounted was rightly False after the prior
fix, but the visual cue was lost — restored without conflating it
with coupon discounting.)
- Line item rendering: replaces quantity field + remove button with a
"quantity locked" pill on the negotiated product, since offers and
auctions are single-unit transactions buyers cannot edit mid-cart.
- Right column total: shows agreed price big, list-vs-savings line
beneath. No conflicting strikethrough.
- Gift card apply hidden on negotiated carts (does not stack).
New Cart properties (test coverage in test_integration.py):
- is_negotiated, negotiation_kind, negotiation_path, negotiated_product
- list_total_in_cents / list_total
- savings_in_cents / savings (clamped to >= 0)
CSS: cart-negotiation-card / cart-negotiation-* / cart-negotiation-pill
all live in common.css, Grid only, design tokens only.
Sandbox-gated artifact storage:
The Artifact Storage section on /u/settings (S3-compatible bucket for
the in-browser sandbox export feature) now renders only when the
current shop has sandbox_mode enabled. The bucket has exactly one
consumer (views/user_sandbox.py via static/js/sandbox.js), so when
sandbox is off there is no reason to surface the credential form.
Functional test in test_functional.py asserts both states.
total_discounted_price_in_cents summed line items and ignored the
cart_offer / cart_auction override. is_discounted then reported True
(agreed ≠ list), so total_in_cents — what the charge path reads —
returned the list price. UI strikethroughed the agreed price and
charged the list price.
Short-circuit the discounted path on override, the same way
total_price_in_cents does. An offer/auction is a negotiated price,
not a discount, so coupons and gift-card balances do not stack on
top of it.
Extended both override tests to assert total_in_cents,
total_discounted_price_in_cents, and is_discounted — the gap that
let this ship.
When an offer is accepted but unpaid, the seller's view now has an
"Awaiting payment" block: the buyer's name (linked to their profile), the
agreed amount, and the offer URL pre-filled in a read-only input plus a
"Copy link" button (capability-driven — the input is selectable for
manual copy when JS is absent or clipboard API is blocked). The notice
banner also says "waiting on the buyer to pay $X" in the seller's view.
The buyer's pay-now button is now mps-button-green so it's unmistakable
as the call-to-action.
Tests: TestOfferCheckout gains two render tests — seller sees the
awaiting-payment block + URL but not a pay-now form; buyer sees the
$75.00 pay-now form but not the awaiting block. 9 in class pass.
New text/event-stream endpoints — /o/{offer_id}/events (buyer/seller only)
and /a/{auction_id}/events (public). Each polls the row ~every 1.5s, emits
a `data: {json}` frame on connect and whenever the state-machine state
changes, sends a heartbeat comment, then closes after ~25s so the browser
EventSource reconnects — "bounded" because uWSGI is sync (~16 worker
threads) and a long-lived SSE would starve the pool. Shared helper
lib/sse.py (sse_response / event_stream); it uses its own short-lived DB
session per poll (request.dbsession is already closed by pyramid_tm by the
time the streaming generator runs). Timings come from settings
(app.sse.hold_seconds / app.sse.poll_interval_seconds; test.ini sets them
tiny so the streaming tests finish in ~0.06s).
Client: auction.js opens the EventSource and feeds each frame into its
existing applyState(); it falls back to polling /a/{id}.json every 5s
where EventSource is unavailable. offer.js opens the EventSource on the
offer page and reload()s on a state change (the whole layout depends on
state / can_act). offer.j2 carries data-offer-state. Caddy auto-detects
text/event-stream and stops buffering — no Salt change.
Tests: 4 new functional tests (both endpoints stream the right
content-type + a state frame; 404 for outsiders / unknown ids). 994 passed.
offer_checkout and auction_checkout were creating a cart and hand-setting
cart.active = True, which left the user with two active carts for the
shop; /cart then resolved to the older, empty one — so "Pay $X" appeared
to do nothing. Both now use shop.create_new_cart_for_user() (deactivates
the user's other carts for that shop, activates the new one) and redirect
to /cart/{cart_id} directly, so it works regardless of which shop the
request is scoped to.
Tests: TestOfferCheckout / TestAuctionCheckout now assert there is exactly
one active cart, it carries the cart_offer/cart_auction association, it is
non-empty, and the redirect targets that cart by id. 990 passed.
These end the negotiation, so the forms now carry onsubmit="return
confirm(...)". offer.js bails when the prompt is cancelled — it checks
event.defaultPrevented before firing the AJAX request — so the JS path
respects the confirmation too, and the no-JS path gets the native dialog
before the POST+redirect.
Crossfades (user-initiated and DJ auto-transitions) faded the incoming
track up to volume 1.0 regardless of how loud the playing tab actually
was, producing a jarring jump in loudness mid-transition. Read
activeMedia.volume when a fade begins and use it as the ceiling for both
the outgoing and incoming tracks; restore that same level if a DJ
crossfade is cancelled.
The previous .action-button-grid used auto-fit/minmax(15rem,1fr) which
produced four columns on a wide well, and .mps-button's `min-width: 100%`
+ auto margins fought grid track sizing so the gap collapsed and buttons
touched. Now: single column on narrow, exactly two equal columns at
≥720px (auto-flow keeps them balanced), explicit column/row gap, and the
grid resets .mps-button min-width/margins. The "🤝 Offers" button is now
always shown to shop owners (was gated on shop.offer_enabled) so the
offers inbox is always reachable.
- Offer history & offer page show the buyer's display name (User.display_name
= the public `name` handle; `full_name` is private) linked to a profile
page — never the email. _serialize_offer drops buyer_email; events carry
actor_name/actor_handle/actor_id, header carries buyer_name/buyer_handle.
- New public profile page: GET /profile/{handle} (views/user.py:user_profile,
template profile.j2). Shows gravatar (User.gravatar_url(size) — forced
identicon unless the user opted into Gravatar), member-since, owned/edited
shops, and a <details> "Show email" that is server-gated: only the user
themselves, or a shop owner/editor viewing in that shop's context
(?shop={shop_id}) when the profile user has transacted there (an offer or
an invoice).
- New operator offers inbox: GET /s/{shop_id}/offers (@shop_editor_required,
shop_offers.j2) — open offers first, each row links to /o/{id} and the
buyer's profile. Reachable from /actions/view via a new "Offers" button
(shown when shop.offer_enabled).
- /actions/view rebuilt: one flat .action-button-grid (Grid auto-fit,
minmax(15rem,1fr)) inside a properly-padded .action-columns well — fixes
the off-balance two-column layout and buttons overflowing the well; no
<br> spacers. Styleguide gains profile-card and action-button-grid
patterns.
- offer.j2: buyer name shown (linked to profile); "Buyer:"/"Seller:" message
lines renamed "Buyer note:"/"Seller note:" to disambiguate.
Tests: 11 new functional tests (profile render + email gating, offers inbox,
actions button, styleguide). 989 passed.
- offer.j2 renders a state-aware notice (.offer-state-notice, .alert
variants) above the action forms: declined / withdrawn / expired /
accepted (+ pay-now hint for buyer) / your-turn / waiting — so the
viewer always understands the offer state without depending on a flash
a JS redirect would skip. _serialize_offer now exposes is_declined,
is_withdrawn, is_expired, is_pending, is_countered, is_accepted.
- Shop-settings Make-an-Offer section restyled with the new
.settings-form / .settings-form-grid / .settings-field /
.settings-field-hint system (two-up grid, per-field hints, submit
pinned right). Added a styleguide entry under #forms.
- Reworded the section blurb: auto-declined offers are NOT silent — the
buyer is told their offer was too low; only the seller isn't pinged.
- Fixed --color-text-muted typo (→ --text-muted) on .offer-js-flash-info.
Tests: TestOfferRoutes gains 3 state-notice render tests;
TestSettingsFormStyleguide covers the styleguide + live shop-settings
markup and asserts the old wording is gone. 978 passed.
Every bid/buy-now/watch and offer open/counter/accept/decline/withdraw
POST now works as a plain browser submit: flash + 302 redirect to the
auction/offer page. JSON is returned only when the request carries
X-Requested-With: XMLHttpRequest. Adds offer.js progressive-enhancement
layer (mirrors auction.js); pay-now CTA on accepted offers; .offer-js-flash
styling; grid layout for offer/action forms. offer_accept emails the
buyer only on the transition into ACCEPTED.
Tests: TestOfferRoutes/TestAuctionRoutes now drive the JSON path via an
AJAX helper; new TestOfferNoJsFallback/TestAuctionNoJsFallback cover the
plain-POST redirect path. 973 passed.
All transactional mail now sends From app.email.sender (default
no-reply@origin.makepostsell.com) instead of per-shop no-reply@<domain>,
with the shop name (or email.from_name) as the display name. The origin
identity is DKIM-signed (d=makepostsell.com) and SPF-authorized and
relays via mx1's warm IP, so operator custom-domain shops stop getting
spam-foldered. format_from_header() builds the From; send_email() gained
a from_name kwarg. Reply-To / per-shop contact email still TODO.
Per fox: "a touch more whitespace around the bid and optional message".
The expanded Make-an-offer form had the label, amount input, message
input, and submit button stacked flush against each other inside the
$42.00 well — no vertical rhythm.
New .product-offer-form CSS:
- display: grid; gap: var(--space-3, 12px)
- top margin var(--space-3) separates it from the summary button
- inputs get box-sizing: border-box, width: 100%, padding 8px 12px so
they fill the column with comfortable internal spacing
Grid only.
Per fox: the price / Add-To-Cart / Make-an-offer box gets squeezed too
narrow on mid-width viewports (960px–1200px) where the 2fr/1fr split
gives the 1fr column only ~250-300px, crushing the buttons.
section.two-column grid columns: minmax(0, 2fr) minmax(280px, 1fr)
- the images column can shrink (minmax(0, 2fr)) so the purchase
column always gets its 280px minimum first
- once the viewport is wide enough, the columns return to the 2:1
ratio up to the 1200px max-width
Grid only, no flex.
Tests pass (10 in target slices).
The inline-grid + place-items: center change put each child of a button
into its own grid row. Buttons that hold an icon span + label text (the
windows-95 hamburger: <span>☰</span> + "shop.unturf.com") got the icon
on one line and the label on the next.
.mps-button is now display: inline-block again — multi-child content
flows on one line as expected. The squish fix is retained without grid:
- box-sizing: border-box (normalizes <button> vs <a>)
- padding: 12px 18px (symmetric, generous horizontal — no more text
jammed against the edge)
- line-height: 1.4 (explicit, not UA "normal")
- min-height: 44px (tap-target floor)
- appearance: none, font-family: inherit
.mps-button-primary likewise back to inline-block with box-sizing,
line-height, text-align: center.
(.edit-card-icon stays inline-grid + place-items: center — it holds a
single glyph, so grid centering is correct there. Still Grid only,
no flex anywhere.)
Tests pass (10 in target slices).
I shipped display:inline-flex / display:flex across .mps-button,
.mps-button-primary, .edit-card-icon, edit-page h3 headers,
.edit-status-bar, .edit-status-pill, .edit-save-bar,
.upload-thumbnails-header, .upload-thumbnail-item, and an inline
style on the torrent_opt_in label — all violations of the project's
Grid-only rule. fox caught it. All converted:
- .mps-button / .mps-button-primary / .edit-card-icon:
display: inline-grid; place-items: center (was inline-flex + center)
- edit-page h3 headers (icon + title):
display: grid; grid-template-columns: auto 1fr; align-items: center
- .edit-status-bar: text-align: right + inline-grid pills that flow/wrap
(was flex + flex-wrap + justify-content: flex-end)
- .edit-status-pill: display: inline-grid; grid-auto-flow: column
- .edit-save-bar: display: grid; grid-template-columns: 1fr auto
- .upload-thumbnails-header: display: grid; grid-template-columns: 1fr auto
with a max-width: 600px media query collapsing to 1fr
- .upload-thumbnail-item: display: grid; grid-auto-rows: min-content;
align-content: space-between (replaces the flex margin-top: auto trick
for pinning the upload form to the bottom of the stretched cell)
- torrent_opt_in label inline style: display:grid;grid-template-columns:auto 1fr
CLAUDE.md updated: the CSS-layout rule now spells out the Grid
equivalent for every flex pattern, clarifies which alignment
properties ARE valid in grid context, and includes a dated SHAME LOG
entry for this transgression.
Tests pass (11 in target slices).
Browsers were caching /static/css/common.css indefinitely (the <link>
had no version param), so CSS fixes didn't show up until a hard
refresh. Fox kept seeing the old squished-button CSS after deploys.
- New request property request.git_hash (reified) returns the short
git hash baked in at deploy time (views.version.GIT_HASH)
- base.j2 tokens.css and common.css links now carry ?v={{ request.git_hash }}
Each deploy bumps GIT_HASH, so the CSS URL changes, so browsers fetch
the fresh file. No more stale-CSS confusion.
Per fox: Add To Cart / Make an offer buttons looked squished (text
crammed at top, little vertical space) while .mps-button on <a>
elements (cart page) looked fine.
Root cause: <button> elements inherit UA-stylesheet line-height and
box-sizing that differ from <a>. The class set padding-top/bottom: 14px
but with the native button line-height the text didn't center properly.
Normalized .mps-button:
- appearance: none (+ -webkit-) — strip native button chrome
- box-sizing: border-box — consistent across <button>/<a>/<input>
- font-family: inherit — buttons default to a different font
- line-height: 1.4 — explicit, was relying on UA "normal"
- padding: 12px 16px (was 14px 0) — symmetric, gives horizontal
breathing room too
- vertical-align: middle
Now <button class="mps-button">, <a class="mps-button">, and
<input type=submit class="mps-button"> all render the same.
Tests pass (9 in pricing_mode slice).
Per fox: "i only see add to cart not offer" — viewing their own
offer-mode (3/4) product as the shop owner showed just Add To Cart
with no sign that offers were active. The Make-an-offer form is
correctly hidden for owners (can't offer on your own product, same
as auctions), but that left owners with no feedback.
Now when product.offers_allowed and the viewer is a shop owner,
the page shows:
✓ Offers enabled — buyers see a "Make an offer" button here.
You can't offer on your own product.
Buyers (non-owner, logged in) still see the expandable offer form.
Anon visitors still see "Log in to make an offer".
Updated test_make_offer_hidden_for_seller: owner now asserts no
Submit Offer button + no "Log in to make an offer" CTA, but DOES
see "Offers enabled" indicator.
Root cause found via prod debug log: the product edit form's
onsubmit="submit.disabled = true" handler disables the submit button
before the browser collects form data, so the "submit" key is never
sent in the POST. The pricing_mode / allow_offers / auction-config
block was gated on `if "submit" in request.params:` — always False —
so it never ran. Hence no flash, no save.
The standard handlers (title, description, price, visibility) don't
have this gate; they check whether the field's value changed. The
pricing_mode block now follows the same pattern: gated on
`if "pricing_mode" in request.params:` (that radio only appears on
the product-title-and-description edit form, so other forms on the
page — uploads, inventory, bundle — don't trigger it).
- pricing_mode: read request.params["pricing_mode"], apply if valid
and changed; immediate flush; flash; auto-create draft auction when
switching into auction mode
- allow_offers: only processed when "allow_offers" in request.params
(so a different form submitting won't reset the override)
- auction config block: unchanged, still inside the pricing_mode gate
- torrent_opt_in: also re-gated on "pricing_mode" in params instead of
"submit" (was equally broken, just masked by the torrent kill switch)
Removed all the temporary debug logging (ENTER line, /opt and /tmp
file writes, journalctl warning).
Tests pass (15 in pricing_mode + auction-config slices).
The /tmp/mps-pricing-debug.log file didn't appear after fox triggered
a save attempt. Two changes to narrow down why:
1. Move the debug log to /opt/make_post_sell/pricing-debug.log (where
uwsgi has owner write access) with /tmp as fallback. PrivateTmp or
permissions might be blocking /tmp writes on this systemd setup.
2. Add an ENTER log line at the top of product_edit() that fires for
every request — GET, POST, save, etc. If this line never appears,
the view itself isn't being reached and the form is going somewhere
else. If it appears but the pricing_mode line doesn't, the submit
field is missing from params.