diff --git a/docs/tickets/an-algo/03-comment-sentiment-analysis.md b/docs/tickets/an-algo/03-comment-sentiment-analysis.md index cd3d604..2be02a9 100644 --- a/docs/tickets/an-algo/03-comment-sentiment-analysis.md +++ b/docs/tickets/an-algo/03-comment-sentiment-analysis.md @@ -1,12 +1,16 @@ # Add sentiment analysis to comments -Integrate sentiment analysis for comments to support the Love pillar: +Lightweight, self-contained feature. Comments exist, sentiment scoring doesn't. + +## Scope - Classify comments as positive/neutral/negative -- Products with >7:1 positive:negative ratio get "Love Verified" badge -- Weight thoughtful 42+ word comments 14x more than emoji-only or single-word reactions -- Feed sentiment scores into the engagement weighting system (positive comments = 7x, neutral = 1x, negative = 0x) -- Use a lightweight local model or rule-based approach to avoid external API dependency +- Use a lightweight local rule-based approach (no external API dependency) - Store sentiment score per comment in DB +- Feed sentiment data into existing analytics dashboard (MPS-3) -Blocked by: engagement signal weighting system. +## Notes + +- No longer blocked by engagement signal weighting — analytics is live (MPS-3) +- Products with high positive:negative ratio could surface in analytics +- Keep it simple: rule-based first, upgrade to ML later if needed diff --git a/docs/tickets/an-algo/04-love-gifts-tipping.md b/docs/tickets/an-algo/04-love-gifts-tipping.md index bb20ea5..c343ac2 100644 --- a/docs/tickets/an-algo/04-love-gifts-tipping.md +++ b/docs/tickets/an-algo/04-love-gifts-tipping.md @@ -1,11 +1,14 @@ # Implement Love Gifts (voluntary tipping system) -Add voluntary tip/gift system beyond purchases: +**Decision**: Descoped. xmrchat.com already solves tipping better and accepts +more coins. If revisited, scope to internal shop owners and power shoppers or +paying members only — not a public feature. + +## Original scope (archived) - Love Gifts: Tips from 1-420 units of currency - No platform cut on the first $0.69 (pure creator love) - UI: Gift button on product pages and in watch mode - Integrate with existing payment infrastructure (Stripe, PayPal, crypto) -- Track gifts in DB for engagement scoring (counts as strong positive signal) -- Update watch.js SPA to include gift button in updatePageContent() -- Server-rendered gift button for non-JS users + +## Status: ON HOLD — revisit when membership tiers exist diff --git a/docs/tickets/an-algo/07-pricing-history.md b/docs/tickets/an-algo/07-pricing-history.md index da459a2..258acf9 100644 --- a/docs/tickets/an-algo/07-pricing-history.md +++ b/docs/tickets/an-algo/07-pricing-history.md @@ -1,9 +1,17 @@ # Build transparent pricing history display -Show price history for products to enforce Truth: +Price model already exists (`price_history` relationship on Product). Needs display. -- Display 14-day, 21-day, and 42-day price history on product pages -- Track all price changes in a price_history table (product_id, price, timestamp) +## Scope + +- Visible to **shop owners** in settings/analytics — not public by default +- Optionally visible to paying members (future membership tier gate) +- Display price timeline on product pages (expandable section) - No dark patterns — "was $420, now $7!" must be verifiable from history -- Simple visual: small text or expandable section showing price timeline -- If product has digital scarcity limits, enforce and display actual remaining count + +## Implementation + +- `price_history` relationship already on Product model — just wire up display +- Small, honest, useful: show price change dates and amounts +- Add to analytics dashboard for shop owner view +- Keep public display behind a toggle (off by default) diff --git a/make_post_sell/GIT_HASH b/make_post_sell/GIT_HASH index 8d488a5..bd98d66 100644 --- a/make_post_sell/GIT_HASH +++ b/make_post_sell/GIT_HASH @@ -1 +1 @@ -e1a8b2f \ No newline at end of file +2f5498a \ No newline at end of file diff --git a/make_post_sell/lib/sentiment.py b/make_post_sell/lib/sentiment.py new file mode 100644 index 0000000..aff4d1e --- /dev/null +++ b/make_post_sell/lib/sentiment.py @@ -0,0 +1,98 @@ +""" +Rule-based comment sentiment classifier. + +No external dependencies — uses only the ``re`` module. Returns −1 +(negative), 0 (neutral), or 1 (positive) for a given comment string. +""" + +import re + +POSITIVE_WORDS = frozenset({ + "love", "great", "awesome", "amazing", "excellent", "fantastic", + "wonderful", "beautiful", "perfect", "brilliant", "outstanding", + "superb", "incredible", "magnificent", "thank", "thanks", "good", + "best", "cool", "nice", "happy", "enjoy", "enjoyed", "helpful", + "fire", "impressive", "solid", "recommend", "recommended", + "favorite", "favourite", "gem", "inspiring", "inspirational", + "masterpiece", "phenomenal", "stellar", "bravo", "wow", + "delightful", "exceptional", "glorious", "dope", +}) + +NEGATIVE_WORDS = frozenset({ + "bad", "terrible", "awful", "horrible", "worst", "hate", + "disappointing", "disappointed", "poor", "boring", "waste", + "ugly", "broken", "useless", "annoying", "frustrating", + "mediocre", "overpriced", "scam", "trash", "garbage", + "rubbish", "dreadful", "pathetic", "lame", "sucks", + "meh", "bland", "uninspired", "weak", "confusing", + "disgusting", "offensive", "painful", "regret", "refund", + "spam", "fake", "ripoff", "avoid", "lackluster", +}) + +NEGATION_WORDS = frozenset({ + "not", "no", "never", "neither", "nor", "nothing", + "nowhere", "hardly", "barely", "scarcely", "dont", + "doesnt", "didnt", "wasnt", "werent", "wont", + "wouldnt", "couldnt", "shouldnt", "isnt", "arent", + "cant", "cannot", +}) + +INTENSIFIERS = frozenset({ + "very", "really", "extremely", "absolutely", "incredibly", + "totally", "completely", "utterly", "super", "so", +}) + +_TOKEN_RE = re.compile(r"[a-zA-Z]{2,}") +_NEGATION_WINDOW = 3 + + +def classify_sentiment(text): + """Classify *text* and return ``-1``, ``0``, or ``1``.""" + if not text: + return 0 + + tokens = [t.lower() for t in _TOKEN_RE.findall(text)] + if not tokens: + return 0 + + score = 0.0 + negation_countdown = 0 + intensifier_active = False + + for token in tokens: + # Strip trailing apostrophe contractions already handled by regex + clean = token.replace("'", "") + + if clean in NEGATION_WORDS: + negation_countdown = _NEGATION_WINDOW + continue + + if clean in INTENSIFIERS: + intensifier_active = True + continue + + weight = 0.0 + if clean in POSITIVE_WORDS: + weight = 1.0 + elif clean in NEGATIVE_WORDS: + weight = -1.0 + + if weight != 0.0: + if intensifier_active: + weight *= 1.5 + if negation_countdown > 0: + weight = -weight + score += weight + + intensifier_active = False + if negation_countdown > 0: + negation_countdown -= 1 + + # Normalize by token count to avoid bias toward long comments + normalized = score / len(tokens) + + if normalized > 0.04: + return 1 + elif normalized < -0.04: + return -1 + return 0 diff --git a/make_post_sell/models/comment.py b/make_post_sell/models/comment.py index 5ab3856..f8c41a0 100644 --- a/make_post_sell/models/comment.py +++ b/make_post_sell/models/comment.py @@ -26,6 +26,7 @@ from make_post_sell.lib.time_funcs import ( ) from make_post_sell.lib.render import markdown_to_html +from make_post_sell.lib.sentiment import classify_sentiment from .meta import Base, RBase from .meta import UUIDType @@ -82,6 +83,9 @@ class Comment(RBase, Base): # by default comments are approved. unless shop requires approval. approved = Column(Boolean, default=True) + # sentiment: -1 = negative, 0 = neutral, 1 = positive, None = unscored + sentiment = Column(Integer, default=None) + ip_address = Column(Unicode(45), default=None) # lazy='joined' performs a left join to reduce queries, it's magic. @@ -190,6 +194,22 @@ class Comment(RBase, Base): return True return False + @property + def human_sentiment(self): + if self.sentiment == 1: + return "positive" + elif self.sentiment == -1: + return "negative" + return "neutral" + + @property + def sentiment_icon(self): + if self.sentiment == 1: + return "+" + elif self.sentiment == -1: + return "-" + return "~" + def set_data(self, data): """Set comment data and generate HTML.""" self.data = data @@ -197,8 +217,10 @@ class Comment(RBase, Base): # Pass shop context if available through product relationship shop = self.product.shop if self.product else None self.data_html = markdown_to_html(data, shop) + self.sentiment = classify_sentiment(data) else: self.data_html = None + self.sentiment = 0 self.updated_timestamp = now_timestamp() def stamp_updated_timestamp(self): @@ -330,3 +352,66 @@ def get_total_comment_count_for_product(dbsession, product_id, shop=None, user=N ) return query.count() + + +def get_sentiment_summary_for_shop(dbsession, shop_id, cutoff_ms): + """Return counts of positive/neutral/negative comments since *cutoff_ms*. + + Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``. + """ + from .product import Product + + rows = ( + dbsession.query(Comment.sentiment, func.count().label("cnt")) + .join(Product) + .filter( + Product.shop_id == shop_id, + Comment.disabled == False, + Comment.approved == True, + Comment.sentiment.isnot(None), + Comment.created_timestamp > cutoff_ms, + ) + .group_by(Comment.sentiment) + .all() + ) + + summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0} + for sentiment_val, cnt in rows: + if sentiment_val == 1: + summary["positive"] = cnt + elif sentiment_val == -1: + summary["negative"] = cnt + else: + summary["neutral"] = cnt + summary["total"] += cnt + return summary + + +def get_sentiment_summary_for_product(dbsession, product_id, cutoff_ms): + """Return counts of positive/neutral/negative comments for a product since *cutoff_ms*. + + Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``. + """ + rows = ( + dbsession.query(Comment.sentiment, func.count().label("cnt")) + .filter( + Comment.product_id == product_id, + Comment.disabled == False, + Comment.approved == True, + Comment.sentiment.isnot(None), + Comment.created_timestamp > cutoff_ms, + ) + .group_by(Comment.sentiment) + .all() + ) + + summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0} + for sentiment_val, cnt in rows: + if sentiment_val == 1: + summary["positive"] = cnt + elif sentiment_val == -1: + summary["negative"] = cnt + else: + summary["neutral"] = cnt + summary["total"] += cnt + return summary diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 02208b0..92da513 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -123,6 +123,7 @@ def includeme(config): config.add_route("shop_sales", "/s/{shop_id}/sales") config.add_route("shop_comments", "/s/{shop_id}/comments") config.add_route("shop_analytics", "/s/{shop_id}/analytics") + config.add_route("product_analytics", "/s/{shop_id}/analytics/{product_id}") config.add_route("shop_settings", "/s/{shop_id}/settings") config.add_route( diff --git a/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py b/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py new file mode 100644 index 0000000..3a111d3 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py @@ -0,0 +1,52 @@ +"""add sentiment column to comment + +Revision ID: 3c0996ae20f3 +Revises: e620bf529ed9 +Create Date: 2026-02-11 06:43:24.008752 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3c0996ae20f3' +down_revision = 'e620bf529ed9' +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +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(): + if not _column_exists("mps_comment", "sentiment"): + op.add_column( + "mps_comment", + sa.Column("sentiment", sa.Integer(), nullable=True), + ) + + # Backfill existing comments + from make_post_sell.lib.sentiment import classify_sentiment + + conn = op.get_bind() + rows = conn.execute( + sa.text("SELECT id, data FROM mps_comment WHERE sentiment IS NULL AND data IS NOT NULL") + ).fetchall() + + for row in rows: + comment_id, data = row + sentiment = classify_sentiment(data) + conn.execute( + sa.text("UPDATE mps_comment SET sentiment = :s WHERE id = :id"), + {"s": sentiment, "id": comment_id}, + ) + + +def downgrade(): + op.drop_column("mps_comment", "sentiment") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 0a356d3..d430c77 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -2721,12 +2721,18 @@ textarea { .ring-header-controls { display: grid; - grid-template-columns: auto auto; + grid-template-columns: auto auto auto; gap: 16px; justify-content: end; align-items: center; } +.skip-next-btn { + font-size: 0.75em; + padding: 4px 10px; + cursor: pointer; +} + .related-content-header h3 { margin: 0; font-size: 0.85em; @@ -2811,6 +2817,9 @@ textarea { .related-content-row-current .related-content-index { color: white; } +.related-content-row-current .queue-add-btn { + color: white; +} .related-content-now-playing { font-weight: bold; } @@ -3291,3 +3300,7 @@ textarea { grid-template-columns: 1fr 1fr; } } + +.price-history-current td { + font-weight: bold; +} diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index 7ea7f7d..a8a2053 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -20,6 +20,9 @@ var djCrossfadeData = null; var preloadingId = null; var currentProductId = null; + var currentCanonicalUrl = null; + var currentProductMediaType = null; + var currentThumbnailUrl = null; var urlRefreshTimer = null; var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes var PROGRESS_KEY = 'mps_watch_progress'; @@ -537,6 +540,9 @@ } if (data.is_mod) isMod = true; currentProductId = data.product_id; + currentCanonicalUrl = data.canonical_url || null; + currentProductMediaType = data.media_type || null; + currentThumbnailUrl = data.thumbnail_url || null; markWatched(data.product_id); syncRingPosition(data.product_id); scheduleUrlRefresh(); @@ -581,6 +587,26 @@ if (dlWell) dlWell.style.display = 'none'; } + // Update non-media product-main images (audio cover art, thumbnail-to-file links) + var mainImgs = document.querySelectorAll('img.product-main'); + for (var mi = 0; mi < mainImgs.length; mi++) { + if (data.thumbnail_url) { + mainImgs[mi].src = data.thumbnail_url; + } + var parentLink = mainImgs[mi].closest('a'); + if (parentLink && data.file_url) { + parentLink.href = data.file_url; + } + } + + // Clear stale extra thumbnails from previous product + var staleThumbs = document.querySelectorAll('.product-thumbnail'); + for (var st = 0; st < staleThumbs.length; st++) { + var thumbLink = staleThumbs[st].closest('a'); + if (thumbLink) thumbLink.remove(); + else staleThumbs[st].remove(); + } + // Update file type/size info var descDiv = document.querySelector('.product-description'); if (descDiv) { @@ -672,6 +698,7 @@ + '' + '' + '
' + + '' + '
'; } @@ -805,6 +833,47 @@ }; } + // --- Skip to next unwatched ring item --- + function getNextUnwatchedItem() { + if (!ringProductIds.length) return getNextItem(); + + // Walk forward through the ring, skipping watched items + for (var i = 1; i < ringProductIds.length; i++) { + var pos = (ringPosition + i * ringDirection + ringProductIds.length * ringProductIds.length) % ringProductIds.length; + var id = ringProductIds[pos]; + if (!isWatched(id)) { + var el = document.querySelector('[data-watch-id="' + id + '"]'); + return { + id: id, + title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '', + thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null, + url: el ? el.getAttribute('href') : '/c/' + id + }; + } + } + + // All items watched — fall back to regular next + return getNextItem(); + } + + function skipToNextUnwatched() { + if (djCrossfadeActive) cancelDjCrossfade(); + cancelCountdown(); + if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; } + + var item = getNextUnwatchedItem(); + if (!item || !item.id) return; + + // Advance ring position to the target item + var idx = ringProductIds.indexOf(item.id); + if (idx !== -1) { + ringPosition = idx; + saveRingState(); + } + + fetchAndNavigate(item.id); + } + function showCountdownOverlay(next) { var overlay = document.getElementById('watch-countdown'); if (!overlay) return; @@ -1370,6 +1439,12 @@ return; } + if (e.target.id === 'skip-next-btn' || e.target.closest('#skip-next-btn')) { + e.preventDefault(); + skipToNextUnwatched(); + return; + } + var link = e.target.closest('.related-content-item[data-watch-id]'); if (link) { e.preventDefault(); diff --git a/make_post_sell/templates/analytics.j2 b/make_post_sell/templates/analytics.j2 index 1f0a214..1b88ca0 100644 --- a/make_post_sell/templates/analytics.j2 +++ b/make_post_sell/templates/analytics.j2 @@ -83,7 +83,7 @@ {% for p in top_products %} {{ p.rank }} - {{ p.title }} + {{ p.title }} {{ p.views_7d }} {{ p.views_14d }} {{ p.views_21d }} @@ -113,7 +113,7 @@ {% for r in ring_entries %} {{ r.rank }} - {{ r.title }} + {{ r.title }} {{ r.count }} {{ r.pct }} @@ -143,7 +143,7 @@ {% for e in engagement %} {{ e.rank }} - {{ e.title }} + {{ e.title }} {{ e.score }} {{ e.sessions }} @@ -170,7 +170,7 @@ {% for a in attention %} {{ a.rank }} - {{ a.title }} + {{ a.title }} {{ a.score }} {{ a.sessions }} @@ -202,7 +202,7 @@ {% for l in learning %} {{ l.rank }} - {{ l.title }} + {{ l.title }} {{ l.score }} {{ l.sessions }} @@ -229,7 +229,7 @@ {% for b in passive %} {{ b.rank }} - {{ b.title }} + {{ b.title }} {{ b.score }} {{ b.sessions }} @@ -241,6 +241,71 @@ {% endif %} + {# --- Video Watch Metrics (21d) --- #} + {% if video_overview %} +

Video Watch Metrics (last 21 days)

+

Aggregate viewing behavior across {{ video_overview.total_sessions }} media sessions.

+
+
+ {{ video_overview.avg_pct_played }} + Avg % Played +
+
+ {{ video_overview.completion_rate }} + Completion Rate +
+
+ {{ video_overview.avg_seeks }} + Avg Seeks +
+
+ {{ video_overview.avg_rewinds }} + Avg Rewinds +
+
+ {{ video_overview.avg_pauses }} + Avg Pauses +
+
+ {{ video_overview.avg_speed }} + Avg Speed +
+
+ + {% if video_products %} +

Per-Product Video Breakdown

+

Top 14 by average % played (min {{ video_products[0].sessions if video_products else 7 }} sessions).

+
+ + + + + + + + + + + + + + {% for v in video_products %} + + + + + + + + + + {% endfor %} + +
#TitleAvg %CompletedSeeksPausesSessions
{{ v.rank }}{{ v.title }}{{ v.avg_pct }}{{ v.completion_rate }}{{ v.avg_seeks }}{{ v.avg_pauses }}{{ v.sessions }}
+
+ {% endif %} + {% endif %} + {# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #} {% if traffic or devices %}
@@ -296,6 +361,54 @@ {% endif %}{# end overview.total_views check #} + {# --- Comment Sentiment (21d) --- #} + {% if sentiment and sentiment.total > 0 %} +

Comment Sentiment (last 21 days)

+
+
+ {{ sentiment.positive }} + Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.neutral }} + Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.negative }} + Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.total }} + Total +
+
+ {% endif %} + + {# --- Recent Price Changes (21d) --- #} + {% if price_changes %} +

Recent Price Changes (last 21 days)

+
+ + + + + + + + + + {% for pc in price_changes %} + + + + + + {% endfor %} + +
ProductPriceWhen
{{ pc.title }}{{ pc.price }}{{ pc.ago }}
+
+ {% endif %} +

All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.

diff --git a/make_post_sell/templates/analytics_product.j2 b/make_post_sell/templates/analytics_product.j2 new file mode 100644 index 0000000..8a9a050 --- /dev/null +++ b/make_post_sell/templates/analytics_product.j2 @@ -0,0 +1,238 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + Analytics - {{ product.title }} - {{ request.shop.name }} +{%- endblock append_to_head_tag_section -%} + +{% block content -%} + +
+ +

← Shop Analytics · View Product

+ +

{{ product.title }}

+ + {# --- Overview Strip --- #} +
+
+ {{ overview.views_7d }} + Views (7d) +
+
+ {{ overview.avg_session }} + Avg Session +
+
+ {{ overview.bounce_rate }} + Bounce Rate +
+
+ {{ overview.lifetime_views }} + Lifetime Views +
+
+ + {# --- Views Over Time --- #} +

Views Over Time

+
+
+ {{ views_over_time['7d'] }} + 7 days +
+
+ {{ views_over_time['14d'] }} + 14 days +
+
+ {{ views_over_time['21d'] }} + 21 days +
+
+ {{ views_over_time['28d'] }} + 28 days +
+
+ {{ views_over_time['365d'] }} + Year +
+
+ {{ views_over_time['lifetime'] }} + Lifetime +
+
+ + {# --- Video Watch Metrics --- #} + {% if video %} +

Video Watch Metrics (last 21 days)

+

Based on {{ video.sessions }} media sessions.

+
+
+ {{ video.avg_pct_played }} + Avg % Played +
+
+ {{ video.completion_rate }} + Completion Rate +
+
+ {{ video.avg_seeks }} + Avg Seeks +
+
+ {{ video.avg_rewinds }} + Avg Rewinds +
+
+ {{ video.avg_pauses }} + Avg Pauses +
+
+ {{ video.avg_speed }} + Avg Speed +
+
+ {{ video.play_duration_ratio }} + Play/Duration +
+
+ {% endif %} + + {# --- Traffic Sources & Devices (side by side) --- #} + {% if traffic or devices %} +
+ {% if traffic %} +
+

Traffic Sources (last 21 days)

+ + + + + + + + + + {% for t in traffic %} + + + + + + {% endfor %} + +
SourceSessions%
{{ t.source }}{{ t.count }}{{ t.pct }}
+
+ {% endif %} + + {% if devices %} +
+

Device Split (last 21 days)

+ + + + + + + + + + {% for d in devices %} + + + + + + {% endfor %} + +
DeviceSessions%
{{ d.device }}{{ d.count }}{{ d.pct }}
+
+ {% endif %} +
+ {% endif %} + + {# --- Ring Entries --- #} + {% if ring_entry_count > 0 %} +

Ring Entry Sessions (last 21 days)

+
+
+ {{ ring_entry_count }} + Ring Entries +
+
+ {% endif %} + + {# --- Engagement --- #} + {% if engagement %} +

Engagement (last 21 days)

+

Based on {{ engagement.sessions }} sessions with 7s+ wall clock.

+
+
+ {{ engagement.active_ratio }} + Active / Wall +
+
+ {{ engagement.visible_ratio }} + Visible / Wall +
+
+ {{ engagement.avg_scroll_depth }} + Avg Scroll Depth +
+
+ {{ engagement.avg_direction_changes }} + Direction Changes +
+
+ {% endif %} + + {# --- Comment Sentiment --- #} + {% if sentiment and sentiment.total > 0 %} +

Comment Sentiment (last 21 days)

+
+
+ {{ sentiment.positive }} + Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.neutral }} + Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.negative }} + Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%) +
+
+ {{ sentiment.total }} + Total +
+
+ {% endif %} + + {# --- Price History --- #} + {% if price_changes %} +

Price History (last 21 changes)

+
+ + + + + + + + + {% for pc in price_changes %} + + + + + {% endfor %} + +
PriceWhen
{{ pc.price }}{{ pc.ago }}
+
+ {% endif %} + +

All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.

+ +
+ +{%- endblock -%} diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 index d1d4c0d..8ca9d14 100644 --- a/make_post_sell/templates/product_edit.j2 +++ b/make_post_sell/templates/product_edit.j2 @@ -250,6 +250,32 @@ Your cover (thumbnail1) will show up on search pages. +{% if price_history | length > 1 %} +
+

Price History

+
+ + + + + + + + + + {% for ph in price_history %} + + + + + + {% endfor %} + +
PriceWhenDate
{{ ph.price_formatted }}{{ ph.ago }}{{ ph.datetime }}
+
+
+{% endif %} +
{%- endblock -%} diff --git a/make_post_sell/templates/shop_comments.j2 b/make_post_sell/templates/shop_comments.j2 index b190859..051e6eb 100644 --- a/make_post_sell/templates/shop_comments.j2 +++ b/make_post_sell/templates/shop_comments.j2 @@ -19,6 +19,7 @@ Product Author Comment + Tone Date Status Actions @@ -30,6 +31,7 @@ {{ comment.product.title | truncate(30) }} {{ comment.user.name if comment.user else "Anonymous" }} {{ comment.data | truncate(80) }} + {{ comment.sentiment_icon }} {{ comment.ago_string }} {% if comment.disabled %} diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2 index b96942c..bc22dbf 100644 --- a/make_post_sell/templates/snippets/related_content.j2 +++ b/make_post_sell/templates/snippets/related_content.j2 @@ -9,6 +9,7 @@ {% if request.shop.watch_mode_enabled %}
+
{% endif %}