
This block reads live subscription data, so it only runs inside a real customer portal. The animation shows what subscribers see.
What it does
A guided tour of the portal for new subscribers, with a gift waiting at the end.
Why it exists
For merchants who want an onboarding experience inside the portal. New subscribers learn the things they can change without canceling, and collect a stamp for each one.
The code
Paste it as-is, or download portal-passport.html<!-- ==========================================================================
PORTAL PASSPORT — Skio CPv3 custom block
Ten stamps for the ten things a customer can change without cancelling,
a spotlight tour that walks them, and 50 loyalty credits for a full page.
Paste this whole thing into the custom embedded block field.
Edit STEPS below; everything else can stay as-is.
REWARD_CREDITS is display copy — the Worker decides the real payout.
========================================================================== -->
<div class="sk-tour" data-sk-tour hidden>
<!-- the passport page. The header doubles as the collapse toggle: collapsed,
the whole block is that ruled strip and nothing else. -->
<div class="sk-tour__menu" hidden>
<div class="sk-tour__page" data-collapsed="0">
<div class="sk-tour__head">
<button type="button" class="sk-tour__toggle" aria-expanded="true" aria-controls="sk-tour-body">
<b class="sk-tour__h">Subscription passport</b>
<span class="sk-tour__count"></span>
<span class="sk-tour__chev" aria-hidden="true">
<svg viewBox="0 0 14 14" width="13" height="13" focusable="false">
<path d="M3 5.5 L7 9.5 L11 5.5" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="square"></path>
</svg>
</span>
</button>
<button type="button" class="sk-tour__put" aria-label="Put the passport away">×</button>
</div>
<div class="sk-tour__body" id="sk-tour-body">
<div class="sk-tour__chips"></div>
<div class="sk-tour__foot">
<button type="button" class="sk-tour__cta">Collect your stamps</button>
<span class="sk-tour__hint">or pick one to jump to it</span>
</div>
</div>
</div>
</div>
<!-- expanded, hero variant: used when LAUNCHER is "hero" -->
<div class="sk-tour__hero" hidden>
<div class="sk-tour__herotxt">
<b class="sk-tour__heroh">You're in control here</b>
<p class="sk-tour__herop">Skip an order, move a date, change how often it arrives or swap what's
in it — all from this page, in a couple of taps.</p>
<div class="sk-tour__herofoot">
<button type="button" class="sk-tour__cta">Take the tour</button>
<span class="sk-tour__count"></span>
</div>
</div>
<div class="sk-tour__herovis" aria-hidden="true">
<svg viewBox="0 0 220 132" width="100%" height="auto" focusable="false">
<rect x="8" y="10" width="204" height="24" rx="6" class="sk-v-soft"></rect>
<rect x="8" y="44" width="94" height="34" rx="7" class="sk-v-ring"></rect>
<rect x="112" y="44" width="100" height="34" rx="7" class="sk-v-soft"></rect>
<rect x="8" y="88" width="204" height="34" rx="7" class="sk-v-soft"></rect>
<rect x="20" y="55" width="50" height="6" rx="3" class="sk-v-line"></rect>
<rect x="124" y="55" width="62" height="6" rx="3" class="sk-v-line"></rect>
<rect x="20" y="99" width="120" height="6" rx="3" class="sk-v-line"></rect>
<rect x="20" y="19" width="70" height="6" rx="3" class="sk-v-line"></rect>
</svg>
</div>
</div>
<!-- collapsed: once everything has been seen -->
<div class="sk-tour__slim" hidden>
<span class="sk-tour__slimtxt">Ten things you can change here, without cancelling</span>
<button type="button" class="sk-tour__replay">Replay tour</button>
<button type="button" class="sk-tour__close" aria-label="Hide this">×</button>
</div>
</div>
<style>
.sk-tour{
--sk-brand:var(--color-clr-btn-bg-primary,#14504f);
--sk-brand-txt:var(--color-clr-btn-txt-primary,#ffffff);
--sk-ink:var(--color-clr-txt-heading,#14181f);
--sk-body:var(--color-clr-txt-general,#48545e);
--sk-hair:var(--color-clr-bdr,#dbe3ef);
--sk-surface:var(--color-clr-bg-primary,#ffffff);
--sk-tint:var(--color-clr-bg-secondary,#f4f7fc);
--sk-radius:var(--radius-bdr-radius,12px);
}
.sk-tour,.sk-tour *{box-sizing:border-box}
.sk-tour[hidden],.sk-tour__menu[hidden],.sk-tour__slim[hidden]{display:none !important}
/* ---- the passport page ------------------------------------------------- */
.sk-tour__menu{margin:0 0 16px !important}
.sk-tour__page{position:relative !important;overflow:hidden !important;
background:#F7F2E7 !important;border:1px solid #C9BFA6 !important;
box-shadow:inset 0 0 0 1px #F7F2E7, inset 0 0 0 4px #C9BFA6 !important;
padding:20px 22px !important;font-family:inherit !important}
/* Guilloche is a pseudo-element, not an empty div — `div:empty{display:none}` in a
merchant theme deletes empty decorative elements outright. Two crossed gradient
grids read as woven security paper and cost nothing to paint. */
.sk-tour__page::before{content:"" !important;position:absolute !important;inset:0 !important;
pointer-events:none !important;opacity:.5 !important;
background-image:
repeating-linear-gradient(45deg,#C9BFA6 0 1px,transparent 1px 7px),
repeating-linear-gradient(-45deg,#C9BFA6 0 1px,transparent 1px 7px) !important}
.sk-tour__page > *{position:relative !important}
.sk-tour__head{display:flex !important;align-items:center !important;gap:8px !important;
margin:0 0 4px !important;
border-bottom:1px solid #C9BFA6 !important;padding-bottom:9px !important}
.sk-tour__toggle{flex:1 1 auto !important;min-width:0 !important;display:flex !important;
align-items:baseline !important;gap:12px !important;flex-wrap:wrap !important;
appearance:none !important;background:none !important;border:0 !important;padding:0 !important;
margin:0 !important;font:inherit !important;color:inherit !important;text-align:left !important;
cursor:pointer !important}
.sk-tour__h{flex:1 1 200px !important;font-size:13px !important;font-weight:700 !important;
letter-spacing:.17em !important;text-transform:uppercase !important;color:#4A4133 !important}
.sk-tour__count{font-size:11px !important;letter-spacing:.13em !important;
text-transform:uppercase !important;color:#8A7F6B !important;font-weight:700 !important}
.sk-tour__chev{flex:0 0 auto !important;align-self:center !important;display:block !important;
line-height:0 !important;color:#8A7F6B !important;
transform:rotate(0deg) !important}
.sk-tour__chev svg{display:block !important}
.sk-tour__toggle:hover .sk-tour__h,.sk-tour__toggle:hover .sk-tour__chev{color:#2E2A22 !important}
/* Putting it away is only offered once the page is closed — no dismissing a page
you are looking at. */
.sk-tour__put{appearance:none !important;background:none !important;border:0 !important;
font:inherit !important;font-size:17px !important;line-height:1 !important;
padding:1px 2px 3px !important;color:#8A7F6B !important;cursor:pointer !important;
flex:0 0 auto !important;align-self:center !important;display:none !important}
.sk-tour__put:hover{color:#2E2A22 !important}
/* ---- collapsed: header strip only -------------------------------------- */
.sk-tour__page[data-collapsed="1"]{padding:13px 22px 11px !important}
.sk-tour__page[data-collapsed="1"] .sk-tour__head{margin-bottom:0 !important;
padding-bottom:10px !important}
.sk-tour__page[data-collapsed="1"] .sk-tour__body,
.sk-tour__page[data-collapsed="1"] .sk-tour__done{display:none !important}
.sk-tour__page[data-collapsed="1"] .sk-tour__chev{transform:rotate(-90deg) !important}
.sk-tour__page[data-collapsed="1"] .sk-tour__put{display:block !important}
/* The plain fallback card has nothing to collapse. */
.sk-tour__page[data-plain="1"] .sk-tour__chev{display:none !important}
.sk-tour__page[data-plain="1"] .sk-tour__toggle{cursor:default !important;
pointer-events:none !important}
.sk-tour__chips{display:grid !important;
grid-template-columns:repeat(auto-fit,minmax(104px,1fr)) !important;
gap:6px 4px !important;margin:14px 0 4px !important}
.sk-tour__stamp{appearance:none !important;background:none !important;border:0 !important;
cursor:pointer !important;font:inherit !important;padding:4px 2px 8px !important;
display:flex !important;flex-direction:column !important;align-items:center !important;
gap:5px !important;min-width:0 !important}
/* Two nested spans on purpose: the outer animates, the inner holds the static tilt.
One element cannot do both — a hardened transform kills the keyframe, and an
unhardened one gets overwritten by the animation. */
.sk-tour__thunk{display:block !important;line-height:0 !important}
.sk-tour__tilt{display:block !important;line-height:0 !important}
.sk-tour__stamp svg{display:block !important;width:74px !important;height:74px !important}
.sk-tour__label{font-size:10.5px !important;line-height:1.25 !important;text-align:center !important;
letter-spacing:.04em !important;text-transform:uppercase !important;font-weight:700 !important;
color:#6B6152 !important;max-width:100% !important}
.sk-tour__stamp[data-earned="0"] .sk-tour__label{color:#A9A08D !important;font-weight:600 !important}
.sk-tour__stamp:hover .sk-tour__label{color:#2E2A22 !important}
.sk-tour__foot{display:flex !important;align-items:center !important;gap:12px !important;
flex-wrap:wrap !important;border-top:1px solid #C9BFA6 !important;padding-top:13px !important;
margin-top:6px !important}
.sk-tour__cta{appearance:none !important;cursor:pointer !important;border:0 !important;
border-radius:2px !important;background:#2E2A22 !important;color:#F7F2E7 !important;
font:inherit !important;font-size:12px !important;font-weight:700 !important;
letter-spacing:.11em !important;text-transform:uppercase !important;
padding:11px 18px !important;line-height:1.2 !important}
.sk-tour__cta:hover{background:#4A4133 !important}
.sk-tour__hint{font-size:11px !important;color:#8A7F6B !important;letter-spacing:.05em !important;
display:flex !important;align-items:center !important;gap:8px !important;min-width:0 !important}
.sk-tour__hint[data-reward="1"]{font-size:11.5px !important;font-weight:700 !important;
text-transform:uppercase !important;letter-spacing:.09em !important;color:#6B5A33 !important}
.sk-tour__seal{flex:0 0 auto !important;display:block !important;line-height:0 !important}
.sk-tour__seal svg{display:block !important}
/* The completion stamp sits over the filled page. */
.sk-tour__done{position:absolute !important;inset:0 !important;display:flex !important;
align-items:center !important;justify-content:center !important;pointer-events:none !important;
z-index:3 !important}
.sk-tour__done svg{width:min(232px,62%) !important;height:auto !important;opacity:.92 !important}
@media (prefers-reduced-motion:no-preference){
@keyframes skStampIn{from{opacity:0;transform:scale(1.24)}to{opacity:1;transform:none}}
@keyframes skDoneIn{from{opacity:0;transform:scale(1.5) rotate(-2deg)}
to{opacity:.92;transform:scale(1) rotate(0)}}
.sk-tour__thunk{animation:skStampIn 340ms cubic-bezier(.2,1.5,.4,1) both;
animation-delay:calc(var(--i,0) * 45ms)}
.sk-tour__done svg{animation:skDoneIn 520ms 260ms cubic-bezier(.2,1.5,.4,1) both}
.sk-tour__stamp{transition:transform 140ms ease}
.sk-tour__stamp:hover{transform:translateY(-2px)}
/* Safe to harden the chevron's transform above: a transition outranks
`!important`, where a keyframe does not. */
.sk-tour__chev{transition:transform 180ms ease}
}
.sk-tour__slim{display:flex !important;align-items:center !important;gap:12px !important;
flex-wrap:wrap !important;border:1px solid var(--sk-hair) !important;
border-radius:var(--sk-radius) !important;background:var(--sk-surface) !important;
padding:11px 14px !important;margin:0 0 16px !important;font-family:inherit !important}
.sk-tour__slimtxt{flex:1 1 180px !important;font-size:13.5px !important;
color:var(--sk-body) !important;min-width:0 !important}
.sk-tour__replay{appearance:none !important;cursor:pointer !important;
border:1px solid var(--sk-hair) !important;border-radius:8px !important;
background:transparent !important;color:var(--sk-ink) !important;font:inherit !important;
font-size:12.5px !important;font-weight:600 !important;padding:7px 13px !important}
.sk-tour__replay:hover{border-color:var(--sk-brand) !important}
.sk-tour__close{appearance:none !important;cursor:pointer !important;background:none !important;
border:0 !important;color:var(--sk-body) !important;font:inherit !important;
font-size:18px !important;line-height:1 !important;padding:4px 6px !important;opacity:.6 !important}
.sk-tour__close:hover{opacity:1 !important}
/* The tour overlay is NOT styled here on purpose. CPv3 scopes this stylesheet
inside #skio-root and the overlay lives on <body>, outside that scope, so
these rules would be dropped in the portal while still winning locally. It
styles itself in JS at !important priority instead. */
</style>
<script>
(function () {
"use strict";
/* ---------------------------------------------------------------- config */
var STEPS = [
{ name: "generic-action-button-skip", cat: "Timing", chip: "Skip a delivery",
title: "Skip a delivery",
body: "Skips the whole upcoming order in one tap. Your schedule picks up as normal after it." },
{ name: "subscription-header-next-date-button", cat: "Timing", chip: "Adjust next date",
title: "Adjust your next delivery date",
body: "Stocked up? Push this order back a week or a month. Everything else stays the same." },
{ name: "subscription-header-edit-frequency-button", cat: "Timing", chip: "Change frequency",
title: "Change delivery frequency",
body: "Piling up? Change how often it arrives — every 8 weeks instead of every 4." },
{ name: "variant-card-swap-button", cat: "Products", chip: "Swap a product",
title: "Swap a product",
body: "Change what's in your order without touching the rest of the subscription." },
{ name: "variant-card-skip-button", cat: "Products", chip: "Skip one product",
title: "Skip a single product",
body: "Skips just this item on the next order. Everything else still ships as normal." },
{ name: "products-card-add-product-button", cat: "Products", chip: "Add a product",
title: "Add a product",
body: "Add something to your next order. It ships together, with no extra delivery." },
{ name: "generic-action-button-order-now", cat: "Details", chip: "Get it sooner",
title: "Get it sooner",
body: "Ran out early? Send the next order now and the schedule shifts forward with it." },
{ name: "shipping-card-edit-button", cat: "Details", chip: "Shipping address",
title: "Edit your shipping address",
body: "Moved, or sending it somewhere else? Update the address before the next order." },
{ name: "billing-card-edit-button", cat: "Details", chip: "Payment method",
title: "Edit your payment method",
body: "New card, or one about to expire? Update it here so the next order doesn't fail." },
{ name: "more-actions-dropdown-trigger-button", cat: "More", chip: "Cancel or pause",
title: "Cancel your subscription",
body: "Cancelling, pausing and everything else lives behind More. No emailing anyone." }
];
// "menu" = action grid with per-step chips (option A, collapses to the slim bar)
// "hero" = single headline card with a visual (option C, also collapses)
var LAUNCHER = "menu";
var MIN_STEPS = 3; // below this, show a plain card instead of the menu
var KEY = "skio-tour-v2";
var OLD_KEY = "skio-tour-seen-v1";
var BOOT_MS = 10000; // hard deadline before we give up waiting
var QUIET_MS = 800; // anchor count must stop changing for this long
var MIN_WAIT_MS = 2000; // ...and we never settle before this
var POLL_MS = 200;
var LATE_MS = 10000; // keep watching this long for a late-rendering portal
// Reward for finishing the tour. Leave REWARD_ENDPOINT empty to disable.
// The block never decides the amount or who it is — the Worker reads both from
// a verified session token, because anything paying out from client code is a
// payout the customer can rewrite.
var REWARD_ENDPOINT = ""; // your own Worker URL, e.g. "https://your-rewards.your-account.workers.dev"
// Display only. The Worker decides the real payout from a verified session token
// — this number just tells the customer what is on offer. Change it in the Worker
// first, then match it here, or the block will advertise the wrong figure.
var REWARD_CREDITS = 50;
var SHEET_MAX = 480; // below this width the card docks to the bottom
var FALLBACK = "Change your delivery date or how often it arrives, skip a single order, swap a product, or send your next order early — all without cancelling.";
/* ------------------------------------------------------------- utilities */
function track(event, detail) {
try {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(Object.assign({ event: "skio_tour_" + event }, detail || {}));
} catch (e) {}
}
// Every declaration goes in inline at !important priority — the top of the
// author cascade. This is not belt-and-braces: a single theme rule such as
// `div:empty{display:none}` deletes the dim panels, the ring, the beak and the
// progress ticks (all empty elements) while leaving the card, which has
// children, rendering perfectly. That looks exactly like "the highlight is
// broken" and nothing in this file is wrong.
function css(el, o) {
if (!el) return;
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) {
try {
el.style.setProperty(
k.replace(/[A-Z]/g, function (m) { return "-" + m.toLowerCase(); }),
String(o[k]), "important");
} catch (e) { try { el.style[k] = o[k]; } catch (e2) {} }
}
}
// A node counts only if it is really on screen. The portal renders BOTH the
// mobile and web copies of some cards, and anything inside the more-actions
// dropdown is present but collapsed — so "exists in the DOM" is not the same
// question as "can be pointed at".
function visible(el) {
if (!el) return false;
if (typeof el.checkVisibility === "function") {
if (!el.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true })) return false;
} else if (!el.offsetParent && getComputedStyle(el).position !== "fixed") {
return false;
}
var r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1;
}
function resolve(name) {
var all = document.querySelectorAll('[data-skio-name="' + name + '"]');
for (var i = 0; i < all.length; i++) if (visible(all[i])) return all[i];
return null;
}
function collect() {
var out = [];
for (var i = 0; i < STEPS.length; i++) {
var el = resolve(STEPS[i].name);
if (el) out.push({ el: el, name: STEPS[i].name, cat: STEPS[i].cat,
chip: STEPS[i].chip, title: STEPS[i].title, body: STEPS[i].body });
}
return out;
}
/* ---------------------------------------------------------------- reward */
// The portal keeps many Cognito tokens, including unexpired ones belonging to
// other accounts. Resolve via LastAuthUser and confirm the token is current AND
// carries this customer's id before sending it anywhere.
function idToken() {
var uid = null;
try { uid = window.getStorefrontUserId && window.getStorefrontUserId(); } catch (e) {}
function claims(tok) {
try {
var p = String(tok).split(".");
if (p.length !== 3) return null;
var b = p[1].replace(/-/g, "+").replace(/_/g, "/");
while (b.length % 4) b += "=";
return JSON.parse(decodeURIComponent(atob(b).split("").map(function (c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); }).join("")));
} catch (e) { return null; }
}
function usable(tok) {
var c = claims(tok);
if (!c || c.token_use !== "id") return null;
if (!c.exp || c.exp * 1000 <= Date.now() + 30000) return null;
if (uid && c["custom:storefrontUserId"] !== uid) return null;
return tok;
}
var ls;
try { ls = window.localStorage; if (!ls) return null; } catch (e) { return null; }
var keys = [];
try { for (var i = 0; i < ls.length; i++) keys.push(ls.key(i)); } catch (e) {}
for (var j = 0; j < keys.length; j++) {
var m = /^(CognitoIdentityServiceProvider\.[^.]+)\.LastAuthUser$/.exec(keys[j]);
if (!m) continue;
var t = usable(ls.getItem(m[1] + "." + ls.getItem(keys[j]) + ".idToken"));
if (t) return t;
}
for (var k = 0; k < keys.length; k++) {
if (!/\.idToken$/.test(keys[k])) continue;
var t2 = usable(ls.getItem(keys[k]));
if (t2) return t2;
}
return null;
}
function claimReward(cb) {
if (!REWARD_ENDPOINT) return;
var tok = idToken();
if (!tok) return;
fetch(REWARD_ENDPOINT + "/claim?for=tour", {
method: "POST", headers: { authorization: "Bearer " + tok }
}).then(function (r) { return r.json(); })
.then(function (res) {
var granted = !!(res && res.granted), already = !!(res && res.alreadyClaimed);
track("reward", { granted: granted, already: already });
// "Already claimed" matters as much as a grant: it is how the block learns
// not to keep advertising an offer this customer has already taken.
if (granted) cb(res.credits, "granted");
else if (already) cb(null, "already");
})
.catch(function () {}); // a reward that fails must never break the tour
}
/* --------------------------------------------------------------- storage */
function load() {
try {
var raw = localStorage.getItem(KEY);
if (raw) {
var s = JSON.parse(raw);
if (s && Object.prototype.toString.call(s.seen) === "[object Array]") return s;
}
// migrate the old boolean: anyone who already finished stays collapsed
if (localStorage.getItem(OLD_KEY) === "1") return { seen: [], done: true, dismissed: false };
} catch (e) {}
return { seen: [], done: false, dismissed: false };
}
function save(s) { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) {} }
var state = load();
function markSeen(name) {
if (state.seen.indexOf(name) === -1) { state.seen.push(name); save(state); }
}
/* ---------------------------------------------------------------- the tour */
function runTour(steps, startAt, onDone) {
var idx = startAt || 0, lastFocus = document.activeElement, cleanup = [], placed = false;
var reduce = false;
try { reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
var brand = "#ffffff";
try {
var b = getComputedStyle(document.querySelector("[data-sk-tour]"))
.getPropertyValue("--sk-brand").trim();
if (b) brand = b;
} catch (e) {}
var layer = document.createElement("div");
layer.className = "sk-tourx";
layer.setAttribute("role", "dialog");
layer.setAttribute("aria-modal", "false");
layer.setAttribute("aria-label", "Portal tour");
css(layer, { position: "fixed", top: "0", right: "0", bottom: "0", left: "0",
zIndex: "2147483000", pointerEvents: "none", margin: "0",
font: '14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif' });
// Dimming is four solid panels, NOT one element with a huge box-shadow spread.
// A 9999px spread asks the compositor for a ~20,000px paint box, which some
// pages and GPUs drop outright — the dim silently vanishes while the rest of
// the overlay still renders.
var DIM = "rgba(10,16,20,.58)";
function panel() {
var d = document.createElement("div");
d.className = "sk-tourx__dim";
css(d, { position: "fixed", background: DIM, pointerEvents: "none",
top: "0", left: "0", width: "0", height: "0", display: "block" });
return d;
}
var dTop = panel(), dBottom = panel(), dLeft = panel(), dRight = panel();
var ring = document.createElement("div");
css(ring, { position: "fixed", top: "0", left: "0", width: "0", height: "0",
borderRadius: "8px", boxSizing: "border-box", pointerEvents: "none",
display: "block", border: "2px solid " + brand,
transition: reduce ? "none"
: "top .22s ease,left .22s ease,width .22s ease,height .22s ease" });
var beak = document.createElement("div");
css(beak, { position: "fixed", width: "0", height: "0", pointerEvents: "none",
display: "block", borderLeft: "9px solid transparent",
borderRight: "9px solid transparent", top: "0", left: "0" });
var pop = document.createElement("div");
pop.tabIndex = -1;
pop.innerHTML =
'<span class="sk-tourx__cat"></span>' +
'<b class="sk-tourx__h"></b>' +
'<p class="sk-tourx__p"></p>' +
'<div class="sk-tourx__ticks"></div>' +
'<div class="sk-tourx__row">' +
'<button type="button" class="sk-tourx__skip">Skip tour</button>' +
'<button type="button" class="sk-tourx__back" hidden>Back</button>' +
'<button type="button" class="sk-tourx__next"></button>' +
'</div>';
layer.appendChild(dTop); layer.appendChild(dBottom);
layer.appendChild(dLeft); layer.appendChild(dRight);
layer.appendChild(ring); layer.appendChild(beak); layer.appendChild(pop);
document.body.appendChild(layer);
var elCat = pop.querySelector(".sk-tourx__cat"),
elH = pop.querySelector(".sk-tourx__h"),
elP = pop.querySelector(".sk-tourx__p"),
elTicks = pop.querySelector(".sk-tourx__ticks"),
elRow = pop.querySelector(".sk-tourx__row"),
elSkip = pop.querySelector(".sk-tourx__skip"),
elBack = pop.querySelector(".sk-tourx__back"),
elNext = pop.querySelector(".sk-tourx__next");
css(elCat, { display: "block", fontSize: "10.5px", letterSpacing: ".12em",
textTransform: "uppercase", color: brand, fontWeight: "700",
margin: "0 0 5px" });
css(elH, { display: "block", fontSize: "15.5px", fontWeight: "660",
margin: "0 0 5px", lineHeight: "1.3", color: "#14181f" });
css(elP, { margin: "0 0 12px", fontSize: "13.5px", color: "#48545e", lineHeight: "1.5" });
css(elTicks, { display: "flex", gap: "3px", margin: "0 0 13px" });
css(elRow, { display: "flex", alignItems: "center", gap: "8px", margin: "0" });
css(elSkip, { appearance: "none", background: "none", border: "0", cursor: "pointer",
font: "inherit", fontSize: "12.5px", color: "#7b8794",
padding: "6px 2px", textDecoration: "underline" });
css(elBack, { appearance: "none", cursor: "pointer", background: "none",
border: "1px solid #d7dee6", borderRadius: "8px", color: "#48545e",
font: "inherit", fontSize: "13px", padding: "8px 12px" });
css(elNext, { marginLeft: "auto", appearance: "none", cursor: "pointer", border: "0",
borderRadius: "8px", background: brand, color: "#ffffff",
font: "inherit", fontSize: "13px", fontWeight: "600", padding: "9px 16px" });
// one tick per step, filled as you go, clickable to jump
var ticks = [];
for (var t = 0; t < steps.length; t++) {
(function (n) {
var b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-label", "Go to step " + (n + 1) + ": " + steps[n].title);
css(b, { appearance: "none", border: "0", cursor: "pointer", padding: "0",
flex: "1 1 0", height: "4px", borderRadius: "2px", display: "block",
background: "#e3e8ee", minWidth: "6px" });
b.addEventListener("click", function () { track("jump", { to: n + 1 }); go(n); });
elTicks.appendChild(b); ticks.push(b);
})(t);
}
function sheetMode() { return window.innerWidth < SHEET_MAX; }
function stylePop() {
if (sheetMode()) {
css(pop, { position: "fixed", left: "8px", right: "8px", width: "auto",
top: "auto", bottom: "8px", maxHeight: "62vh", overflow: "auto",
background: "#ffffff", color: "#14181f",
borderRadius: "16px 16px 12px 12px", padding: "15px 16px 14px",
pointerEvents: "auto", textAlign: "left", boxSizing: "border-box",
boxShadow: "0 -10px 34px -14px rgba(8,14,20,.5)" });
css(beak, { display: "none" });
} else {
css(pop, { position: "fixed", top: "0", left: "0", right: "auto",
width: "min(330px, calc(100vw - 24px))", maxHeight: "calc(100vh - 24px)",
overflow: "auto", background: "#ffffff", color: "#14181f",
borderRadius: "13px", padding: "16px 17px 14px", pointerEvents: "auto",
textAlign: "left", boxSizing: "border-box", bottom: "auto",
boxShadow: "0 18px 44px -14px rgba(8,14,20,.55)" });
css(beak, { display: "block" });
}
}
stylePop();
function place() {
var s = steps[idx];
if (!s) return end("target-lost");
// The element can still be IN the document yet no longer laid out — the
// portal swaps its mobile/desktop copies on resize, collapses the
// more-actions menu, and re-renders cards. Re-validate, and re-resolve to a
// live copy of the same anchor before giving up on the step.
if (!document.contains(s.el) || !visible(s.el)) {
var again = resolve(s.name);
if (again) { s.el = again; } else { return go(idx + 1); }
}
var r = s.el.getBoundingClientRect(), pad = 6;
if (r.width < 1 || r.height < 1) return go(idx + 1);
// The ring starts life at 0x0 in the corner. Animating that first hop means
// the tour opens with the page dimmed and nothing highlighted while a tiny
// rectangle flies in — so the first placement jumps, and only the moves
// between steps animate.
if (!placed) css(ring, { transition: "none" });
var vw = window.innerWidth, vh = window.innerHeight;
var x1 = r.left - pad, y1 = r.top - pad, x2 = r.right + pad, y2 = r.bottom + pad;
function box(el, t, l, w, h) {
css(el, { top: Math.max(0, t) + "px", left: Math.max(0, l) + "px",
width: Math.max(0, w) + "px", height: Math.max(0, h) + "px",
display: "block", visibility: "visible", opacity: "1" });
}
box(dTop, 0, 0, vw, y1);
box(dBottom, y2, 0, vw, vh - y2);
box(dLeft, y1, 0, x1, y2 - y1);
box(dRight, y1, x2, vw - x2, y2 - y1);
box(ring, y1, x1, x2 - x1, y2 - y1);
// match the control's own corner rounding rather than a fixed radius
var rad = "8px";
try {
var got = getComputedStyle(s.el).borderTopLeftRadius;
if (got) { var n = parseFloat(got); rad = (n >= 60 ? 999 : n + pad) + "px"; }
} catch (e) {}
css(ring, { borderRadius: rad });
stylePop();
if (sheetMode()) return;
var pr = pop.getBoundingClientRect(), gap = 14;
var below = r.bottom + gap, above = r.top - pr.height - gap;
var placeBelow = below + pr.height < vh - 8;
var top = placeBelow ? below
: (above > 8 ? above
: Math.max(8, Math.min(vh - pr.height - 8, r.top)));
var left = Math.max(10, Math.min(vw - pr.width - 10, r.left + r.width / 2 - pr.width / 2));
css(pop, { top: top + "px", left: left + "px" });
var bx = Math.max(left + 14, Math.min(left + pr.width - 23, r.left + r.width / 2 - 9));
if (placeBelow) {
css(beak, { top: (top - 9) + "px", left: bx + "px",
borderBottom: "9px solid #ffffff", borderTop: "0" });
} else {
css(beak, { top: (top + pr.height) + "px", left: bx + "px",
borderTop: "9px solid #ffffff", borderBottom: "0" });
}
if (!placed) {
placed = true;
if (!reduce) requestAnimationFrame(function () {
css(ring, { transition: "top .22s ease,left .22s ease,width .22s ease,height .22s ease" });
});
}
}
function render() {
var s = steps[idx];
elCat.textContent = s.cat + " · " + (idx + 1) + " of " + steps.length;
elH.textContent = s.title;
elP.textContent = s.body;
elBack.hidden = idx === 0;
elNext.textContent = idx === steps.length - 1
? (rewardOffered() ? "Collect " + REWARD_CREDITS + " credits" : "Done")
: "Next";
for (var i = 0; i < ticks.length; i++) {
css(ticks[i], { background: i <= idx ? brand : "#e3e8ee",
opacity: i <= idx ? "1" : "1" });
}
markSeen(s.name);
scrollToTarget(false);
if (!reduce) {
css(pop, { opacity: "0", transform: "translateY(6px)" });
requestAnimationFrame(function () {
css(pop, { transition: "opacity .18s ease, transform .18s ease",
opacity: "1", transform: "translateY(0)" });
});
}
requestAnimationFrame(place);
// Verify rather than trust: a smooth scroll can be interrupted or simply not
// finish, and in sheet mode the card owns the bottom of the screen — so if the
// target is still outside the usable area, correct it instantly.
setTimeout(function () {
var s2 = steps[idx];
if (s2 && document.contains(s2.el)) {
var rr = s2.el.getBoundingClientRect();
if (rr.top < 4 || rr.bottom > usableBottom()) scrollToTarget(true);
}
place();
}, 340);
pop.focus();
track("step", { step: idx + 1, anchor: s.name });
}
// How much vertical room the spotlight actually has. In sheet mode the card
// is docked to the bottom, so the target must sit above it, not merely on screen.
function usableBottom() {
return sheetMode()
? window.innerHeight - pop.getBoundingClientRect().height - 20
: window.innerHeight;
}
function scrollToTarget(instant) {
var s = steps[idx];
if (!s || !document.contains(s.el)) return;
var r = s.el.getBoundingClientRect();
var room = usableBottom();
var want = window.scrollY + r.top - Math.max(12, (room - r.height) / 2);
want = Math.max(0, want);
try { window.scrollTo({ top: want, behavior: (instant || reduce) ? "auto" : "smooth" }); }
catch (e) { window.scrollTo(0, want); }
}
function go(n) {
if (n >= steps.length) return end("completed");
if (n < 0) return;
idx = n; render();
}
function end(reason) {
// Deliberately does NOT set state.done. paint() shows the completed page with
// its COMPLETE stamp once, and sets done then, so the payoff is not skipped.
save(state);
// Only a real finish pays out. Skipping and Escape do not.
if (reason === "completed") claimReward(onReward);
cleanup.forEach(function (fn) { try { fn(); } catch (e) {} });
if (layer.parentNode) layer.parentNode.removeChild(layer);
try { lastFocus && lastFocus.focus && lastFocus.focus(); } catch (e) {}
track("end", { reason: reason, lastStep: idx + 1, seen: state.seen.length });
onDone && onDone(reason);
}
function onKey(e) {
if (e.key === "Escape") { e.preventDefault(); end("escape"); }
else if (e.key === "ArrowRight") go(idx + 1);
else if (e.key === "ArrowLeft") go(idx - 1);
}
elNext.addEventListener("click", function () { go(idx + 1); });
elBack.addEventListener("click", function () { go(idx - 1); });
elSkip.addEventListener("click", function () { end("skipped"); });
document.addEventListener("keydown", onKey, true);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
cleanup.push(function () {
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
});
if (window.ResizeObserver) {
var ro = new ResizeObserver(place);
ro.observe(document.documentElement);
cleanup.push(function () { ro.disconnect(); });
}
go(idx);
}
/* ------------------------------------------------------------------ boot */
function countResolved() {
var n = 0;
for (var i = 0; i < STEPS.length; i++) if (resolve(STEPS[i].name)) n++;
return n;
}
// Told by the Worker what happened — say so where the launcher lives, rather
// than in a toast that vanishes.
var rewardNote = null;
function onReward(credits, how) {
state.rewarded = true; save(state);
rewardNote = how === "granted"
? credit(credits) + " added to your account."
: "Those credits are already on your account.";
var block = document.querySelector("[data-sk-tour]");
if (block) paint(block, collect());
}
function credit(n) {
n = Number(n);
return n.toLocaleString() + (n === 1 ? " credit" : " credits");
}
// Is there still something to win? Once claimed, the block stops advertising it.
function rewardOffered() {
return !!(REWARD_ENDPOINT && REWARD_CREDITS && !state.rewarded);
}
// The visa granted for a full page of stamps.
function sealSVG(on) {
var ink = on ? "#8A5A11" : "#BDB49C",
size = String(REWARD_CREDITS).length > 2 ? 11 : 14;
return '<svg viewBox="0 0 40 40" width="30" height="30" focusable="false" aria-hidden="true">' +
'<circle cx="20" cy="20" r="18" fill="none" stroke="' + ink + '" stroke-width="2"' +
(on ? '' : ' stroke-dasharray="3 3"') + '></circle>' +
'<circle cx="20" cy="20" r="14" fill="none" stroke="' + ink + '" stroke-width="1"></circle>' +
'<text x="20" y="' + (20 + size / 2.6) + '" text-anchor="middle" fill="' + ink + '" ' +
'font-size="' + size + '" font-weight="800" letter-spacing="-.5">' + REWARD_CREDITS +
'</text></svg>';
}
// Passport inks — deliberately not themed to the merchant. The variety is what
// makes a page of stamps read as a collection rather than a list.
var INKS = ["#2A4B8D","#1F5B3A","#A32219","#5B3A7E","#166B72",
"#8A5A11","#3F4A57","#7A2233","#2E6B4F","#5A2E6B"];
var SHAPE = { Timing:"circle", Products:"oval", Details:"rect", More:"tri" };
/**
* One stamp. `on` false draws the empty slot: same silhouette, dashed and pale,
* so the page shows what is still missing rather than just what is collected.
*/
function stampSVG(i, cat, ink, on) {
var c = on ? ink : "#B3A991";
var uid = "skarc" + i;
var dash = on ? "" : ' stroke-dasharray="4 3"';
var op = on ? "1" : ".55";
var head = '<svg viewBox="0 0 92 92" aria-hidden="true" focusable="false">' +
'<g fill="none" stroke="' + c + '" opacity="' + op + '">';
var body = "";
var shape = SHAPE[cat] || "circle";
if (shape === "circle") {
body =
'<circle cx="46" cy="46" r="39" stroke-width="2.4"' + dash + '/>' +
'<circle cx="46" cy="46" r="32" stroke-width="1"' + dash + '/>' +
'<path id="' + uid + '" d="M46 74 m-24 0 a24 24 0 1 1 48 0" fill="none" stroke="none"/>';
} else if (shape === "oval") {
body =
'<ellipse cx="46" cy="46" rx="41" ry="30" stroke-width="2.4"' + dash + '/>' +
'<ellipse cx="46" cy="46" rx="34" ry="23" stroke-width="1"' + dash + '/>' +
'<path id="' + uid + '" d="M46 70 m-22 0 a22 18 0 1 1 44 0" fill="none" stroke="none"/>';
} else if (shape === "rect") {
body =
'<rect x="7" y="16" width="78" height="60" stroke-width="2.4"' + dash + '/>' +
'<rect x="13" y="22" width="66" height="48" stroke-width="1" stroke-dasharray="3 3"/>' +
'<path id="' + uid + '" d="M14 66 H78" fill="none" stroke="none"/>';
} else {
body =
'<path d="M46 10 L84 76 H8 Z" stroke-width="2.4" stroke-linejoin="round"' + dash + '/>' +
'<path d="M46 22 L74 70 H18 Z" stroke-width="1" stroke-linejoin="round"/>' +
'<path id="' + uid + '" d="M22 64 H70" fill="none" stroke="none"/>';
}
var ink_ =
'<text fill="' + c + '" opacity="' + op + '" text-anchor="middle" ' +
'font-size="' + (shape === "tri" ? 19 : 23) + '" font-weight="700" ' +
'x="46" y="' + (shape === "tri" ? 60 : 53) + '" ' +
'letter-spacing="1">' + (on ? String(i + 1) : "") + '</text>' +
'<text fill="' + c + '" opacity="' + op + '" font-size="8.5" font-weight="700" ' +
'letter-spacing="2.4">' +
'<textPath href="#' + uid + '" startOffset="50%" text-anchor="middle">' +
(on ? cat.toUpperCase() : "") + '</textPath></text>';
return head + body + '</g>' + ink_ + '</svg>';
}
function doneStamp() {
return '<svg viewBox="0 0 240 96" aria-hidden="true" focusable="false">' +
'<g transform="rotate(-9 120 48)" fill="none" stroke="#A32219">' +
'<rect x="6" y="10" width="228" height="76" stroke-width="4"/>' +
'<rect x="14" y="18" width="212" height="60" stroke-width="1.4"/>' +
'</g>' +
'<text transform="rotate(-9 120 48)" x="120" y="59" text-anchor="middle" ' +
'fill="#A32219" font-size="30" font-weight="800" letter-spacing="6">COMPLETE</text>' +
'</svg>';
}
function paint(block, resolved) {
var menu = block.querySelector(LAUNCHER === "hero" ? ".sk-tour__hero" : ".sk-tour__menu"),
slim = block.querySelector(".sk-tour__slim"),
isHero = LAUNCHER === "hero";
if (state.dismissed) { block.hidden = true; return; }
// Not enough anchors on screen for a menu that makes sense — say the same
// thing as a plain sentence instead. This state cannot fail, which is the point.
if (resolved.length < MIN_STEPS) {
menu.hidden = false; slim.hidden = true;
if (isHero) {
block.querySelector(".sk-tour__heroh").textContent = "A few things you can do here";
block.querySelector(".sk-tour__herop").textContent = FALLBACK;
block.querySelector(".sk-tour__herofoot").innerHTML = "";
block.hidden = false;
return;
}
var plain = block.querySelector(".sk-tour__page");
plain.setAttribute("data-plain", "1");
plain.setAttribute("data-collapsed", "0");
block.querySelector(".sk-tour__h").textContent = "A few things you can do here";
block.querySelector(".sk-tour__count").textContent = "";
block.querySelector(".sk-tour__chips").textContent = "";
block.querySelector(".sk-tour__foot").innerHTML = "";
var p = document.createElement("p");
p.textContent = FALLBACK;
css(p, { margin: "0", fontSize: "13px", lineHeight: "1.5" });
block.querySelector(".sk-tour__foot").appendChild(p);
block.hidden = false;
return;
}
var seenCount = 0;
for (var i = 0; i < resolved.length; i++)
if (state.seen.indexOf(resolved[i].name) !== -1) seenCount++;
// The passport no longer hands off to a separate slim bar — it folds up into
// its own header. Only the hero launcher still uses the slim bar.
function launch(at, how) {
track("launch", { from: how, at: at + 1 });
runTour(resolved, at, function () { paint(block, resolved); });
}
if (isHero && state.done) {
menu.hidden = true; slim.hidden = false; block.hidden = false;
slim.querySelector(".sk-tour__slimtxt").textContent = rewardNote ||
(resolved.length + " things you can change here, without cancelling");
slim.querySelector(".sk-tour__replay").onclick = function () { launch(0, "replay"); };
slim.querySelector(".sk-tour__close").onclick = function () {
state.dismissed = true; save(state); block.hidden = true; track("dismissed", {});
};
return;
}
menu.hidden = false; slim.hidden = true; block.hidden = false;
var countEl = menu.querySelector(".sk-tour__count");
if (countEl) countEl.textContent = isHero
? (seenCount ? seenCount + " of " + resolved.length + " explored"
: "About a minute, " + resolved.length + " things")
: "Stamps " + seenCount + " / " + resolved.length;
if (isHero) {
var hcta = menu.querySelector(".sk-tour__cta");
hcta.textContent = seenCount ? "Pick up where you left off" : "Take the tour";
hcta.onclick = function () {
var first = 0;
for (var i = 0; i < resolved.length; i++)
if (state.seen.indexOf(resolved[i].name) === -1) { first = i; break; }
launch(seenCount ? first : 0, "cta");
};
return;
}
// Collapse state. Unset means "follow completion": a finished passport opens
// folded, an unfinished one opens open. Once the customer touches the header
// their choice is the one that sticks.
var page = block.querySelector(".sk-tour__page"),
toggle = page.querySelector(".sk-tour__toggle"),
folded = typeof state.collapsed === "boolean" ? state.collapsed : !!state.done,
// Only advertise what is genuinely still winnable: claimed is claimed, and a
// full page with no grant on record is not a promise worth repeating.
offerLive = rewardOffered() && seenCount < resolved.length;
page.removeAttribute("data-plain");
function fold(on) {
page.setAttribute("data-collapsed", on ? "1" : "0");
toggle.setAttribute("aria-expanded", on ? "false" : "true");
// Folded is the resting state, so the offer has to travel with it — otherwise
// the reason to open the passport is buried inside the passport.
if (countEl) countEl.textContent = "Stamps " + seenCount + " / " + resolved.length +
(on && offerLive ? " · " + REWARD_CREDITS + " credits" : "");
}
fold(folded);
toggle.onclick = function () {
folded = !folded;
state.collapsed = folded; save(state);
fold(folded);
track("toggle", { collapsed: folded, stamps: seenCount });
};
page.querySelector(".sk-tour__put").onclick = function () {
state.dismissed = true; save(state); block.hidden = true; track("dismissed", {});
};
var wrap = block.querySelector(".sk-tour__chips");
wrap.textContent = "";
resolved.forEach(function (s, n) {
var on = state.seen.indexOf(s.name) !== -1;
var b = document.createElement("button");
b.type = "button";
b.className = "sk-tour__stamp";
b.setAttribute("data-earned", on ? "1" : "0");
b.setAttribute("aria-label", (on ? "Collected: " : "Not collected yet: ") + s.title);
b.style.setProperty("--i", n);
// Outer span animates, inner span holds the tilt. Neither is hardened, or the
// keyframe would silently do nothing.
var thunk = document.createElement("span");
thunk.className = "sk-tour__thunk";
var tilt = document.createElement("span");
tilt.className = "sk-tour__tilt";
// A little rotation, varied per stamp so the page does not look set in a grid.
tilt.style.transform = "rotate(" + (((n * 37) % 13) - 6) + "deg)";
tilt.innerHTML = stampSVG(n, s.cat, INKS[n % INKS.length], on);
thunk.appendChild(tilt);
var label = document.createElement("span");
label.className = "sk-tour__label";
label.textContent = s.chip;
b.appendChild(thunk); b.appendChild(label);
b.onclick = function () { track("chip_click", { step: n + 1, anchor: s.name }); launch(n, "chip"); };
wrap.appendChild(b);
});
// A finished passport keeps its COMPLETE stamp — that is the whole payoff of
// collecting them. paint() can run twice in a visit, so clear any previous one
// rather than stacking a second on top.
var prevDone = page.querySelector(".sk-tour__done");
if (prevDone) page.removeChild(prevDone);
if (seenCount >= resolved.length && resolved.length) {
var done = document.createElement("div");
done.className = "sk-tour__done";
done.setAttribute("aria-hidden", "true");
done.innerHTML = doneStamp();
page.appendChild(done);
if (!state.done) {
// First sight of the finished page: it stays open now, and opens folded
// from the next load unless the customer says otherwise.
state.done = true; save(state);
track("completed_page", { stamps: seenCount });
}
}
// The footer carries the reward: a locked seal while it is on offer, an inked
// one once it has been paid out.
var hint = block.querySelector(".sk-tour__hint");
if (hint) {
var sealed = null, line;
if (rewardNote) { line = rewardNote; sealed = true; }
else if (state.rewarded) { line = credit(REWARD_CREDITS) + " collected."; sealed = true; }
else if (offerLive) { line = credit(REWARD_CREDITS) + " when the page is full."; sealed = false; }
else { line = "or pick one to jump to it"; }
hint.textContent = "";
hint.setAttribute("data-reward", sealed === null ? "0" : "1");
if (sealed !== null) {
var s1 = document.createElement("span");
s1.className = "sk-tour__seal";
s1.innerHTML = sealSVG(sealed);
hint.appendChild(s1);
}
var s2 = document.createElement("span");
s2.textContent = line;
hint.appendChild(s2);
}
var cta = block.querySelector(".sk-tour__cta");
cta.textContent = seenCount >= resolved.length ? "Walk it again"
: seenCount ? "Collect the rest"
: "Collect your stamps";
cta.onclick = function () {
var first = 0;
for (var i = 0; i < resolved.length; i++)
if (state.seen.indexOf(resolved[i].name) === -1) { first = i; break; }
launch(seenCount ? first : 0, "cta");
};
}
// Settling early is a guess, and the cost of guessing wrong is a permanent
// fallback on a portal that simply rendered late. So keep looking afterwards:
// if the missing anchors turn up, quietly upgrade what is on screen.
function watchForLate(block) {
var t0 = Date.now();
var iv = setInterval(function () {
var r = collect();
if (r.length >= MIN_STEPS) {
clearInterval(iv);
track("upgraded", { found: r.length, ms: Date.now() - t0 });
paint(block, r);
} else if (Date.now() - t0 > LATE_MS) { clearInterval(iv); }
}, 400);
}
function start(block) {
var resolved = collect();
track("resolved", { found: resolved.length, planned: STEPS.length });
paint(block, resolved);
if (resolved.length < MIN_STEPS) { track("fallback", { found: resolved.length }); watchForLate(block); }
}
// The portal renders after this block runs, and how long that takes is not
// knowable from in here. So never decide once: keep re-resolving until either
// every step is present or the page has clearly stopped changing.
function waitForAnchors(block) {
var t0 = Date.now(), lastCount = -1, quietSince = Date.now(), done = false;
function tick() {
if (done) return;
var count = document.querySelectorAll("[data-skio-name]").length;
var resolved = countResolved();
var elapsed = Date.now() - t0;
if (count !== lastCount) { lastCount = count; quietSince = Date.now(); }
// A stable anchor count is NOT proof the portal has finished — it can sit
// still between render passes. So a quiet count only counts once past
// MIN_WAIT_MS. The common case never waits: every-step-present exits at once.
var allThere = resolved === STEPS.length;
var settled = count > 0 && elapsed >= MIN_WAIT_MS && (Date.now() - quietSince) >= QUIET_MS;
if (allThere || settled || elapsed >= BOOT_MS) {
done = true;
if (count === 0) {
track("timeout", { ms: elapsed });
if (block.parentNode) block.parentNode.removeChild(block);
return;
}
track("ready", { ms: elapsed, resolved: resolved, anchors: count,
why: allThere ? "all-steps" : settled ? "settled" : "deadline" });
return start(block);
}
setTimeout(tick, POLL_MS);
}
tick();
}
// There is no per-block error boundary in the portal, so anything that throws
// here takes the block off the page rather than leaving wreckage behind it.
try {
var block = document.querySelector("[data-sk-tour]");
if (block) waitForAnchors(block);
} catch (e) {
try {
var b2 = document.querySelector("[data-sk-tour]");
if (b2 && b2.parentNode) b2.parentNode.removeChild(b2);
} catch (e2) {}
}
})();
</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.
Edit the STEPS list. The credit amount shown is display copy; paying it out needs your own rewards endpoint (REWARD_ENDPOINT), which ships empty.
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: Portal Passport (No. 07) Page: /blocks/portal-passport What it does: A guided tour of the portal for new subscribers, with a gift waiting at the end. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Edit the STEPS list. The credit amount shown is display copy; paying it out needs your own rewards endpoint (REWARD_ENDPOINT), which ships empty. 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 portal-passport.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 portal-passport-<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.
