feat: separate "respond" countdown on PENDING/COUNTERED offers

The offer page already showed a pay-by countdown once the offer
was ACCEPTED. The negotiation window (pre-acceptance) had a
deadline server-side (offer.expires_timestamp) but no countdown
in the UI — buyers and sellers had to guess how long they had to
respond.

offer_page view now exposes respond_deadline_human (ago.human)
and respond_deadline_timestamp_ms alongside the existing pay
deadline pair. offer.j2 surfaces it in two places:

- "Your turn" panel: "Respond in 5 days, 12 hours, or this offer
   auto-expires." (the user is the current_party, can act).
- "Waiting on the other party" panel: "They have in 5 days, 12
   hours to respond, or this offer auto-expires." (the other
   party owes the next move).

Both render through the same [data-pay-deadline] attribute the
existing ticker scans — the surrounding copy disambiguates
respond-vs-pay. One countdown shape, two semantic uses, depending
on state.

Regression test test_pending_offer_renders_respond_countdown locks
in the markup (PENDING offer, seller view, "Respond" + the regex
for the prose ago.human() output).
This commit is contained in:
russell@unturf.com 2026-05-13 13:38:32 -04:00
parent fedd6abe35
commit 762462a16c
No known key found for this signature in database
3 changed files with 76 additions and 14 deletions

View file

@ -62,6 +62,10 @@
<section class="offer-actions well">
<h3 class="type-title">Your turn</h3>
{% if respond_deadline_human %}
<p class="offer-respond-deadline-note">Respond <strong data-pay-deadline="{{ respond_deadline_timestamp_ms }}">{{ respond_deadline_human }}</strong>, or this offer auto-expires.</p>
{% endif %}
{# Terminal actions (accept / decline / withdraw) carry a confirm
prompt. offer.js bails if the prompt was cancelled (it checks
event.defaultPrevented), so the AJAX path respects it too. #}
@ -93,7 +97,11 @@
</section>
{% elif is_open %}
<section class="well">
<p><em>Waiting on the other party.</em></p>
<p><em>Waiting on the other party.</em>
{% if respond_deadline_human %}
They have <strong data-pay-deadline="{{ respond_deadline_timestamp_ms }}">{{ respond_deadline_human }}</strong> to respond, or this offer auto-expires.
{% endif %}
</p>
</section>
{% endif %}

View file

@ -6932,6 +6932,51 @@ 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_pending_offer_renders_respond_countdown(self):
"""A PENDING / COUNTERED offer carries a 'respond by' countdown
(separate from the post-acceptance pay countdown). Both render
via the same [data-pay-deadline] ticker the surrounding copy
tells the user what to do.
"""
from ..models.offer import (
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
)
from ..models.product import Product
from ..models.user import get_or_create_user_by_email
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="Negotiable", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() + 3 * 24 * 3600 * 1000,
)
offer.state = OFFER_STATE_PENDING
self.dbsession.add(offer)
self.dbsession.flush()
offer_id = offer.uuid_str
transaction.commit()
# Seller (user1, already logged in from _create_shop_helper) sees
# "Your turn" with a respond-by countdown.
body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode()
self.assertIn("Your turn", body)
self.assertIn("Respond", body)
self.assertRegex(
body,
r'<strong data-pay-deadline="\d+">in \d+ \w+',
)
def test_accepted_offer_renders_pay_countdown_for_buyer(self):
"""The buyer's accepted-offer view shows the pay-by deadline as
an `ago.human()` delta ("in 23 hours, 59 minutes") in a

View file

@ -239,23 +239,32 @@ def offer_page(request):
and actor_party == offer.current_party
)
deadline = offer.acceptance_pay_deadline_ms
if deadline is not None:
import ago
from datetime import datetime, timezone
deadline_dt = datetime.fromtimestamp(deadline / 1000, tz=timezone.utc)
# Human delta form ("in 23 hours, 13 minutes") instead of a UTC
# wall-clock string. JS refines this to second precision on
# capable browsers; this is the no-JS fallback.
ctx["pay_deadline_human"] = ago.human(
deadline_dt, precision=2,
import ago
from datetime import datetime, timezone
def _human(ms):
if ms is None:
return None
return ago.human(
datetime.fromtimestamp(ms / 1000, tz=timezone.utc),
precision=2,
past_tense="expired {} ago",
future_tense="in {}",
)
ctx["pay_deadline_timestamp_ms"] = deadline
# Post-acceptance pay window (ACCEPTED → must pay before this).
pay_deadline = offer.acceptance_pay_deadline_ms
ctx["pay_deadline_human"] = _human(pay_deadline)
ctx["pay_deadline_timestamp_ms"] = pay_deadline
# Pre-acceptance negotiation window (PENDING/COUNTERED → must
# accept/counter/decline before this, else the offer auto-expires).
if offer.is_open:
ctx["respond_deadline_human"] = _human(offer.expires_timestamp)
ctx["respond_deadline_timestamp_ms"] = offer.expires_timestamp
else:
ctx["pay_deadline_human"] = None
ctx["pay_deadline_timestamp_ms"] = None
ctx["respond_deadline_human"] = None
ctx["respond_deadline_timestamp_ms"] = None
return ctx