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:
parent
764e0ae1ef
commit
0c0258f9bb
5 changed files with 123 additions and 4 deletions
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
58
make_post_sell/static/js/pay-countdown.js
Normal file
58
make_post_sell/static/js/pay-countdown.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 →{% else %}View offer details →{% 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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue