Reorder Last Purchased Products

Shows logged-in customers a "Buy it again" list of products from their past orders, with one-tap Add to Cart. Pulls order history via Storefront API, de-dupes by variant (most recent first), and supports grid or carousel layout. Add the following config to the block

Config NameUnique IdInput Type
Block Titleblock-titlestring
Block Subtitleblock-subtitlestring
Layout Typelayout-typedropdown (carousel, grid)
Image Ratioimage-ratiodropdown (yes, no)
Grid Rowsgrid-rowsstring
Max Itemsmax-itemsstring
Add to Cart Coloradd-to-cart-button-colorstring
Add to Cart Button Text Coloradd-to-cart-button-text-colorstring
<div class="reorder" id="reorderBlock" hidden>
  <div class="reorder-header">
    <div class="reorder-title" id="reorderTitle"></div>
    <div class="reorder-subtitle" id="reorderSubtitle"></div>
  </div>
  <div class="rv-list" id="reorderList"></div>
  <div class="reorder-state" id="reorderState" hidden>
    <div class="reorder-state-msg" id="stateMessage"></div>
    <button type="button" class="reorder-state-btn" id="stateActionBtn" hidden></button>
  </div>
</div>
.reorder {
  width: 100%;
  box-sizing: border-box;
  padding: 12px;
}
.reorder [hidden] {
  display: none !important;
}
.reorder-header {
  margin-bottom: 12px;
}
.reorder-title {
  margin: 0;
  font-size: 20px;
  font-weight: 700;
}
.reorder-subtitle {
  margin: 4px 0 0;
  font-size: 15px;
  font-weight: 600;
  opacity: 0.7;
}
/* 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 {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 0 0;
}
.rv-card-title {
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.rv-card-meta {
  margin: 0;
  font-size: 12px;
  opacity: 0.6;
}
.rv-card-price {
  display: flex;
  align-items: baseline;
  gap: 6px;
  flex-wrap: wrap;
  margin: 0;
}
.rv-compare {
  opacity: 0.5;
  text-decoration: line-through;
}
.rv-add-btn {
  width: 100%;
  margin-top: 2px;
  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;
}
/* State (login / empty / error) */
.reorder-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  text-align: center;
  padding: 28px 12px;
}
.reorder-state-msg {
  opacity: 0.75;
}
.reorder-state-btn {
  min-width: 200px;
  padding: 12px 16px;
  border: 1px solid #111;
  border-radius: 10px;
  background: #111;
  color: #fff;
  cursor: pointer;
  box-sizing: border-box;
}
// ---- Configs (Liquid placeholders fall back to defaults when unsett) ----
const blockTitle = "{{block-title}}" || "Buy it again";
const blockSubtitle = "{{block-subtitle}}" || "Quickly reorder items from your past orders.";
const layoutType = "{{layout-type}}".trim().toLowerCase() === "grid" ? "grid" : "carousel";
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;
const maxItems = parseInt(String("{{max-items}}").replace(/[^0-9]/g, ""), 10) || 0; // 0 = show all
const buttonColor = "{{add-to-cart-button-color}}".trim();
const buttonTextColor = "{{add-to-cart-button-text-color}}".trim();
const LOGIN_PROMPT_TEXT = "Log in to see items from your past orders.";
const EMPTY_TEXT = "You don't have any past purchases yet.";
const ERROR_TEXT = "We couldn't load your past orders. Please try again.";
const MAX_ORDERS = 25;
const MAX_LINE_ITEMS = 50;
const reorderBlock = document.getElementById("reorderBlock");
const reorderTitle = document.getElementById("reorderTitle");
const reorderSubtitle = document.getElementById("reorderSubtitle");
const reorderList = document.getElementById("reorderList");
const reorderState = document.getElementById("reorderState");
const stateMessage = document.getElementById("stateMessage");
const stateActionBtn = document.getElementById("stateActionBtn");
const CUSTOMER_ORDERS_QUERY = `
  query CustomerOrders($token: String!, $orders: Int!, $lineItems: Int!) {
    customer(customerAccessToken: $token) {
      orders(first: $orders, sortKey: PROCESSED_AT, reverse: true) {
        edges {
          node {
            processedAt
            lineItems(first: $lineItems) {
              edges {
                node {
                  title
                  variant {
                    id
                    title
                    availableForSale
                    image { url }
                    price { amount currencyCode }
                    compareAtPrice { amount currencyCode }
                    product {
                      id
                      handle
                      title
                      featuredImage { url }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
`;
function resizeCurrentBlock() {
  setTimeout(() => {
    Superfans.actions.resizeBlock();
  }, 50);
}
function stripGid(id) {
  return String(id || "").replace(/^gid:\/\/shopify\/\w+\//, "");
}
function parseRatio(ratio) {
  const [w, h] = String(ratio).split(/[:\/]/).map(Number);
  if (w > 0 && h > 0) {
    return `${w} / ${h}`;
  }
  return "1 / 1";
}
function getCurrencyCode(fallback) {
  return Superfans.variables.store?.currencyCode || fallback || "USD";
}
function formatMoney(amount, currencyCode) {
  const value = Number(amount);
  if (!Number.isFinite(value)) return "";
  try {
    return new Intl.NumberFormat(undefined, {
      style: "currency",
      currency: getCurrencyCode(currencyCode)
    }).format(value);
  } catch (error) {
    return String(value);
  }
}
function formatLastOrdered(processedAt) {
  if (!processedAt) return "";
  const date = new Date(processedAt);
  if (Number.isNaN(date.getTime())) return "";
  return `Last ordered ${date.toLocaleDateString(undefined, {
    month: "short",
    day: "numeric",
    year: "numeric"
  })}`;
}
function showState(message, actionLabel, onAction) {
  reorderList.hidden = true;
  reorderList.innerHTML = "";
  reorderState.hidden = false;
  stateMessage.textContent = message;
  if (actionLabel && typeof onAction === "function") {
    stateActionBtn.hidden = false;
    stateActionBtn.textContent = actionLabel;
    stateActionBtn.onclick = onAction;
  } else {
    stateActionBtn.hidden = true;
    stateActionBtn.onclick = null;
  }
  resizeCurrentBlock();
}
function hideState() {
  reorderState.hidden = true;
}
// Collapse all order line items into a unique list of variants (most recent first).
function buildPurchaseList(orders) {
  const seen = new Set();
  const purchases = [];
  orders.forEach(orderEdge => {
    const order = orderEdge?.node;
    const processedAt = order?.processedAt;
    const lineItems = order?.lineItems?.edges || [];
    lineItems.forEach(lineEdge => {
      const line = lineEdge?.node;
      const variant = line?.variant;
      const variantId = stripGid(variant?.id);
      if (!variant || !variantId || seen.has(variantId)) return;
      seen.add(variantId);
      purchases.push({
        variantId,
        variantTitle: variant.title,
        productTitle: variant.product?.title || line.title || "",
        productId: stripGid(variant.product?.id),
        productHandle: variant.product?.handle || "",
        imageUrl: variant.image?.url || variant.product?.featuredImage?.url || "",
        priceAmount: variant.price?.amount,
        currencyCode: variant.price?.currencyCode,
        compareAtAmount: variant.compareAtPrice?.amount,
        available: Boolean(variant.availableForSale),
        processedAt
      });
    });
  });
  return purchases;
}
async function fetchPastPurchases() {
  const customer = Superfans.variables.customer;
  const token = customer?.accessToken;
  if (!token) {
    return null;
  }
  const response = await Superfans.helpers.getStorefrontData({
    query: CUSTOMER_ORDERS_QUERY,
    variables: {
      token,
      orders: MAX_ORDERS,
      lineItems: MAX_LINE_ITEMS
    }
  });
  if (response?.status === "error") {
    throw new Error(response.message || "Storefront request failed.");
  }
  const orders = response?.data?.customer?.orders?.edges || [];
  return buildPurchaseList(orders);
}
async function openPurchaseProduct(purchase) {
  try {
    await Superfans.actions.openProduct({
      productId: purchase.productId,
      variantId: purchase.variantId
    });
  } catch (error) {
    await Superfans.actions.showToast({
      title: "Something went wrong",
      message: "Could not open this product. Please try again."
    });
  }
}
async function addPurchaseToCart(purchase, button) {
  const originalText = button.textContent;
  button.disabled = true;
  button.textContent = "Adding...";
  try {
    await Superfans.actions.addToCart([{ variantId: purchase.variantId, quantity: 1 }]);
    await Superfans.actions.showToast({
      title: "Added to cart",
      message: purchase.productTitle || ""
    });
  } catch (error) {
    await Superfans.actions.showToast({
      title: "Something went wrong",
      message: "Please try again."
    });
  } finally {
    button.disabled = false;
    button.textContent = originalText;
  }
}
function buildCard(purchase) {
  const card = document.createElement("div");
  card.className = "rv-card";
  const media = document.createElement("div");
  media.className = "rv-card-media";
  if (purchase.imageUrl) {
    const img = document.createElement("img");
    img.className = "rv-card-image";
    img.src = purchase.imageUrl;
    img.alt = purchase.productTitle;
    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 = purchase.productTitle;
  body.appendChild(title);
  const lastOrderedText = formatLastOrdered(purchase.processedAt);
  if (lastOrderedText) {
    const meta = document.createElement("div");
    meta.className = "rv-card-meta";
    meta.textContent = lastOrderedText;
    body.appendChild(meta);
  }
  if (purchase.priceAmount != null) {
    const priceRow = document.createElement("div");
    priceRow.className = "rv-card-price";
    const priceEl = document.createElement("span");
    priceEl.className = "rv-price";
    priceEl.textContent = formatMoney(purchase.priceAmount, purchase.currencyCode);
    priceRow.appendChild(priceEl);
    if (
      purchase.compareAtAmount != null &&
      Number(purchase.compareAtAmount) > Number(purchase.priceAmount)
    ) {
      const compareEl = document.createElement("span");
      compareEl.className = "rv-compare";
      compareEl.textContent = formatMoney(purchase.compareAtAmount, purchase.currencyCode);
      priceRow.appendChild(compareEl);
    }
    body.appendChild(priceRow);
  }
  const addBtn = document.createElement("button");
  addBtn.className = "rv-add-btn";
  if (!purchase.available) {
    addBtn.textContent = "Sold Out";
    addBtn.disabled = true;
  } else {
    addBtn.textContent = "Add to Cart";
    if (buttonColor) {
      addBtn.style.background = buttonColor;
      addBtn.style.borderColor = buttonColor;
    }
    if (buttonTextColor) {
      addBtn.style.color = buttonTextColor;
    }
    addBtn.addEventListener("click", event => {
      event.stopPropagation();
      addPurchaseToCart(purchase, addBtn);
    });
  }
  body.appendChild(addBtn);
  card.appendChild(body);
  card.addEventListener("click", () => openPurchaseProduct(purchase));
  return card;
}
function renderPurchases(purchases) {
  reorderBlock.style.setProperty("--rv-ratio", parseRatio(imageRatio));
  reorderBlock.style.setProperty("--rv-cols", GRID_COLS);
  reorderList.className = `rv-list ${layoutType === "grid" ? "rv-list--grid" : "rv-list--carousel"}`;
  reorderList.innerHTML = "";
  let visible = purchases;
  if (maxItems > 0) {
    visible = visible.slice(0, maxItems);
  }
  // In grid layout, cap to a fixed number of rows.
  if (layoutType === "grid") {
    visible = visible.slice(0, gridRows * GRID_COLS);
  }
  visible.forEach(purchase => reorderList.appendChild(buildCard(purchase)));
  hideState();
  reorderList.hidden = false;
  resizeCurrentBlock();
}
async function init() {
  reorderTitle.textContent = blockTitle;
  reorderSubtitle.textContent = blockSubtitle;
  reorderBlock.hidden = false;
  const customer = Superfans.variables.customer;
  if (!customer?.id || !customer?.accessToken) {
    showState(LOGIN_PROMPT_TEXT, "Log in", () => {
      Superfans.actions.openAuthentication({ type: "login" });
    });
    return;
  }
  try {
    const purchases = await fetchPastPurchases();
    if (!purchases || !purchases.length) {
      showState(EMPTY_TEXT);
      return;
    }
    renderPurchases(purchases);
  } catch (error) {
    showState(ERROR_TEXT, "Retry", init);
  }
}
// Re-run when the customer logs in or out.
Superfans.listeners.onLoginStatusChanged(() => {
  init();
});
init();