feat: MPS-24 Phase 2.5 — product page polish (description wrap + price-history toggle)

Two product-page issues surfaced while shopping printableprompts on
mobile. Both fixed in one commit since they're tightly scoped to the
product page experience.

Description text clipping the right edge on mobile:

- .content-card uses CSS Grid but its grid items had default
  min-width: auto, so they expanded to their content's intrinsic
  width — long unbreakable tokens (URLs, etc.) pushed the card
  wider than the viewport. Then .content's overflow-x: clip
  silently hid the right side instead of wrapping the text.
- Add min-width: 0 + overflow-wrap: break-word to .content-card,
  .content-card-header, .content-card-body. Add word-break:
  break-word to inner <a> / <p> so URLs hyphenate at any character.

Price history shown by default:

- The price history table (commit 1e5fe27, 2026-02-11) was always
  visible to anyone who could edit the shop. Operator feedback:
  "wait for a sale" psychology hurts conversions; shoppers
  shouldn't see a timeline of past prices.
- New Shop.show_price_history Boolean (default False, server-default
  "0") with idempotent Alembic migration c792642911e2.
- Toggle lives in the existing ribbon-settings form section.
- views/product.py (public view) + views/watch.py JSON gate the
  price_history list on the toggle. Template product.j2 also gates
  rendering as belt-and-suspenders.
- Edit page (also views/product.py:product_edit) intentionally
  remains always-on — the operator needs price audit access from
  their own admin surface regardless of the shopper-facing toggle.
- New shop matrix entry in docs/architecture.md.
- 2 new functional tests (default-off + toggle round-trip).

1090 tests passing.
This commit is contained in:
russell@unturf.com 2026-05-15 13:54:24 -04:00
parent 7fe6b56cdb
commit dc79d13358
No known key found for this signature in database
12 changed files with 192 additions and 14 deletions

View file

@ -192,6 +192,7 @@ mps_page_session (raw rows)
|---------|-------------|--------------|---------|
| Watch mode | `shop.watch_mode_enabled` | `ribbon-settings` | Off |
| Sandbox mode | `shop.sandbox_mode` | `ribbon-settings` | Off |
| Show price history (MPS-24 Phase 2.5) | `shop.show_price_history` | `ribbon-settings` | Off |
| Show dates | `shop.show_dates` | `ribbon-settings` | On |
| Grid lanes | `shop.grid_lanes_enabled` | `ribbon-settings` | Off |
| Color filter | `shop.color_filter` | `ribbon-settings` | 0 (none) |

View file

@ -319,6 +319,30 @@ picker is Phase 2).
| `tests/test_models.py` | `TestTagSuggestPureFunctions` — 11 unit tests over tokenize / stem / cluster |
| `tests/test_functional.py` | `test_suggest_clusters_renders_candidates`, `test_apply_suggestion_creates_tag_and_attaches_products`, `test_dismiss_suggestion_adds_to_stopwords`, `test_apply_suggestion_rejects_empty_input` |
### Phase 2.5 — product page polish: description wrap + price-history toggle (shipped 2026-05-15)
Two product-page bugs surfaced while shopping printableprompts:
- **Description text clipping right edge on mobile.** `.content-card`
uses CSS Grid but its grid items had default `min-width: auto`
they expanded to their content's intrinsic width, pushing the card
past the viewport. `.content`'s `overflow-x: clip` then silently
hid the right side of the text instead of wrapping. Fix: `min-width:
0` + `overflow-wrap: break-word` on `.content-card`, `.content-card-
header`, `.content-card-body`, plus `word-break: break-word` on the
inner `<a>`/`<p>` elements so long URLs hyphenate at any character.
- **Price history shown by default.** The price history table
(commit `1e5fe27`, 2026-02-11) was always visible to anyone who
could edit the shop. Operator feedback: "wait for a sale" psychology
hurts conversions; shoppers shouldn't see a timeline of past prices.
Added `Shop.show_price_history` Boolean (default `False`, server-
default `"0"`) with idempotent Alembic migration `c792642911e2`.
Toggle lives in `ribbon-settings` form. View + watch JSON now gate
the `price_history` list on the toggle; template gates rendering
separately as belt-and-suspenders. When off (default), nobody sees
the table — including the operator on their own product page.
Operator can still review history in shop analytics.
### Phase 2.4 — SPA bulk tagger + Netflix-style lanes (shipped 2026-05-15)
Two improvements that compound for the operator workflow:

View file

@ -241,6 +241,13 @@ class Shop(RBase, Base):
UnicodeText, nullable=False, default="", server_default=""
)
# Show full price-history table on product pages. Off by default —
# most shops just want shoppers to see the current price, not a
# historical timeline that can read as "wait for a sale."
show_price_history = Column(
Boolean, nullable=False, default=False, server_default="0"
)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))

View file

@ -0,0 +1,44 @@
"""add shop show_price_history column
Revision ID: c792642911e2
Revises: 2dbdb8c89e66
Create Date: 2026-05-15 13:09:49.673592
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c792642911e2"
down_revision = "2dbdb8c89e66"
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
# Default off — most shops just want shoppers to see the current
# price, not a timeline that reads as "wait for a sale". The
# original price-history feature shipped in commit 1e5fe27
# (2026-02-11) was always-on.
if not _column_exists("mps_shop", "show_price_history"):
op.add_column(
"mps_shop",
sa.Column(
"show_price_history",
sa.Boolean(),
nullable=False,
server_default="0",
),
)
def downgrade():
if _column_exists("mps_shop", "show_price_history"):
op.drop_column("mps_shop", "show_price_history")

View file

@ -2413,6 +2413,14 @@ section.checkout-page .well {
margin-bottom: var(--space-4, 16px);
display: grid;
gap: var(--space-3, 12px);
/* min-width: 0 lets this grid item shrink below its content's
intrinsic width (default: auto). Without it, descriptions with
long links or unbreakable tokens push the card wider than the
viewport and since .content has overflow-x: clip, the right
edge silently gets cut off instead of wrapping. */
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
}
.content-card-header {
@ -2422,9 +2430,25 @@ section.checkout-page .well {
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #777);
min-width: 0;
overflow-wrap: break-word;
}
.content-card-body { margin: 0; }
.content-card-body {
margin: 0;
min-width: 0;
overflow-wrap: break-word;
}
/* Long URLs / unbroken tokens inside rendered markdown still need to
wrap break-word lets the browser hyphenate them at any character. */
.content-card-body a,
.content-card-body p,
.product-description a,
.product-description p {
overflow-wrap: break-word;
word-break: break-word;
}
.content-card-meta {
margin: 0;

View file

@ -73,8 +73,7 @@
<br />
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a>
{% endif %}
<br>
<a href="{{ product.absolute_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a>
{# Shop name intentionally omitted — see home.j2 comment. #}
</div>
{% endif %}

View file

@ -1086,6 +1086,22 @@
<br />
<br />
{# MPS-24 Phase 2.5: price history toggle. Default off. #}
<label>Price History on Product Pages</label>
<br />
<input type="radio" name="show_price_history" id="show_price_history_on" value="1"
{% if request.shop.show_price_history %}checked{% endif %} />
<label for="show_price_history_on" class="inline-label">Show price history table</label>
<br />
<input type="radio" name="show_price_history" id="show_price_history_off" value="0"
{% if not request.shop.show_price_history %}checked{% endif %} />
<label for="show_price_history_off" class="inline-label">Hide (only show current price) &mdash; default</label>
<br />
<small class="note-text">When off, product pages display only the current price. Turn on to reveal a timeline of past prices. Most shops keep this off &mdash; a visible history can read as "wait for a sale" and slow conversions.</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />

View file

@ -834,16 +834,12 @@ Dark mode overrides via --notice-*-bg and --notice-*-border tokens.</div>
<b><a href="#">Product Title</a></b>
<br>
<a href="#">$19.99</a>
<br/>
<a href="#">Shop Name</a>
</div>
<div class="serp-item">
<div style="background: var(--input-border, #ddd); border-radius: 4px; width: 100%; height: 120px; display: grid; place-items: center; opacity: 0.5;">Image</div>
<b><a href="#">Another Product</a></b>
<br>
<a href="#">$7.50</a>
<br/>
<a href="#">Shop Name</a>
</div>
</section>
</div>

View file

@ -8812,3 +8812,49 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
# NO X-Requested-With
)
self.assertEqual(res.status_int, 302)
# --- MPS-24 Phase 2.5: show_price_history shop toggle -------------
def test_show_price_history_default_off(self):
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "price-hist-default"},
)
self.dbsession.expire(shop)
self.assertFalse(shop.show_price_history)
def test_show_price_history_toggle_via_ribbon_settings(self):
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": "price-hist-toggle"},
)
# Turn it on
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"show_price_history": "1",
"submit": "Save Settings",
},
)
if res.status_int == 302:
res = res.follow()
self.assertIn("price history is now shown", res.body.decode())
self.dbsession.expire(shop)
self.assertTrue(shop.show_price_history)
# Turn it off
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"show_price_history": "0",
"submit": "Save Settings",
},
)
if res.status_int == 302:
res = res.follow()
self.assertIn("price history is now hidden", res.body.decode())
self.dbsession.expire(shop)
self.assertFalse(shop.show_price_history)

View file

@ -104,12 +104,14 @@ def product(request):
else:
related_products = get_related_products(product)
# Price history is gated by shop.show_price_history (off by default
# per operator feedback — "wait for a sale" psychology kills sales).
# When the toggle is ON, everyone sees the table. When OFF (default),
# the table is hidden for both shoppers and operators (operator can
# still review historical prices in shop analytics). The template
# gates rendering separately as belt-and-suspenders.
price_history = []
if (
product.is_sellable
and request.user
and request.user.can_edit_shop(product.shop)
):
if product.shop.show_price_history and product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
@ -741,6 +743,11 @@ def product_edit(request):
inv.shop_location.id: inv.quantity for inv in product.inventories
}
# Edit page is operator-only — always show price history here so
# the operator can reference what they've set. The shop-wide toggle
# (shop.show_price_history) gates only shopper-facing surfaces:
# the public product view (above) and watch.py JSON. Operators
# need their own price audit trail regardless of the toggle.
price_history = []
if product.is_sellable:
history_rows = product.price_history.limit(20).all()

View file

@ -935,6 +935,20 @@ def shop_settings(request):
)
)
# MPS-24 Phase 2.5: show_price_history toggle (default off).
show_price_history_value = request.params.get("show_price_history")
if show_price_history_value is not None:
show_price_history = int(show_price_history_value) == 1
if shop.show_price_history != show_price_history:
shop.show_price_history = show_price_history
status = "shown" if show_price_history else "hidden"
request.session.flash(
(
f"Product page price history is now {status}.",
"success",
)
)
# Handle public sales stats setting
public_sales_stats_value = request.params.get("public_sales_stats")
if public_sales_stats_value is not None:

View file

@ -200,9 +200,9 @@ def watch_json(request):
if request.user and request.user.authenticated:
is_mod = request.user.can_edit_shop(request.shop)
# Build price history for mod users
# MPS-24 Phase 2.5: price history gated by shop.show_price_history.
price_history = []
if is_mod and product.is_sellable:
if request.shop.show_price_history and product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({