Commit graph

1114 commits

Author SHA1 Message Date
9c68971971
bump GIT_HASH to 7dc82dd 2026-05-13 10:09:22 -04:00
7dc82dda50
fix: offer checkout is one-shot; seller copy says so
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").
2026-05-13 10:09:16 -04:00
6827fb8ac9
bump GIT_HASH to 812e116 2026-05-13 09:55:54 -04:00
812e116f50
feat: buyer dashboards — /u/offers + /u/bids — gated per shop
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.
2026-05-13 09:55:46 -04:00
759d98a968
bump GIT_HASH to 2c56717 2026-05-13 08:50:47 -04:00
2c56717579
feat: polished negotiation-aware cart + sandbox-gated user storage
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.
2026-05-13 08:50:35 -04:00
1088805520
bump GIT_HASH to c58b6b2 2026-05-13 07:10:22 -04:00
c58b6b27a7
fix: offer/auction cart charges agreed price, not list price
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.
2026-05-13 07:09:56 -04:00
6119489c40
bump GIT_HASH to ebeb8af 2026-05-13 06:28:26 -04:00
ebeb8afa87
fix: offer page — seller sees a shareable pay link, buyer pay button stands out
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.
2026-05-13 06:28:18 -04:00
8b40e90d58
bump GIT_HASH to 157e6f7 2026-05-12 21:04:01 -04:00
157e6f769a
feat: bounded SSE feeds for offer & auction state machines
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.
2026-05-12 21:03:50 -04:00
93c0facbbc
bump GIT_HASH to 5b44132 2026-05-12 20:37:13 -04:00
5b44132058
fix: offer/auction checkout — land in the new cart, not a stale empty one
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.
2026-05-12 20:37:07 -04:00
adf6175d04
bump GIT_HASH to 87ac9d9 2026-05-12 20:10:27 -04:00
87ac9d90ca
fix: confirm prompt on terminal offer actions (accept / decline / withdraw)
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.
2026-05-12 20:10:13 -04:00
c43336925c
bump GIT_HASH to 2886a42 2026-05-12 19:53:58 -04:00
2886a4255d
fix: crossfade clamps both tracks to active tab's current volume
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.
2026-05-12 19:53:42 -04:00
31f8449c7d
bump GIT_HASH to 6f52af0 2026-05-12 18:40:27 -04:00
6f52af0c2c
fix: actions hub — cap at two columns, restore gap, always show Offers button
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.
2026-05-12 18:40:22 -04:00
ff6a35b887
bump GIT_HASH to e97d18b 2026-05-12 17:47:29 -04:00
e97d18bccd
MPS-21: public profile page, offer-history identity, shop offers inbox, actions hub rebuild
- 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.
2026-05-12 17:47:24 -04:00
cb61227b2c
bump GIT_HASH to 227fc4e 2026-05-12 16:34:25 -04:00
227fc4e564
MPS-21: offer-page state notice + design-system Make-an-Offer settings form
- 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.
2026-05-12 16:34:21 -04:00
38b5739ce7
bump GIT_HASH to 2cad248 2026-05-12 12:04:28 -04:00
2cad2482b8
MPS-20/MPS-21: capability-driven presentation for auction & offer actions
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.
2026-05-12 12:04:19 -04:00
981b1316f2
bump GIT_HASH to a90979a 2026-05-12 11:13:11 -04:00
a90979a46c
MPS-23: single warm sending identity for transactional mail
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.
2026-05-12 11:13:11 -04:00
0f19da5ba8
docs: add MPS-23 ticket — consolidated transactional sender identity 2026-05-12 11:07:38 -04:00
f4be4f4612
bump GIT_HASH to 9a0782e 2026-05-12 08:14:57 -04:00
9a0782e61f
verification-challenge: center on login-card, add 'try again' link to login page 2026-05-12 08:14:57 -04:00
4b96acae0c
bump GIT_HASH to f9b73bb 2026-05-11 18:14:12 -04:00
f9b73bb9de
product offer form: breathing room around bid + optional message
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.
2026-05-11 18:13:53 -04:00
9c00d52dfd
bump GIT_HASH to 5cad5c3 2026-05-11 17:30:11 -04:00
5cad5c3a0c
product page: 280px floor on the purchase column (two-column grid)
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).
2026-05-11 17:29:55 -04:00
46dd2cb929
bump GIT_HASH to 2630b78 2026-05-11 12:55:34 -04:00
2630b78175
fix: .mps-button back to inline-block — inline-grid broke multi-child buttons
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).
2026-05-11 12:55:14 -04:00
3af81a38e9
bump GIT_HASH to 7df421d 2026-05-11 10:44:33 -04:00
7df421dba3
fix: remove all flexbox — Grid only (CLAUDE.md transgression)
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).
2026-05-11 10:44:11 -04:00
cec78ccd45
bump GIT_HASH to 37e314d 2026-05-11 09:45:37 -04:00
37e314d534
cache-bust static CSS with ?v={git_hash} on every deploy
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.
2026-05-11 09:45:17 -04:00
40d25edb6d
bump GIT_HASH to 8853a5a 2026-05-11 08:19:31 -04:00
8853a5aa08
fix: .mps-button squished on <button> elements — normalize box model
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).
2026-05-11 08:19:18 -04:00
08ba11ab9d
bump GIT_HASH to d593062 2026-05-11 07:28:09 -04:00
d593062e6b
product page: owner sees "offers enabled" indicator instead of nothing
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.
2026-05-11 07:27:55 -04:00
d56c49f3ee
bump GIT_HASH to ee4bd86 2026-05-11 06:38:42 -04:00
ee4bd862ce
fix: pricing_mode never saved — handler gated on absent "submit" param
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).
2026-05-11 06:38:26 -04:00
938fda3e4a
bump GIT_HASH to a186b4d 2026-05-10 20:06:37 -04:00
a186b4d000
debug: log every product_edit view hit + use /opt path for log
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.
2026-05-10 20:06:24 -04:00
5c2694d7ae
bump GIT_HASH to 08b5440 2026-05-10 19:20:07 -04:00