feat: polished negotiation-aware cart + sandbox-gated user storage

Cart polish for offer/auction carts:
- Negotiation card at top of cart-left: eyebrow ("Offer accepted" /
  "Auction won"), list price (strike) vs agreed price (bold green),
  savings line, link to /o/{id} or /a/{id}.
- Shop subtotal honors the override: strikethrough list price, bold
  negotiated price. (is_discounted was rightly False after the prior
  fix, but the visual cue was lost — restored without conflating it
  with coupon discounting.)
- Line item rendering: replaces quantity field + remove button with a
  "quantity locked" pill on the negotiated product, since offers and
  auctions are single-unit transactions buyers cannot edit mid-cart.
- Right column total: shows agreed price big, list-vs-savings line
  beneath. No conflicting strikethrough.
- Gift card apply hidden on negotiated carts (does not stack).

New Cart properties (test coverage in test_integration.py):
- is_negotiated, negotiation_kind, negotiation_path, negotiated_product
- list_total_in_cents / list_total
- savings_in_cents / savings (clamped to >= 0)

CSS: cart-negotiation-card / cart-negotiation-* / cart-negotiation-pill
all live in common.css, Grid only, design tokens only.

Sandbox-gated artifact storage:
The Artifact Storage section on /u/settings (S3-compatible bucket for
the in-browser sandbox export feature) now renders only when the
current shop has sandbox_mode enabled. The bucket has exactly one
consumer (views/user_sandbox.py via static/js/sandbox.js), so when
sandbox is off there is no reason to surface the credential form.
Functional test in test_functional.py asserts both states.
This commit is contained in:
russell@unturf.com 2026-05-13 08:50:35 -04:00
parent 1088805520
commit 2c56717579
No known key found for this signature in database
6 changed files with 313 additions and 2 deletions

View file

@ -363,6 +363,69 @@ class Cart(RBase, Base):
return self.cart_offers[0].offer.current_amount_in_cents
return None
@property
def is_negotiated(self):
"""MPS-20 + MPS-21: True when the cart total is set by an
accepted offer or winning auction bid, not by list-price summation.
Used by templates to render the negotiation card and to suppress
coupon / gift-card controls that don't stack on a negotiated price.
"""
return bool(self.cart_auctions or self.cart_offers)
@property
def negotiation_kind(self):
"""'auction' | 'offer' | None — the kind of negotiation behind
this cart's override. Auction wins if both are set."""
if self.cart_auctions:
return "auction"
if self.cart_offers:
return "offer"
return None
@property
def negotiation_path(self):
"""Relative URL to the offer or auction page, or None."""
if self.cart_auctions:
return f"/a/{self.cart_auctions[0].auction.uuid_str}"
if self.cart_offers:
return f"/o/{self.cart_offers[0].offer.uuid_str}"
return None
@property
def negotiated_product(self):
"""The product the offer / auction was negotiated on, or None."""
if self.cart_auctions:
return self.cart_auctions[0].auction.product
if self.cart_offers:
return self.cart_offers[0].offer.product
return None
@property
def list_total_in_cents(self):
"""Sum of line items at list price — the would-be total if no
offer / auction were attached. Used to show savings on
negotiated carts."""
return sum(self.line_totals_in_cents.values())
@property
def list_total(self):
return cents_to_dollars(self.list_total_in_cents)
@property
def savings_in_cents(self):
"""Positive when the negotiated price is below list. Zero when
the cart isn't negotiated, or when negotiated amount >= list
(e.g. an auction bid above list)."""
if not self.is_negotiated:
return 0
override = self.auction_offer_override_in_cents or 0
diff = self.list_total_in_cents - override
return diff if diff > 0 else 0
@property
def savings(self):
return cents_to_dollars(self.savings_in_cents)
@property
def total_price_in_cents(self):
"""

View file

@ -3088,6 +3088,149 @@ img.crypto-button-icon {
font-size: 0.8em;
}
/* ====================================================================
MPS-20 + MPS-21 cart negotiation card (offer accepted / auction won)
Highlights an agreed price over the line-item list price so the buyer
sees the deal they negotiated. Pure CSS Grid, design tokens only.
==================================================================== */
.cart-negotiation-card {
background: var(--alert-success-bg, #e8f5d4);
border: 1px solid var(--alert-success-border, #c3e6cb);
border-left: 4px solid var(--color-green-dark, #8ab34e);
border-radius: var(--radius-lg, 12px);
padding: var(--space-5, 20px) var(--space-6, 24px);
}
.cart-negotiation-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4, 16px);
}
@media (min-width: 720px) {
.cart-negotiation-grid {
grid-template-columns: 1fr auto;
align-items: center;
column-gap: var(--space-8, 32px);
}
.cart-negotiation-footer {
grid-column: 1 / -1;
}
}
.cart-negotiation-eyebrow {
display: inline-block;
font-size: var(--text-xs, 0.75rem);
font-weight: var(--weight-bold, 700);
text-transform: uppercase;
letter-spacing: var(--tracking-wider, 0.05em);
color: var(--color-green-dark, #8ab34e);
margin-bottom: var(--space-1, 4px);
}
.cart-negotiation-title {
font-size: var(--text-xl, 1.5rem);
font-weight: var(--weight-bold, 700);
line-height: var(--leading-tight, 1.15);
color: var(--text-primary, #0b0b0b);
margin: 0;
}
.cart-negotiation-product {
margin: var(--space-1, 4px) 0 0 0;
color: var(--text-body, #515151);
font-size: var(--text-sm, 0.875rem);
}
.cart-negotiation-numbers {
display: grid;
grid-template-columns: auto auto;
column-gap: var(--space-5, 20px);
row-gap: var(--space-1, 4px);
margin: 0;
justify-content: end;
}
.cart-negotiation-row {
display: contents;
}
.cart-negotiation-numbers dt {
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #666);
text-align: left;
margin: 0;
}
.cart-negotiation-numbers dd {
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #666);
text-align: right;
margin: 0;
font-variant-numeric: tabular-nums;
}
.cart-negotiation-agreed {
font-size: var(--text-xl, 1.5rem);
font-weight: var(--weight-bold, 700);
color: var(--color-green-dark, #8ab34e);
line-height: var(--leading-tight, 1.15);
}
.cart-negotiation-savings-row dt,
.cart-negotiation-savings-row dd {
padding-top: var(--space-2, 8px);
border-top: 1px dashed var(--alert-success-border, #c3e6cb);
}
.cart-negotiation-savings {
color: var(--color-green-dark, #8ab34e);
font-weight: var(--weight-bold, 700);
}
.cart-negotiation-footer {
text-align: right;
}
.cart-negotiation-link {
font-size: var(--text-sm, 0.875rem);
text-decoration: none;
}
.cart-negotiation-link:hover {
text-decoration: underline;
}
.cart-negotiation-pill {
display: inline-grid;
place-items: center;
grid-auto-flow: column;
background: var(--alert-success-bg, #e8f5d4);
color: var(--color-green-dark, #8ab34e);
border: 1px solid var(--alert-success-border, #c3e6cb);
border-radius: var(--radius-full, 9999px);
padding: var(--space-1, 4px) var(--space-3, 12px);
font-size: var(--text-xs, 0.75rem);
font-weight: var(--weight-bold, 700);
letter-spacing: var(--tracking-wide, 0.02em);
text-transform: uppercase;
}
.cart-total-savings-note {
margin: var(--space-2, 8px) 0 var(--space-3, 12px) 0;
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #666);
}
.cart-total-savings-note s {
color: var(--text-faint, #999);
}
.cart-total-savings-note b {
color: var(--color-green-dark, #8ab34e);
}
/* Phase 1: Static inline style replacements */
.no-bottom-margin {
margin-bottom: 0px;

View file

@ -16,6 +16,50 @@
<section class="cart-left">
{% endif %}
{% if cart.is_negotiated %}
{% set neg_product = cart.negotiated_product %}
<section class="cart-negotiation-card well">
<div class="cart-negotiation-grid">
<div class="cart-negotiation-heading">
{% if cart.negotiation_kind == "auction" %}
<span class="cart-negotiation-eyebrow">Auction won</span>
<h2 class="cart-negotiation-title">Your winning bid applies.</h2>
{% else %}
<span class="cart-negotiation-eyebrow">Offer accepted</span>
<h2 class="cart-negotiation-title">Your negotiated price applies.</h2>
{% endif %}
{% if neg_product %}
<p class="cart-negotiation-product">on <a href="/p/{{ neg_product.uuid_str }}" class="shop-theme-link-color">{{ neg_product.title }}</a></p>
{% endif %}
</div>
<dl class="cart-negotiation-numbers">
<div class="cart-negotiation-row">
<dt>List price</dt>
<dd><s>${{ '{:,.2f}'.format(cart.list_total) }}</s></dd>
</div>
<div class="cart-negotiation-row">
<dt>{% if cart.negotiation_kind == "auction" %}Winning bid{% else %}Agreed price{% endif %}</dt>
<dd class="cart-negotiation-agreed">${{ '{:,.2f}'.format(cart.auction_offer_override_in_cents / 100) }}</dd>
</div>
{% if cart.savings_in_cents > 0 %}
<div class="cart-negotiation-row cart-negotiation-savings-row">
<dt>You save</dt>
<dd class="cart-negotiation-savings">${{ '{:,.2f}'.format(cart.savings) }}</dd>
</div>
{% endif %}
</dl>
<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 %}
</a>
</div>
</div>
</section>
<br/>
{% endif %}
{% for coupon in cart.coupons %}
<section class="coupon">
<b>{{ coupon.code }}</b><br/>
@ -131,6 +175,12 @@
<a href="/p/{{ product.uuid_str }}" class="shop-theme-link-color">{{ product.title }}</a> sold by
<a href="{{ shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ shop.name }}</a>
</span>
{% if cart.is_negotiated and cart.negotiated_product and cart.negotiated_product.uuid_str == product.uuid_str %}
<br/>
<span class="cart-negotiation-pill">{% if cart.negotiation_kind == "auction" %}Auction won &middot; quantity locked{% else %}Offer accepted &middot; quantity locked{% endif %}</span>
<br/>
<a href="{{ cart.negotiation_path }}" class="cart-remove-link">view {{ cart.negotiation_kind }} &rarr;</a>
{% else %}
<br/>
<form action="/cart/{{ cart.uuid_str }}/quantity" method="POST" onsubmit="submit.disabled = true; return true;" class="cart-inline-form">
@ -146,6 +196,7 @@
<input type="hidden" name="product_id" value="{{ product.uuid_str }}">
<button type="submit" class="cart-remove-link">remove</button>
</form>
{% endif %}
</div>
<div class="cart-total-section">
@ -190,7 +241,11 @@
<div class="cart-total-grid-span">
{% if cart.is_discounted %}
{% if cart.is_negotiated and cart.savings_in_cents > 0 %}
<b class="cart-total-amount"><s>${{ '{:,.2f}'.format(cart.shop_totals[shop.uuid_str]) }}</s></b>
<br/>
<b class="discounted cart-total-amount">${{ '{:,.2f}'.format(cart.auction_offer_override_in_cents / 100) }}</b>
{% elif cart.is_discounted %}
<b class="cart-total-amount"><s>${{ '{:,.2f}'.format(cart.shop_totals[shop.uuid_str]) }}</s></b>
<br/>
<b class="discounted cart-total-amount">${{ '{:,.2f}'.format(discounted_shop_totals[shop.uuid_str]) }}</b>
@ -229,7 +284,13 @@
{% if not cart.is_empty %}
<section class="cart-right well">
{% if cart.is_discounted %}
{% if cart.is_negotiated and cart.savings_in_cents > 0 %}
<h2>Total: <span class="discounted">${{ '{:,.2f}'.format(cart.total) }}</span></h2>
<p class="cart-total-savings-note">
<s>${{ '{:,.2f}'.format(cart.list_total) }}</s>
&middot; you save <b>${{ '{:,.2f}'.format(cart.savings) }}</b>
</p>
{% elif cart.is_discounted %}
<h2>Total: <s>${{ '{:,.2f}'.format(cart.total_price) }}</s>
<span class="discounted">${{ '{:,.2f}'.format(total_discounted_price) }}
</h2></span>
@ -311,6 +372,7 @@
{% endif %}
{% if not cart.is_negotiated %}
<section class="cart-gift-card-apply">
<h3>Gift Card</h3>
<form method="POST" action="/gift-card/apply" class="cart-inline-form">
@ -320,6 +382,7 @@
</form>
</section>
<br/>
{% endif %}
<center>
<a href="/" class="mps-button cart-continue-shopping-button">Continue shopping</a>

View file

@ -103,6 +103,7 @@
});
</script>
{% if request.shop and request.shop.sandbox_mode %}
<br />
<h3>Artifact Storage</h3>
@ -173,6 +174,7 @@
<br />
</form>
{% endif %}
</div>

View file

@ -4175,6 +4175,33 @@ class TestAnalytics(_AuthenticatedBase):
self.assertIn('sandbox.js', res.text)
self.assertIn('sandbox-toolbar', res.text)
def test_user_settings_storage_hidden_without_sandbox_mode(self):
"""Artifact Storage section is gated on Shop.sandbox_mode.
The S3 bucket is only consumed by the in-browser sandbox feature
(lib/views/user_sandbox.py). When a shop doesn't expose the
sandbox toolbar, the credential form has no consumer and is hidden.
"""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Default shop has sandbox_mode=False — section hidden.
res = self.testapp.get("/u/settings", status=200)
self.assertNotIn("Artifact Storage", res.text)
# Flip sandbox_mode on — section appears.
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
res = self.testapp.get("/u/settings", status=200)
self.assertIn("Artifact Storage", res.text)
def test_user_s3_bucket_default_empty(self):
"""Test that new users have no S3 bucket credentials."""
self._create_shop_helper(

View file

@ -4948,6 +4948,12 @@ class TestCartTotalOverride(DatabaseIntegrationTests):
self.assertEqual(cart.total_discounted_price_in_cents, 4200)
self.assertEqual(cart.total_in_cents, 4200)
self.assertFalse(cart.is_discounted)
# Negotiation card needs these.
self.assertTrue(cart.is_negotiated)
self.assertEqual(cart.negotiation_kind, "auction")
self.assertTrue(cart.negotiation_path.startswith("/a/"))
self.assertEqual(cart.list_total_in_cents, 10000)
self.assertEqual(cart.savings_in_cents, 5800)
def test_offer_override_uses_current_amount(self):
from ..models.cart import Cart
@ -4987,6 +4993,13 @@ class TestCartTotalOverride(DatabaseIntegrationTests):
self.assertEqual(cart.total_discounted_price_in_cents, 8000)
self.assertEqual(cart.total_in_cents, 8000)
self.assertFalse(cart.is_discounted)
# Negotiation card props.
self.assertTrue(cart.is_negotiated)
self.assertEqual(cart.negotiation_kind, "offer")
self.assertTrue(cart.negotiation_path.startswith("/o/"))
self.assertEqual(cart.negotiated_product.id, product.id)
self.assertEqual(cart.list_total_in_cents, 10000)
self.assertEqual(cart.savings_in_cents, 2000)
class TestAuctionTickIntegration(DatabaseIntegrationTests):