fix: legacy offer countdown + add countdown to cart page

Two coupled fixes the live shop.unturf.com data exposed:

1. Legacy accepted offers (those flipped to ACCEPTED before the
   accepted_timestamp column existed) have NULL there, so
   acceptance_pay_deadline_ms returned None and the countdown never
   rendered. Fall back to last_action_timestamp — for an
   untouched-since-acceptance offer that IS the moment of acceptance
   (the accept event was the last action recorded). Also guard the
   property to only return a deadline when state == ACCEPTED, so
   PAID / EXPIRED / WITHDRAWN offers don't accidentally surface
   stale deadlines.

2. Cart page (/cart/{id}) had no countdown — the buyer landed in
   the cart from the offer accept email, saw the agreed price, but
   no live indicator of when this deal expires. Cart.negotiation_pay_
   deadline_ms exposes the linked offer or auction's deadline;
   cart.negotiation_pay_deadline_human renders ago.human() for the
   no-JS fallback. cart.j2 adds a "Pay <strong>in 23 hours, 14
   minutes</strong>, or this offer expires." line inside the green
   negotiation card.

Pulled the countdown tick out of offer.js into a shared
static/js/pay-countdown.js so cart.j2 can include just the ticker
without pulling the offer-detail form wiring it doesn't need.
Other pages still load their own JS — offer.js and auction.js keep
their own implementations for now; this is the cart-page addition.
This commit is contained in:
russell@unturf.com 2026-05-13 13:35:38 -04:00
parent 764e0ae1ef
commit 0c0258f9bb
No known key found for this signature in database
5 changed files with 123 additions and 4 deletions

View file

@ -426,6 +426,37 @@ class Cart(RBase, Base):
def savings(self):
return cents_to_dollars(self.savings_in_cents)
@property
def negotiation_pay_deadline_ms(self):
"""When this negotiated cart must be paid by, in absolute ms.
Returns None if the cart isn't negotiated or no deadline is set
on the linked offer / auction.
- cart_offers offer.acceptance_pay_deadline_ms
- cart_auctions auction.payment_deadline_timestamp
"""
if self.cart_offers:
return self.cart_offers[0].offer.acceptance_pay_deadline_ms
if self.cart_auctions:
return self.cart_auctions[0].auction.payment_deadline_timestamp
return None
@property
def negotiation_pay_deadline_human(self):
"""Human-readable delta of the pay-by deadline ("in 23 hours,
14 minutes"), via ago.human. None when no deadline applies."""
ms = self.negotiation_pay_deadline_ms
if ms is None:
return None
import ago
from datetime import datetime, timezone
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
return ago.human(
dt, precision=2,
past_tense="expired {} ago",
future_tense="in {}",
)
@property
def total_price_in_cents(self):
"""

View file

@ -218,17 +218,26 @@ class MpsOffer(RBase, Base):
@property
def acceptance_pay_deadline_ms(self):
"""ACCEPTED offers must be paid before this absolute timestamp,
else they auto-expire. Returns None for offers that never reached
acceptance.
else they auto-expire. Returns None for offers that never
reached acceptance.
accepted_timestamp was added in migration 632878c8f243; offers
already in ACCEPTED state at deploy time have NULL there. For
those legacy rows we fall back to last_action_timestamp, which
for an untouched-since-acceptance offer was the moment of
acceptance (the accept event was the last action).
"""
if self.accepted_timestamp is None:
if self.state != OFFER_STATE_ACCEPTED:
return None
base = self.accepted_timestamp or self.last_action_timestamp
if base is None:
return None
hours = (
self.shop.offer_acceptance_payment_hours
if self.shop is not None
else DEFAULT_OFFER_ACCEPTANCE_PAYMENT_HOURS
)
return self.accepted_timestamp + (hours * 3600 * 1000)
return base + (hours * 3600 * 1000)
@property
def pay_time_remaining_ms(self):

View file

@ -3189,6 +3189,20 @@ img.crypto-button-icon {
font-weight: var(--weight-bold, 700);
}
.cart-negotiation-deadline {
grid-column: 1 / -1;
margin: var(--space-3, 12px) 0 0 0;
padding-top: var(--space-3, 12px);
border-top: 1px dashed var(--alert-success-border, #c3e6cb);
font-size: var(--text-sm, 0.875rem);
color: var(--text-body, #515151);
}
.cart-negotiation-deadline strong {
color: var(--color-green-dark, #8ab34e);
font-variant-numeric: tabular-nums;
}
.cart-negotiation-footer {
text-align: right;
}

View file

@ -0,0 +1,58 @@
/* Shared pay-by countdown rewrites every [data-pay-deadline] element
* on the page once per second with a prose human delta matching the
* server-rendered ago.human() output:
*
* "in 23 hours, 14 minutes" "in 23 hours, 14 minutes, 8 seconds"
*
* Same shape used on /o/{id} (offer detail), /a/{id} (auction detail),
* and /cart/{id} (negotiation card). Each page sets:
*
* <strong data-pay-deadline="<absolute ms>">in 23 hours, 14 minutes</strong>
*
* The script-only version refines the precision; the server-rendered
* version is the no-JS fallback.
*/
(function () {
"use strict";
function fmtRemaining(ms) {
if (ms <= 0) return "expired";
var totalSeconds = Math.floor(ms / 1000);
var units = [
{ name: "day", value: Math.floor(totalSeconds / 86400) },
{ name: "hour", value: Math.floor((totalSeconds % 86400) / 3600) },
{ name: "minute", value: Math.floor((totalSeconds % 3600) / 60) },
{ name: "second", value: totalSeconds % 60 },
];
var parts = [];
for (var i = 0; i < units.length && parts.length < 2; i++) {
var u = units[i];
if (u.value === 0 && parts.length === 0 && i < units.length - 1) continue;
parts.push(u.value + " " + u.name + (u.value === 1 ? "" : "s"));
}
return "in " + parts.join(", ");
}
function tick() {
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;
node.textContent = fmtRemaining(deadline - now);
});
}
function start() {
tick();
setInterval(tick, 1000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();

View file

@ -50,6 +50,12 @@
{% endif %}
</dl>
{% if cart.negotiation_pay_deadline_ms %}
<p class="cart-negotiation-deadline">
Pay <strong data-pay-deadline="{{ cart.negotiation_pay_deadline_ms }}">{{ cart.negotiation_pay_deadline_human }}</strong>, or this {{ cart.negotiation_kind }} expires.
</p>
{% endif %}
<div class="cart-negotiation-footer">
<a href="{{ cart.negotiation_path }}" class="cart-negotiation-link shop-theme-link-color">
{% if cart.negotiation_kind == "auction" %}View auction details &rarr;{% else %}View offer details &rarr;{% endif %}
@ -58,6 +64,7 @@
</div>
</section>
<br/>
<script src="/static/js/pay-countdown.js?v={{ request.git_hash }}"></script>
{% endif %}
{% for coupon in cart.coupons %}