
This block reads live subscription data, so it only runs inside a real customer portal. The animation shows what subscribers see.
What it does
Every other variant of a subscriber’s product, each with a badge and a one-tap Swap or Add.
Why it exists
For merchants selling one product in lots of flavors, scents or sizes. Subscribers see what else exists and can swap or add without leaving the portal.
The code
Paste it as-is, or download full-lineup.html<!--
FULL LINEUP — Skio custom block (self-contained: paste this whole file into
the block's single HTML field — one blob, style/script inline, no separate
CSS/JS fields).
============================== SETUP CHECKLIST ==============================
Before this does anything for a merchant, someone needs to:
1. Confirm the merchant actually fits: ONE product sold as several
standalone variants (flavors/scents/sizes) — NOT a variety pack/bundle
(Build-a-Box has its own swap mechanism and this isn't built for it).
2. Edit the VARIANTS array below: real flavor/variant labels + matchText
for each one you want tracked. Add or remove rows freely.
3. (Optional but recommended) Set up Quick Actions in the merchant's Skio
dashboard — Grow > Quick Actions — one "Add product variant" stack per
variant, and (if using Swap-via-Quick-Action) one "Swap product" stack
per SOURCE variant. Paste the stackIds into addStackId /
SWAP_STACKS_BY_SOURCE. Leave blank and it still works via the native
portal controls instead — just with an extra click for the customer.
4. Before relying on this for a real merchant, open THEIR portal's Products
card in DevTools and confirm the four data-skio-name selectors below
still match (see "WHAT'S CONFIRMED VS. NOT"). They were sourced from an
internal WIP capability map, not (yet) Skio's confirmed reference.
5. Only add a "badge" (the small social-proof pill) if it's something true
you can back up for THIS merchant — a real bestseller/subscriber fact,
a real review-platform stat. Don't carry over any example numbers.
===============================================================================
WHAT THIS SOLVES
Skio's native upsell carousel won't show a customer a product they already
subscribe to — including other variants of that same product. A merchant
who sells ONE product in many variants (flavors, scents, sizes) is always
"already subscribed to the product," so the whole carousel goes dark for
their most common setup. This block adds a dedicated, always-visible card
that surfaces the sibling variants instead.
HOW IT WORKS
1. Reads which of a merchant-curated list of sibling variants the customer
already has, off the product titles the portal already renders.
2. For everything else in that list, shows a card with two actions:
- "Swap to this" (replaces the current line)
- "Add as well" (adds alongside the current line)
Each action prefers a real, one-tap Quick Action link
(/a/account/quick-action-v3?stackId=...&subscriptionId=...) when the
merchant has that variant's stack configured in the Skio dashboard.
If not configured yet, it falls back to opening Skio's own native
Swap / Add Product control (scoped to the right line) and tells the
customer what to look for. Nothing here calls Skio's GraphQL API
directly, and nothing here embeds an API secret.
WHAT'S CONFIRMED VS. NOT (please read before shipping to a real merchant)
These four `data-skio-name` values are what this block depends on to find
the Products card and its controls — variant-card, variant-card-product-title,
variant-card-swap-button, products-card-add-product-button. They come from
an internal WIP capability map, NOT yet Skio's confirmed data-skio-name
reference. The single highest-value next step before relying on this at
scale for a new merchant: open THEIR real portal's Products card in
DevTools, right-click the variant row → Copy → Copy outerHTML, and confirm
these four selectors still match (or fix them here if not).
If the selectors don't match at all, this block fails safe — it just stays
hidden (see render()'s first check) rather than showing something broken.
-->
<!-- Placeholder font pairing (Cormorant Garamond + Work Sans) — swap for the
merchant's own brand fonts, or delete this <link> and the --sb-serif/
--sb-sans variables below to just inherit the portal's existing font.
If Skio's CSP ever blocks fonts.googleapis.com this line just silently
no-ops and the declared fallback stacks (Georgia / system sans) render
instead — nothing breaks either way. -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;1,500&family=Work+Sans:wght@500;600;700&display=swap">
<div class="sb-lineup" hidden>
<div class="sb-lineup__head">
<span class="sb-lineup__eyebrow">Also in this lineup</span>
<b class="sb-lineup__h">You're only trying one</b>
<p class="sb-lineup__p">This product comes in more ways than one. Swap to try something new, or add it alongside what you've already got — no need to leave your portal.</p>
</div>
<div class="sb-lineup__list" role="list"></div>
<p class="sb-lineup__status" role="status"></p>
</div>
<style>
.sb-lineup{
/* merchant override -> CPv3 theme -> our neutral fallback. Colour/radius
on the buttons is deliberately NOT !important (see :where() below) so
that if you also enable the Tailwind utility-class enhancement further
down, the merchant's real design-token classes can win instead.
The fallback palette below is a plain, brand-neutral placeholder
(slate/blue) — it's ONLY what renders if the merchant's own CPv3 theme
tokens (--color-clr-*) aren't present. Swap these hex values for the
merchant's brand colors if you want the out-of-the-box look to match
them more closely, but it's not required — their real tokens win first
either way. */
--sb-brand:var(--color-clr-btn-bg-primary,#1f2937);
--sb-brand-txt:var(--color-clr-btn-txt-primary,#ffffff);
--sb-accent:#2563eb;
--sb-ink:var(--color-clr-txt-heading,#111827);
--sb-hair:var(--color-clr-bdr,#e5e7eb);
--sb-surface:var(--color-clr-bg-primary,#ffffff);
--sb-tint:var(--color-clr-bg-secondary,#f9fafb);
--sb-tint-2:var(--color-clr-bg-tertiary,#f3f4f6);
--sb-muted:#6b7280;
--sb-focus:var(--color-focus-area,#2563eb);
--sb-radius:var(--radius-bdr-radius,10px);
--sb-bdr-w:var(--radius-bdr-width,1px);
--sb-serif:'Cormorant Garamond',Georgia,'Times New Roman',serif;
--sb-sans:'Work Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
container-type: inline-size;
}
:where(.sb-lineup) p,
:where(.sb-lineup) a,
:where(.sb-lineup) button,
:where(.sb-lineup) b,
:where(.sb-lineup) span{
font-family:inherit !important;font-size:inherit !important;font-weight:inherit !important;
font-style:inherit !important;line-height:inherit !important;letter-spacing:normal !important;
text-transform:none !important;text-align:inherit !important;color:inherit !important;
background:none !important;border:0 !important;border-radius:0 !important;
box-shadow:none !important;margin:0 !important;padding:0 !important;width:auto !important;
height:auto !important;min-width:0 !important;min-height:0 !important;max-width:none !important;
text-decoration:none !important;
}
:where(.sb-lineup) a,:where(.sb-lineup) button{appearance:none !important;-webkit-appearance:none !important;cursor:pointer !important}
.sb-lineup,.sb-lineup *{box-sizing:border-box}
.sb-lineup{
display:block !important;width:100% !important;margin:0 0 20px !important;
padding:17px 18px !important;background:var(--sb-surface) !important;
border:var(--sb-bdr-w) solid var(--sb-hair) !important;
border-left:4px solid var(--sb-accent) !important;
border-radius:var(--sb-radius) !important;
font-family:var(--sb-font,inherit) !important;line-height:1.5 !important;
}
.sb-lineup[hidden]{display:none !important}
.sb-lineup__eyebrow{display:block !important;font-family:var(--sb-sans) !important;font-size:10px !important;font-weight:700 !important;letter-spacing:.14em !important;text-transform:uppercase !important;color:var(--sb-accent) !important;margin:0 0 7px !important}
.sb-lineup__h{display:block !important;font-family:var(--sb-serif) !important;font-size:22px !important;font-weight:600 !important;line-height:1.2 !important;letter-spacing:.002em !important;color:var(--sb-ink) !important;margin:0 0 7px !important;text-wrap:balance !important}
.sb-lineup__p{margin:0 0 14px !important;font-size:13px !important;line-height:1.55 !important;color:var(--sb-muted) !important}
.sb-lineup__list{display:flex !important;gap:10px !important;overflow-x:auto !important;scroll-snap-type:x proximity !important;padding:2px 2px 6px !important;margin:0 !important;-webkit-overflow-scrolling:touch !important}
.sb-lineup__list::-webkit-scrollbar{height:6px !important}
.sb-lineup__list::-webkit-scrollbar-thumb{background:var(--sb-hair) !important;border-radius:6px !important}
.sb-lineup__card{scroll-snap-align:start !important;flex:0 0 auto !important;width:168px !important;padding:12px !important;background:var(--sb-tint) !important;border:1px solid var(--sb-hair) !important;border-radius:9px !important}
.sb-lineup__card-name{display:block !important;font-family:var(--sb-serif) !important;font-size:16px !important;font-weight:600 !important;line-height:1.25 !important;color:var(--sb-ink) !important;margin:0 0 10px !important}
.sb-lineup__card-actions{display:flex !important;flex-direction:column !important;gap:6px !important}
.sb-lineup__tag{display:block !important;font-family:var(--sb-sans) !important;font-size:10.5px !important;color:var(--sb-muted) !important;margin:5px 0 0 !important;font-style:italic !important}
.sb-lineup__card-badge{display:inline-block !important;font-family:var(--sb-sans) !important;font-size:9.5px !important;font-weight:700 !important;letter-spacing:.08em !important;text-transform:uppercase !important;color:var(--sb-brand) !important;background:var(--sb-tint-2) !important;border:1px solid var(--sb-hair) !important;border-radius:999px !important;padding:3px 8px !important;margin:0 0 8px !important}
.sb-lineup__card-desc{display:block !important;font-family:var(--sb-sans) !important;font-size:11.5px !important;line-height:1.45 !important;color:var(--sb-muted) !important;margin:0 0 10px !important}
/* Layout/sizing: kept !important — the portal's theme CSS is the thing
these need to win against, and Tailwind utilities don't compete here. */
.sb-lineup a.sb-lineup__btn,
.sb-lineup button.sb-lineup__btn{
display:inline-flex !important;align-items:center !important;justify-content:center !important;
width:100% !important;min-height:34px !important;padding:8px 10px !important;
font-family:var(--sb-sans) !important;font-size:12px !important;font-weight:600 !important;
letter-spacing:.01em !important;line-height:1.2 !important;white-space:nowrap !important;
}
.sb-lineup a.sb-lineup__btn:focus-visible,
.sb-lineup button.sb-lineup__btn:focus-visible{outline:2px solid var(--sb-focus) !important;outline-offset:2px !important}
.sb-lineup button.sb-lineup__btn[disabled]{opacity:.55 !important;cursor:default !important}
/* Colour/radius: !important, matching the rest of this file, so these win
over the blanket reset above by default (TW_PRIMARY_CLASSES is off by
default, so this fallback is what renders out of the box).
IF you turn TW_PRIMARY_CLASSES back on after confirming the exact class
names in DevTools on a real portal: delete "!important" from the four
background/color/border-radius declarations below so the merchant's real
design-token classes can win instead of this neutral fallback. */
.sb-lineup .sb-lineup__btn--swap{background:var(--sb-brand) !important;color:var(--sb-brand-txt) !important;border-radius:7px !important}
.sb-lineup .sb-lineup__btn--swap:hover{filter:brightness(.94) !important}
.sb-lineup .sb-lineup__btn--add{background:var(--sb-surface) !important;color:var(--sb-brand) !important;border:1px solid var(--sb-brand) !important;border-radius:7px !important}
.sb-lineup .sb-lineup__btn--add:hover{background:var(--sb-tint-2) !important}
.sb-lineup__status{margin:11px 0 0 !important;font-size:12.5px !important;color:var(--sb-muted) !important;min-height:1em !important}
.sb-lineup__status[data-tone="warn"]{color:#92551a !important;font-weight:600 !important}
@container (min-width:460px){
.sb-lineup__card{width:188px !important}
}
</style>
<script>
(function () {
// ---------------------------------------------------------------- CONFIG
// Edit per merchant. Only variants NOT already on the customer's
// subscription are shown.
//
// label — what the subscriber sees on the card
// matchText — lowercase substring identifying this variant in the
// portal's own product-title text
// (e.g. "vanilla" matches "Protein Powder - Vanilla")
// addStackId — OPTIONAL. A Skio Quick Actions stack id (dashboard >
// Grow > Quick Actions, action type "Add product variant")
// that adds this variant alongside the current line in
// one tap. Leave "" until it's set up — the button still
// works, just via the native picker instead.
// description — OPTIONAL. A short one-line note shown under the
// variant name (a tasting/scent note, etc). Leave "" to
// omit it for a given variant.
// badge — OPTIONAL. A small social-proof pill shown under the
// variant name (e.g. "Most popular", "Bestseller").
// Leave "" to omit. IMPORTANT — only put a claim here
// you can actually back up for THIS merchant (a real
// bestseller/subscriber fact, a real review-platform
// stat). Never invent a number.
//
// Swap has NO per-variant stack id here — see SWAP_STACKS_BY_SOURCE
// below instead. Skio's "Swap product" Quick Action groups several
// destination variants under ONE stack keyed by whichever variant the
// customer is CURRENTLY on, not by the destination, so it doesn't fit
// this per-row shape.
//
// EXAMPLE below — replace label/matchText with the merchant's real
// variants (add or remove rows freely), and fill in addStackId once
// you've set up Quick Actions for it. To find a variant's real
// productVariantId (handy when Skio's Quick Actions tool asks you to
// pick an exact variant), check the merchant's Products settings.
var VARIANTS = [
{ label: "Variant A", matchText: "variant a", addStackId: "",
description: "", badge: "" },
{ label: "Variant B", matchText: "variant b", addStackId: "",
description: "", badge: "" },
{ label: "Variant C", matchText: "variant c", addStackId: "",
description: "", badge: "" }
];
// Swap Quick Actions: one stack per possible CURRENT/starting variant
// (action type "Swap product"), each one listing the other variants as
// "Product(s) eligible for swap in." OFF by default (see
// PREFER_SWAP_QUICK_ACTION below) — Swap already has a same-page
// automation that works without leaving the portal (it opens the
// variant dropdown right on the page and clicks the chosen option
// directly). Routing Swap through a Quick Action instead means leaving
// the portal for a Skio-hosted confirm page — more friction, in exchange
// for going through Skio's own officially-supported action path instead
// of DOM automation. Keys below must exactly match the matchText values
// above (lowercase).
var SWAP_STACKS_BY_SOURCE = {
"variant a": "",
"variant b": "",
"variant c": ""
};
var PREFER_SWAP_QUICK_ACTION = false;
// Show the "Add" action as well as "Swap". Some merchants only want Swap.
var SHOW_ADD_ACTION = true;
// EXPERIMENTAL / UNVERIFIED — see the file header. Uncomment only after
// confirming these exact class names in DevTools on a real portal; until
// then the CSS variable fallback above is what's live.
// var TW_PRIMARY_CLASSES = "bg-button-bg-primary text-button-text-primary rounded-border-radius";
var TW_PRIMARY_CLASSES = "";
var DEBUG = true;
// ------------------------------------------------------------ END CONFIG
var CARD = "variant-card";
var TITLE = "variant-card-product-title";
var SWAP_BTN = "variant-card-swap-button";
var ADD_PRODUCT_BTN = "products-card-add-product-button";
var root = document.querySelector(".sb-lineup");
if (!root) return;
var list = root.querySelector(".sb-lineup__list");
var status = root.querySelector(".sb-lineup__status");
function log(msg) { if (DEBUG) console.log("[full-lineup block] " + msg); }
function say(msg, tone) {
status.textContent = msg || "";
if (tone) status.setAttribute("data-tone", tone); else status.removeAttribute("data-tone");
}
function sel(name) { return '[data-skio-name="' + name + '"]'; }
if (DEBUG) {
console.log("[full-lineup block] NOTE: variant-card / variant-card-product-title / " +
"variant-card-swap-button / products-card-add-product-button are sourced from an " +
"internal WIP capability map, not yet in Skio's confirmed data-skio-name reference. " +
"Confirm with a real DevTools 'Copy outerHTML' capture of the Products card before " +
"relying on this for a real merchant.");
}
// ---- subscription id + quick action link ----
function getSubscriptionId() {
// Testing hook only — inert in production. Nothing on a real portal
// page defines this global; it exists so a local mock/demo page (which
// has no real "/subscriptions/{id}" URL to read) can exercise the
// Quick Action path deterministically. Safe to leave in as shipped.
if (window.__SB_DEMO_SUBSCRIPTION_ID__) return window.__SB_DEMO_SUBSCRIPTION_ID__;
try {
var m = (window.location.href || "").match(/subscriptions\/([^/?#]+)/);
if (m) return m[1];
} catch (e) {}
// og:url fallback is harmless but never the only source — some gated
// account pages don't render it at all.
try {
var og = document.querySelector('meta[property="og:url"]');
var m2 = og && og.content && og.content.match(/subscriptions\/([^/?#]+)/);
if (m2) return m2[1];
} catch (e) {}
return null;
}
function quickActionUrl(stackId, subscriptionId) {
if (!stackId || !subscriptionId) return null;
try {
return window.location.origin + "/a/account/quick-action-v3?stackId=" +
encodeURIComponent(stackId) + "&subscriptionId=" + encodeURIComponent(subscriptionId);
} catch (e) { return null; }
}
// ---- confirmed-safe way to click a portal control ----
// The portal's interactive controls (variant dropdown trigger, Add
// Product button) are built with a component library (React Aria —
// visible via data-react-aria-pressable="true") that tracks real pointer
// captures internally. Faking a full pointerdown/pointerup/mousedown/
// mouseup sequence throws "Failed to execute 'releasePointerCapture'"
// inside the library's own handler and leaves the widget in a broken
// state, because synthetic events don't carry a real captured pointer id.
// React Aria's usePress explicitly supports a plain native "click" as
// its keyboard/assistive-tech activation path, so a bare .click() is
// both simpler and the thing that actually works here.
function press(el) {
if (!el) return;
try { el.click(); } catch (e) {}
}
function skioControl(name) {
var els = document.querySelectorAll(sel(name));
if (!els.length) return null;
var enabled = [];
for (var i = 0; i < els.length; i++) {
var e = els[i];
if (e.disabled === true) continue;
if (e.getAttribute("aria-disabled") === "true") continue;
enabled.push(e);
}
if (!enabled.length) return null;
for (var j = 0; j < enabled.length; j++) {
var c = enabled[j], visible;
if (typeof c.checkVisibility === "function") visible = c.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
else visible = c.offsetParent !== null || c.getClientRects().length > 0;
if (visible) return c;
}
return enabled[0];
}
function waitForControl(name, timeoutMs, done) {
var found = skioControl(name);
if (found) return done(found);
if (typeof MutationObserver !== "function") return done(null);
var settled = false;
var obs = new MutationObserver(function () {
if (settled) return;
var f = skioControl(name);
if (f) { settled = true; obs.disconnect(); clearTimeout(timer); done(f); }
});
obs.observe(document.documentElement, { childList: true, subtree: true });
var timer = setTimeout(function () { if (settled) return; settled = true; obs.disconnect(); done(null); }, timeoutMs);
}
function readCards() {
var out = [];
document.querySelectorAll(sel(CARD)).forEach(function (card) {
// Prefer the dedicated title element if present, but fall back to the
// card's STATIC text — a merchant's portal can render the product
// name and the variant name as two separate elements (a title line +
// a variant line below it), so matching against just the title
// element misses the variant name entirely. Reading the whole card
// is more forgiving of that, BUT a real portal can also put a native
// variant-swap <select> right inside the same card — its hidden
// <option> text lists every variant, which would make every variant
// look "already owned." So read a clone of the card with
// selects/options/buttons/links/inputs stripped out first, leaving
// only plain display text.
var t = card.querySelector(sel(TITLE));
var titleText = (t && t.textContent || "").trim();
var clone = card.cloneNode(true);
var noise = clone.querySelectorAll("select, option, button, a, input");
for (var k = 0; k < noise.length; k++) { noise[k].remove(); }
var wholeText = (clone.textContent || "").trim();
var text = (titleText + " " + wholeText).trim();
if (!text) return;
out.push({
card: card,
text: text.toLowerCase(),
titleText: titleText,
swap: card.querySelector(sel(SWAP_BTN)),
// The real "Swap" button opens Skio's generic cross-PRODUCT swap
// search, which can't find a variant — variants of the same
// product aren't separate searchable products. The visible thing
// a customer actually sees and clicks is NOT the plain <select> —
// that's a visually-hidden accessibility mirror
// (data-testid="hidden-select-container", clipped to 0x0) that a
// component library keeps around for forms/screen readers. The
// real control is this button (aria-haspopup="listbox"), which
// opens a floating options list. No confirmed data-skio-name for
// it yet, so found generically by its ARIA role.
flavorTrigger: card.querySelector('button[aria-haspopup="listbox"]')
});
});
return out;
}
// Clicks the variant-dropdown trigger, waits for its options popover to
// appear (it renders elsewhere in the DOM, not nested in the card), and
// clicks the option matching matchText. done(true) on success.
function pickFlavorFromListbox(trigger, matchText, timeoutMs, done) {
if (!trigger) return done(false);
press(trigger);
var settled = false;
function tryFind() {
var opts = document.querySelectorAll('[role="option"]');
for (var i = 0; i < opts.length; i++) {
if (opts[i].textContent.toLowerCase().indexOf(matchText) !== -1) return opts[i];
}
return null;
}
var found = tryFind();
if (found) { press(found); return done(true); }
if (typeof MutationObserver !== "function") return done(false);
var obs = new MutationObserver(function () {
if (settled) return;
var f = tryFind();
if (f) { settled = true; obs.disconnect(); clearTimeout(timer); press(f); done(true); }
});
obs.observe(document.documentElement, { childList: true, subtree: true });
var timer = setTimeout(function () { if (settled) return; settled = true; obs.disconnect(); done(false); }, timeoutMs);
}
var lastSignature = null;
function render() {
var cards = readCards();
if (!cards.length) { root.hidden = true; log("no variant-card found on this page"); return; }
var anchor = null, anchorMatchText = null, owned = {};
cards.forEach(function (c) {
VARIANTS.forEach(function (v) {
if (c.text.indexOf(v.matchText.toLowerCase()) !== -1) {
owned[v.matchText] = true;
if (!anchor) { anchor = c; anchorMatchText = v.matchText; }
}
});
});
if (!anchor) { root.hidden = true; log("customer isn't on a tracked variant — nothing to show"); return; }
var remaining = VARIANTS.filter(function (v) { return !owned[v.matchText]; });
if (!remaining.length) { root.hidden = true; log("customer already has every configured variant"); return; }
// Skip rebuilding the list if nothing has actually changed. The portal
// re-renders for all sorts of unrelated reasons (its own React updates),
// and our shared MutationObserver fires render() on every one of them.
// Rebuilding the buttons every time was destroying the very button a
// customer had just clicked, mid-click — which is why "Swap"/"Add"
// sometimes needed several tries before anything visibly happened.
var signature = anchor.text + "::" + remaining.map(function (v) { return v.matchText; }).join(",");
if (signature === lastSignature) { root.hidden = false; return; }
lastSignature = signature;
var subscriptionId = getSubscriptionId();
var productName = (anchor.titleText || "").trim();
// One shared Swap link for every card this render — see
// SWAP_STACKS_BY_SOURCE above for why this is keyed by the customer's
// CURRENT variant rather than by each card's destination variant. Only
// built at all when PREFER_SWAP_QUICK_ACTION is on; otherwise every
// card uses the same-page native automation (the default).
var sharedSwapHref = PREFER_SWAP_QUICK_ACTION
? quickActionUrl(SWAP_STACKS_BY_SOURCE[anchorMatchText] || "", subscriptionId)
: null;
list.innerHTML = "";
remaining.forEach(function (v) {
var card = document.createElement("div");
card.className = "sb-lineup__card";
card.setAttribute("role", "listitem");
var name = document.createElement("b");
name.className = "sb-lineup__card-name";
name.textContent = v.label;
card.appendChild(name);
if (v.badge) {
var badge = document.createElement("span");
badge.className = "sb-lineup__card-badge";
badge.textContent = v.badge;
card.appendChild(badge);
}
if (v.description) {
var desc = document.createElement("p");
desc.className = "sb-lineup__card-desc";
desc.textContent = v.description;
card.appendChild(desc);
}
var actions = document.createElement("div");
actions.className = "sb-lineup__card-actions";
// ---- Swap ----
var swapHref = sharedSwapHref;
if (swapHref) {
var swapLink = document.createElement("a");
swapLink.href = swapHref;
swapLink.className = "sb-lineup__btn sb-lineup__btn--swap" + (TW_PRIMARY_CLASSES ? " " + TW_PRIMARY_CLASSES : "");
swapLink.textContent = "Swap to this";
actions.appendChild(swapLink);
} else {
var swapBtn = document.createElement("button");
swapBtn.type = "button";
swapBtn.className = "sb-lineup__btn sb-lineup__btn--swap" + (TW_PRIMARY_CLASSES ? " " + TW_PRIMARY_CLASSES : "");
swapBtn.textContent = "Swap to this";
swapBtn.addEventListener("click", function () {
var live = readCards().filter(function (c) { return c.text === anchor.text; })[0];
// Prefer the variant <select> on the card — the "Swap" button
// instead opens a cross-PRODUCT search that can never find a
// same-product variant and just shows "No products found."
// Point the customer at the control that actually works instead
// of pressing the wrong one.
var trigger = (live && live.flavorTrigger) || anchor.flavorTrigger;
if (trigger) {
trigger.scrollIntoView({ behavior: "smooth", block: "center" });
say("Switching to " + v.label + "…");
pickFlavorFromListbox(trigger, v.matchText.toLowerCase(), 3000, function (ok) {
say(ok
? "Done — switched to " + v.label + "."
: "Opened the variant list — choose \"" + v.label + "\" from it.");
});
return;
}
var target = (live && live.swap) || anchor.swap;
if (!target) { say("Couldn't find the swap control for this line — try refreshing.", "warn"); return; }
press(target);
say("Opening the swap picker — look for " + v.label + " in the list.");
});
actions.appendChild(swapBtn);
var swapTag = document.createElement("span");
swapTag.className = "sb-lineup__tag";
swapTag.textContent = "Quick action not set up yet";
actions.appendChild(swapTag);
}
// ---- Add ----
if (SHOW_ADD_ACTION) {
var addHref = quickActionUrl(v.addStackId, subscriptionId);
if (addHref) {
var addLink = document.createElement("a");
addLink.href = addHref;
addLink.className = "sb-lineup__btn sb-lineup__btn--add";
addLink.textContent = "Add as well";
actions.appendChild(addLink);
} else {
var addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "sb-lineup__btn sb-lineup__btn--add";
addBtn.textContent = "Add as well";
addBtn.addEventListener("click", function () {
say("Opening Add Product…");
waitForControl(ADD_PRODUCT_BTN, 4000, function (found) {
if (!found) { say("Couldn't open Add Product automatically — use the Add Product button above.", "warn"); return; }
press(found);
// That search box looks up PRODUCTS, not variants — a
// variant name alone returns "No products found." Search the
// product's own name instead, then pick the variant once
// that product is selected.
say(productName
? "Search for \"" + productName + "\" in the list that opens, then choose " + v.label + "."
: "Search for this product in the list that opens, then choose " + v.label + ".");
});
});
actions.appendChild(addBtn);
var addTag = document.createElement("span");
addTag.className = "sb-lineup__tag";
addTag.textContent = "Quick action not set up yet";
actions.appendChild(addTag);
}
}
card.appendChild(actions);
list.appendChild(card);
});
root.hidden = false;
}
// Shared, page-wide mutation watcher: if another block on this page (there
// can be several now that Skio supports multiple custom blocks) already
// installed window.__skioDetect, reuse its single observer instead of
// adding another one.
if (!window.__skioDetect) {
var callbacks = [];
var pending = null;
function runAll() { callbacks.forEach(function (fn) { try { fn(); } catch (e) {} }); }
var sharedObs = new MutationObserver(function () { clearTimeout(pending); pending = setTimeout(runAll, 150); });
sharedObs.observe(document.documentElement, { childList: true, subtree: true });
window.__skioDetect = { onChange: function (fn) { callbacks.push(fn); fn(); } };
}
window.__skioDetect.onChange(render);
})();
</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.
Built for one product sold as standalone variants, not variety packs. Edit the VARIANTS array. For one-tap Swap and Add, create Quick Action stacks under Grow → Quick Actions and paste their IDs. Check the data-skio-name selectors against your portal first.
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: Full Lineup (No. 06) Page: /blocks/full-lineup What it does: Every other variant of a subscriber’s product, each with a badge and a one-tap Swap or Add. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Built for one product sold as standalone variants, not variety packs. Edit the VARIANTS array. For one-tap Swap and Add, create Quick Action stacks under Grow → Quick Actions and paste their IDs. Check the data-skio-name selectors against your portal first. 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 full-lineup.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 full-lineup-<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.
