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:
parent
1088805520
commit
2c56717579
6 changed files with 313 additions and 2 deletions
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 →{% else %}View offer details →{% 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 · quantity locked{% else %}Offer accepted · quantity locked{% endif %}</span>
|
||||
<br/>
|
||||
<a href="{{ cart.negotiation_path }}" class="cart-remove-link">view {{ cart.negotiation_kind }} →</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>
|
||||
· 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue