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).
This commit is contained in:
russell@unturf.com 2026-05-09 21:30:12 -04:00
parent aeeda8c8fe
commit c6ce5fd495
No known key found for this signature in database
5 changed files with 332 additions and 0 deletions

View file

@ -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;
}

View file

@ -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);
})();

View file

@ -89,4 +89,6 @@
<div id="auction-flash" class="auction-flash" role="status" aria-live="polite"></div>
</section>
<script src="/static/js/auction.js"></script>
{% endblock %}

View file

@ -1078,6 +1078,76 @@ CSS: .login-card, .login-form-*</div>
<br/>
{# MPS-20 + MPS-21: Auction & Offer components #}
<h2>Auction (MPS-20) and Offer (MPS-21)</h2>
<p>Pricing-mode components for shops that run auctions or accept offers.</p>
<h3>Auction state badges</h3>
<p>Distinct color per state — Draft, Scheduled, Active, Ended, Settled, Cancelled.</p>
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--space-2); margin-bottom: var(--space-4);">
<span class="auction-state-badge auction-state-0">Draft</span>
<span class="auction-state-badge auction-state-1">Scheduled</span>
<span class="auction-state-badge auction-state-2">Active</span>
<span class="auction-state-badge auction-state-3">Ended</span>
<span class="auction-state-badge auction-state-4">Settled</span>
<span class="auction-state-badge auction-state-5">Cancelled</span>
</div>
<h3>Auction countdown</h3>
<p>Live countdown clock that ticks every second via auction.js. Pre-rendered as static text so the page works without JS.</p>
<div class="auction-stats well" style="margin-bottom: var(--space-4);">
<div class="auction-stat">
<span class="auction-stat-label">Current high</span>
<span class="auction-stat-value">$42.50</span>
</div>
<div class="auction-stat">
<span class="auction-stat-label">Time remaining</span>
<span class="auction-stat-value">2h 14m 38s</span>
</div>
</div>
<h3>Bid form</h3>
<p>Number input with <code>min</code> bound to the next-bid floor. Optional <code>max_proxy</code> ceiling for proxy bidding.</p>
<form class="auction-bid-form well" style="margin-bottom: var(--space-4);" onsubmit="return false;">
<label for="sg-bid-amount">Your bid (USD)</label>
<input type="number" step="0.01" min="42.50" id="sg-bid-amount" value="42.50" />
<small>Minimum next bid: $42.50</small>
<label for="sg-max-proxy">Max proxy ceiling (optional)</label>
<input type="number" step="0.01" min="42.50" id="sg-max-proxy"
placeholder="Auto-bid up to this amount" />
<small>The system will outbid others up to your ceiling.</small>
<input type="submit" class="mps-submit" value="Place Bid" />
</form>
<h3>Offer state badges</h3>
<p>Pending, Accepted, Countered, Declined, Expired, Withdrawn, Paid.</p>
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--space-2); margin-bottom: var(--space-4);">
<span class="offer-state-badge offer-state-0">Pending</span>
<span class="offer-state-badge offer-state-1">Accepted</span>
<span class="offer-state-badge offer-state-2">Countered</span>
<span class="offer-state-badge offer-state-3">Declined</span>
<span class="offer-state-badge offer-state-4">Expired</span>
<span class="offer-state-badge offer-state-5">Withdrawn</span>
<span class="offer-state-badge offer-state-6">Paid</span>
</div>
<h3>Make Offer entry point</h3>
<p>Collapsed by default; expands to a number input on click. Hidden for anon users (replaced with login CTA) and for shop owners.</p>
<details style="margin-bottom: var(--space-4);">
<summary class="mps-button">Make an offer</summary>
<form style="margin-top: var(--space-2)" onsubmit="return false;">
<label for="sg-offer-amount">Your offer (USD)</label>
<input type="number" step="0.01" min="0.01" id="sg-offer-amount" />
<input type="text" placeholder="Optional message" />
<input type="submit" class="mps-submit" value="Submit Offer" />
</form>
</details>
<br/>
</section>
{%- endblock -%}

View file

@ -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."""