
This block reads live subscription data, so it only runs inside a real customer portal. The animation shows what subscribers see.
What it does
Real, verified product reviews in the portal, right under its social proof line.
Why it exists
For merchants who want stronger social proof where subscribers decide what to add next. Reviews attach under the social proof line on each product card.
The code
Paste it as-is, or download social-proof-reviews.html<!--
Skio Customer Portal — Custom Code block
Judge.me reviews shown INSIDE each product card, right under the built-in
Social Proof line ("1+ customers in the last month").
How it places itself: instead of rendering at the block's fixed page slot, the
script finds every [data-skio-name="social-proof-banner"] — present on the
subscription's product cards AND on the "Add product" modal cards — reads the
enclosing card's product title, and injects a matching review widget right under
that banner (squaring off the banner's rounded bottom so it reads as one card).
A MutationObserver re-attaches them when the portal (a React SPA) repaints cards
or opens the modal.
Data source: Judge.me's PUBLIC, TOKENLESS carousel endpoint on cdn.judge.me,
keyed only by shop_domain. No API token, no proxy, nothing secret in this block.
Per widget:
- 0 reviews for a product -> nothing injected for that card
- 1 review -> a single static card (no arrows / dots / autoplay)
- 2+ reviews -> autoplaying carousel with arrows + dots + progress bar
If a product has no reviews yet, it falls back to store-wide reviews so the
space is never awkwardly empty (toggle with FALLBACK_STOREWIDE below).
-->
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap');
.skio-rev-wrap { font-family: 'DM Sans', sans-serif; }
.skio-rev-eyebrow { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: #3B6D11; margin: 0 0 0.5rem 2px; }
.skio-rev-viewport { position: relative; overflow: hidden; border-radius: 14px; border: 0.5px solid #b5d4b5; background: #f7faf5; }
.skio-rev-track { display: flex; transition: transform 0.5s cubic-bezier(0.4,0,0.2,1); }
.skio-rev-slide { min-width: 100%; box-sizing: border-box; padding: 1.4rem 1.6rem; display: flex; flex-direction: column; gap: 0.65rem; }
.skio-rev-stars { font-size: 14px; letter-spacing: 2px; color: #3B6D11; line-height: 1; }
.skio-rev-stars .empty { color: #C0DD97; }
.skio-rev-title { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 500; color: #173404; margin: 0; line-height: 1.35; }
.skio-rev-body { font-size: 13.5px; color: #3B6D11; line-height: 1.65; margin: 0; }
.skio-rev-meta { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; font-size: 11.5px; color: #6b8a4a; margin-top: 0.1rem; }
.skio-rev-author { font-weight: 500; color: #173404; }
.skio-rev-verified { display: inline-flex; align-items: center; gap: 3px; background: #EAF3DE; color: #3B6D11; font-size: 10px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; }
.skio-rev-product { color: #6b8a4a; }
.skio-rev-sep { color: #b5d4b5; }
.skio-rev-progress { position: absolute; bottom: 0; left: 0; height: 3px; width: 0; background: #3B6D11; border-radius: 0 2px 0 0; }
.skio-rev-nav { display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 0.7rem; }
.skio-rev-nav[hidden] { display: none; }
.skio-rev-dot { width: 7px; height: 7px; border-radius: 50%; background: #C0DD97; border: none; cursor: pointer; padding: 0; transition: background 0.2s, transform 0.2s; }
.skio-rev-dot.active { background: #3B6D11; transform: scale(1.3); }
.skio-rev-arrow { background: none; border: 0.5px solid #b5d4b5; border-radius: 50%; width: 30px; height: 30px; cursor: pointer; font-size: 15px; color: #3B6D11; display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
.skio-rev-arrow:hover { background: #EAF3DE; }
</style>
<script>
(function () {
// ---- CONFIG -------------------------------------------------------------
var SHOP_DOMAIN = '305test.myshopify.com'; // <-- your .myshopify.com domain
// true -> if a product has no reviews yet, show store-wide reviews instead.
// false -> show nothing under products that have no reviews.
var FALLBACK_STOREWIDE = true;
var MIN_RATING = 4; // only surface 4-star and up
var MAX_REVIEWS = 12; // cap slides per widget
var AUTOPLAY_MS = 5000; // slide duration
var MAX_BODY = 220; // truncate long review bodies
// Portal hooks. We anchor on the social-proof banner (present on BOTH the
// subscription's product cards AND the "Add product" modal cards), then find the
// enclosing card and its product title. Add more card/title pairs here if Skio
// exposes reviews-worthy cards elsewhere.
var SOCIAL_SELECTOR = '[data-skio-name="social-proof-banner"]';
var CARD_SELECTOR = '[data-skio-name="variant-card"], [data-skio-name="product-grid-card-ui"]';
var TITLE_SELECTOR = '[data-skio-name="variant-card-product-title"], [data-skio-name="product-grid-card-ui-title"]';
// -------------------------------------------------------------------------
// Glyphs from code points so they render regardless of the host page charset.
var STAR = String.fromCharCode(9733), CHECK = String.fromCharCode(10003),
BULLET = String.fromCharCode(8226), ELLIPSIS = String.fromCharCode(8230);
var ALL = null; // normalized reviews, loaded once
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function norm(s) { return String(s == null ? '' : s).trim().toLowerCase(); }
function stars(n) {
n = Math.max(0, Math.min(5, Math.round(n || 0)));
var out = '';
for (var i = 0; i < 5; i++) out += '<span class="' + (i < n ? 'full' : 'empty') + '">' + STAR + '</span>';
return out;
}
function truncate(s, max) {
s = String(s || '').trim();
return s.length > max ? s.slice(0, max).replace(/\s+\S*$/, '') + ELLIPSIS : s;
}
function fmtDate(d) {
if (!d) return '';
var t = new Date(d);
return isNaN(t) ? '' : t.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function slide(r, showProduct) {
var meta = [];
if (r.name) meta.push('<span class="skio-rev-author">' + esc(r.name) + '</span>');
if (r.verified) meta.push('<span class="skio-rev-verified">' + CHECK + ' Verified</span>');
if (showProduct && r.product) meta.push('<span class="skio-rev-sep">' + BULLET + '</span><span class="skio-rev-product">' + esc(r.product) + '</span>');
var dt = fmtDate(r.date);
if (dt) meta.push('<span class="skio-rev-sep">' + BULLET + '</span><span>' + esc(dt) + '</span>');
return '<div class="skio-rev-slide" role="group" aria-roledescription="review">' +
'<div class="skio-rev-stars" aria-label="' + (r.rating || 0) + ' out of 5 stars">' + stars(r.rating) + '</div>' +
(r.title ? '<h3 class="skio-rev-title">' + esc(r.title) + '</h3>' : '') +
'<p class="skio-rev-body">' + esc(truncate(r.body, MAX_BODY)) + '</p>' +
(meta.length ? '<div class="skio-rev-meta">' + meta.join(' ') + '</div>' : '') +
'</div>';
}
// Build one self-contained review widget element (wiring scoped to itself).
function buildWidget(reviews) {
var distinct = {};
for (var i = 0; i < reviews.length; i++) distinct[norm(reviews[i].product)] = 1;
var showProduct = Object.keys(distinct).length > 1; // label products only when mixed
var wrap = document.createElement('div');
wrap.className = 'skio-rev-wrap';
wrap.innerHTML =
'<p class="skio-rev-eyebrow">What subscribers are saying</p>' +
'<div class="skio-rev-viewport" aria-roledescription="carousel" aria-label="Customer reviews">' +
'<div class="skio-rev-track"></div>' +
'<div class="skio-rev-progress"></div>' +
'</div>' +
'<div class="skio-rev-nav" hidden>' +
'<button class="skio-rev-arrow skio-rev-prev" aria-label="Previous review">←</button>' +
'<span class="skio-rev-dots" style="display:flex;gap:9px;"></span>' +
'<button class="skio-rev-arrow skio-rev-next" aria-label="Next review">→</button>' +
'</div>';
var track = wrap.querySelector('.skio-rev-track');
var nav = wrap.querySelector('.skio-rev-nav');
var progress = wrap.querySelector('.skio-rev-progress');
track.innerHTML = reviews.map(function (r) { return slide(r, showProduct); }).join('');
if (reviews.length < 2) { progress.style.display = 'none'; return wrap; }
// carousel wiring (2+)
nav.hidden = false;
var dotsWrap = wrap.querySelector('.skio-rev-dots');
dotsWrap.innerHTML = reviews.map(function (_, i) {
return '<button class="skio-rev-dot' + (i === 0 ? ' active' : '') + '" data-i="' + i +
'" aria-label="Go to review ' + (i + 1) + '"></button>';
}).join('');
var dots = dotsWrap.querySelectorAll('.skio-rev-dot');
var total = reviews.length, cur = 0, timer;
function go(n) {
cur = (n + total) % total;
track.style.transform = 'translateX(-' + (cur * 100) + '%)';
for (var i = 0; i < dots.length; i++) dots[i].classList.toggle('active', i === cur);
progress.style.transition = 'none'; progress.style.width = '0%';
void progress.offsetWidth;
progress.style.transition = 'width ' + AUTOPLAY_MS + 'ms linear'; progress.style.width = '100%';
}
function start() { clearInterval(timer); timer = setInterval(function () { go(cur + 1); }, AUTOPLAY_MS); }
wrap.querySelector('.skio-rev-next').onclick = function () { go(cur + 1); start(); };
wrap.querySelector('.skio-rev-prev').onclick = function () { go(cur - 1); start(); };
for (var d = 0; d < dots.length; d++) dots[d].onclick = function () { go(+this.dataset.i); start(); };
var vp = wrap.querySelector('.skio-rev-viewport');
vp.onmouseenter = function () { clearInterval(timer); };
vp.onmouseleave = start;
go(0); start();
return wrap;
}
// Reviews for a given product title, with optional store-wide fallback.
function reviewsFor(title) {
var scoped = ALL.filter(function (r) { return norm(r.product) === norm(title); });
if (scoped.length) return scoped.slice(0, MAX_REVIEWS);
return FALLBACK_STOREWIDE ? ALL.slice(0, MAX_REVIEWS) : [];
}
// For every social-proof banner (subscription cards + Add-product modal),
// inject a review widget right beneath it, scoped to that card's product.
function mount() {
if (!ALL) return;
var banners = document.querySelectorAll(SOCIAL_SELECTOR);
for (var i = 0; i < banners.length; i++) {
var banner = banners[i];
// already mounted right after this banner?
if (banner.nextElementSibling && banner.nextElementSibling.classList.contains('skio-rev-wrap')) continue;
var card = banner.closest(CARD_SELECTOR) || banner.parentElement;
if (!card) continue;
var titleNode = card.querySelector(TITLE_SELECTOR);
var title = titleNode ? (titleNode.textContent || '').trim() : '';
var list = reviewsFor(title);
if (!list.length) continue;
var widget = buildWidget(list);
widget.style.gridColumn = '1 / -1'; // span full width when the card is a CSS grid
widget.style.marginTop = '0';
// the banner is styled as the card's rounded bottom edge — square it off so
// our reviews continue the card cleanly instead of following a rounded corner
banner.style.marginBottom = '0';
banner.style.borderBottomLeftRadius = '0';
banner.style.borderBottomRightRadius = '0';
banner.parentNode.insertBefore(widget, banner.nextSibling);
}
}
// Re-attach on SPA repaints (skip / swap / quantity change / modal open), debounced.
var scheduled = false;
function schedule() {
if (scheduled) return;
scheduled = true;
setTimeout(function () { scheduled = false; try { mount(); } catch (e) {} }, 150);
}
// ---- fetch reviews (public, tokenless), then mount + observe ----
var endpoint = 'https://cdn.judge.me/reviews/reviews_for_carousel' +
'?reviews_selection=all&carousel_type=cards&star_rating=all' +
'&custom_tags=&tag_filter=&max_reviews=100' +
'&url=' + encodeURIComponent('https://' + SHOP_DOMAIN) +
'&shop_domain=' + encodeURIComponent(SHOP_DOMAIN) +
'&platform=shopify&primary_language=en&display_order=media_first';
fetch(endpoint, { headers: { 'Accept': 'application/json' } })
.then(function (res) { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); })
.then(function (data) {
ALL = (data && data.reviews ? data.reviews : [])
.map(function (r) {
return {
rating: r.rating,
title: r.title || '',
body: (r.body || '').trim(),
name: (r.reviewer_name && !r.is_anonymous_reviewer) ? r.reviewer_name : 'Verified subscriber',
verified: !!r.verified_buyer,
date: r.created_at || null,
product: r.product_title || ''
};
})
.filter(function (r) { return r.body && (r.rating == null || r.rating >= MIN_RATING); });
try { console.log('[SkioReviews] loaded ' + ALL.length + ' reviews for ' + SHOP_DOMAIN); } catch (e) {}
mount();
try {
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
} catch (e) {}
})
.catch(function (err) { try { console.warn('[SkioReviews] could not load reviews:', err); } catch (e) {} });
})();
</script>
How to install
- Copy the code above, or download the .html file.
- Go to
Storefront→Customer Portal→Branding→Layoutand clickAdd custom extension. - Pick
HTML with embedded CSS and JavaScriptand paste the whole file into the code editor. - Swap anything the file asks you to, like
[BRAND], product names or Quick Action IDs. The AI prompt below can walk you through it. - Save, click
+to add it to the layout, thenPublish. Check it on a test subscription’s magic link with?forcePreview=true, since some scripts don’t run in Preview.
Pulls reviews from Judge.me's public endpoint. Set SHOP_DOMAIN to your own .myshopify.com domain; the file ships pointing at a test store.
Customize it with AI
Paste this prompt into Claude Code or Codex. It asks for the code and your brand and product details, then writes a version for your store.
I want to add a custom extension from customizeyourportal.xyz to my subscription customer portal. Walk me through customizing it for my brand, then write the finished code. EXTENSION Name: Social Proof Reviews (No. 09) Page: /blocks/social-proof-reviews What it does: Real, verified product reviews in the portal, right under its social proof line. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Pulls reviews from Judge.me's public endpoint. Set SHOP_DOMAIN to your own .myshopify.com domain; the file ships pointing at a test store. HOW TO WORK 1. Ask me to paste the full code (the "Copy code" button on the page) or to give you the path to the downloaded social-proof-reviews.html. Wait until you have it. 2. Read the whole file. List every value that is specific to the original brand or needs my input: brand name and [PLACEHOLDERS], product and variant names or IDs, prices, discount codes and reward amounts, Quick Action IDs or tokens, store domains and API endpoints, colors, fonts, logos and images, and any copy that names the original brand, products or offers. Include everything in the builder's notes above. 3. Ask me for those values in one numbered list, with the current value next to each, so I can answer or say "keep". If you can open web pages, offer to pull my brand colors, fonts, logo and product names from my store's URL. 4. Don't invent stats, reviews, IDs, prices or claims. If I don't have a value, leave a clearly marked TODO in the code and tell me. 5. Write the customized version as social-proof-reviews-<my-brand>.html. Change only what my brand needs. Keep the logic, behavior and structure the same, and keep it one self-contained file (HTML, <style> and <script>). 6. Finish with a short list of what you changed, any TODOs left, and these install steps for Skio: 1. Go to `Storefront` → `Customer Portal` → `Branding` → `Layout` and click `Add custom extension`. 2. Pick `HTML with embedded CSS and JavaScript` and paste the whole file into the code editor. 3. Save, click `+` to add it to the layout, then `Publish`. Check it on a test subscription’s magic link with `?forcePreview=true`, since some scripts don’t run in Preview.
Blocks are shared as-is by the people who built them. Read the code before you add it to your portal, and test it with a test subscriber first.
