fix: disable Add To Cart on product page when active cart is negotiated
The server-side guards on /cart/add and /cart/{id}/quantity stop the
exploit, but the buyer still saw an enabled "Add To Cart" button that
bounced them. Per fox: disable the button with a one-sentence caption
explaining why, instead of hiding it. A hidden button confuses users;
a disabled one with caption keeps the affordance visible and teaches
the user how to re-enable it (check out or save the current cart).
product.j2 now:
- Adds `disabled` to both Add To Cart buttons (physical + digital
paths) when `request.active_cart and request.active_cart.is_negotiated`.
- Renders a `.product-add-disabled-note` paragraph beneath with the
reason: "Your active cart is locked to an accepted offer/auction.
Save or check out that cart before adding other items."
Token-driven CSS for the caption (no inline styles, no hardcoded
colors). The button keeps its mps-button styling — browser disabled
state is sufficient visual contrast.
Test test_product_page_disables_add_to_cart_on_negotiated_cart walks
the realistic flow: buyer accepts an offer on product A, then visits
product B (a separate buy-now product on the same shop) and sees the
button disabled with the caption.
This commit is contained in:
parent
cb9b3df0cf
commit
36bb6b2ea6
3 changed files with 75 additions and 2 deletions
|
|
@ -3189,6 +3189,13 @@ img.crypto-button-icon {
|
|||
font-weight: var(--weight-bold, 700);
|
||||
}
|
||||
|
||||
.product-add-disabled-note {
|
||||
margin: var(--space-2, 8px) 0 0 0;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--text-secondary, #666);
|
||||
max-width: 30em;
|
||||
}
|
||||
|
||||
.cart-negotiation-deadline {
|
||||
grid-column: 1 / -1;
|
||||
margin: var(--space-3, 12px) 0 0 0;
|
||||
|
|
|
|||
|
|
@ -201,6 +201,15 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Disable Add To Cart when the active cart is locked to an
|
||||
accepted offer / auction. The cart total is overridden by the
|
||||
negotiated amount regardless of quantity, so adding another
|
||||
unit (same or different product) would let the buyer walk away
|
||||
with more merch than they paid for. Disable + caption rather
|
||||
than hiding the button so the affordance stays visible. #}
|
||||
{% set cart_is_negotiated = request.active_cart and request.active_cart.is_negotiated %}
|
||||
{% set cart_negotiation_kind = request.active_cart.negotiation_kind if cart_is_negotiated else "" %}
|
||||
|
||||
{% if product.is_physical %}
|
||||
{# MPS-20 + MPS-21: physical Add To Cart only renders when
|
||||
pricing_mode permits direct purchase (modes 0, 2, 4).
|
||||
|
|
@ -212,8 +221,11 @@
|
|||
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
|
||||
<button type="submit" class="product-download-button mps-button"{% if cart_is_negotiated %} disabled{% endif %}>Add To Cart</button>
|
||||
</form>
|
||||
{% if cart_is_negotiated %}
|
||||
<p class="product-add-disabled-note">Your active cart is locked to an accepted {{ cart_negotiation_kind }}. Save or check out that cart before adding other items.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button class="product-download-button mps-button sold-out-button" disabled>Sold Out</button>
|
||||
{% if request.shop.shop_locations.all()|length > 1 %}
|
||||
|
|
@ -227,8 +239,11 @@
|
|||
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
|
||||
<button type="submit" class="product-download-button mps-button"{% if cart_is_negotiated %} disabled{% endif %}>Add To Cart</button>
|
||||
</form>
|
||||
{% if cart_is_negotiated %}
|
||||
<p class="product-add-disabled-note">Your active cart is locked to an accepted {{ cart_negotiation_kind }}. Save or check out that cart before adding other items.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -7090,6 +7090,57 @@ class TestOfferCheckout(_AuthenticatedBase):
|
|||
self.dbsession.refresh(cart)
|
||||
self.assertEqual(cart.get_product_quantity(product), 1)
|
||||
|
||||
def test_product_page_disables_add_to_cart_on_negotiated_cart(self):
|
||||
"""When the active cart is locked to an accepted offer, the
|
||||
product page renders Add To Cart with `disabled` and a one-line
|
||||
caption explaining why. Per fox: disable, don't hide — the
|
||||
affordance stays visible so the user knows the option exists
|
||||
and what action will re-enable it.
|
||||
|
||||
The offer-only product itself doesn't have an Add To Cart button
|
||||
(offer-only mode hides it). So we check a *second* buy-now
|
||||
product on the same shop — the realistic flow: buyer accepted
|
||||
an offer on product A, browses product B and tries to add it
|
||||
normally.
|
||||
"""
|
||||
from ..models.product import Product
|
||||
import json
|
||||
|
||||
offer_id = self._accepted_offer()
|
||||
# _accepted_offer left user1 (seller) logged in. Add a second
|
||||
# buy-now product to the same shop before the buyer logs in.
|
||||
from ..models.offer import get_offer_by_id
|
||||
offer = get_offer_by_id(self.dbsession, offer_id)
|
||||
shop = offer.shop
|
||||
other = Product(title="Buy now item", description="...")
|
||||
other.shop = shop
|
||||
other.price_in_cents = 500
|
||||
other.is_physical = False
|
||||
other.is_sellable = True
|
||||
other.pricing_mode = 0
|
||||
other.json_file_metadata = json.dumps(
|
||||
{"originals": {}, "extensions": {"product": "pdf"}},
|
||||
)
|
||||
self.dbsession.add(other)
|
||||
self.dbsession.flush()
|
||||
other_id = other.uuid_str
|
||||
other_slug = other.slug
|
||||
transaction.commit()
|
||||
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
self.testapp.post(f"/o/{offer_id}/checkout", status=302)
|
||||
|
||||
body = self.testapp.get(
|
||||
f"/p/{other_id}/{other_slug}", status=200
|
||||
).body.decode()
|
||||
self.assertIn("Add To Cart", body)
|
||||
self.assertRegex(
|
||||
body,
|
||||
r'<button[^>]*\bdisabled\b[^>]*>Add To Cart</button>',
|
||||
)
|
||||
self.assertIn("locked to an accepted offer", body)
|
||||
|
||||
def test_negotiated_cart_refuses_quantity_bump(self):
|
||||
"""Same defense for /cart/{id}/quantity — the buyer can't bump
|
||||
quantity directly on the cart page either.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue