MPS-21: offer-page state notice + design-system Make-an-Offer settings form
- offer.j2 renders a state-aware notice (.offer-state-notice, .alert variants) above the action forms: declined / withdrawn / expired / accepted (+ pay-now hint for buyer) / your-turn / waiting — so the viewer always understands the offer state without depending on a flash a JS redirect would skip. _serialize_offer now exposes is_declined, is_withdrawn, is_expired, is_pending, is_countered, is_accepted. - Shop-settings Make-an-Offer section restyled with the new .settings-form / .settings-form-grid / .settings-field / .settings-field-hint system (two-up grid, per-field hints, submit pinned right). Added a styleguide entry under #forms. - Reworded the section blurb: auto-declined offers are NOT silent — the buyer is told their offer was too low; only the seller isn't pinged. - Fixed --color-text-muted typo (→ --text-muted) on .offer-js-flash-info. Tests: TestOfferRoutes gains 3 state-notice render tests; TestSettingsFormStyleguide covers the styleguide + live shop-settings markup and asserts the old wording is gone. 978 passed.
This commit is contained in:
parent
38b5739ce7
commit
227fc4e564
8 changed files with 266 additions and 49 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,38 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# State-aware notice — explains the current offer state to the viewer
|
||||
regardless of JS / flash availability. #}
|
||||
{% if is_declined %}
|
||||
<div class="alert alert-error offer-state-notice" name="alert">
|
||||
<p class="alert-message">This offer was below the seller's minimum and was automatically declined — try a higher amount.</p>
|
||||
</div>
|
||||
{% elif is_withdrawn %}
|
||||
<div class="alert alert-warning offer-state-notice" name="alert">
|
||||
<p class="alert-message">This offer was withdrawn by the buyer.</p>
|
||||
</div>
|
||||
{% elif is_expired %}
|
||||
<div class="alert alert-warning offer-state-notice" name="alert">
|
||||
<p class="alert-message">This offer expired before it was accepted.</p>
|
||||
</div>
|
||||
{% elif is_paid %}
|
||||
<div class="alert alert-success offer-state-notice" name="alert">
|
||||
<p class="alert-message">Paid — this offer is complete.</p>
|
||||
</div>
|
||||
{% elif is_accepted %}
|
||||
<div class="alert alert-success offer-state-notice" name="alert">
|
||||
<p class="alert-message">Offer accepted{% if request.user and request.user.uuid_str == buyer_id %} — pay now to complete your purchase{% endif %}.</p>
|
||||
</div>
|
||||
{% elif can_act %}
|
||||
<div class="alert alert-info offer-state-notice" name="alert">
|
||||
<p class="alert-message">It's your turn — accept, counter, or decline below.</p>
|
||||
</div>
|
||||
{% elif is_open %}
|
||||
<div class="alert alert-info offer-state-notice" name="alert">
|
||||
<p class="alert-message">Waiting on the other party to respond.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_act %}
|
||||
<section class="offer-actions well">
|
||||
<h3 class="type-title">Your turn</h3>
|
||||
|
|
|
|||
|
|
@ -1174,66 +1174,75 @@ Existing sales honored for download buy purchasers.
|
|||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
<h3>Make an Offer</h3>
|
||||
<p>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.</p>
|
||||
<p>Allow buyers to negotiate a price for products in offer mode.
|
||||
An offer at or above the <strong>auto-accept threshold</strong> is
|
||||
accepted instantly. An offer below the <strong>auto-decline
|
||||
threshold</strong> 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.</p>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.uuid_str }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<form method="post" action="/s/{{ request.shop.uuid_str }}/settings" class="settings-form" onsubmit="submit.disabled = true; return true;">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<input type="hidden" name="form_section" value="offer-settings" />
|
||||
|
||||
<label>
|
||||
<label class="settings-field settings-field--inline settings-field--full">
|
||||
<input type="checkbox" name="offer_enabled_checkbox"
|
||||
{% if request.shop.offer_enabled %}checked{% endif %} />
|
||||
Enable make-an-offer (shop-wide default)
|
||||
<span>Enable make-an-offer (shop-wide default)</span>
|
||||
</label>
|
||||
|
||||
<br /><br />
|
||||
<div class="settings-form-grid">
|
||||
<div class="settings-field">
|
||||
<label for="offer_auto_accept">Auto-accept threshold (% of list price)</label>
|
||||
<input type="number" name="offer_auto_accept_threshold_pct" id="offer_auto_accept"
|
||||
value="{{ request.shop.offer_auto_accept_threshold_pct }}"
|
||||
min="0" max="100" step="1" />
|
||||
</div>
|
||||
|
||||
<label for="offer_auto_accept">Auto-accept threshold (% of list price)</label>
|
||||
<input type="number" name="offer_auto_accept_threshold_pct" id="offer_auto_accept"
|
||||
value="{{ request.shop.offer_auto_accept_threshold_pct }}"
|
||||
min="0" max="100" step="1" class="mps-text-input" />
|
||||
<div class="settings-field">
|
||||
<label for="offer_auto_decline">Auto-decline threshold (% of list price)</label>
|
||||
<input type="number" name="offer_auto_decline_threshold_pct" id="offer_auto_decline"
|
||||
value="{{ request.shop.offer_auto_decline_threshold_pct }}"
|
||||
min="0" max="100" step="1" />
|
||||
<small class="settings-field-hint">Must be strictly below auto-accept; otherwise auto-clamped.</small>
|
||||
</div>
|
||||
|
||||
<br /><br />
|
||||
<div class="settings-field">
|
||||
<label for="offer_min">Minimum offer amount ($)</label>
|
||||
<input type="number" name="offer_min" id="offer_min"
|
||||
value="{% if request.shop.offer_min_in_cents %}{{ '%.2f'|format(request.shop.offer_min_in_cents / 100.0) }}{% endif %}"
|
||||
min="0" step="0.01" placeholder="No floor" />
|
||||
<small class="settings-field-hint">A hard dollar floor, independent of the % thresholds. Leave blank for no floor.</small>
|
||||
</div>
|
||||
|
||||
<label for="offer_auto_decline">Auto-decline threshold (% of list price)</label>
|
||||
<input type="number" name="offer_auto_decline_threshold_pct" id="offer_auto_decline"
|
||||
value="{{ request.shop.offer_auto_decline_threshold_pct }}"
|
||||
min="0" max="100" step="1" class="mps-text-input" />
|
||||
<small>Must be strictly below auto-accept; otherwise auto-clamped.</small>
|
||||
<div class="settings-field">
|
||||
<label for="offer_expiration">Offer expiration (hours)</label>
|
||||
<input type="number" name="offer_expiration_hours" id="offer_expiration"
|
||||
value="{{ request.shop.offer_expiration_hours }}"
|
||||
min="1" max="8760" step="1" />
|
||||
<small class="settings-field-hint">How long a queued offer waits before it auto-expires.</small>
|
||||
</div>
|
||||
|
||||
<br /><br />
|
||||
<div class="settings-field">
|
||||
<label for="offer_max_rounds">Max counter rounds</label>
|
||||
<input type="number" name="offer_max_rounds" id="offer_max_rounds"
|
||||
value="{{ request.shop.offer_max_rounds }}"
|
||||
min="1" max="100" step="1" />
|
||||
<small class="settings-field-hint">How many back-and-forth counters are allowed before the offer is forced to a yes/no.</small>
|
||||
</div>
|
||||
|
||||
<label for="offer_min">Minimum offer amount ($)</label>
|
||||
<input type="number" name="offer_min" id="offer_min"
|
||||
value="{% if request.shop.offer_min_in_cents %}{{ '%.2f'|format(request.shop.offer_min_in_cents / 100.0) }}{% endif %}"
|
||||
min="0" step="0.01" class="mps-text-input"
|
||||
placeholder="No floor" />
|
||||
<div class="settings-field">
|
||||
<label for="offer_min_age">Minimum buyer account age (hours)</label>
|
||||
<input type="number" name="offer_min_buyer_account_age_hours" id="offer_min_age"
|
||||
value="{{ request.shop.offer_min_buyer_account_age_hours }}"
|
||||
min="0" max="8760" step="1" placeholder="0 = open" />
|
||||
<small class="settings-field-hint">Block brand-new accounts from making offers. 0 = anyone may offer.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="offer_expiration">Offer expiration (hours)</label>
|
||||
<input type="number" name="offer_expiration_hours" id="offer_expiration"
|
||||
value="{{ request.shop.offer_expiration_hours }}"
|
||||
min="1" max="8760" step="1" class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="offer_max_rounds">Max counter rounds</label>
|
||||
<input type="number" name="offer_max_rounds" id="offer_max_rounds"
|
||||
value="{{ request.shop.offer_max_rounds }}"
|
||||
min="1" max="100" step="1" class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="offer_min_age">Minimum buyer account age (hours)</label>
|
||||
<input type="number" name="offer_min_buyer_account_age_hours" id="offer_min_age"
|
||||
value="{{ request.shop.offer_min_buyer_account_age_hours }}"
|
||||
min="0" max="8760" step="1" class="mps-text-input"
|
||||
placeholder="0 = open" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
<div class="settings-form-actions">
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -662,6 +662,47 @@ Dark mode: background-image patterns, colored borders</div>
|
|||
textarea — same styling, box-sizing: border-box
|
||||
.mps-submit — submit button
|
||||
Dark mode: var(--input-bg), var(--input-border), var(--input-text)</div>
|
||||
|
||||
<div class="sg-subsection">
|
||||
<div class="sg-label">Settings form (stacked field + hint, two-up grid)</div>
|
||||
<form class="settings-form well" onsubmit="return false;">
|
||||
<label class="settings-field settings-field--inline settings-field--full">
|
||||
<input type="checkbox" checked />
|
||||
<span>Enable this feature (section-wide default)</span>
|
||||
</label>
|
||||
<div class="settings-form-grid">
|
||||
<div class="settings-field">
|
||||
<label>Threshold (%)</label>
|
||||
<input type="number" value="95" min="0" max="100" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Expiration (hours)</label>
|
||||
<input type="number" value="168" min="1" />
|
||||
<small class="settings-field-hint">Short hint explaining the field.</small>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Minimum amount ($)</label>
|
||||
<input type="number" placeholder="No floor" />
|
||||
<small class="settings-field-hint">Another hint — wraps below the control, never beside it.</small>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Max rounds</label>
|
||||
<input type="number" value="3" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-form-actions">
|
||||
<input type="submit" class="mps-submit" value="Save Settings" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="sg-code">.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.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue