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:
parent
aeeda8c8fe
commit
c6ce5fd495
5 changed files with 332 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
121
make_post_sell/static/js/auction.js
Normal file
121
make_post_sell/static/js/auction.js
Normal 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);
|
||||
})();
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 -%}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue