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 Name | Unique Id | Input Type |
|---|---|---|
| Block Title | block-title | string |
| Block Subtitle | block-subtitle | string |
| Layout Type | layout-type | dropdown (carousel, grid) |
| Image Ratio | image-ratio | dropdown (yes, no) |
| Grid Rows | grid-rows | string |
| Max Items | max-items | string |
| Add to Cart Color | add-to-cart-button-color | string |
| Add to Cart Button Text Color | add-to-cart-button-text-color | string |
<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();