Price history on product pages, Fresh toggle, media type filters for ring

Price history: expandable <details> section on sellable product pages,
gated to shop editors. SPA-synced via watch.py JSON + watch.js rebuild.
Ticket 09 for future membership-gated access.

Fresh toggle: hides already-watched ring items (display:none vs dimmed),
persisted in localStorage, so loopers see only unwatched content.

Media type filters: Video/Audio/Image/Docs/Other pill buttons below
ring controls. Toggle off a type to hide those rows from the ring.
All on by default, persisted in localStorage. Server-rendered via
Jinja macro + data-media-type attribute, JS-synced during SPA nav.
This commit is contained in:
russell@unturf.com 2026-02-11 19:22:44 -05:00
parent 13aeab7188
commit 1e5fe27d38
7 changed files with 305 additions and 8 deletions

View file

@ -0,0 +1,40 @@
# Membership tiers that unlock price history for buyers
## Context
Price history is now visible to shop owners/editors on product pages (expandable
`<details>` section). The next step: let shops offer membership tiers that grant
buyers access to price history data. This is valuable for wholesalers dealing in
collectibles (pokemon cards, MTG, vintage vinyl, etc.) who need pricing trend
visibility before committing to purchases.
## Membership tiers
- **Monthly** — base price, full price history access
- **Yearly** — suggested ~15% discount over monthly
- **3-Year** — suggested ~30% discount over monthly
Shop owners set their own prices. The platform suggests discount percentages but
doesn't enforce them.
## What members unlock
- Price history table on product pages (same expandable `<details>` UI that
editors see today)
- Price change notifications (future: email digest of price movements across
followed shops)
- Historical price charts (future: sparkline or simple line chart)
## Implementation notes
- New model: `Membership` (user, shop, tier, start/end timestamps, payment ref)
- Gate: `request.user.has_membership(shop)` check alongside the existing
`can_edit_shop` check in `views/product.py` and `views/watch.py`
- Stripe recurring billing integration for membership payments
- Shop settings form section for configuring tier prices and enabling memberships
## Non-goals
- No public price history by default (opt-in via membership)
- No price prediction or "best time to buy" features
- No cross-shop price comparison

View file

@ -2721,7 +2721,7 @@ textarea {
.ring-header-controls {
display: grid;
grid-template-columns: auto auto auto;
grid-template-columns: auto auto auto auto;
gap: 16px;
justify-content: end;
align-items: center;
@ -2808,6 +2808,63 @@ textarea {
.related-content-row-watched {
opacity: 0.45;
}
.related-content.fresh-mode .related-content-row-watched {
display: none;
}
/* Media type filter — hide rows by data-media-type */
.related-content.hide-video .related-content-row[data-media-type="video"] { display: none; }
.related-content.hide-audio .related-content-row[data-media-type="audio"] { display: none; }
.related-content.hide-image .related-content-row[data-media-type="image"] { display: none; }
.related-content.hide-document .related-content-row[data-media-type="document"] { display: none; }
.related-content.hide-other .related-content-row[data-media-type="other"] { display: none; }
.watch-fresh-label {
display: grid;
grid-template-columns: auto auto;
gap: 6px;
align-items: center;
font-size: 0.85em;
cursor: pointer;
}
.watch-fresh-label input {
display: none;
}
.watch-fresh-label input:checked + .autoplay-slider {
background: var(--blue-color, #98b6fa);
}
.watch-fresh-label input:checked + .autoplay-slider::after {
transform: translateX(16px);
}
.fresh-label-text {
font-size: 0.85em;
color: var(--text-muted, #999);
}
/* Media type filter bar */
.ring-filter-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, auto));
grid-auto-flow: column;
gap: 6px;
justify-content: end;
align-items: center;
}
.ring-filter-btn {
font-size: 0.7em;
padding: 2px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: 12px;
background: transparent;
color: var(--text-muted, #999);
cursor: pointer;
white-space: nowrap;
}
.ring-filter-btn.active {
background: var(--blue-color, #5871ad);
color: white;
border-color: var(--blue-color, #5871ad);
}
.related-content-row-current {
background-color: var(--blue-color, #5871ad);
color: white;

View file

@ -140,6 +140,44 @@
if (saved !== null) autoplayEnabled = saved === '1';
} catch (e) {}
// Fresh mode preference (hide watched items in ring)
var freshMode = false;
try {
var savedFresh = localStorage.getItem('watchFreshMode');
if (savedFresh !== null) freshMode = savedFresh === '1';
} catch (e) {}
// Media type filter preferences (all on by default)
var FILTER_TYPES = ['video', 'audio', 'image', 'document', 'other'];
var FILTER_LABELS = {video: 'Video', audio: 'Audio', image: 'Image', document: 'Docs', other: 'Other'};
var mediaFilters = {};
FILTER_TYPES.forEach(function(t) { mediaFilters[t] = true; });
try {
var savedFilters = localStorage.getItem('watchMediaFilters');
if (savedFilters) {
var parsed = JSON.parse(savedFilters);
FILTER_TYPES.forEach(function(t) {
if (t in parsed) mediaFilters[t] = parsed[t];
});
}
} catch (e) {}
function saveMediaFilters() {
try { localStorage.setItem('watchMediaFilters', JSON.stringify(mediaFilters)); } catch (e) {}
}
function applyMediaFilterClasses(container) {
if (!container) container = document.querySelector('.related-content');
if (!container) return;
FILTER_TYPES.forEach(function(t) {
if (mediaFilters[t]) {
container.classList.remove('hide-' + t);
} else {
container.classList.add('hide-' + t);
}
});
}
// Set up autoplay toggle
var autoplayToggle = document.getElementById('watch-autoplay-toggle');
if (autoplayToggle) {
@ -650,6 +688,34 @@
viewCountEl.textContent = data.human_view_count || '';
}
// Update price history (mod-only)
var priceDetails = document.querySelector('.price-history-details');
if (data.price_history && data.price_history.length > 1) {
var phHtml = '<summary>Price History (' + data.price_history.length + ')</summary>'
+ '<div class="analytics-table-wrap"><table class="analytics-table">'
+ '<thead><tr><th>Price</th><th>When</th></tr></thead><tbody>';
data.price_history.forEach(function(ph) {
phHtml += '<tr' + (ph.is_current ? ' class="price-history-current"' : '') + '>'
+ '<td>' + escapeHtml(ph.price_formatted) + '</td>'
+ '<td title="' + escapeHtml(ph.datetime) + '">' + escapeHtml(ph.ago) + '</td>'
+ '</tr>';
});
phHtml += '</tbody></table></div>';
if (priceDetails) {
priceDetails.innerHTML = phHtml;
} else {
var well = document.querySelector('.product-right .well');
if (well) {
var details = document.createElement('details');
details.className = 'price-history-details';
details.innerHTML = phHtml;
well.parentNode.insertBefore(details, well.nextSibling);
}
}
} else if (priceDetails) {
priceDetails.remove();
}
// Update signal data attributes for signals.js
var signalRoot = document.querySelector('[data-signal-product-id]');
if (signalRoot) {
@ -686,6 +752,7 @@
var autoplayChecked = autoplayEnabled ? ' checked' : '';
var directionChecked = ringDirection === -1 ? ' checked' : '';
var freshChecked = freshMode ? ' checked' : '';
var modBadge = isMod ? '<span class="ring-mod-badge">mod</span>' : '';
var loopsBadge = ringLoops > 0
? '<span id="ring-loops-badge" class="ring-loops-badge">+' + ringLoops + '</span>'
@ -698,7 +765,11 @@
+ '<span id="ring-progress" class="ring-progress"></span>'
+ '</div>'
+ '<div class="ring-header-controls">'
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>'
+ '<label class="watch-fresh-label js-only">'
+ '<span class="fresh-label-text">Fresh</span>'
+ '<input type="checkbox" id="watch-fresh-toggle"' + freshChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '<label class="watch-direction-label js-only">'
+ '<span class="direction-label-text">Reverse</span>'
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
@ -709,7 +780,22 @@
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '</div></div>';
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>'
+ '</div>'
+ '<div class="ring-filter-controls js-only">';
FILTER_TYPES.forEach(function(t) {
var activeClass = mediaFilters[t] ? ' active' : '';
headerHtml += '<button class="ring-filter-btn' + activeClass + '" data-filter-type="' + t + '">'
+ FILTER_LABELS[t] + '</button>';
});
headerHtml += '</div></div>';
// Apply fresh-mode class to container
if (freshMode) {
container.classList.add('fresh-mode');
} else {
container.classList.remove('fresh-mode');
}
if (!related || related.length === 0) {
container.innerHTML = headerHtml + '<p>No more items</p>';
@ -746,7 +832,8 @@
html += '</div>';
}
html += '<div class="related-content-row' + dimClass + prevClass + '">';
var mediaType = item.media_type || 'other';
html += '<div class="related-content-row' + dimClass + prevClass + '" data-media-type="' + mediaType + '">';
html += '<span class="related-content-index">' + offset + '</span>';
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
if (item.thumbnail_url) {
@ -761,6 +848,7 @@
});
container.innerHTML = html;
applyMediaFilterClasses(container);
rebindToggles();
}
@ -790,6 +878,35 @@
});
}
var freshToggle = document.getElementById('watch-fresh-toggle');
if (freshToggle) {
freshToggle.checked = freshMode;
freshToggle.addEventListener('change', function() {
freshMode = this.checked;
try { localStorage.setItem('watchFreshMode', freshMode ? '1' : '0'); } catch (e) {}
var rc = document.querySelector('.related-content');
if (rc) {
if (freshMode) {
rc.classList.add('fresh-mode');
} else {
rc.classList.remove('fresh-mode');
}
}
});
}
// Media type filter buttons
var filterBtns = document.querySelectorAll('.ring-filter-btn');
for (var fi = 0; fi < filterBtns.length; fi++) {
filterBtns[fi].addEventListener('click', function() {
var type = this.getAttribute('data-filter-type');
mediaFilters[type] = !mediaFilters[type];
this.classList.toggle('active', mediaFilters[type]);
saveMediaFilters();
applyMediaFilterClasses();
});
}
updateProgressDisplay();
}
@ -1508,7 +1625,14 @@
}
}
dimWatchedInDOM();
// Apply fresh-mode and media filters to server-rendered related content
if (freshMode) {
var rc = document.querySelector('.related-content');
if (rc) rc.classList.add('fresh-mode');
}
applyMediaFilterClasses();
updateProgressDisplay();
rebindToggles();
renderQueue();
preloadNext();

View file

@ -242,7 +242,31 @@
This product is not ready for sale yet.
{% endif %}
<div>
</div>
{% if price_history | length > 1 %}
<details class="price-history-details">
<summary>Price History ({{ price_history | length }})</summary>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Price</th>
<th>When</th>
</tr>
</thead>
<tbody>
{% for ph in price_history %}
<tr{% if ph.is_current %} class="price-history-current"{% endif %}>
<td>{{ ph.price_formatted }}</td>
<td title="{{ ph.datetime }}">{{ ph.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
<div id="watch-queue" class="js-only" style="display:none">
<h4>Queue</h4>

View file

@ -1,4 +1,9 @@
{% if related_products %}
{% set _video_ext = ["mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "m4v", "ogv"] %}
{% set _audio_ext = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma", "opus"] %}
{% set _image_ext = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"] %}
{% set _doc_ext = ["pdf"] %}
{% macro media_type(prod) %}{% set ext = prod.extensions.get("product", "")|lower %}{% if ext in _video_ext %}video{% elif ext in _audio_ext %}audio{% elif ext in _image_ext %}image{% elif ext in _doc_ext %}document{% else %}other{% endif %}{% endmacro %}
<div class="related-content">
<div class="related-content-header">
<div class="ring-header-info">
@ -9,7 +14,11 @@
</div>
{% if request.shop.watch_mode_enabled %}
<div class="ring-header-controls">
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>
<label class="watch-fresh-label js-only">
<span class="fresh-label-text">Fresh</span>
<input type="checkbox" id="watch-fresh-toggle" />
<span class="autoplay-slider"></span>
</label>
<label class="watch-direction-label js-only">
<span class="direction-label-text">Reverse</span>
<input type="checkbox" id="watch-direction-toggle" />
@ -20,6 +29,14 @@
<input type="checkbox" id="watch-autoplay-toggle" checked />
<span class="autoplay-slider"></span>
</label>
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>
</div>
<div class="ring-filter-controls js-only">
<button class="ring-filter-btn active" data-filter-type="video">Video</button>
<button class="ring-filter-btn active" data-filter-type="audio">Audio</button>
<button class="ring-filter-btn active" data-filter-type="image">Image</button>
<button class="ring-filter-btn active" data-filter-type="document">Docs</button>
<button class="ring-filter-btn active" data-filter-type="other">Other</button>
</div>
{% endif %}
</div>
@ -50,7 +67,7 @@
title="Add to queue">+</button>
</div>
{% endif %}
<div class="related-content-row{% if offset < 0 %} related-content-row-prev{% endif %}">
<div class="related-content-row{% if offset < 0 %} related-content-row-prev{% endif %}" data-media-type="{{ media_type(related)|trim }}">
<span class="related-content-index">{{ offset }}</span>
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
{% if "thumbnail1" in related.extensions %}

View file

@ -102,6 +102,21 @@ def product(request):
else:
related_products = get_related_products(product)
price_history = []
if (
product.is_sellable
and request.user
and request.user.can_edit_shop(product.shop)
):
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(ph.created_timestamp),
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
"is_current": i == 0,
})
return {
"product": product,
"product_size": product_size,
@ -109,6 +124,7 @@ def product(request):
"comments": comments,
"shop": product.shop,
"related_products": related_products,
"price_history": price_history,
}

View file

@ -2,6 +2,8 @@ from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products, get_ring_related_products
from ..models.shop import get_shop_by_id
from ..lib.currency import cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
@view_config(route_name="watch_json", renderer="json")
@ -148,7 +150,19 @@ def watch_json(request):
if request.user and request.user.authenticated:
is_mod = request.user.can_edit_shop(request.shop)
return {
# Build price history for mod users
price_history = []
if is_mod and product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(ph.created_timestamp),
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
"is_current": i == 0,
})
result = {
"product_id": str(product.id),
"shop_id": str(product.shop_id),
"title": product.title,
@ -170,6 +184,11 @@ def watch_json(request):
"is_mod": is_mod,
}
if price_history:
result["price_history"] = price_history
return result
@view_config(route_name="discovery_ring_json", renderer="json")
def discovery_ring_json(request):