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.
-
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)
+
+
.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.",