What it does
Subscribers check in on a daily challenge in the portal and unlock a reward at the finish.
Why it exists
For merchants who want subscribers opening the portal every day. A streak they can see, and a reward waiting on the last day, gives people a reason to come back.
The code
Paste it as-is, or download challenge-tracker.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>[BRAND] 30-Day Challenge — Day 30 Complete</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Everything is scoped under #tl30-challenge with tl30- prefixed classes.
This is deliberate: dropped into a real theme, generic names like
.block / .tag / .amount / .day collide with the theme's own global CSS
and silently lose their styling (this is what happened in an earlier test).
(tl30-challenge / tl30- are just internal scoped selector names — not tied
to any brand — feel free to rename if you'd prefer different prefixes for
your own project.) */
#tl30-challenge, #tl30-challenge *{
box-sizing:border-box;
margin:0;
padding:0;
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
line-height:normal;
-webkit-font-smoothing:antialiased;
}
/* text-align is intentionally left off the universal reset above — it's an
inherited property, so forcing it there would out-specificity (and break)
the centered .tl30-prize block below. Left as the natural default instead. */
#tl30-challenge{
--bg:#0a0a0b;
--panel:#141416;
--panel-2:#1c1c1f;
--line:#2a2a2e;
--ink:#f5f5f4;
--ink-dim:#a8a8ac;
--red:#e6293f;
--red-2:#ff4d5e;
--gold:#f4b93c;
--gold-2:#ffd873;
--green:#33d17a;
display:flex;
justify-content:center;
padding:24px 16px;
color:var(--ink);
}
#tl30-challenge .tl30-card{
width:100%;
max-width:560px;
background:linear-gradient(180deg, var(--panel) 0%, #0f0f11 100%);
border:1px solid var(--line);
border-radius:20px;
padding:28px 26px 30px;
position:relative;
overflow:hidden;
box-shadow:0 30px 60px -20px rgba(0,0,0,.6);
isolation:isolate; /* keeps the glow pseudo-element from bleeding into siblings on the host page */
}
/* ambient glow wash behind the whole card */
#tl30-challenge .tl30-card::before{
content:"";
position:absolute;
inset:-40% -40% auto -40%;
height:280px;
background:radial-gradient(closest-side, rgba(230,41,63,.35), transparent 70%);
filter:blur(10px);
animation:tl30Wash 5s ease-in-out infinite;
pointer-events:none;
z-index:0;
}
@keyframes tl30Wash{
0%,100%{opacity:.55; transform:translateY(0);}
50%{opacity:.9; transform:translateY(10px);}
}
#tl30-challenge .tl30-eyebrow{
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:11px;
letter-spacing:.16em;
text-transform:uppercase;
color:var(--red-2);
font-weight:600;
display:flex;
align-items:center;
gap:8px;
position:relative;
z-index:1;
}
#tl30-challenge .tl30-dot{width:6px;height:6px;border-radius:50%;background:var(--red-2);box-shadow:0 0 8px var(--red-2); flex:none;}
#tl30-challenge .tl30-title{
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:28px;
font-weight:700;
line-height:1.15;
letter-spacing:.01em;
margin:10px 0 4px;
position:relative;
z-index:1;
}
#tl30-challenge .tl30-title em{color:var(--gold-2); font-style:normal;}
#tl30-challenge .tl30-sub{
color:var(--ink-dim);
font-size:14px;
margin:0 0 26px;
position:relative;
z-index:1;
}
/* ---- progress bar with fire marker ---- */
#tl30-challenge .tl30-progress-wrap{
position:relative;
z-index:1;
margin-bottom:6px;
padding-right:8px; /* room for the fire marker so it never sits flush on the card edge */
}
#tl30-challenge .tl30-progress-labels{
display:flex;
justify-content:space-between;
align-items:baseline;
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:11px;
letter-spacing:.03em;
color:var(--ink-dim);
text-transform:uppercase;
white-space:nowrap;
margin-bottom:28px; /* fire marker's glow needs this clearance or it visually eats the label */
}
#tl30-challenge .tl30-progress-labels b{color:var(--ink); font-weight:600; margin-left:12px;}
#tl30-challenge .tl30-progress-track{
position:relative;
height:14px;
border-radius:999px;
background:var(--panel-2);
border:1px solid var(--line);
}
#tl30-challenge .tl30-progress-fill{
height:100%;
width:100%;
border-radius:999px;
background:linear-gradient(90deg, var(--red) 0%, var(--red-2) 45%, var(--gold) 100%);
box-shadow:0 0 14px rgba(255,77,94,.45);
animation:tl30FillGlow 2.4s ease-in-out infinite;
}
@keyframes tl30FillGlow{
0%,100%{box-shadow:0 0 12px rgba(255,77,94,.4);}
50%{box-shadow:0 0 20px rgba(244,185,60,.55);}
}
#tl30-challenge .tl30-fire-marker{
position:absolute;
right:-2px;
top:50%;
transform:translateY(-50%);
width:30px;
height:30px;
border-radius:50%;
background:radial-gradient(circle at 35% 30%, #ffdf8f, var(--gold) 45%, var(--red) 100%);
display:flex;
align-items:center;
justify-content:center;
font-size:16px;
box-shadow:0 0 0 4px var(--panel), 0 0 14px 2px rgba(244,185,60,.7);
animation:tl30FireBounce 1.1s ease-in-out infinite;
z-index:2;
}
@keyframes tl30FireBounce{
0%,100%{transform:translateY(-50%) scale(1);}
50%{transform:translateY(-56%) scale(1.06);}
}
#tl30-challenge .tl30-fire-trail{
position:absolute;
top:50%;
right:24px;
transform:translateY(-50%);
display:flex;
gap:5px;
opacity:.5;
}
#tl30-challenge .tl30-fire-trail span{
font-size:10px;
display:inline-block;
animation:tl30TrailFade 1.6s ease-in-out infinite;
}
#tl30-challenge .tl30-fire-trail span:nth-child(1){animation-delay:0s;}
#tl30-challenge .tl30-fire-trail span:nth-child(2){animation-delay:.2s;}
#tl30-challenge .tl30-fire-trail span:nth-child(3){animation-delay:.4s;}
@keyframes tl30TrailFade{
0%,100%{opacity:.15; transform:translateY(0) scale(.85);}
50%{opacity:.6; transform:translateY(-3px) scale(1);}
}
/* ---- 30 day grid ---- */
#tl30-challenge .tl30-days-title{
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:12px;
font-weight:600;
letter-spacing:.1em;
text-transform:uppercase;
color:var(--ink-dim);
margin:24px 0 10px;
position:relative;
z-index:1;
}
#tl30-challenge .tl30-days-grid{
position:relative;
z-index:1;
display:grid;
grid-template-columns:repeat(10, 1fr);
gap:7px;
margin-bottom:22px;
}
#tl30-challenge .tl30-day{
aspect-ratio:1;
border-radius:8px;
display:flex;
align-items:center;
justify-content:center;
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:10px;
font-weight:600;
color:var(--green);
background:rgba(51,209,122,.08);
border:1px solid rgba(51,209,122,.45);
position:relative;
animation:tl30DayPulse 2.8s ease-in-out infinite;
}
#tl30-challenge .tl30-day span{position:relative; z-index:1;}
#tl30-challenge .tl30-day:nth-child(3n){animation-delay:.3s;}
#tl30-challenge .tl30-day:nth-child(4n){animation-delay:.6s;}
#tl30-challenge .tl30-day:nth-child(5n){animation-delay:.9s;}
#tl30-challenge .tl30-day:nth-child(7n){animation-delay:1.2s;}
@keyframes tl30DayPulse{
0%,100%{box-shadow:0 0 0 0 rgba(51,209,122,.35); border-color:rgba(51,209,122,.45);}
50%{box-shadow:0 0 9px 1px rgba(51,209,122,.55); border-color:rgba(51,209,122,.9);}
}
#tl30-challenge .tl30-day.tl30-final{
color:#1a1206;
background:linear-gradient(160deg, var(--gold-2), var(--gold));
border:1px solid var(--gold-2);
animation:tl30FinalPulse 1.4s ease-in-out infinite;
}
@keyframes tl30FinalPulse{
0%,100%{box-shadow:0 0 0 0 rgba(244,185,60,.55), 0 0 10px rgba(244,185,60,.5);}
50%{box-shadow:0 0 0 6px rgba(244,185,60,0), 0 0 22px rgba(244,185,60,.9);}
}
/* ---- prize card ---- */
#tl30-challenge .tl30-prize{
position:relative;
z-index:1;
border-radius:16px;
padding:20px;
text-align:center;
background:linear-gradient(160deg, rgba(244,185,60,.14), rgba(230,41,63,.08));
border:1px solid rgba(244,185,60,.4);
animation:tl30PrizeIn .7s cubic-bezier(.2,.9,.25,1.2) both, tl30PrizeGlow 2.6s ease-in-out infinite 1s;
}
@keyframes tl30PrizeIn{
0%{opacity:0; transform:scale(.85) translateY(10px);}
100%{opacity:1; transform:scale(1) translateY(0);}
}
@keyframes tl30PrizeGlow{
0%,100%{box-shadow:0 0 0 rgba(244,185,60,0);}
50%{box-shadow:0 0 30px rgba(244,185,60,.35);}
}
#tl30-challenge .tl30-tag{
display:inline-block;
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:11px;
font-weight:600;
letter-spacing:.14em;
text-transform:uppercase;
color:var(--green);
background:rgba(51,209,122,.12);
border:1px solid rgba(51,209,122,.4);
padding:5px 12px !important;
border-radius:999px;
margin-bottom:12px;
line-height:1.4;
}
#tl30-challenge .tl30-amount{
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:44px;
font-weight:700;
color:var(--gold-2);
line-height:1;
text-shadow:0 0 24px rgba(244,185,60,.55);
}
#tl30-challenge .tl30-desc{
color:var(--ink-dim);
font-size:13.5px;
margin:10px auto 18px !important;
max-width:360px;
}
#tl30-challenge .tl30-desc b{color:var(--ink); font-weight:600;}
#tl30-challenge button.tl30-claim-btn{
-webkit-appearance:none;
appearance:none;
display:inline-block;
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:14px;
font-weight:600;
letter-spacing:.04em;
text-transform:uppercase;
color:#1a1206;
background:linear-gradient(180deg, var(--gold-2), var(--gold)) !important;
background-image:linear-gradient(180deg, var(--gold-2), var(--gold)) !important;
border:none !important;
padding:13px 30px !important;
border-radius:10px !important;
cursor:pointer;
box-shadow:0 8px 20px -6px rgba(244,185,60,.6);
line-height:normal;
}
#tl30-challenge button.tl30-claim-btn:disabled{
opacity:.5;
cursor:not-allowed;
box-shadow:none;
}
/* secondary/informational variant used for "View Reward" once applied —
same footprint, quieter than the gold CTA so it doesn't read as
"the button that grants the reward" anymore */
#tl30-challenge button.tl30-secondary-btn{
-webkit-appearance:none;
appearance:none;
display:inline-block;
font-family:'Oswald','Arial Narrow',Impact,sans-serif;
font-size:13px;
font-weight:600;
letter-spacing:.04em;
text-transform:uppercase;
color:var(--ink);
background:rgba(245,245,244,.06) !important;
border:1px solid var(--line) !important;
padding:11px 26px !important;
border-radius:10px !important;
cursor:pointer;
line-height:normal;
}
#tl30-challenge .tl30-spinner{
display:inline-block;
width:12px;
height:12px;
border-radius:50%;
border:2px solid rgba(244,185,60,.25);
border-top-color:var(--gold-2);
animation:tl30Spin .8s linear infinite;
vertical-align:-2px;
margin-right:7px;
}
@keyframes tl30Spin{ to { transform:rotate(360deg); } }
/* ---- confetti canvas overlay ---- */
#tl30-confetti-canvas{
position:fixed;
inset:0;
width:100%;
height:100%;
pointer-events:none;
z-index:9999;
}
</style>
</head>
<body style="margin:0; background:#0a0a0b;">
<canvas id="tl30-confetti-canvas"></canvas>
<div id="tl30-challenge">
<div class="tl30-card">
<div class="tl30-eyebrow"><span class="tl30-dot"></span> [BRAND] · 30-DAY CHALLENGE</div>
<div class="tl30-title">Challenge complete. <em>You did the work.</em></div>
<p class="tl30-sub">30 days, no skipped check-ins — your reward just unlocked.</p>
<div class="tl30-progress-wrap">
<div class="tl30-progress-labels"><span>Day 1</span> <b>Day 30 of 30 · 100%</b></div>
<div class="tl30-progress-track">
<div class="tl30-progress-fill"></div>
<div class="tl30-fire-trail"><span>🔥</span><span>🔥</span><span>🔥</span></div>
<div class="tl30-fire-marker">🔥</div>
</div>
</div>
<div class="tl30-days-title">30 / 30 Days Logged</div>
<div class="tl30-days-grid" id="tl30DaysGrid"></div>
<!-- .tl30-prize's inner content is fully owned by renderPrize() below.
The markup here is just the pre-JS fallback shown for an instant
while the reward endpoint responds. -->
<div class="tl30-prize" id="tl30Prize">
<span class="tl30-tag">Reward Unlocked</span>
<div class="tl30-amount">$50</div>
<p class="tl30-desc"><span class="tl30-spinner"></span>Applying to your next order…</p>
<button type="button" class="tl30-claim-btn" disabled>View Reward</button>
</div>
</div>
</div>
<script>
(function () {
/* ==================================================================
CONFIG
================================================================== */
// Set to false once the backend endpoint below and the Skio
// integration are live. While true: no network call is made, no
// real discount is ever applied, and the UI is driven entirely by
// DEMO_SCENARIO. This is the only thing that should change when
// this stops being a prototype.
var DEMO_MODE = true;
// Only read while DEMO_MODE is true. Flip this and reload to preview
// (and record) each reward state on demand:
// 'granted' -> fresh completion this session (confetti fires)
// 'already_granted' -> returning visit, already rewarded (no confetti)
// 'error' -> the apply call failed
var DEMO_SCENARIO = 'granted';
// The production discount code. Must already exist in Shopify/Skio as
// a $50 fixed-amount subscription discount, configured to apply to
// exactly one recurring payment, before DEMO_MODE is switched off.
var REWARD_DISCOUNT_CODE = '[BRAND]30CHALLENGE50'; // TODO: swap to [BRAND]'s real discount code prefix
// Backend endpoint that owns eligibility, idempotency, subscription
// selection, and the actual Skio applyDiscountCode call. This file
// NEVER calls Skio's API directly and NEVER holds a Skio credential —
// see brand-challenge-backend.ts for that side. // TODO: rename to [BRAND]'s actual backend filename convention
var CHALLENGE_COMPLETE_ENDPOINT = '/api/brand-challenge/complete';
/* ==================================================================
30-DAY GRID (unchanged from the visual prototype)
================================================================== */
var grid = document.getElementById('tl30DaysGrid');
for (var i = 1; i <= 30; i++) {
var cell = document.createElement('div');
cell.className = 'tl30-day' + (i === 30 ? ' tl30-final' : '');
cell.innerHTML = '<span>' + (i === 30 ? '★' : i) + '</span>';
grid.appendChild(cell);
}
/* ==================================================================
CONFETTI — extracted into a function so it only fires on a fresh
grant, never on a returning "already applied" visit.
================================================================== */
function fireConfetti() {
var canvas = document.getElementById('tl30-confetti-canvas');
var ctx = canvas.getContext('2d');
var W, H;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
var colors = ['#e6293f', '#ff4d5e', '#f4b93c', '#ffd873', '#33d17a', '#ffffff'];
var pieces = [];
var COUNT = 160;
function spawn() {
for (var i = 0; i < COUNT; i++) {
pieces.push({
x: W / 2 + (Math.random() - 0.5) * 120,
y: H * 0.32,
vx: (Math.random() - 0.5) * 11,
vy: -Math.random() * 11 - 4,
size: Math.random() * 6 + 4,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3,
shape: Math.random() > 0.5 ? 'rect' : 'circle',
life: 0
});
}
}
var GRAVITY = 0.28;
var DRAG = 0.992;
function tick() {
ctx.clearRect(0, 0, W, H);
for (var i = pieces.length - 1; i >= 0; i--) {
var p = pieces[i];
p.vy += GRAVITY;
p.vx *= DRAG;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life++;
var fade = Math.max(0, 1 - p.life / 260);
ctx.save();
ctx.globalAlpha = fade;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.shape === 'rect') {
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.6);
} else {
ctx.beginPath();
ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
if (p.y > H + 40 || fade <= 0) pieces.splice(i, 1);
}
requestAnimationFrame(tick);
}
spawn();
tick();
setTimeout(spawn, 260);
}
/* ==================================================================
getRewardSubscription() — CLIENT-SIDE, DISPLAY-ONLY.
This does NOT decide which subscription receives the $50 discount.
That decision is made authoritatively server-side (see
selectSubscriptionForReward() in brand-challenge-backend.ts) from the
authenticated storefrontUser context — never from anything the
browser reports. This exists only so the UI can eventually show
something like "applies to your Whey Protein subscription" instead
of generic copy.
TODO(Skio/[BRAND] eng): confirm whether the customer portal exposes an
authenticated, read-only accessor for the logged-in storefrontUser's
subscriptions inside a Custom Block's JS context (e.g. a
window.Skio.portal.* bridge, or a postMessage API to the parent
portal frame). We don't have visibility into that mechanism from
this file alone, so this intentionally returns null instead of
guessing at a global that may not exist — the UI copy below stays
generic ("your next subscription order") until this is confirmed.
================================================================== */
async function getRewardSubscription() {
if (typeof window.SkioPortal !== 'undefined' &&
window.SkioPortal.customer &&
typeof window.SkioPortal.customer.getActiveSubscriptions === 'function') {
try {
var subs = await window.SkioPortal.customer.getActiveSubscriptions();
return Array.isArray(subs) && subs.length === 1 ? subs[0] : null;
} catch (e) {
return null;
}
}
return null; // bridge not confirmed — see TODO above
}
/* ==================================================================
REWARD STATE MACHINE
================================================================== */
var prizeEl = document.getElementById('tl30Prize');
var hasFiredConfettiThisSession = false;
function renderPrize(state) {
if (state === 'applying') {
prizeEl.innerHTML =
'<span class="tl30-tag">Reward Unlocked</span>' +
'<div class="tl30-amount">$50</div>' +
'<p class="tl30-desc"><span class="tl30-spinner"></span>Applying to your next order…</p>' +
'<button type="button" class="tl30-claim-btn" disabled>View Reward</button>';
} else if (state === 'success') {
prizeEl.innerHTML =
'<span class="tl30-tag">Reward Applied</span>' +
'<div class="tl30-amount">$50</div>' +
'<p class="tl30-desc">Applied to your next subscription order — no code needed.</p>' +
'<button type="button" class="tl30-secondary-btn" id="tl30ViewReward">View Reward</button>';
var viewBtn = document.getElementById('tl30ViewReward');
if (viewBtn) viewBtn.addEventListener('click', viewRewardDetails);
} else if (state === 'error') {
prizeEl.innerHTML =
'<span class="tl30-tag">Reward Unlocked</span>' +
'<div class="tl30-amount">$50</div>' +
'<p class="tl30-desc">We couldn’t apply your reward automatically.<br>' +
'Your reward is safe — please try again.</p>' +
'<button type="button" class="tl30-claim-btn" id="tl30TryAgain">Try Again</button>';
var retryBtn = document.getElementById('tl30TryAgain');
if (retryBtn) retryBtn.addEventListener('click', function () { init(); });
}
}
// Secondary, informational only — does NOT grant anything. Point this
// at wherever [BRAND] wants customers sent for reward detail
// (e.g. the portal's billing/discount history). Left as a stub since
// that destination isn't something we can know from this file.
function viewRewardDetails() {
// TODO([BRAND]/Skio eng): wire to the real reward-detail destination.
console.log('[tl30-challenge] View Reward clicked — informational only, no action wired yet.');
}
/* ------------------------------------------------------------------
Talking to the backend. In production this is the ONLY network
call this component makes, it is safe to call more than once
(the backend is idempotent — see brand-challenge-backend.ts), and
there is no polling: one call on mount, plus one retry if the
customer clicks "Try Again" after a failure.
------------------------------------------------------------------ */
function completeChallenge() {
if (DEMO_MODE) {
return demoComplete();
}
return fetch(CHALLENGE_COMPLETE_ENDPOINT, {
method: 'POST',
credentials: 'include', // reuses the portal's existing authenticated session
headers: { 'Content-Type': 'application/json' },
// Intentionally no challenge/subscription/amount data in the body.
// The backend resolves the authenticated storefrontUser from the
// session itself and looks up everything else server-side — the
// frontend has no channel by which it could award itself a reward.
body: JSON.stringify({})
}).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) {
throw new Error(body && body.message ? body.message : ('Request failed (' + res.status + ')'));
}
return body; // { status: 'granted'|'already_granted'|'not_eligible'|'error', message, rewardGrantedAt? }
});
});
}
function demoComplete() {
return new Promise(function (resolve, reject) {
setTimeout(function () {
if (DEMO_SCENARIO === 'error') {
reject(new Error('Simulated failure for demo purposes'));
} else if (DEMO_SCENARIO === 'already_granted') {
resolve({ status: 'already_granted', message: 'Reward was already granted for this challenge.', rewardGrantedAt: '2026-09-01T12:00:00Z' });
} else {
resolve({ status: 'granted', message: 'Reward applied.', rewardGrantedAt: new Date().toISOString() });
}
}, 1200);
});
}
function init() {
renderPrize('applying');
completeChallenge().then(function (result) {
if (result.status === 'granted') {
renderPrize('success');
if (!hasFiredConfettiThisSession) {
fireConfetti();
hasFiredConfettiThisSession = true;
}
} else if (result.status === 'already_granted') {
// Returning visit — show the same success state, but no confetti.
renderPrize('success');
} else {
// 'not_eligible' or anything unexpected. This component should
// only ever mount once challenge.completedDays === 30, so this
// is a defensive fallback, not an expected path.
console.warn('[tl30-challenge] unexpected status from backend:', result.status, result.message);
renderPrize('error');
}
}).catch(function (err) {
// Logged for debugging; the customer only ever sees the generic
// "we couldn't apply your reward automatically" copy.
console.error('[tl30-challenge] reward request failed:', err);
renderPrize('error');
});
}
init();
})();
</script>
</body>
</html>
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.
Replace [BRAND] throughout, and set the reward amount and discount code in the file. It loads Oswald and Inter from Google Fonts.
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: Challenge Tracker (No. 01) Page: /blocks/challenge-tracker What it does: Subscribers check in on a daily challenge in the portal and unlock a reward at the finish. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Replace [BRAND] throughout, and set the reward amount and discount code in the file. It loads Oswald and Inter from Google Fonts. 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 challenge-tracker.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 challenge-tracker-<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.
