Skip to main content

Quickstart

This is a complete, working loyalty page: points balance, reward catalogue, a redeem button, and the join and login states. Paste it into your theme, then restyle it — the markup is deliberately plain.

It uses the App Proxy, so there is nothing to configure and no key to store.

1. Add the section​

Create sections/loyalty.liquid in your theme:

<div id="loyalty" data-login-url="{{ routes.account_login_url }}" data-currency="{{ shop.currency }}"></div>

<script src="{{ 'loyalty.js' | asset_url }}" defer></script>

{% schema %}
{
"name": "Loyalty",
"settings": [],
"presets": [{ "name": "Loyalty" }]
}
{% endschema %}

The API returns money in minor units without a currency code, so the section passes shop.currency in for you to format with.

2. Add the script​

Create assets/loyalty.js:

const API = '/apps/loyalty-proxy';

async function call(path, options) {
const res = await fetch(API + path, {
headers: {'Content-Type': 'application/json'},
...options,
});
return {status: res.status, body: res.status === 204 ? null : await res.json()};
}

// Reward names, coupon titles and the welcome message are merchant-authored text, so they are escaped
// before they reach the DOM.
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
}

function render(root, html) {
root.innerHTML = html;
}

function messageFor(status) {
return status === 503 ? 'Please try again in a moment.' : 'Something went wrong.';
}

async function load(root) {
const config = await call('/config');
const member = await call('/member');

if (member.status === 401) {
render(root, `<a href="${escapeHtml(root.dataset.loginUrl)}">Log in to see your rewards</a>`);
return;
}

if (member.status === 404) {
render(root, `<p>${escapeHtml(config.body.branding.programWelcomeMessage)}</p>
<button id="loyalty-join">Join the programme</button>
<p id="loyalty-error" hidden></p>`);

const join = root.querySelector('#loyalty-join');
const error = root.querySelector('#loyalty-error');

join.addEventListener('click', async () => {
join.disabled = true;
// 400 (consent missing) and 503 (Shopify unreachable) both land here — never assume success.
const joined = await call('/register', {method: 'POST', body: JSON.stringify({consentGiven: true})});
if (joined.status !== 200) {
join.disabled = false;
error.textContent = messageFor(joined.status);
error.hidden = false;
return;
}
load(root);
});
return;
}

const {branding, rewardOptions} = config.body;
const {activePoints, pendingPoints, coupons} = member.body;
// /member returns every coupon the member ever had; isValid is the one that is usable today.
const usableCoupons = coupons.filter((coupon) => coupon.isValid);

render(root, `
<h2>${activePoints} ${escapeHtml(branding.rewardCurrencyName)}</h2>
${pendingPoints > 0 ? `<p>${pendingPoints} more are still pending.</p>` : ''}

<h3>Rewards</h3>
<ul>
${rewardOptions.map((option) => `
<li>
${escapeHtml(option.name)} — ${option.pointsCost} ${escapeHtml(branding.rewardCurrencyName)}
<button data-option="${escapeHtml(option.optionId)}" ${activePoints < option.pointsCost ? 'disabled' : ''}>
Redeem
</button>
</li>`).join('')}
</ul>

<h3>Your coupons</h3>
${usableCoupons.length === 0 ? '<p>None yet.</p>' : `<ul>${usableCoupons.map((coupon) => `
<li><code>${escapeHtml(coupon.code)}</code> — ${escapeHtml(coupon.title)}</li>`).join('')}</ul>`}

<p id="loyalty-error" hidden></p>
`);

const error = root.querySelector('#loyalty-error');

root.querySelectorAll('[data-option]').forEach((button) => {
button.addEventListener('click', async () => {
button.disabled = true;
error.hidden = true;
const redeemed = await call('/redeem', {
method: 'POST',
body: JSON.stringify({optionId: button.dataset.option}),
});
if (redeemed.status !== 200) {
button.disabled = false;
error.textContent = messageFor(redeemed.status);
error.hidden = false;
return;
}
load(root);
});
});
}

const root = document.getElementById('loyalty');
if (root) {
load(root);
}

3. Add the section to a page​

In the theme editor, open the page you want and add the Loyalty section.

What to do next​

  • Points are awarded asynchronously after an order is paid, so a balance can move from pendingPoints to activePoints minutes later. Re-fetch /member when the customer returns rather than caching it.
  • GET /config is the same for every visitor, so it is safe to cache; GET /member is per customer and is not.
  • Show activePoints, not totalPoints — totalPoints includes points the customer cannot spend yet.
  • GET /activities gives you a "how did I earn this" history. Print description: it is already a finished sentence in the shop's language. activityType is there for icons and filtering, and new types can appear at any time, so always have a fallback.
  • POST /birthday takes an ISO-8601 MonthDay — --MM-DD, no year. Anything else is a 400, and a second attempt is a 409 carrying the date already on file.
  • Every ...Cents field in GET /config is in the minor unit of the shop's currency; the currency code comes from Shopify, not from us.

Every field of every response is listed in the API reference.