feat: live pay-by countdown on offer + auction pages (JS-enhanced)

When JS is available, the buyer now sees a live countdown next to the
absolute pay-by date on both the accepted-offer page and the
auction-won page. Without JS, the existing static "You have until
<date>" copy still renders — the countdown element is .js-only.

Offer (/o/{id}):
- offer_page view now exposes pay_deadline_timestamp_ms alongside the
  human string.
- offer.j2 adds a [data-pay-deadline] span next to the deadline copy
  on both the buyer (pay-now) and seller (awaiting-payment) sides.
- offer.js scans for [data-pay-deadline] every second and writes the
  formatted remaining time into .offer-pay-countdown-value. Reuses the
  same fmtRemaining shape as auction.js (Nd Nh Nm / Nh Nm Ns / Nm Ns).

Auction (/a/{id}):
- _serialize_auction adds payment_deadline_timestamp, user_is_winner,
  and is_settled.
- auction.j2 renders a new "You won this auction!" well when state is
  ENDED, the current user is the winner, the auction isn't SETTLED,
  and payment_deadline_timestamp is set. The well carries the pay
  button + a countdown that auction.js fills in. The human deadline
  is also resolved client-side (toLocaleString) so the buyer sees
  it in their own timezone.
- auction.js adds tickPayCountdown() in addition to the existing
  tickCountdown() that counts auction end.

Test fixture _accepted_offer now sets offer.accepted_timestamp (the
fixture bypasses accept_offer() which would set it for free).
test_accepted_offer_renders_pay_countdown_for_buyer locks in the
markup.
This commit is contained in:
russell@unturf.com 2026-05-13 13:01:11 -04:00
parent cd1ede638c
commit b27c0748a6
No known key found for this signature in database
7 changed files with 117 additions and 2 deletions

View file

@ -114,9 +114,36 @@
});
}
// Pay-by countdown (post-end): same fmtRemaining; written into
// #auction-pay-countdown if present. The human deadline string is
// also resolved locally from the same data attribute so the user
// sees a consistent wall-clock + countdown in their own timezone.
const payCountdownEl = document.getElementById("auction-pay-countdown");
const payDeadlineHumanEl = document.getElementById(
"auction-pay-deadline-human"
);
function tickPayCountdown() {
if (!payCountdownEl) return;
const raw = payCountdownEl.getAttribute("data-pay-deadline");
if (!raw) return;
const deadline = parseInt(raw, 10);
if (isNaN(deadline)) return;
payCountdownEl.textContent = fmtRemaining(deadline - Date.now());
if (payDeadlineHumanEl && payDeadlineHumanEl.textContent === "computing…") {
try {
payDeadlineHumanEl.textContent = new Date(deadline).toLocaleString();
} catch (e) {
/* leave the placeholder */
}
}
}
// Countdown ticks locally every second.
setInterval(tickCountdown, 1000);
setInterval(tickPayCountdown, 1000);
tickCountdown();
tickPayCountdown();
// Live state: prefer a bounded SSE feed (the server closes it after
// ~25s; EventSource reconnects on its own), and fall back to polling

View file

@ -118,9 +118,46 @@
});
}
/* Pay-by deadline countdown.
*
* Any element carrying [data-pay-deadline] (an absolute ms timestamp)
* with a child .offer-pay-countdown-value will tick once a second
* showing how long the buyer has left to pay. When the window
* elapses the offer auto-expires server-side; we reflect that
* locally as "expired — refresh to update."
*/
function fmtRemaining(ms) {
if (ms <= 0) return "expired";
var totalSeconds = Math.floor(ms / 1000);
var days = Math.floor(totalSeconds / 86400);
var hours = Math.floor((totalSeconds % 86400) / 3600);
var minutes = Math.floor((totalSeconds % 3600) / 60);
var 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 tickPayCountdown() {
var nodes = document.querySelectorAll("[data-pay-deadline]");
if (!nodes.length) return;
var now = Date.now();
Array.prototype.forEach.call(nodes, function (node) {
var raw = node.getAttribute("data-pay-deadline");
var deadline = parseInt(raw, 10);
if (isNaN(deadline)) return;
var value = node.querySelector(".offer-pay-countdown-value");
if (!value) return;
value.textContent = fmtRemaining(deadline - now);
});
}
function init() {
wire();
watchOfferState();
tickPayCountdown();
setInterval(tickPayCountdown, 1000);
}
if (document.readyState === "loading") {

View file

@ -40,6 +40,20 @@
{% endif %}
</div>
{% if is_ended and user_is_winner and not is_settled and payment_deadline_timestamp %}
<section class="well auction-winner-pay">
<h3 class="type-title">You won this auction!</h3>
<p>
Complete payment by <strong id="auction-pay-deadline-human">computing&hellip;</strong>
<span class="js-only">&middot; <span id="auction-pay-countdown" data-pay-deadline="{{ payment_deadline_timestamp }}">computing&hellip;</span> remaining</span>,
or the auction will be released to the next-highest bidder.
</p>
<form method="post" action="/a/{{ id }}/checkout" class="auction-checkout-form">
<input type="submit" class="mps-submit mps-button-green" value="Pay ${{ '%.2f'|format(current_high) }} now" />
</form>
</section>
{% endif %}
{% if is_active %}
{% if user_is_seller %}
<div class="well">

View file

@ -107,7 +107,11 @@
<input type="submit" class="mps-submit mps-button-green" value="Pay ${{ "%.2f"|format(current_amount) }} now" />
</form>
{% if pay_deadline_human %}
<p class="offer-pay-deadline-note">You have until <strong>{{ pay_deadline_human }}</strong> to complete payment, after which this offer auto-expires.</p>
<p class="offer-pay-deadline-note">You have until <strong>{{ pay_deadline_human }}</strong> to complete payment, after which this offer auto-expires.
{% if pay_deadline_timestamp_ms %}
<span class="offer-pay-countdown js-only" data-pay-deadline="{{ pay_deadline_timestamp_ms }}">&middot; <span class="offer-pay-countdown-value">computing&hellip;</span> remaining</span>
{% endif %}
</p>
{% endif %}
<form method="post" action="/o/{{ id }}/cancel" class="offer-cancel-form" onsubmit="return confirm('Cancel this accepted offer? You will not be able to pay this amount again unless the seller accepts a new offer.');">
<button type="submit" class="cart-remove-link">Cancel this offer</button>
@ -118,7 +122,11 @@
<h3 class="type-title">Awaiting payment</h3>
<p>You accepted <a href="/profile/{{ buyer_handle }}?shop={{ shop_id }}">{{ buyer_name }}</a>&rsquo;s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. We emailed them a one-time checkout link &mdash; this offer can be redeemed only once.</p>
{% if pay_deadline_human %}
<p class="offer-await-deadline-note">Buyer must pay by <strong>{{ pay_deadline_human }}</strong>; the offer auto-expires after that.</p>
<p class="offer-await-deadline-note">Buyer must pay by <strong>{{ pay_deadline_human }}</strong>; the offer auto-expires after that.
{% if pay_deadline_timestamp_ms %}
<span class="offer-pay-countdown js-only" data-pay-deadline="{{ pay_deadline_timestamp_ms }}">&middot; <span class="offer-pay-countdown-value">computing&hellip;</span> remaining</span>
{% endif %}
</p>
{% endif %}
<p class="offer-await-share-note">If they need it again, share this same page:</p>
<div class="offer-pay-link-row">

View file

@ -6880,6 +6880,9 @@ class TestOfferCheckout(_AuthenticatedBase):
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
# Mimic accept_offer() — set accepted_timestamp so the pay-by
# deadline (and its JS countdown) resolves.
offer.accepted_timestamp = now_timestamp()
self.dbsession.add(offer)
self.dbsession.flush()
offer_id = offer.uuid_str
@ -6929,6 +6932,22 @@ class TestOfferCheckout(_AuthenticatedBase):
# Seller never sees the buyer's pay-now form (they cannot pay).
self.assertNotIn(f"/o/{offer_id}/checkout", body)
def test_accepted_offer_renders_pay_countdown_for_buyer(self):
"""The buyer's accepted-offer view exposes a [data-pay-deadline]
countdown element with an absolute ms timestamp so offer.js can
tick a live "time remaining to pay" indicator. Without JS, the
existing static "You have until <date>" text still renders; the
countdown element is .js-only.
"""
offer_id = self._accepted_offer()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode()
self.assertIn("Pay $75.00 now", body)
self.assertIn("offer-pay-countdown", body)
self.assertIn("data-pay-deadline=\"", body)
self.assertIn("offer-pay-countdown-value", body)
def test_buyer_can_cancel_accepted_offer(self):
"""Buyer back-out path: POST /o/{id}/cancel flips ACCEPTED →
BUYER_CANCELLED. The seller's accept still stands as historical

View file

@ -28,6 +28,7 @@ from ..lib.currency import cents_to_dollars
from ..models.auction import (
AUCTION_STATE_ACTIVE,
AUCTION_STATE_ENDED,
AUCTION_STATE_SETTLED,
MpsAuction,
MpsAuctionWatcher,
get_auction_by_id,
@ -93,6 +94,13 @@ def _serialize_auction(auction, request):
and current_winning is not None
and current_winning.bidder == request.user
),
"user_is_winner": (
request.user is not None
and auction.winner is not None
and auction.winner == request.user
),
"payment_deadline_timestamp": auction.payment_deadline_timestamp,
"is_settled": auction.state == AUCTION_STATE_SETTLED,
}

View file

@ -245,8 +245,10 @@ def offer_page(request):
ctx["pay_deadline_human"] = datetime.fromtimestamp(
deadline / 1000, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M UTC")
ctx["pay_deadline_timestamp_ms"] = deadline
else:
ctx["pay_deadline_human"] = None
ctx["pay_deadline_timestamp_ms"] = None
return ctx