Commit graph

1055 commits

Author SHA1 Message Date
705271ec76
edit page: Material-style cards, status bar, primary save action
Per fox: "make this look awesome", "use styleguide", "think material
design to make it stand out as to what to do".

Status bar at top of edit page — three pills surface the product's
current state in one glance:
- Visibility: Public (blue) / Unlisted (purple) / Private (gray)
- Ready to sell (green) / Missing required files (gold warning)
- Current price (gray)

The pills tell the owner immediately what's done and what isn't.

Section cards (.well2 on edit page) get Material-card treatment:
- Background, rounded corners, border, elevation-1 shadow
- Hover/focus-within bumps to elevation-3 (subtle lift)
- h3 header gets a 36px circular icon badge (.edit-card-icon),
  thin bottom border separating header from content
- Icons: 📁 product file, 📺 preview, 🎨 thumbnails, ✏️ details,
  📦 inventory, 📦 bundle

Primary save action: Save Settings button is now a filled,
prominent CTA (navy bg, white text, bold, elevation-1 shadow that
lifts to elevation-2 on hover, disabled state dims). Class
.mps-button-primary on the styleguide for reuse.

.edit-save-bar provides a sticky-bottom save container CSS for a
follow-up wrapper around the Save Settings input (not yet wired
into product_edit.j2 — same hash adds the CSS so it's available).

All wired into the styleguide:
- Status bar + 3 pill states (public ready, private incomplete, etc.)
- Section card with icon header (3 example cards)
- Primary save action button

Tokens used (with fallbacks for safety): --elevation-1/3, --radius-lg,
--radius-pill, --color-navy/navy-dark, --color-blue-tint, --space-N,
--motion-fast, --color-green-dark, --color-gold, --color-purple.

Tests still pass (10 in target slice).
2026-05-10 16:26:30 -04:00
2c63c60118
bump GIT_HASH to ea58c12 2026-05-10 16:09:38 -04:00
ea58c12fda
edit page: file-meta line + thumbnail labels + merged file stats
Per fox's feedback on shop.unturf.com edit page:

- File Type and File Size now stack on a single line with · separator
  (was 2 stacked <p> tags). Class file-meta on the wrapper, file-meta-item
  on each child. Applied to product, preview, thumbnail blocks.

- File Statistics removed as a standalone well; "Total capacity" line
  moved into the upload-product well at top, using the same file-meta
  pattern. Eliminates the half-empty 4th column in the 2-col grid.

- Thumbnail upload sections now have an <h4 class="thumbnail-label">
  showing thumbnail1 / thumbnail2 / etc. so owners can tell which slot
  is the cover (thumbnail1 shows on search pages) vs additional images.

- Description label split fix: the label, helper text, and textarea
  are now properly stacked. The section.product-title-and-description
  rule (display: grid for product page) is overridden to display: block
  on the edit page so the form flows normally.

- Helper text under the Description label uses <small class="form-help">
  with token-based styling.

- Textarea max-width 600px lifted on the edit page so it fills the
  available well width.

Styleguide entries added at /styleguide for: file-meta line, thumbnail
label, form-help.

Tests still pass (10 in target slice).
2026-05-10 16:09:29 -04:00
843130d2a9
bump GIT_HASH to 966c61e 2026-05-10 15:20:13 -04:00
966c61ec04
edit page: styleguide-driven layout + pricing_mode debug log
UI (per fox: "use styleguide to make this edit page way way better"):
- div.edit-page now max-width 1200px, centered, with token-based gap
- wells get larger internal padding (--space-4 + --space-5) so content
  isn't crammed against the well edge
- well h3:first-child resets margin-top and uses 1.125rem / 600 weight
- fieldsets inside edit page get tokens-based border, padding, legend
  styling that matches the rest of the design system

Debug:
- TEMPORARY log line in product_edit pricing_mode handler logs the raw
  POST params for the pricing_mode field plus the submit value. Will
  remove once the prod save-revert issue is diagnosed. Fox reports
  saving "Make an offer with buy-now" reverts to fixed price; cannot
  reproduce locally. Log will show whether the form is sending the
  field at all when fox saves.

Total: tests still pass (9 pricing_mode + 15 in target slice).
2026-05-10 15:19:58 -04:00
abab69ea3c
bump GIT_HASH to 0652f8c 2026-05-10 14:01:59 -04:00
0652f8cf31
MPS-20 + edit page: defensive pricing_mode flush + 2-col grid layout
Two fixes in one commit:

1) defensive: flush pricing_mode change to DB immediately after the
   radio writes it, instead of relying on the trailing
   product_modified-block flush at end of view. Fox reported saving
   "Make an offer with buy-now" reverts to fixed price; could not
   reproduce locally. Direct flush eliminates any code path between
   the radio change and end-of-view that might raise and abort the
   txn before the deferred save.

2) layout: div.edit-page goes from auto-fit minmax(240px, 1fr)
   (fits 4+ wells on wide screens) to a strict 2-column max grid:
   1 column on mobile, exactly 2 columns above 720px. The 4 wells
   on the product edit page now stack 2x2 instead of 4-across.

Tests:
- test_flip_to_offer_with_buy_now_mode_4_persists — direct POST
  pricing_mode=4 (Make Offer with Buy Now) persists
- test_flip_from_3_to_4_persists — Offer-only → Offer+buy-now
  transition persists
- test_render_form_then_submit_mode_4_via_form — fetches the rendered
  edit form via webtest and submits it back with mode=4 selected; the
  closest reproduction of fox's browser flow

All three pass locally. The save path is correct in the test harness.
If the prod issue persists after this commit, it's almost certainly
a browser-level issue (form not sending the field), not server-side.

Total: 961 tests pass (was 958 + 3).
2026-05-10 14:01:47 -04:00
5fbaf8fbfa
bump GIT_HASH to 891f289 2026-05-10 10:52:20 -04:00
891f2899b2
MPS-20: auction quantity (lot size) + full owner config form
Lets a shop owner auction a portion of inventory rather than all of it,
and reach every auction field from the product edit form (today's flow
only flipped pricing_mode and left start/end/reserve/buy-now unreachable).

Schema:
- mps_auction.quantity (Integer, default 1, server_default="1") —
  how many units this auction sells. Digital products force 1.
- Idempotent migration 1a419114ddf7 (column_exists guard)

Model:
- MpsAuction.is_lot_auction property (quantity > 1)

Owner-side form (product_edit.j2 + views/product.py):
- All auction config fields editable while state == DRAFT:
  quantity (physical only), start/end (datetime-local), start_price,
  reserve_price, buy_now_price (modes 2/4 only), bid_increment,
  soft_close_seconds
- "Schedule auction" checkbox flips DRAFT → SCHEDULED (or → ACTIVE
  if start_timestamp is already past)
- Validates end > start; blocks scheduling when invalid
- Locks all fields once SCHEDULED to preserve bidder trust
  (no rules-changes mid-flight)
- views/product.py serializes auction timestamps to YYYY-MM-DDTHH:MM
  for the datetime-local input

Cart integration:
- auction_checkout view sets cart.set_product_quantity(product, auction.quantity)
  so the cart success path's update_inventory() naturally deducts
  the right number of physical units. Cart total still uses the
  winning bid amount via auction_offer_override_in_cents — quantity
  affects inventory only, not price.

Tests:
- 2 unit (test_models.py): is_lot_auction default False, True for
  quantity > 1 (multiple values)
- 7 functional (test_functional.py): set quantity on physical auction,
  digital quantity forced to 1, schedule with future start →
  SCHEDULED, schedule with passed start → ACTIVE, end-before-start
  rejected, locked fields not overwritten after scheduled,
  auction_checkout sets cart quantity to lot size

Total: 958 tests pass (was 949 + 9).
2026-05-10 10:52:11 -04:00
4f751c6c03
bump GIT_HASH to 364af1f 2026-05-10 09:26:11 -04:00
364af1fcca
MPS-20 + MPS-21: fix physical Add To Cart bypassing auction/offer
Defect: product.j2 always rendered "Add To Cart" at list price for
physical products, even when pricing_mode=1 (auction only) or
pricing_mode=3 (offer only). A buyer browsing a physical auction
could click Add To Cart and pay list price, completely bypassing
the bidding flow.

Fix: wrap the physical Add To Cart / Sold Out branches in
{% if product.is_buy_now_allowed %} so they only render in modes
that permit direct purchase (0 fixed, 2 auction+buy_now, 4 offer+
buy_now). Modes 1 (auction-only) and 3 (offer-only) hide both the
Add To Cart button and the Sold Out indicator entirely; buyers must
use the View Auction or Make Offer entry points.

Tests:
- test_digital_fixed_price_shows_add_to_cart
- test_digital_auction_only_hides_add_to_cart
- test_digital_auction_with_buy_now_shows_add_to_cart
- test_digital_offer_only_hides_add_to_cart
- test_physical_auction_only_hides_add_to_cart (the regression fix)
- test_physical_offer_only_hides_add_to_cart (same)

Total: 949 tests pass (was 943 + 6).
2026-05-10 09:26:02 -04:00
d79082c6ff
bump GIT_HASH to 5e13a59 2026-05-09 21:46:47 -04:00
5e13a59412
MPS-20 + MPS-21: docs — auction-house.md, make-offer.md, architecture index
- docs/auction-house.md: MPS-20 reference — state machine, models,
  bidding logic (validate_bid, soft-close, proxy resolution),
  buy-now flow, tick scheduling, routes, cart integration, live UI,
  email notifications, test summary
- docs/make-offer.md: MPS-21 reference — state machine, models,
  shop settings, lib/offer pure validators + orchestrators, tick,
  routes, cart integration, email notifications, test summary
- docs/architecture.md: MPS-20 + MPS-21 marked Complete in ticket
  index; new docs added to Related Docs table
- CLAUDE.md: new "Auction & Make-an-Offer" section listing tables,
  cart integration, cron scripts, form sections, route registration
  rules, and pointers to the per-feature docs

Total: 943 tests pass (no code change in this commit).
2026-05-09 21:46:18 -04:00
2abf103676
bump GIT_HASH to c6ce5fd 2026-05-09 21:30:24 -04:00
c6ce5fd495
MPS-20 + MPS-21: live UI — auction countdown JS, styleguide, design tokens
static/js/auction.js (progressive enhancement; page works without JS):
- Live countdown clock that ticks every 1s using data-end-timestamp
  attribute pre-rendered by auction.j2
- /a/{id}.json poll every 5s for state changes (current high,
  end_timestamp updates from soft-close, terminal state)
- AJAX bid form submit with X-Requested-With header; updates UI
  without full page reload, shows flash with "winning" or "outbid by
  proxy" message; on terminal state disables bid + buy-now buttons

static/css/common.css — token-only auction & offer components:
- .auction-stats grid + .auction-stat label/value
- .auction-state-* color per state (Draft/Scheduled/Active/Ended/
  Settled/Cancelled)
- .offer-state-* color per state (Pending/Accepted/Countered/
  Declined/Expired/Withdrawn/Paid)
- .auction-bid-form grid layout
- .auction-flash success/error banner
- .offer-events / .offer-event audit timeline

styleguide.j2 entries (live previewable at /styleguide):
- Auction state badges (all 6 states)
- Auction countdown stat block
- Bid form (with min and proxy ceiling)
- Offer state badges (all 7 states)
- Make Offer entry-point (collapsed details)

Functional tests:
- /styleguide renders the new components
- /static/js/auction.js is served (200, contains auction-page hook)
- auction.j2 references /static/js/auction.js

Total: 943 tests pass (was 940 + 3).
2026-05-09 21:30:12 -04:00
aeeda8c8fe
bump GIT_HASH to 59a4e8d 2026-05-09 21:13:41 -04:00
59a4e8d722
MPS-20 + MPS-21: email notifications
Adds three email types and wires them into the bid + offer flows:
- AUCTION_OUTBID — sent to the previous high bidder when their bid
  is beaten (auction_bid view, after place_bid succeeds and is_winning
  flips to a new bidder)
- OFFER_RECEIVED — sent to all shop owners when a new pending offer
  arrives (offer_open view, only when offer queues; auto-accept and
  auto-decline use different paths)
- OFFER_ACCEPTED — sent to the buyer when the seller (or buyer
  themselves) accepts the current amount (open_offer auto-accept lane;
  offer_accept view explicit accept)

Email failures are caught and logged; the bid/offer state is already
persisted by the time we attempt to send, so a misconfigured SMTP
relay does not break the user flow.

Tick-driven emails (auction won when tick.tick ends an auction; offer
expired when tick.tick expires an offer) are deferred — they require
a request context for URL building, and tick scripts run from cron
without one. A future commit can either wire a request-less email
sender or queue the events for the next view that runs.

Templates added to lib/mail_messages.py:
- AUCTION_OUTBID_TEXT / AUCTION_OUTBID_HTML
- OFFER_RECEIVED_TEXT / OFFER_RECEIVED_HTML
- OFFER_ACCEPTED_TEXT / OFFER_ACCEPTED_HTML

Send helpers in lib/mail.py:
- send_auction_outbid_email(request, to, auction)
- send_offer_received_email(request, to, offer)
- send_offer_accepted_email(request, to, offer)

Tests:
- 3 unit (test_models.py) — verify each helper builds the right
  subject/body/url with mocked send_pyramid_email
- 1 functional — POST /o/{id}/accept calls send_offer_accepted_email
  with the buyer's email

Total: 940 tests pass (was 936 + 4).
2026-05-09 21:13:29 -04:00
a15d760ff4
bump GIT_HASH to e044c13 2026-05-09 20:56:24 -04:00
e044c13c74
MPS-20 + MPS-21: tick scripts — auction state transitions, offer expiration
Without these scripts, auctions stay ACTIVE forever and offers never
expire. Cron runs them on a schedule to drive the state machines.

lib/auction_tick.py:
- Pure functions transition_scheduled_to_active and transition_active_to_ended
- tick(dbsession) orchestrator scans:
  - SCHEDULED + start_timestamp <= now → ACTIVE
  - ACTIVE + end_timestamp <= now → ENDED (winner from is_winning bid;
    payment_deadline_timestamp set to end + 48h)

lib/offer_tick.py:
- tick(dbsession) scans non-terminal offers (PENDING / COUNTERED) past
  expires_timestamp and calls lib/offer.expire_offer on each, which
  flips state to EXPIRED and writes the OFFER_EVENT_EXPIRE audit row

Both ticks are idempotent — running twice on already-transitioned rows
is a no-op.

Cron entry points:
- scripts/auction_tick.py — every minute (* * * * *) recommended;
  60s default soft-close window means 1-min granularity is fine
- scripts/offer_tick.py — every 15 minutes; offers expire at hour
  granularity so coarse polling is enough

Tests:
- 2 unit (test_models.py): pure transition_scheduled_to_active /
  transition_active_to_ended for state, timestamp, and missing-data branches
- 4 integration auction: SCHEDULED→ACTIVE on start passing, ACTIVE→
  ENDED with winner recorded, ACTIVE→ENDED with no bids leaves winner
  None, idempotent
- 4 integration offer: PENDING past expires → EXPIRED, live offer not
  touched, terminal offer not touched, idempotent

Total: 936 tests pass (was 926 + 10).
2026-05-09 20:56:16 -04:00
31ab7507ba
bump GIT_HASH to 3c9fc55 2026-05-09 20:41:04 -04:00
3c9fc5544b
MPS-20 + MPS-21: cart integration — checkout, total override, settle hook
Closes the payment loop for both auction and offer modes. After this
commit, an auction winner can pay the agreed bid amount; an offer-
accepted buyer can pay the agreed offer amount. Cart total computation
uses the auction's winning_bid or offer.current_amount when an
association is present.

New tables (idempotent migration):
- mps_cart_auction (cart_id ↔ auction_id) — cart total uses winning bid
- mps_cart_offer   (cart_id ↔ offer_id)   — cart total uses agreed amount

Cart.auction_offer_override_in_cents property returns the override
amount when either association is set; None otherwise. total_price_in_cents
short-circuits to the override + handling + gift-card-purchases when an
override is present.

Routes:
- POST /a/{auction_id}/checkout  winner only; auction must be ENDED;
  builds a fresh cart, adds product, creates MpsCartAuction
- POST /o/{offer_id}/checkout    buyer only; offer must be ACCEPTED;
  builds a fresh cart, adds product, creates MpsCartOffer

Both checkouts redirect to /cart on success or back to /a/{id} or
/o/{id} on rejection (with flash message).

Settle hook in views/cart.py: after invoice is paid, _finalize_auction_offer_state
flips auction.state=SETTLED (recording winner_user_id + winning_bid_id)
and calls lib/offer.mark_paid which transitions offer ACCEPTED → PAID
and writes the OFFER_EVENT_PAY audit row.

Tests:
- 4 integration: cart-auction + cart-offer associations persist + cascade
- 3 integration: cart total override (no association → list price;
  with auction → winning bid; with offer → agreed amount)
- 3 functional auction checkout: winner builds cart with override,
  non-winner blocked, active auction blocked
- 3 functional offer checkout: buyer builds cart with override,
  non-buyer blocked, pending offer blocked

Total: 926 tests pass (was 913 + 13).
2026-05-09 20:40:54 -04:00
17ce2ae173
bump GIT_HASH to 5f92e65 2026-05-09 20:08:39 -04:00
5f92e65482
MPS-20 + MPS-21: form sections + buyer entry points
Shop owner-facing:
- shop_settings.j2: new "Make an Offer" form section (form_section=offer-settings)
  with offer_enabled, auto_accept_threshold_pct, auto_decline_threshold_pct,
  offer_min, offer_expiration_hours, offer_max_rounds,
  offer_min_buyer_account_age_hours
- views/shop.py: form_section=offer-settings handler with input clamping
  (decline forced strictly below accept; numeric inputs bounded)
- product_edit.j2: pricing_mode radio (5 options: fixed, auction, auction+
  buy_now, offer, offer+buy_now), allow_offers radio (inherit/yes/no)
  appearing only when product is in offer mode, link to live auction
  page when one exists
- views/product.py: handles pricing_mode change; flipping into auction
  mode (1 or 2) creates a draft MpsAuction with start_price seeded
  from product.price_in_cents and default bid_increment / soft_close
  from lib/auction defaults

Buyer-facing:
- product.j2: "View live auction" link when pricing_mode is auction;
  "Make an offer" details/form when offers_allowed AND user authenticated
  AND user not in shop owners; "Log in to make an offer" CTA for anon;
  Add To Cart only renders when is_buy_now_allowed (modes 0, 2, 4)

Tests (9 new, all passing):
- TestOfferSettingsForm: enable+set thresholds (round-trip), decline
  clamped below accept, blank offer_min clears the floor
- TestPricingModeFormSection: flip to auction creates draft auction,
  flip to offer leaves auction None, allow_offers override (yes/no/
  inherit), invalid pricing_mode value ignored, Make Offer button
  renders when eligible, Make Offer hidden for shop owner

Total: 913 tests pass (was 904 + 9).
2026-05-09 20:08:33 -04:00
2de9229264
bump GIT_HASH to 9b61218 2026-05-09 19:43:49 -04:00
9b612182b8
MPS-21: offer views — open, page, counter, accept/decline, withdraw
Routes:
- POST /p/{product_id}/offer  open new offer (login required)
- GET  /o/{offer_id}          offer detail page (buyer + seller only)
- POST /o/{offer_id}/counter  counter the current amount
- POST /o/{offer_id}/accept   accept current amount (terminal)
- POST /o/{offer_id}/decline  decline current amount (terminal)
- POST /o/{offer_id}/withdraw buyer-only terminal pull

offer_open registered before product_slug catch-all so /p/{id}/offer is
not shadowed (same shadowing rule as auction routes).

views/offer.py:
- _user_party resolves request.user → OFFER_PARTY_BUYER or _SELLER (or
  None for third party), gates offer detail page accordingly
- _serialize_offer: dict shape used by both template ctx and JSON;
  events array carries human-readable event_human
- offer_open enforces product.offers_allowed and self-offer block
  (buyer is shop owner)
- _offer_action wraps counter/accept/decline/withdraw, parses amount
  for counter, blocks non-buyer withdraw
- All write actions return JSON {ok, offer} or {error}

templates/offer.j2:
- Live page with state badge, current amount, both messages, action
  forms (accept / counter / decline; withdraw for buyer only) when it's
  the actor's turn
- Event timeline rendered from offer.events

Functional tests (12): anon redirected to login, seller cannot offer
on own product (403), buyer queues mid-range offer, auto-accept high
offer, auto-decline low offer, 404 unknown offer id, third-party 404,
full negotiation (open → counter → accept), wrong-party counter
rejected (400), buyer withdraw, seller cannot withdraw (403), offers
disabled on fixed-price product (403).

Cart integration deferred to commit 7 (bundling auction + offer).

Total: 904 tests pass (was 892 + 12).
2026-05-09 19:43:42 -04:00
f34e7e9ec5
bump GIT_HASH to 41884fb 2026-05-09 19:25:40 -04:00
41884fbf8d
MPS-20: auction views — page, JSON, bid, buy-now, watch
Routes (registered before shop_slug-style catch-alls so .json suffix is
not shadowed):
- GET  /a/{auction_id}.json   live state for poll
- POST /a/{auction_id}/bid    place a bid (login required)
- POST /a/{auction_id}/buy-now  end auction at buy_now price (mode 2)
- POST /a/{auction_id}/watch  toggle watcher
- GET  /a/{auction_id}        live page (templates/auction.j2)

views/auction.py:
- _serialize_auction shapes the same dict for both template ctx and JSON
- _user_is_seller checks request.user against auction.shop.owners — pure
  validate_bid in lib/auction does not have visibility into ownership,
  so the view enforces "no self-bidding" with HTTP 403
- auction_bid parses dollar input, converts to cents, calls place_bid,
  returns either {ok, bid_amount, is_winning, auction_state} or {error}
- auction_buy_now places a bid at buy_now price, sets state to ENDED,
  records winner_user_id and winning_bid_id
- auction_watch toggles MpsAuctionWatcher row

templates/auction.j2:
- Live page extending base.j2 with countdown placeholder, current high,
  bid form (amount + optional max_proxy), buy-now form when configured,
  watch toggle
- Hides bid form when seller views own auction or anon visitor (with
  log-in CTA)

Functional tests (11): page renders for anon, 404 unknown id, JSON
state, seller cannot bid (403), anon cannot bid (redirect), buyer
places first bid, bid below increment rejected (400), invalid amount
rejected (400), watch toggle round-trip, buy-now 404 when not offered,
buy-now ends auction and records winner.

Cart integration deferred to commit 5 (bundling with offer cart
integration since both add a non-list-price line item pattern).

Total: 892 tests pass (was 881 + 11).
2026-05-09 19:25:28 -04:00
f2f273ebaa
bump GIT_HASH to 4fcd1ea 2026-05-09 19:06:43 -04:00
4fcd1eacaa
MPS-21: lib/offer.py — counter/accept/decline/expire state machine
Pure validators:
- validate_actor_turn: actor's party must match offer.current_party;
  terminal-state offers reject all actions
- validate_round_cap: rejects when round_count >= shop.offer_max_rounds
  (forces accept/decline at the cap)
- validate_floor: silent reject below shop.offer_min_in_cents
- auto_resolve_open: classifies a new offer as accept/decline/queue
  using shop's auto_accept_threshold_pct (default 95) and
  auto_decline_threshold_pct (default 50); list_price=0 always queues

Orchestrators (write OFFER_EVENT_* rows for audit log):
- open_offer: writes offer + OPEN event; applies auto-accept/decline
  thresholds before queuing seller; expires_timestamp = now + shop's
  expiration_hours
- counter_offer: flips current_party, increments round_count, sets
  state COUNTERED, persists actor's message
- accept_offer: terminal — caller's responsibility to write a cart
  line item at offer.current_amount_in_cents
- decline_offer: terminal
- withdraw_offer: terminal; buyer-only (caller validates identity)
- expire_offer: idempotent system action — flips non-terminal offers
  past expires_timestamp to EXPIRED
- mark_paid: cart-success hook; ACCEPTED → PAID; raises if not in
  ACCEPTED state

OfferRejected exception carries reason in .args[0].

Self-offer (buyer == seller) blocking is the view layer's job — same
pattern as auctions.

Tests:
- 12 unit tests: validate_actor_turn (terminal/wrong-party/correct-party),
  validate_round_cap (at-or-above/below), validate_floor (none/below/at),
  auto_resolve_open (accept/decline/queue/free-product)
- 10 integration tests: auto-accept high offer, auto-decline low offer,
  queue mid-range offer, floor enforcement, full negotiation flow
  (open → seller counter → buyer counter → seller accept), round cap,
  decline terminal, withdraw, expire only past expiration, mark_paid
  only after accept

Total: 881 tests pass (was 859 + 22).
2026-05-09 19:06:36 -04:00
a75cacdfcb
bump GIT_HASH to 5b8f6bc 2026-05-09 18:50:05 -04:00
5b8f6bcb58
MPS-20: lib/auction.py — bid placement, proxy, soft-close
Pure functions:
- validate_bid: state must be ACTIVE; first bid >= start_price; subsequent
  bids >= current_high + bid_increment; positive integer; max_proxy >= amount
- is_within_soft_close: now_ms inside (end - soft_close_seconds*1000, end]
- extended_end_timestamp: now_ms + soft_close_seconds*1000
- resolve_proxy: eBay-style — higher proxy wins; loser auto-bids defending
  bidder up to min(loser_proxy + increment, winner_proxy); ties go to
  the existing top (first-in wins)

Orchestrator place_bid:
- writes the new bid, marks the prior winning bid is_winning=False with
  outbid_timestamp, applies proxy resolution to choose visible amounts,
  applies soft-close to extend end_timestamp when bid lands in window,
  bumps auction.updated_timestamp

BidRejected exception carries reason in .args[0].

Tests:
- 16 unit tests: validate_bid matrix (state, start_price, increment,
  proxy >= amount), soft-close window math, 6 proxy resolution edge
  cases (no proxy, defending proxy auto-increments, breaking through
  top proxy, tie tie-break, capped at ceiling)
- 8 integration tests: first bid wins, increment floor, outbid marks
  prior bid is_winning=False, exactly-one-winner invariant, proxy
  defending bidder auto-increments, soft-close fires only inside window,
  inactive auction rejects

Self-bid (bidder == seller) blocking is the view layer's responsibility —
the pure validate_bid does not have visibility into seller identity.

Total: 859 tests pass (was 835 + 24).
2026-05-09 18:49:55 -04:00
2f1a41d089
bump GIT_HASH to 80b4fa6 2026-05-09 18:31:40 -04:00
80b4fa6698
MPS-20 + MPS-21: foundation — pricing_mode + auction/offer models
Auction (MPS-20) and make-an-offer (MPS-21) modes share a Product.pricing_mode
column so a single migration adds the foundation for both.

Schema:
- mps_product.pricing_mode (Integer, default 0): 0=fixed, 1=auction,
  2=auction+buy_now, 3=offer, 4=offer+buy_now
- mps_product.allow_offers (Boolean nullable): per-product override of
  shop default; NULL = inherit shop.offer_enabled
- mps_shop: 7 offer-* settings columns (enabled, min, auto-accept/decline
  thresholds, expiration, max rounds, min buyer age)
- 5 new tables: mps_auction, mps_bid, mps_auction_watcher,
  mps_offer, mps_offer_event

Models:
- MpsAuction: state machine helpers (is_draft/active/ended/settled/etc.),
  current_high_in_cents (queries top bid), reserve_met, time_remaining_ms,
  has_buy_now, has_reserve, min_next_bid_in_cents
- MpsBid: amount + max_proxy_in_cents (proxy bidding ceiling) + is_winning
  flag the bid resolution code will flip
- MpsAuctionWatcher: per-user notification preferences
- MpsOffer: state machine (pending/countered/accepted/declined/expired/
  withdrawn/paid), waiting_on_buyer/seller, time_remaining_ms, is_expired
- MpsOfferEvent: audit log row per action (open/counter/accept/etc.)

Product gets is_fixed_price / is_auction / is_buy_now_allowed /
is_offer_mode / offers_allowed helpers — offers_allowed resolves the
per-product override + shop default.

Migration is idempotent (table_exists / column_exists guards) since
make init-db creates tables from models.

Tests:
- 17 unit tests in test_models.py (state helpers, pricing_mode classifiers,
  inheritance rules)
- 10 integration tests in test_integration.py (DB persistence, cascade
  delete bids/watchers/events when parent deleted, unique product_id
  on auction, defaults applied)

Functional tests defer to commits 4-5 (auction views, offer views).
2026-05-09 18:31:29 -04:00
98352496a9
bump GIT_HASH to 2659ebd 2026-05-09 16:51:38 -04:00
2659ebdcbe
MPS-22: kill-switch feature flags for karaoke + torrent
Karaoke (MPS-18) and torrent (MPS-19) are broken in production. Adding
two global feature flags off by default so neither feature surfaces in
UI or accepts route traffic until they're fixed.

Pattern mirrors app.features.popout_player.enabled — reified request
properties (request.karaoke_enabled, request.torrent_enabled) read from
ini settings. Templates wrap UI in {% if %}, views return HTTPNotFound
on form sections + routes, view contexts blank out feature-specific
keys when flag off so SPA navigation does not try to render them.

test.ini sets both flags True so existing feature tests keep working.
TestKillSwitches builds a fresh app with both False and verifies the
off path: form_section POSTs return 404, settings page omits sections,
karaoke route 404s, landing page omits karaoke marketing copy.

GET /s/{shop_id}/torrent-backfill-status is shadowed by an earlier
shop_slug catch-all route in production — pre-existing routing defect
that MPS-19 needs to fix when it lands.
2026-05-09 16:51:24 -04:00
b3d9b2b39c
docs: tickets MPS-18..21 — karaoke/torrent fixes + auction/offer proposals
MPS-18: diagnose and fix broken karaoke vocal isolation pipeline.
MPS-19: diagnose and fix broken torrent / magnet link distribution.
MPS-20: propose eBay-style auction house mode (bidding, reserve,
soft-close, proxy, buy-now).
MPS-21: propose make-an-offer mode (counter/accept/decline/expire
state machine, auto-accept and auto-decline thresholds).

Each ticket carries proposal, full file list, models, state machine,
GTM plan, and unit/integration/functional test requirements.

architecture.md ticket index extended with MPS-17 (was missing) plus
the four new tickets.
2026-05-09 16:13:57 -04:00
f38e9d58ec
modified: .gitignore 2026-05-09 11:31:01 -04:00
6f5c6be3a5 bump GIT_HASH to e159d2f 2026-04-23 17:37:51 -04:00
e159d2f2bf fix: consistent section order across mobile, desktop, and cinema modes
Mobile previously placed product-right (price, download, Up Next)
as the 2nd section, right after images — a different reading flow
from desktop and cinema modes, which keep description + comments
above/alongside product-right.

Unified order for every mode and viewport:

  1. images         (sticky video/cover on mobile + desktop watch)
  2. description
  3. comments
  4. product-right  (price, download, Up Next)

Desktop normal: column 1 = images → description → comments (stacked),
column 2 = product-right (spans all rows on the right).

Desktop cinema: row 1 = images full-width, row 2 = content (description
+ comments stack) on the left, product-right on the right.

Mobile normal: all four stacked single-column in that order. Cinema
stays a no-op below 800px; the classes exist but match no rules and
the page falls through to the consistent mobile watch-mode layout.

CLAUDE.md mobile layout section updated to match.
2026-04-23 17:37:45 -04:00
795b845cf9 bump GIT_HASH to 7fa9c15 2026-04-23 13:58:28 -04:00
7fa9c15a03 fix: cinema mode is no-op on mobile; section order matches normal mode
Mobile (<800px) never really needed a different cinema layout — the
normal watch-mode mobile rules already stack images, purchase,
description, and comments in a single column with full-viewport-width
media. Cinema was re-ordering those sections (putting purchase AFTER
description/comments) and creating an inconsistency between cinema
and normal modes on the same device.

Fix: gate every cinema layout rule on @media (min-width: 800px). Below
that the .cinema-mode.cinema-wide classes exist but match no layout
rules, and the page falls through to normal mobile watch-mode.

JS: isViewportWideEnoughForCinema() uses the same 800px boundary to
decide whether to relocate the hamburger+Edit taskbar into the
sidebar. A debounced resize listener re-runs applyCinemaMode() so
the layout flips cleanly when the viewport crosses the boundary.
2026-04-23 13:58:21 -04:00
ababe32a0a bump GIT_HASH to f57abd5 2026-04-22 17:41:48 -04:00
f57abd5193 fix: cinema 2-col at 800px, comment section breathing room, button gap
Three polish tweaks for cinema mode in narrow desktop panels:

1. 2-column layout kicks in at >=800px instead of >=960px. Cinema's
   video already fills the full width, so the content | purchase
   split below it doesn't need a typical desktop-wide viewport —
   it works fine on smaller panels. This removes the 800-960px dead
   zone where cinema-wide was still single-column stacking.

2. Comments get 24px margin-top + 20px padding-top + a top border so
   they read as their own section instead of running continuous into
   the description above and the sidebar controls below.

3. Stacked sidebar buttons (hamburger, Edit, Download) now have an
   8px margin-bottom between each so they don't look shoulder-to-
   shoulder. Inherits evenly from the task-bar grid gap.
2026-04-22 17:41:41 -04:00
3c6c579b73 bump GIT_HASH to ba424d5 2026-04-22 17:24:40 -04:00
ba424d537a fix: uniform button shape in cinema sidebar
Hamburger, Edit, and Download now all render as the same component
in the cinema sidebar — full column width, same padding (14px 16px),
same font size, same line height. mps-button-small's narrow min-width
was making Edit look like a leftover chip next to the full-width
hamburger and Download buttons; overridden in the cinema sidebar
scope only.

Task-bar grid gap bumped to 8px so hamburger + Edit don't touch
each other, and their nested padding zeroed so spacing lives in
the outer grid gap rather than in mixed inline padding.
2026-04-22 17:24:35 -04:00
d6487f8861 bump GIT_HASH to 92a3050 2026-04-22 15:46:40 -04:00
92a305072e feat: cinema only activates for landscape media (aspect > 1)
Portrait and square media stay in the normal watch layout even when
the Cinema toggle is on — tall phone videos no longer get stretched
into a skinny column on a wide screen. Only wider-than-square media
triggers the layout adjustment that makes wide/ultrawide content
fill the horizontal viewport.

Two-class gate:
  .cinema-mode       — user intent (from the toggle)
  .cinema-wide       — runtime state (current media aspect > 1)

CSS layout rules now require BOTH classes. When cinema is on but
media is portrait, .cinema-mode is on and .cinema-wide is off, and
the selectors don't match — normal watch layout applies.

Aspect detection reads video.videoWidth/videoHeight or
img.naturalWidth/naturalHeight. Unknown dimensions (metadata not
yet loaded) defaults to not wide; a loadedmetadata / img.load
listener re-invokes applyCinemaMode once real dimensions are known.

Taskbar relocation also gated on cinema-wide — portrait doesn't
steal the hamburger + Edit from their normal home.
2026-04-22 15:46:33 -04:00
aa72430323 bump GIT_HASH to 872a712 2026-04-22 15:43:18 -04:00
872a7121f8 feat: cinema mode — hamburger + Edit into sidebar above Download
Two cinema-mode polishes:

1. Top task bar (hamburger / shop name / Edit button) relocates into
   .product-right above the .well on cinema-on. Cached original parent
   + next sibling at init lets us put it back on cinema-off. Moved
   within the same DOM subtree that survives SPA nav so it persists
   across product changes. CSS stacks its children vertically inside
   the sidebar instead of the horizontal strip shape up top.

2. The 'click image to open in new window' wrapper link on static
   products (images, PDFs) is redundant in cinema mode since media
   already fills the viewport. pointer-events:none disables the
   click without removing the anchor from the DOM.
2026-04-22 15:43:12 -04:00
f6654ba7d2 bump GIT_HASH to 23dd740 2026-04-22 15:30:32 -04:00