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:
parent
7fe6b56cdb
commit
dc79d13358
12 changed files with 192 additions and 14 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) — 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 — 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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue