diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 02e274b..a280d3c 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -4136,3 +4136,97 @@ html[data-color-filter="7"] { color: var(--info, #17a2b8); font-weight: bold; } + +/* MPS-20 + MPS-21: auction & offer components */ + +.auction-page, +.offer-page { + padding: var(--space-3); +} + +.auction-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-3); +} + +.auction-stat { + display: grid; + gap: var(--space-1); +} + +.auction-stat-label { + color: var(--color-text-muted, #888); + font-size: 0.85em; +} + +.auction-stat-value { + font-size: 1.5em; + font-weight: bold; +} + +.auction-bid-form { + display: grid; + gap: var(--space-2); +} + +.auction-state-badge, +.offer-state-badge { + display: inline-block; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-md, 4px); + font-size: 0.85em; + font-weight: bold; + text-align: center; + color: #fff; + background: var(--color-text-muted, #888); +} + +.auction-state-0 { background: var(--color-text-muted, #888); } /* Draft */ +.auction-state-1 { background: var(--color-info, #17a2b8); } /* Scheduled */ +.auction-state-2 { background: var(--color-success, #28a745); } /* Active */ +.auction-state-3 { background: var(--color-warning, #ffc107); color: #333; } /* Ended */ +.auction-state-4 { background: var(--color-primary, #5871ad); } /* Settled */ +.auction-state-5 { background: var(--color-error, #dc3545); } /* Cancelled */ + +.offer-state-0 { background: var(--color-info, #17a2b8); } /* Pending */ +.offer-state-1 { background: var(--color-success, #28a745); } /* Accepted */ +.offer-state-2 { background: var(--color-primary, #5871ad); } /* Countered */ +.offer-state-3 { background: var(--color-error, #dc3545); } /* Declined */ +.offer-state-4 { background: var(--color-text-muted, #888); } /* Expired */ +.offer-state-5 { background: var(--color-text-muted, #888); } /* Withdrawn */ +.offer-state-6 { background: var(--color-success, #28a745); } /* Paid */ + +.auction-flash { + margin-top: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md, 4px); +} + +.auction-flash:empty { + display: none; +} + +.auction-flash-success { + background: var(--color-success-subtle, #d4edda); + color: var(--color-success-strong, #155724); +} + +.auction-flash-error { + background: var(--color-error-subtle, #f8d7da); + color: var(--color-error-strong, #721c24); +} + +.offer-events { + list-style: none; + padding: 0; +} + +.offer-event { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-border, #eee); +} + +.offer-event:last-child { + border-bottom: 0; +} diff --git a/make_post_sell/static/js/auction.js b/make_post_sell/static/js/auction.js new file mode 100644 index 0000000..9712bc8 --- /dev/null +++ b/make_post_sell/static/js/auction.js @@ -0,0 +1,121 @@ +/* MPS-20 auction page live UI: + * - Updates the countdown clock every second based on data-end-timestamp + * - Polls /a/{id}.json every 5 seconds for state changes (current high, + * end_timestamp updates from soft-close, state transitions) + * - Hooks the bid form to submit via fetch and update UI without a full + * page reload (progressive enhancement — form still works without JS) + * + * Per CLAUDE.md "capability-driven presentation" — the page works fine + * without this script; the script enhances when JS is available. + */ +(function () { + "use strict"; + + const root = document.querySelector(".auction-page"); + if (!root) return; + const auctionId = root.getAttribute("data-auction-id"); + if (!auctionId) return; + + const countdownEl = document.getElementById("auction-countdown"); + const currentHighEl = document.getElementById("auction-current-high"); + const flashEl = document.getElementById("auction-flash"); + const bidForm = document.getElementById("auction-bid-form"); + const buyNowForm = document.getElementById("auction-buy-now-form"); + + function fmtRemaining(ms) { + if (ms <= 0) return "ended"; + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; + } + + function tickCountdown() { + if (!countdownEl) return; + const endRaw = countdownEl.getAttribute("data-end-timestamp"); + if (!endRaw) return; + const end = parseInt(endRaw, 10); + if (isNaN(end)) return; + const remaining = end - Date.now(); + countdownEl.textContent = fmtRemaining(remaining); + } + + function flashMessage(text, kind) { + if (!flashEl) return; + flashEl.textContent = text; + flashEl.className = "auction-flash auction-flash-" + (kind || "info"); + } + + function applyState(state) { + if (currentHighEl && typeof state.current_high === "number") { + currentHighEl.textContent = "$" + state.current_high.toFixed(2); + } + if (countdownEl && state.end_timestamp) { + countdownEl.setAttribute("data-end-timestamp", String(state.end_timestamp)); + } + if (state.is_terminal) { + // Disable bid + buy-now forms. + [bidForm, buyNowForm].forEach((f) => { + if (!f) return; + const submit = f.querySelector('[type="submit"]'); + if (submit) submit.disabled = true; + }); + } + } + + let pollInterval = null; + function poll() { + fetch("/a/" + auctionId + ".json", { credentials: "same-origin" }) + .then(function (r) { + if (!r.ok) throw new Error("status " + r.status); + return r.json(); + }) + .then(applyState) + .catch(function () { + // Silent — non-fatal; user can refresh. + }); + } + + if (bidForm) { + bidForm.addEventListener("submit", function (e) { + e.preventDefault(); + const data = new FormData(bidForm); + fetch("/a/" + auctionId + "/bid", { + method: "POST", + body: data, + credentials: "same-origin", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }) + .then(function (r) { + return r.json().then(function (j) { + return { status: r.status, body: j }; + }); + }) + .then(function (res) { + if (res.status === 200 && res.body.ok) { + flashMessage( + "Bid placed at $" + res.body.bid_amount.toFixed(2) + + (res.body.is_winning ? " — you are winning!" : " — outbid by proxy."), + "success" + ); + applyState(res.body.auction_state); + } else { + flashMessage(res.body.error || "Bid failed.", "error"); + } + }) + .catch(function () { + flashMessage("Network error placing bid.", "error"); + }); + }); + } + + // Start ticking every second; poll every 5 seconds. + setInterval(tickCountdown, 1000); + tickCountdown(); + pollInterval = setInterval(poll, 5000); +})(); diff --git a/make_post_sell/templates/auction.j2 b/make_post_sell/templates/auction.j2 index 5f58117..06c2267 100644 --- a/make_post_sell/templates/auction.j2 +++ b/make_post_sell/templates/auction.j2 @@ -89,4 +89,6 @@
+ + {% endblock %} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index 395a7d6..6daf619 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -1078,6 +1078,76 @@ CSS: .login-card, .login-form-*
+ {# MPS-20 + MPS-21: Auction & Offer components #} +

Auction (MPS-20) and Offer (MPS-21)

+ +

Pricing-mode components for shops that run auctions or accept offers.

+ +

Auction state badges

+

Distinct color per state — Draft, Scheduled, Active, Ended, Settled, Cancelled.

+
+ Draft + Scheduled + Active + Ended + Settled + Cancelled +
+ +

Auction countdown

+

Live countdown clock that ticks every second via auction.js. Pre-rendered as static text so the page works without JS.

+
+
+ Current high + $42.50 +
+
+ Time remaining + 2h 14m 38s +
+
+ +

Bid form

+

Number input with min bound to the next-bid floor. Optional max_proxy ceiling for proxy bidding.

+
+ + + Minimum next bid: $42.50 + + + + The system will outbid others up to your ceiling. + + +
+ +

Offer state badges

+

Pending, Accepted, Countered, Declined, Expired, Withdrawn, Paid.

+
+ Pending + Accepted + Countered + Declined + Expired + Withdrawn + Paid +
+ +

Make Offer entry point

+

Collapsed by default; expands to a number input on click. Hidden for anon users (replaced with login CTA) and for shop owners.

+
+ Make an offer +
+ + + + +
+
+ +
+ {%- endblock -%} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 61e7100..fa8c56a 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -6342,6 +6342,51 @@ class TestOfferCheckout(_AuthenticatedBase): self.assertIn("/o/", res.location) self.assertNotIn("/cart", res.location) + def test_styleguide_renders_auction_offer_components(self): + """MPS-20 + MPS-21 components appear on /styleguide.""" + res = self.testapp.get("/styleguide", status=200) + body = res.body.decode() + self.assertIn("Auction (MPS-20)", body) + self.assertIn("auction-state-badge", body) + self.assertIn("offer-state-badge", body) + self.assertIn("auction-bid-form", body) + + def test_auction_js_served(self): + """MPS-20: /static/js/auction.js is served.""" + res = self.testapp.get("/static/js/auction.js", status=200) + body = res.body.decode() + self.assertIn("auction-page", body) + self.assertIn("auction-countdown", body) + + def test_auction_page_loads_auction_js(self): + """MPS-20: auction.j2 references /static/js/auction.js.""" + from ..models.auction import ( + MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp, + ) + from ..models.product import Product + shop = self._create_shop_helper(user_creds=self.user1_creds) + product = Product(title="JS test", description="...") + product.shop = shop + product.price_in_cents = 1000 + product.is_physical = False + product.is_sellable = True + product.pricing_mode = 1 + self.dbsession.add(product) + self.dbsession.flush() + auction = MpsAuction( + product=product, shop=shop, start_price_in_cents=500, + ) + auction.state = AUCTION_STATE_ACTIVE + auction.start_timestamp = now_timestamp() - 1_000 + auction.end_timestamp = now_timestamp() + 3_600_000 + self.dbsession.add(auction) + self.dbsession.flush() + auction_id = auction.uuid_str + transaction.commit() + + res = self.testapp.get(f"/a/{auction_id}", status=200) + self.assertIn(b"/static/js/auction.js", res.body) + def test_buyer_offer_accepted_email_wired(self): """Patch send_offer_accepted_email and verify offer accept wires it. Defensive — if email send fails, the offer state still flips."""