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:
parent
13aeab7188
commit
1e5fe27d38
7 changed files with 305 additions and 8 deletions
40
docs/tickets/an-algo/09-membership-price-history.md
Normal file
40
docs/tickets/an-algo/09-membership-price-history.md
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ▶</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 ▶</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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ▶</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 ▶</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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue