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:
parent
cd1ede638c
commit
b27c0748a6
7 changed files with 117 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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…</strong>
|
||||
<span class="js-only">· <span id="auction-pay-countdown" data-pay-deadline="{{ payment_deadline_timestamp }}">computing…</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">
|
||||
|
|
|
|||
|
|
@ -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 }}">· <span class="offer-pay-countdown-value">computing…</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>’s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. We emailed them a one-time checkout link — 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 }}">· <span class="offer-pay-countdown-value">computing…</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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue