diff --git a/docs/design-system.md b/docs/design-system.md index ba65df1..63be926 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -228,7 +228,7 @@ All components are documented with live examples at `/styleguide`. The styleguid | States | `#states` | Interactive state layers | | Loading | `#loading` | Skeleton and spinner patterns | | Buttons | `#buttons` | Button variants (green, blue, red, navy, outline) | -| Forms | `#forms` | Input fields, textareas, selects | +| Forms | `#forms` | Input fields, textareas, selects, settings-form (`.settings-form` / `.settings-form-grid` / `.settings-field` / `.settings-field-hint`) | | Wells | `#wells` | Content wells and containers | | Alerts | `#alerts` | Success, info, warning, danger alerts | | Status | `#status` | Status indicators | diff --git a/docs/make-offer.md b/docs/make-offer.md index 8a8ef86..326ba24 100644 --- a/docs/make-offer.md +++ b/docs/make-offer.md @@ -128,6 +128,14 @@ page. The no-JS path is the source of truth; JSON is an enhancement. Functional coverage: `TestOfferRoutes` drives the JSON path, `TestOfferNoJsFallback` the plain-POST path. +`offer.j2` also renders a state-aware notice (`.offer-state-notice`, +styled via the `.alert` variants) above the action forms — declined / +withdrawn / expired / accepted (+ pay-now hint for the buyer) / your-turn +/ waiting — so the viewer always understands the offer's state without +relying on a flash message that a JS redirect would skip. The booleans +come from `_serialize_offer` (`is_declined`, `is_withdrawn`, `is_expired`, +`is_accepted`, `is_paid`, plus `can_act` / `is_open`). + ## Cart Integration When `cart.cart_offers` has one row, `cart.total_price_in_cents` diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index b002ded..15e98f6 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -1623,9 +1623,67 @@ div.edit-page > section.edit-card-full { } .offer-js-flash-info { background: var(--surface-dim, #f9f9fa); - color: var(--color-text-muted, #666); + color: var(--text-muted, #666); } +/* State notice banner on the offer detail page (offer.j2). Reuses the + .alert color variants; this just gives it block spacing/shape since + the base .alert rule is laid out for the dismissible flash strip. */ +.offer-state-notice { + margin: var(--space-3, 12px) 0; + padding: var(--space-2, 8px) var(--space-4, 16px); + border-radius: var(--radius-md, 8px); +} + +/* Design-system settings form: stacked label + control + hint per + field, two-up grid on wider viewports. Grid only — no flex. */ +.settings-form { + display: grid; + gap: var(--space-4, 16px); + margin-top: var(--space-3, 12px); +} +.settings-form-grid { + display: grid; + gap: var(--space-4, 16px); +} +@media (min-width: 640px) { + .settings-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.settings-field { + display: grid; + gap: var(--space-1, 4px); + align-content: start; +} +.settings-field--full { grid-column: 1 / -1; } +.settings-field > label { + margin: 0; + font-weight: 600; +} +.settings-field--inline { + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-2, 8px); + font-weight: 600; +} +.settings-field input[type="number"], +.settings-field input[type="text"] { + width: 100%; + max-width: 18rem; + box-sizing: border-box; + padding: var(--space-2, 8px) var(--space-3, 12px); +} +.settings-field-hint { + color: var(--text-muted, #777); + font-size: var(--text-sm, 0.875rem); +} +.settings-form-actions { + display: grid; + grid-template-columns: 1fr auto; +} +.settings-form-actions .mps-submit { grid-column: 2; } + /* Render order on the edit page (CSS order property reorders without changing HTML source order): 1. Edit Title, Description, or Visibility (full width, top) diff --git a/make_post_sell/templates/offer.j2 b/make_post_sell/templates/offer.j2 index bd4e581..5d212ba 100644 --- a/make_post_sell/templates/offer.j2 +++ b/make_post_sell/templates/offer.j2 @@ -25,6 +25,38 @@ {% endif %} + {# State-aware notice — explains the current offer state to the viewer + regardless of JS / flash availability. #} + {% if is_declined %} +
+

This offer was below the seller's minimum and was automatically declined — try a higher amount.

+
+ {% elif is_withdrawn %} +
+

This offer was withdrawn by the buyer.

+
+ {% elif is_expired %} +
+

This offer expired before it was accepted.

+
+ {% elif is_paid %} +
+

Paid — this offer is complete.

+
+ {% elif is_accepted %} +
+

Offer accepted{% if request.user and request.user.uuid_str == buyer_id %} — pay now to complete your purchase{% endif %}.

+
+ {% elif can_act %} +
+

It's your turn — accept, counter, or decline below.

+
+ {% elif is_open %} +
+

Waiting on the other party to respond.

+
+ {% endif %} + {% if can_act %}

Your turn

diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 941673c..3700465 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -1174,66 +1174,75 @@ Existing sales honored for download buy purchasers.

Make an Offer

-

Allow buyers to negotiate a price for products in offer mode. Offers ≥ auto-accept threshold are accepted instantly. Offers below auto-decline threshold are silently rejected.

+

Allow buyers to negotiate a price for products in offer mode. + An offer at or above the auto-accept threshold is + accepted instantly. An offer below the auto-decline + threshold is declined automatically — the buyer is + told their offer was too low, but you are not notified (no lowball + pings). Offers in between queue for your review.

-
+ {% include "snippets/csrf.j2" %} -
diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index a9ce974..2f41ac7 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -662,6 +662,47 @@ Dark mode: background-image patterns, colored borders textarea — same styling, box-sizing: border-box .mps-submit — submit button Dark mode: var(--input-bg), var(--input-border), var(--input-text) + +
+
Settings form (stacked field + hint, two-up grid)
+
+ +
+
+ + +
+
+ + + Short hint explaining the field. +
+
+ + + Another hint — wraps below the control, never beside it. +
+
+ + +
+
+
+ +
+
+
.settings-form — grid, gap var(--space-4) +.settings-form-grid — 1 col, 2 cols @640px (repeat(2, minmax(0,1fr))) +.settings-field — grid: label / control / .settings-field-hint stacked +.settings-field--inline — auto 1fr (checkbox + label) +.settings-field--full — grid-column: 1 / -1 (span both columns) +.settings-field-hint — var(--text-muted), var(--text-sm) +.settings-form-actions — 1fr auto; submit pinned right +Grid only — no flex.
+
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 50ab160..d1cc8da 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -5993,6 +5993,67 @@ class TestOfferRoutes(_AuthenticatedBase): ) self.assertEqual(res.status_int, 403) + def test_offer_page_shows_declined_notice(self): + # 10% of list → auto-declined; the detail page must explain why + # without relying on a flash (capability-driven presentation). + product_id = self._make_offer_product(list_price=10000) + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "10.00"}) + offer_id = res.json["offer_id"] + page = self.testapp.get(f"/o/{offer_id}", status=200) + body = page.body.decode() + self.assertIn("offer-state-notice", body) + self.assertIn("automatically declined", body) + + def test_offer_page_shows_accepted_notice_with_paynow(self): + product_id = self._make_offer_product(list_price=10000) + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + # 96% → auto-accepted. + res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "96.00"}) + offer_id = res.json["offer_id"] + page = self.testapp.get(f"/o/{offer_id}", status=200) + body = page.body.decode() + self.assertIn("Offer accepted", body) + self.assertIn("pay now", body.lower()) + # And the pay-now form is present. + self.assertIn(f"/o/{offer_id}/checkout", body) + + def test_offer_page_shows_your_turn_notice_for_seller(self): + product_id = self._make_offer_product(list_price=10000) + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"}) + offer_id = res.json["offer_id"] + # Seller views the pending offer — it's their turn. + self.testapp.get("/log-out") + self.log_in_user(self.user1_creds) + page = self.testapp.get(f"/o/{offer_id}", status=200) + self.assertIn("your turn", page.body.decode().lower()) + + +class TestSettingsFormStyleguide(_AuthenticatedBase): + """MPS-21: the design-system settings-form markup renders on both the + styleguide and the live shop-settings offer section.""" + + def test_styleguide_includes_settings_form(self): + res = self.testapp.get("/styleguide", status=200) + body = res.body.decode() + self.assertIn("settings-form-grid", body) + self.assertIn("settings-field-hint", body) + + def test_shop_settings_offer_section_uses_settings_form(self): + shop = self._create_shop_helper(user_creds=self.user1_creds) + res = self.testapp.get( + f"/s/{shop.uuid_str}/settings", status=200 + ) + body = res.body.decode() + self.assertIn('value="offer-settings"', body) + self.assertIn("settings-form-grid", body) + # The misleading "silently rejected" wording is gone. + self.assertNotIn("silently rejected", body) + class TestOfferNoJsFallback(_AuthenticatedBase): """MPS-21 capability-driven presentation: every offer action works diff --git a/make_post_sell/views/offer.py b/make_post_sell/views/offer.py index 8c8cfc9..ca78f5d 100644 --- a/make_post_sell/views/offer.py +++ b/make_post_sell/views/offer.py @@ -35,6 +35,8 @@ from ..lib.currency import cents_to_dollars from ..models.offer import ( OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, + OFFER_STATE_DECLINED, + OFFER_STATE_WITHDRAWN, OFFER_EVENT_INT_TO_HUMAN, get_offer_by_id, ) @@ -87,6 +89,12 @@ def _serialize_offer(offer): "is_open": offer.is_open, "is_terminal": offer.is_terminal, "is_paid": offer.is_paid, + "is_pending": offer.is_pending, + "is_countered": offer.is_countered, + "is_accepted": offer.is_accepted, + "is_expired": offer.is_expired, + "is_declined": offer.state == OFFER_STATE_DECLINED, + "is_withdrawn": offer.state == OFFER_STATE_WITHDRAWN, "current_amount_in_cents": offer.current_amount_in_cents, "current_amount": offer.current_amount, "current_party": offer.current_party, @@ -184,7 +192,7 @@ def offer_open(request): # Flash message tuned to the offer's resolved state. if offer.is_accepted: flash = ("Offer accepted! Pay now to complete the purchase.", "success") - elif offer.state == 3: # OFFER_STATE_DECLINED (auto-decline threshold) + elif offer.state == OFFER_STATE_DECLINED: # auto-decline threshold flash = ( "Your offer was below the seller's minimum and was automatically " "declined. Try a higher amount.",