diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index 074dbf1..1869929 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -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): """ diff --git a/make_post_sell/models/offer.py b/make_post_sell/models/offer.py index d665595..9413ea6 100644 --- a/make_post_sell/models/offer.py +++ b/make_post_sell/models/offer.py @@ -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): diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index f5facc2..028e988 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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; } diff --git a/make_post_sell/static/js/pay-countdown.js b/make_post_sell/static/js/pay-countdown.js new file mode 100644 index 0000000..47cfaa4 --- /dev/null +++ b/make_post_sell/static/js/pay-countdown.js @@ -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: + * + * in 23 hours, 14 minutes + * + * 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(); + } +})(); diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index a644d8b..6fc9461 100644 --- a/make_post_sell/templates/cart.j2 +++ b/make_post_sell/templates/cart.j2 @@ -50,6 +50,12 @@ {% endif %} + {% if cart.negotiation_pay_deadline_ms %} +
+ Pay {{ cart.negotiation_pay_deadline_human }}, or this {{ cart.negotiation_kind }} expires. +
+ {% endif %} +