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 NameUnique IdInput Type
Block Titleblock-titlestring
Layout Typelayout-typedropdown (carousel, grid)
Grid Rowsgrid-rowsstring
Show Priceshow-pricedropdown (yes, no)
Image Ratioimage-ratiostring
<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();