diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index 96ff0a6..074dbf1 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -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): """ diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 1c7cdfa..e7f5983 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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; diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index fba3c78..a644d8b 100644 --- a/make_post_sell/templates/cart.j2 +++ b/make_post_sell/templates/cart.j2 @@ -16,6 +16,50 @@
{% endif %} + {% if cart.is_negotiated %} + {% set neg_product = cart.negotiated_product %} +
+
+
+ {% if cart.negotiation_kind == "auction" %} + Auction won +

Your winning bid applies.

+ {% else %} + Offer accepted +

Your negotiated price applies.

+ {% endif %} + {% if neg_product %} +

on {{ neg_product.title }}

+ {% endif %} +
+ +
+
+
List price
+
${{ '{:,.2f}'.format(cart.list_total) }}
+
+
+
{% if cart.negotiation_kind == "auction" %}Winning bid{% else %}Agreed price{% endif %}
+
${{ '{:,.2f}'.format(cart.auction_offer_override_in_cents / 100) }}
+
+ {% if cart.savings_in_cents > 0 %} +
+
You save
+
${{ '{:,.2f}'.format(cart.savings) }}
+
+ {% endif %} +
+ + +
+
+
+ {% endif %} + {% for coupon in cart.coupons %}
{{ coupon.code }}
@@ -131,6 +175,12 @@ {{ product.title }} sold by {{ shop.name }} + {% if cart.is_negotiated and cart.negotiated_product and cart.negotiated_product.uuid_str == product.uuid_str %} +
+ {% if cart.negotiation_kind == "auction" %}Auction won · quantity locked{% else %}Offer accepted · quantity locked{% endif %} +
+ view {{ cart.negotiation_kind }} → + {% else %}
@@ -146,6 +196,7 @@
+ {% endif %}
@@ -190,7 +241,11 @@
- {% if cart.is_discounted %} + {% if cart.is_negotiated and cart.savings_in_cents > 0 %} + ${{ '{:,.2f}'.format(cart.shop_totals[shop.uuid_str]) }} +
+ ${{ '{:,.2f}'.format(cart.auction_offer_override_in_cents / 100) }} + {% elif cart.is_discounted %} ${{ '{:,.2f}'.format(cart.shop_totals[shop.uuid_str]) }}
${{ '{:,.2f}'.format(discounted_shop_totals[shop.uuid_str]) }} @@ -229,7 +284,13 @@ {% if not cart.is_empty %}
- {% if cart.is_discounted %} + {% if cart.is_negotiated and cart.savings_in_cents > 0 %} +

Total: ${{ '{:,.2f}'.format(cart.total) }}

+

+ ${{ '{:,.2f}'.format(cart.list_total) }} + · you save ${{ '{:,.2f}'.format(cart.savings) }} +

+ {% elif cart.is_discounted %}

Total: ${{ '{:,.2f}'.format(cart.total_price) }} ${{ '{:,.2f}'.format(total_discounted_price) }}

@@ -311,6 +372,7 @@ {% endif %} + {% if not cart.is_negotiated %}

Gift Card

@@ -320,6 +382,7 @@

+ {% endif %}
Continue shopping diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2 index 0cbc5e3..5da3003 100644 --- a/make_post_sell/templates/user_settings.j2 +++ b/make_post_sell/templates/user_settings.j2 @@ -103,6 +103,7 @@ }); + {% if request.shop and request.shop.sandbox_mode %}

Artifact Storage

@@ -173,6 +174,7 @@
+ {% endif %}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 4634eda..1cfed3a 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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( diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 599f581..520eecb 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -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):