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-*Pricing-mode components for shops that run auctions or accept offers.
+ +Distinct color per state — Draft, Scheduled, Active, Ended, Settled, Cancelled.
+Live countdown clock that ticks every second via auction.js. Pre-rendered as static text so the page works without JS.
+Number input with min bound to the next-bid floor. Optional max_proxy ceiling for proxy bidding.
Pending, Accepted, Countered, Declined, Expired, Withdrawn, Paid.
+Collapsed by default; expands to a number input on click. Hidden for anon users (replaced with login CTA) and for shop owners.
+