Recently Viewed Products Display
Shows a shopper's recently viewed products as a carousel or grid with product cards, prices, and add-to-cart. Reads from the data saved by the Recently Viewed Tracker block on the product page. Add the following config to the block
| Config Name | Unique Id | Input Type |
|---|---|---|
| Block Title | block-title | string |
| Layout Type | layout-type | dropdown (carousel, grid) |
| Grid Rows | grid-rows | string |
| Show Price | show-price | dropdown (yes, no) |
| Image Ratio | image-ratio | string |
<div class="recently-viewed" id="recentlyViewed" hidden>
<div class="rv-header">
<div class="rv-title" id="rvTitle"></div>
</div>
<div class="rv-list" id="rvList"></div>
</div>.recently-viewed {
width: 100%;
box-sizing: border-box;
padding: 12px;
}
.rv-header {
margin-bottom: 12px;
}
.rv-title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
/* Carousel layout */
.rv-list--carousel {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.rv-list--carousel::-webkit-scrollbar {
display: none;
}
.rv-list--carousel .rv-card {
flex: 0 0 auto;
width: 150px;
scroll-snap-align: start;
}
/* Grid layout */
.rv-list--grid {
display: grid;
grid-template-columns: repeat(var(--rv-cols, 2), 1fr);
gap: 12px;
}
.rv-card {
box-sizing: border-box;
background: transparent;
cursor: pointer;
}
.rv-card-media {
width: 100%;
aspect-ratio: var(--rv-ratio, 1 / 1);
background: #f4f4f4;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
overflow: hidden;
}
.rv-card-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.rv-card-body {
padding: 8px 0 0;
}
.rv-card-title {
margin: 0 0 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rv-card-price {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.rv-compare {
opacity: 0.5;
text-decoration: line-through;
}
.rv-add-btn {
width: 100%;
margin-top: 0;
padding: 10px 12px;
border: 1px solid #111;
border-radius: 10px;
background: #111;
color: #fff;
cursor: pointer;
box-sizing: border-box;
}
.rv-add-btn[disabled] {
opacity: 0.5;
cursor: default;
}const RV_KEY = "recentlyViewedProducts";
const RV_TTL = 24 * 60 * 60 * 1000; // keep in sync with the tracker block
// Configs (Liquid placeholders fall back to defaults when unset).
const blockTitle = "{{block-title}}" || "Recently Viewed";
const layoutType = "{{layout-type}}".trim().toLowerCase() === "grid" ? "grid" : "carousel";
const showPrice = "{{show-price}}".trim().toLowerCase() !== "no";
const imageRatio = "{{image-ratio}}".trim() || "1:1";
const GRID_COLS = 2; // cards per row in grid layout
const gridRows = parseInt(String("{{grid-rows}}").replace(/[^0-9]/g, ""), 10) || 2;
function resizeCurrentBlock() {
setTimeout(() => {
Superfans.actions.resizeBlock();
}, 50);
}
function isFresh(entry) {
if (!entry?.viewedAt) {
return false;
}
return Date.now() - entry.viewedAt < RV_TTL;
}
async function getRecentlyViewedList() {
const raw = await Superfans.actions.getLocalStorage(RV_KEY);
let list = [];
try {
list = raw ? JSON.parse(raw) : [];
} catch (error) {
list = [];
}
return Array.isArray(list) ? list.filter(isFresh) : [];
}
function getVariant(entry) {
const variants = Array.isArray(entry?.variants) ? entry.variants : [];
if (entry?.selectedVariant?.id) {
return entry.selectedVariant;
}
return variants.find(variant => variant.isAvailable) || variants[0] || null;
}
function getImageUrl(entry) {
const image = entry?.images?.[0];
if (typeof image === "string") {
return image;
}
return image?.url || image?.src || image?.originalSrc || "";
}
function parseRatio(ratio) {
const parts = String(ratio).split(/[:\/]/).map(Number);
const [w, h] = parts;
if (w > 0 && h > 0) {
return `${w} / ${h}`;
}
return "1 / 1";
}
function normalizeMoney(value) {
if (!value) {
return null;
}
if (typeof value === "object") {
return {
amount: Number(value.amount || 0),
currencyCode: value.currencyCode || "USD"
};
}
return { amount: Number(value), currencyCode: "USD" };
}
function formatMoney(money) {
if (!money) {
return "";
}
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: money.currencyCode
}).format(money.amount);
} catch (error) {
return String(money.amount);
}
}
async function openProductFromEntry(entry, variant) {
try {
await Superfans.actions.openProduct({
productId: entry.id,
variantId: variant?.id
});
} catch (error) {
await Superfans.actions.showToast({
title: "Something went wrong",
message: "Could not open this product. Please try again."
});
}
}
async function addToCartFromEntry(entry, variant, button) {
if (!variant?.id) {
return;
}
const originalText = button.textContent;
button.disabled = true;
button.textContent = "Adding...";
try {
await Superfans.actions.addToCart([{ variantId: variant.id, quantity: 1 }]);
await Superfans.actions.showToast({
title: "Added to cart",
message: entry.title || ""
});
} catch (error) {
await Superfans.actions.showToast({
title: "Something went wrong",
message: "Please try again."
});
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
function buildCard(entry) {
const variants = Array.isArray(entry.variants) ? entry.variants : [];
const hasMultipleVariants = variants.length > 1;
const variant = getVariant(entry);
const available = Boolean(variant && variant.isAvailable);
const card = document.createElement("div");
card.className = "rv-card";
const media = document.createElement("div");
media.className = "rv-card-media";
const imageUrl = getImageUrl(entry);
if (imageUrl) {
const img = document.createElement("img");
img.className = "rv-card-image";
img.src = imageUrl;
img.alt = entry.title || "";
img.loading = "lazy";
media.appendChild(img);
}
card.appendChild(media);
const body = document.createElement("div");
body.className = "rv-card-body";
const title = document.createElement("div");
title.className = "rv-card-title";
title.textContent = entry.title || "";
body.appendChild(title);
if (showPrice && variant) {
const price = normalizeMoney(variant.price);
const compareAt = normalizeMoney(variant.compareAtPrice);
const priceRow = document.createElement("div");
priceRow.className = "rv-card-price";
if (price) {
const priceEl = document.createElement("span");
priceEl.className = "rv-price";
priceEl.textContent = formatMoney(price);
priceRow.appendChild(priceEl);
}
if (compareAt && price && compareAt.amount > price.amount) {
const compareEl = document.createElement("span");
compareEl.className = "rv-compare";
compareEl.textContent = formatMoney(compareAt);
priceRow.appendChild(compareEl);
}
body.appendChild(priceRow);
}
const addBtn = document.createElement("button");
addBtn.className = "rv-add-btn";
if (!available) {
addBtn.textContent = "Sold Out";
addBtn.disabled = true;
} else if (hasMultipleVariants) {
// Multiple variants: send shopper to the product page to choose options.
addBtn.textContent = "Choose Options";
addBtn.addEventListener("click", event => {
event.stopPropagation();
openProductFromEntry(entry, variant);
});
} else {
addBtn.textContent = "Add to Cart";
addBtn.addEventListener("click", event => {
event.stopPropagation();
addToCartFromEntry(entry, variant, addBtn);
});
}
body.appendChild(addBtn);
card.appendChild(body);
card.addEventListener("click", () => openProductFromEntry(entry, variant));
return card;
}
async function renderRecentlyViewed() {
const container = document.getElementById("recentlyViewed");
const listEl = document.getElementById("rvList");
const titleEl = document.getElementById("rvTitle");
titleEl.textContent = blockTitle;
const products = await getRecentlyViewedList();
listEl.innerHTML = "";
// Hide the whole block when there is nothing to show.
if (!products.length) {
container.hidden = true;
resizeCurrentBlock();
return;
}
container.style.setProperty("--rv-ratio", parseRatio(imageRatio));
listEl.classList.toggle("rv-list--carousel", layoutType === "carousel");
listEl.classList.toggle("rv-list--grid", layoutType === "grid");
// In grid layout, render a fixed 2 cards per row for the configured number of rows.
let visibleProducts = products;
if (layoutType === "grid") {
visibleProducts = products.slice(0, gridRows * GRID_COLS);
}
visibleProducts.forEach(entry => listEl.appendChild(buildCard(entry)));
container.hidden = false;
resizeCurrentBlock();
}
renderRecentlyViewed();