
This block reads live subscription data, so it only runs inside a real customer portal. The animation shows what subscribers see.
What it does
Three dunning banners: fix a failed payment, replace an expiring card, or add a backup card.
Why it exists
For any merchant that likes getting paid. Failed and expiring cards end subscriptions quietly; these banners only appear when there is a payment problem and send the subscriber straight to the fix.
The code
Paste it as-is, or download the-collector.html<div class="sb-ph" data-sb-ph hidden></div>
<style>
.sb-ph{
/* This design deliberately does NOT theme to the merchant. The whole language is
red/black/white, and diluting it to a brand palette leaves neither. Semantic
colour would be hardcoded here anyway. */
--k-red:#E01B22;
--k-ink:#111111;
--k-paper:#ffffff;
container-type:inline-size;
}
.sb-ph,.sb-ph *{box-sizing:border-box}
.sb-ph[hidden]{display:none!important}
/* Zero-specificity reset. transform, opacity and clip-path are absent on purpose —
hardening any of them would silently kill the animations that use them. */
:where(.sb-ph) p,:where(.sb-ph) span,:where(.sb-ph) button{
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-ph) button{appearance:none!important;-webkit-appearance:none!important;cursor:pointer}
.sb-ph{display:flex!important;flex-direction:column!important;gap:10px!important;
margin:0 0 16px!important;font-family:inherit!important}
.sb-ph__row{position:relative!important;overflow:hidden!important;isolation:isolate!important;
display:flex!important;align-items:center!important;
background:var(--k-paper)!important;border:3px solid var(--k-red)!important;
padding:20px 20px 20px 0!important}
.sb-ph__row[data-tone="calm"]{border-color:var(--k-ink)!important}
/* Halftone is a pseudo-element, not an empty div — `div:empty{display:none}` in a
merchant theme deletes empty decorative elements outright. */
.sb-ph__art{flex:0 0 96px!important;position:relative!important;align-self:stretch!important;
display:flex!important;align-items:center!important;justify-content:center!important}
.sb-ph__art::before{content:""!important;position:absolute!important;inset:0!important;
opacity:.16!important;background-image:radial-gradient(var(--k-ink) 1px,transparent 1.4px)!important;
background-size:5px 5px!important}
.sb-ph__art svg{position:relative!important}
/* Layout B: headline left, CTA anchored to the right edge, so a 1108px row has
weight at both ends instead of pocketing everything on the left. */
.sb-ph__body{flex:1 1 auto!important;min-width:0!important;display:flex!important;
align-items:center!important;gap:20px!important;flex-wrap:wrap!important}
.sb-ph__wrap{position:relative!important;display:inline-block!important;max-width:100%!important}
/* The ghost holds the final size so characters arriving reflow nothing outside
this element — animating width would reflow the whole portal. */
.sb-ph__ghost{visibility:hidden!important;pointer-events:none!important}
.sb-ph__bar,.sb-ph__ghost{background:var(--k-red)!important;color:var(--k-paper)!important;
padding:4px 14px 7px!important;font-weight:800!important;font-style:italic!important;
font-size:clamp(22px,4.2cqi,46px)!important;line-height:1.06!important;
letter-spacing:-.024em!important;text-transform:uppercase!important;
display:block!important;white-space:pre-wrap!important}
.sb-ph__bar{position:absolute!important;inset:0!important}
.sb-ph__row[data-tone="calm"] .sb-ph__bar,
.sb-ph__row[data-tone="calm"] .sb-ph__ghost{background:var(--k-ink)!important}
.sb-ph__caret{display:inline-block!important;width:.5ch!important;
background:var(--k-paper)!important;margin-left:.05em!important}
.sb-ph__cta{margin-left:auto!important;flex:0 0 auto!important;border:0!important;
cursor:pointer!important;font-weight:800!important;font-style:italic!important;
font-size:13px!important;letter-spacing:.03em!important;text-transform:uppercase!important;
padding:12px 20px!important;white-space:nowrap!important;text-decoration:none!important;
display:inline-block!important;
background:var(--k-red)!important;color:var(--k-paper)!important}
.sb-ph__row[data-tone="calm"] .sb-ph__cta{background:var(--k-ink)!important}
.sb-ph__cta:hover{background:var(--k-ink)!important}
.sb-ph__row[data-tone="calm"] .sb-ph__cta:hover{background:var(--k-red)!important}
.sb-ph__cta:focus-visible{outline:3px solid var(--k-ink)!important;outline-offset:3px!important}
@container (max-width:380px){
.sb-ph__art{display:none!important}
.sb-ph__row,.sb-ph__flip{padding:16px!important}
.sb-ph__cta{margin-left:0!important;width:100%!important;text-align:center!important}
}
/* The flipped copy: white -> red, black -> white, red -> white. Blend modes cannot
express this — `difference` maps white to black, and screening red back over the
inverted red lands on grey. Two inputs also need the same output, which no
per-pixel blend can do. So it is a real second copy, revealed by a clip band. */
/* The flip is a pixel copy of the row, so it must carry the row's padding too —
without it the duplicated CTA sits 20px off and the offset shows as the sweep
passes over it. */
.sb-ph__flip{position:absolute!important;inset:0!important;z-index:4!important;
pointer-events:none!important;display:flex!important;align-items:center!important;
padding:20px 20px 20px 0!important;
background:var(--k-red)!important;clip-path:inset(0 0 100% 0)}
.sb-ph__flip .sb-ph__bar,.sb-ph__flip .sb-ph__ghost,
.sb-ph__flip .sb-ph__cta{background:var(--k-paper)!important;color:var(--k-red)!important}
.sb-ph__flip .sb-ph__art::before{opacity:.22!important;
background-image:radial-gradient(var(--k-paper) 1px,transparent 1.4px)!important}
.sb-ph__flip .s-w,.sb-ph__flip .s-r{stroke:var(--k-paper)}
.sb-ph__flip .f-w,.sb-ph__flip .f-r{fill:var(--k-paper)}
.sb-ph__row[data-tone="calm"] .sb-ph__flip{background:var(--k-ink)!important}
.sb-ph__row[data-tone="calm"] .sb-ph__flip .sb-ph__bar,
.sb-ph__row[data-tone="calm"] .sb-ph__flip .sb-ph__cta{
background:var(--k-paper)!important;color:var(--k-ink)!important}
.s-w{stroke:var(--k-ink);fill:none;stroke-width:2.6}
.s-r{stroke:var(--k-red);fill:none;stroke-width:2.6}
.f-r{fill:var(--k-red)}
.f-w{fill:var(--k-ink)}
/* Motion is a no-preference floor, so reduced-motion users get the static version
by default rather than an override that has to win a fight. */
@media (prefers-reduced-motion:no-preference){
.sb-ph__caret{animation:sbPhBlink 900ms steps(1) infinite}
.sb-ph__flip{animation:sbPhSweep var(--sweep,5s) linear infinite;
animation-delay:calc(var(--sweep,5s) + var(--i,0) * 180ms)}
}
@keyframes sbPhBlink{0%,49%{opacity:1}50%,100%{opacity:0}}
@keyframes sbPhSweep{
0%,2%{clip-path:inset(0 0 100% 0)}
16%{clip-path:inset(0 0 0 0)}
30%{clip-path:inset(100% 0 0 0)}
100%{clip-path:inset(100% 0 0 0)}
}
@media (prefers-reduced-motion:reduce){
.sb-ph__caret{display:none!important}
.sb-ph__flip{display:none!important}
}
</style>
<script>
(function () {
"use strict";
// "" = live. Or force one state for a demo: "failed", "expired", "backup".
var PREVIEW = "";
// Quick action: change card + charge now, in one link. Parameterised rather than
// tokenised, so the block can build it — {{subscription_id}} is filled from the
// subscription this banner is about. Leave empty to fall back to the modal.
// e.g. "https://YOUR-STORE.myshopify.com/a/account/quick-action-v3" +
// "?stackId=YOUR-STACK-ID&subscriptionId={{subscription_id}}"
var QUICK_ACTION = "";
// Warn this many months ahead of the expiry date. A card that dies before the
// next order is the thing worth catching, and 3 months covers most cadences.
var WARN_MONTHS = 3;
var TYPE_MS = 42; // ms per character
var STAGGER = 700; // ms between rows starting to type
var SWEEP = "5s"; // wave period, and the delay before the first pass
var BOOT_MS = 10000, POLL_MS = 250;
// Claim our own instance: two copies on one page otherwise both bind to the
// first, so one renders twice and the other never unhides.
var block = null, all = document.querySelectorAll("[data-sb-ph]");
for (var n = 0; n < all.length; n++) {
if (!all[n].hasAttribute("data-sb-claimed")) { block = all[n]; break; }
}
if (!block) return;
block.setAttribute("data-sb-claimed", "1");
function track(event, detail) {
try {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(Object.assign({ event: "skio_dunning_" + event }, detail || {}));
} catch (e) {}
}
function visible(el) {
if (!el) return false;
if (typeof el.checkVisibility === "function" && !el.checkVisibility()) return false;
var r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1;
}
function anchor(name) {
var els = document.querySelectorAll('[data-skio-name="' + name + '"]');
for (var i = 0; i < els.length; i++) if (visible(els[i])) return els[i];
return null;
}
function openControl(names) {
for (var i = 0; i < names.length; i++) {
var el = anchor(names[i]);
if (el) { el.scrollIntoView({ block: "center" }); el.click(); return names[i]; }
}
return null;
}
var EDIT_CARD = ["billing-card-edit-button"];
var ADD_BACKUP = ["billing-card-add-backup-button", "billing-card-edit-button"];
// Expiry comes from the DOM: Skio's stored expiryMonth/expiryYear are null on
// this store, and PaymentMethods is access-denied customer-side.
function expiry() {
var el = anchor("billing-card-payment-method-expiry-date");
var m = /(\d{1,2})\s*\/\s*(\d{2,4})/.exec((el ? el.textContent : "") || "");
if (!m) return null;
var mm = parseInt(m[1], 10), yy = parseInt(m[2], 10);
if (!(mm >= 1 && mm <= 12)) return null;
if (yy < 100) yy += 2000;
return { month: mm, year: yy, endOfMonth: new Date(yy, mm, 1) - 1 };
}
function mmyy(e) { return String(e.month).padStart(2, "0") + "/" + String(e.year).slice(-2); }
// Real month arithmetic, not a 30-day approximation — "3 months from 31 Jan"
// has to land in April, and day-count maths gets that wrong.
function expiringSoon(e) {
if (!e) return false;
var cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() + WARN_MONTHS);
return e.endOfMonth < cutoff.getTime();
}
// Scope to the subscription this page is about, or the block warns about a
// different one — this store has 157 subscriptions and at least one is FAILED.
function viewedId() {
var m = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
.exec(location.pathname + location.search + location.hash);
return m ? m[0].toLowerCase() : null;
}
// One query, one round trip. StorefrontUsers is row-scoped to the logged-in
// customer, so this returns only them — no identity to pass, nothing to verify.
//
// Note the field: BackupPaymentMethod is a RELATION, not a scalar.
// `backupPaymentMethodId` exists in the warehouse but in neither GraphQL schema,
// and asking for it fails in a way that looks like a permissions problem.
var STATE_Q = "query {" +
" Subscriptions { id status }" +
" StorefrontUsers { id BackupPaymentMethod { id } }" +
"}";
function readState() {
var none = { failed: false, failedId: null, scoped: false, backup: null };
if (typeof window.fetchSkioAPI !== "function") return Promise.resolve(none);
return window.fetchSkioAPI(STATE_Q, {})
.then(function (r) {
if (!r || r.errors || !r.data) return none;
var rows = r.data.Subscriptions || [];
var users = r.data.StorefrontUsers || [];
// null means "could not tell" — stay quiet rather than tell someone to add
// a backup they already have.
var backup = users.length ? !!users[0].BackupPaymentMethod : null;
var id = viewedId();
var mine = id ? rows.filter(function (s) {
return String(s.id || "").toLowerCase() === id; }) : [];
var scoped = mine.length > 0;
var use = scoped ? mine : rows; // falling back can over-warn, never under-warn
var bad = use.filter(function (s) { return s.status === "FAILED"; })[0];
return { failed: !!bad, failedId: bad ? bad.id : (id || null),
scoped: scoped, backup: backup };
})
.catch(function () { return none; }); // never a false alarm
}
var ART = {
failed:
'<svg viewBox="0 0 68 60" width="62" height="55" aria-hidden="true">' +
'<rect x="6" y="10" width="56" height="38" class="s-r"/>' +
'<rect x="6" y="19" width="56" height="7" class="f-r"/>' +
'<path d="M22 33 l6 5 M28 33 l-6 5 M40 33 l6 5 M46 33 l-6 5" class="s-w"/>' +
'<path d="M25 45 q9 -7 18 0" class="s-w"/></svg>',
expired:
'<svg viewBox="0 0 68 60" width="62" height="55" aria-hidden="true">' +
'<rect x="4" y="10" width="52" height="34" class="s-r"/>' +
'<rect x="4" y="18" width="52" height="7" class="f-r"/>' +
'<circle cx="48" cy="42" r="13" class="s-w"/>' +
'<path d="M48 42 L48 34 M48 42 L54 45" class="s-w"/></svg>',
backup:
'<svg viewBox="0 0 68 60" width="62" height="55" aria-hidden="true">' +
'<rect x="2" y="18" width="44" height="28" class="s-w" opacity=".4"/>' +
'<rect x="14" y="10" width="44" height="28" class="s-w"/>' +
'<rect x="14" y="18" width="44" height="6" class="f-w" opacity=".2"/>' +
'<circle cx="48" cy="42" r="11" class="s-w"/>' +
'<path d="M43 42 l3.5 3.6 l7 -7.5" class="s-w"/></svg>'
};
var reduce = false;
try { reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
function banner(i, tone, head, cta, targets, art, href) {
var row = document.createElement("div");
row.className = "sb-ph__row";
row.setAttribute("data-tone", tone);
row.style.setProperty("--i", i);
row.style.setProperty("--sweep", SWEEP);
var inner =
'<div class="sb-ph__art">' + art + '</div>' +
'<div class="sb-ph__body"><span class="sb-ph__wrap">' +
'<span class="sb-ph__ghost"></span><span class="sb-ph__bar"></span>' +
'</span></div>';
row.innerHTML = inner;
row.querySelector(".sb-ph__ghost").textContent = head; // text, never markup
// A real link when we have a destination — right-click, open-in-new-tab and
// screen readers all behave correctly, which a button faking navigation breaks.
var el;
if (href) {
el = document.createElement("a");
el.href = href;
el.addEventListener("click", function () { track("cta", { state: tone, via: "quick-action" }); });
} else {
el = document.createElement("button");
el.type = "button";
el.onclick = function () { track("cta", { state: tone, opened: openControl(targets) }); };
}
el.className = "sb-ph__cta";
el.textContent = cta;
row.querySelector(".sb-ph__body").appendChild(el);
// The flipped copy is decorative: hidden from assistive tech and inert, or the
// duplicated button and headline would be announced twice.
var flip = document.createElement("div");
flip.className = "sb-ph__flip";
flip.setAttribute("aria-hidden", "true");
flip.innerHTML = row.innerHTML;
try { flip.inert = true; } catch (e) {}
row.appendChild(flip);
block.appendChild(row);
var bar = row.querySelector(".sb-ph__body .sb-ph__bar");
var barFlip = flip.querySelector(".sb-ph__bar");
if (reduce) { bar.textContent = head; barFlip.textContent = head; return; }
var c = 0;
setTimeout(function tick() {
bar.textContent = head.slice(0, ++c);
barFlip.textContent = bar.textContent; // both layers stay in step
if (c < head.length) {
var caret = document.createElement("i");
caret.className = "sb-ph__caret"; caret.textContent = " ";
bar.appendChild(caret);
setTimeout(tick, TYPE_MS);
}
}, 300 + i * STAGGER);
}
function render(state) {
var exp = expiry();
// PREVIEW now names a single state, since only one can show.
function on(name) {
if (PREVIEW) return PREVIEW === name;
if (name === "failed") return state.failed;
if (name === "expired") return expiringSoon(exp);
if (name === "backup") return state.backup === false;
return false;
}
// Strict hierarchy: the first condition that matches is the only thing shown.
// Declined outranks expiring, which outranks no-backup — a customer with a
// dead card does not need to hear about a backup in the same breath.
var state1 = null;
if (on("failed")) {
// Only build the link if we actually know which subscription — a quick action
// pointed at a blank id is worse than the modal.
var qa = (QUICK_ACTION && state.failedId)
? QUICK_ACTION.replace("{{subscription_id}}", encodeURIComponent(state.failedId))
: null;
banner(0, "alert", "Card declined", "Fix and charge now", EDIT_CARD, ART.failed, qa);
state1 = "failed";
} else if (on("expired")) {
// Past tense only when it has genuinely gone; otherwise this is a warning
// about something that has not happened yet.
var gone = exp.endOfMonth < Date.now();
banner(0, "alert", (gone ? "Card expired " : "Card expires ") + mmyy(exp),
"Add a card", EDIT_CARD, ART.expired);
state1 = gone ? "expired" : "expiring";
} else if (on("backup")) {
banner(0, "calm", "No backup on file", "Add a backup", ADD_BACKUP, ART.backup);
state1 = "backup";
}
if (!state1) {
track("silent", { hadExpiry: !!exp, backup: state.backup, scoped: state.scoped });
return;
}
block.hidden = false;
track("shown", { state: state1, scoped: state.scoped });
}
var t0 = Date.now();
(function wait() {
if (anchor("billing-card") || anchor("billing-card-payment-method-expiry-date")) {
return readState().then(render);
}
if (Date.now() - t0 > BOOT_MS) {
track("timeout", {});
if (block.parentNode) block.parentNode.removeChild(block); // leave nothing behind
return;
}
setTimeout(wait, POLL_MS);
})();
})();
</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.
Skio portals only. It reads the subscriber's payment state through the portal's built-in fetchSkioAPI.
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: The Collector (No. 02) Page: /blocks/the-collector What it does: Three dunning banners: fix a failed payment, replace an expiring card, or add a backup card. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Skio portals only. It reads the subscriber's payment state through the portal's built-in fetchSkioAPI. 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 the-collector.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 the-collector-<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.
