This guide is for developers. It needs access to your theme code. For a loyalty page you set up without code, use the Shopify Loyalty Hub.

This guide builds a complete loyalty page with the Storefront SDK: points and tier progress, rewards the customer can claim, discount codes, ways to earn and the wallet card, plus a sign-in view for visitors. You own all the HTML and CSS, so you can change the design freely.

Before you start

  • The SDK app embed is turned on in Shopify Admin > Online Store > Themes > Customize > App embeds.
  • Your loyalty program has at least one reward and one way to earn in JeriCommerce, so the page has something to show.

Add the page to your theme

1

Create the section

Go to Shopify Admin > Online Store > Themes > Edit code. In sections/, click Add a new section, name it jeri-loyalty and paste the template below.

2

Create a page template

In templates/, add a JSON template that uses the section:

{
  "sections": {
    "loyalty": {
      "type": "jeri-loyalty"
    }
  },
  "order": ["loyalty"]
}

You can also add the section to an existing template from the theme editor with Add section.

3

Create the page

Go to Shopify Admin > Online Store > Pages > Add page, give it a title such as "Rewards", choose the page.loyalty template and save.

4

Check it as a visitor and as a customer

Open the page signed out: you see the public rewards and a sign-in button. Sign in with a test customer: you see their points, tier progress and codes, and you can claim a reward.

The template

  • Visitor or customer: auth.getSession() decides which parts to show. Visitors see the public rewards and a button that calls auth.login().
  • First load: the page reads the ways to earn, then the customer, rewards, codes and wallet links in parallel. The wallet section only appears when wallet.getLinks() returns links.
  • Claiming: rewards.claim() resolves with the code, shown straight away. The SDK then reads the customer, rewards and codes again and fires customer:updated, rewards:updated and discounts:updated, which update the balance, the reward buttons and the list of codes.
  • Ways to earn: earnings with a button call earnings.execute() with action: "flow" and open their link, if they have one. The button turns on again while remainingExecutions is above 0. Earnings that only open a link show it as a button. Follow us links open the social page and record the click with action: "click". Referral and complete-profile earnings don't get that button, because they never run that way.
  • Errors: every call is wrapped so the customer sees a short message. The template shows a specific message for NOT_ENOUGH_POINTS. If the SDK or the customer's session can't load, the page shows a message instead of staying blank.

The template claims Points as cash rewards with the minimum points. To let customers choose, add a number input between minPoints and maxPoints and pass its value as pointsToRedeem.

sections/jeri-loyalty.liquid
<section class="jl" data-jl>
  <p class="jl-notice" data-jl-notice hidden></p>

  <header class="jl-hero">
    <h1>{{ section.settings.title }}</h1>
    <div data-jl-guest hidden>
      <p>Join our rewards club to earn points on every order.</p>
      <button class="jl-button" type="button" data-jl-login>Sign in or join</button>
    </div>
    <div data-jl-member hidden>
      <p class="jl-balance"><strong data-jl-balance>0</strong> points</p>
      <p data-jl-tier></p>
      <div class="jl-progress"><span data-jl-progress></span></div>
    </div>
  </header>

  <h2>Rewards</h2>
  <div class="jl-grid" data-jl-rewards></div>
  <p class="jl-code" data-jl-claimed hidden>Your code: <strong data-jl-claimed-code></strong></p>

  <div data-jl-member hidden>
    <h2>Your discount codes</h2>
    <ul class="jl-list" data-jl-codes></ul>
  </div>

  <h2>Ways to earn</h2>
  <div class="jl-grid" data-jl-earnings></div>

  <div data-jl-wallet hidden>
    <h2>Your loyalty card</h2>
    <a class="jl-button" data-jl-wallet-ios href="#">Add to Apple Wallet</a>
    <a class="jl-button" data-jl-wallet-android href="#">Add to Google Wallet</a>
  </div>
</section>

<style>
  .jl { max-width: 960px; margin: 0 auto; padding: 32px 16px; }
  .jl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
  .jl-card { border: 1px solid #e5e5e5; border-radius: 12px; padding: 16px; }
  .jl-button { display: inline-block; padding: 10px 16px; border: 0; border-radius: 8px; background: #111; color: #fff; cursor: pointer; text-decoration: none; }
  .jl-button[disabled] { opacity: 0.5; cursor: default; }
  .jl-progress { height: 8px; border-radius: 4px; background: #eee; overflow: hidden; }
  .jl-progress span { display: block; height: 100%; background: #111; }
  .jl-notice { padding: 12px; border-radius: 8px; background: #fdecea; }
  .jl-list { list-style: none; padding: 0; }
  [hidden] { display: none !important; }
</style>

<script type="module">
  const root = document.querySelector("[data-jl]");
  const $ = selector => root.querySelector(selector);
  const $ = selector => [...root.querySelectorAll(selector)];
  const notice = message => {
    $("[data-jl-notice]").textContent = message;
    $("[data-jl-notice]").hidden = !message;
  };
  const card = (title, text) => {
    const element = document.createElement("article");
    element.className = "jl-card";
    element.innerHTML = "<h3></h3><p></p>";
    element.querySelector("h3").textContent = title;
    element.querySelector("p").textContent = text ?? "";
    return element;
  };
  const button = (label, onClick) => {
    const element = document.createElement("button");
    element.type = "button";
    element.className = "jl-button";
    element.textContent = label;
    element.addEventListener("click", () => onClick(element));
    return element;
  };
  const errorText = error =>
    error?.code === "api_error" && error.serverCode === "NOT_ENOUGH_POINTS"
      ? "You don't have enough points for this reward."
      : "Something went wrong. Please try again.";

  const socialUrl = (network, username) => {
    const name = username.trim().replace(/^@/, "");
    if (/^https?:\/\//.test(name)) return name;
    // Same profile URLs as JeriCommerce. Accounts saved from the admin already are full links.
    const urls = {
      facebook: `https://www.facebook.com/${name}`,
      instagram: `https://www.instagram.com/${name}`,
      tiktok: `https://www.tiktok.com/@${name}`,
      x: `https://www.x.com/${name}`,
      youtube: `https://www.youtube.com/@${name}`,
      spotify: `https://open.spotify.com/user/${name}`,
      whatsapp: `https://www.whatsapp.com/channel/${name}`,
    };
    return urls[network] ?? null;
  };
  // These earnings have their own actions, so they never run with action: "flow".
  const NOT_EXECUTABLE = ["FOLLOW_US", "MEMBER_GET_MEMBER", "COMPLETE_USER_PROFILE"];

  async function start() {
    const jeri = await window.JeriCommerce.ready();
    const { authenticated } = await jeri.auth.getSession();
    $(authenticated ? "[data-jl-member]" : "[data-jl-guest]").forEach(element => (element.hidden = false));
    $("[data-jl-login]").addEventListener("click", () => jeri.auth.login());

    const renderCustomer = async data => {
      if (!data) return;
      $("[data-jl-balance]").textContent = data.customer.loyaltyData.balance;
      const progress = await jeri.customer.getTierProgress();
      $("[data-jl-tier]").textContent = progress.nextTier
        ? `${progress.currentTier?.name ?? ""} · ${progress.pointsToNextTier} points to ${progress.nextTier.name}`
        : progress.currentTier?.name ?? "";
      $("[data-jl-progress]").style.width = `${progress.progressValue}%`;
    };

    const renderRewards = rewards =>
      $("[data-jl-rewards]").replaceChildren(
        ...rewards.map(reward => {
          const dynamic = reward.type === "dynamic";
          const cost = dynamic ? `From ${reward.minPoints ?? 1} points` : `${reward.cost ?? reward.redeemCost} points`;
          const element = card(reward.title, cost);
          if (!authenticated) return element;
          element.append(
            button(reward.redeemable ? "Claim" : reward.nonRedeemableReason === "NOT_ENOUGH_BALANCE" ? "Not enough points" : "Not available", async claim => {
              claim.disabled = true;
              try {
                const input = { rewardId: reward.id };
                if (dynamic) input.pointsToRedeem = reward.minPoints ?? 1;
                const claimed = await jeri.rewards.claim(input);
                $("[data-jl-claimed-code]").textContent = claimed.code;
                $("[data-jl-claimed]").hidden = false;
                notice("");
              } catch (error) {
                notice(errorText(error));
                claim.disabled = false;
              }
            }),
          );
          element.querySelector("button").disabled = !reward.redeemable;
          return element;
        }),
      );

    const renderCodes = ({ discounts }) =>
      $("[data-jl-codes]").replaceChildren(
        ...discounts.data.map(code => {
          const item = document.createElement("li");
          item.textContent = code.value;
          return item;
        }),
      );

    const renderEarnings = earnings =>
      $("[data-jl-earnings]").replaceChildren(
        ...earnings.flatMap(definition => {
          const earning = definition.programEngagementFlows?.[0];
          if (!earning?.active) return [];
          const element = card(earning.title, earning.description);
          if (authenticated && earning.properties.executeOnClick && !NOT_EXECUTABLE.includes(definition.name)) {
            element.append(
              button(earning.buttonText || "Earn points", async run => {
                run.disabled = true;
                if (earning.url) window.open(earning.url, "_blank", "noopener");
                try {
                  const result = await jeri.earnings.execute({ earningId: earning.id, action: "flow" });
                  run.textContent = `+${result.pointsAwarded} points`;
                  run.disabled = result.remainingExecutions === 0;
                } catch (error) {
                  notice(errorText(error));
                  run.disabled = false;
                }
              }),
            );
          } else if (earning.url && !NOT_EXECUTABLE.includes(definition.name)) {
            const link = document.createElement("a");
            link.className = "jl-button";
            link.href = earning.url;
            link.target = "_blank";
            link.rel = "noopener";
            link.textContent = earning.buttonText || "Learn more";
            element.append(link);
          }
          if (authenticated && definition.name === "FOLLOW_US") {
            for (const [network, account] of Object.entries(earning.properties.followUs ?? {})) {
              const url = account?.username ? socialUrl(network, account.username) : null;
              if (!url) continue;
              element.append(
                button(network, () => {
                  window.open(url, "_blank", "noopener");
                  jeri.earnings.execute({ earningId: earning.id, action: "click", linkId: network }).catch(() => {});
                }),
              );
            }
          }
          return [element];
        }),
      );

    renderEarnings(await jeri.earnings.list());
    if (authenticated) {
      const [customer, rewards, discounts, wallet] = await Promise.all([
        jeri.customer.get(),
        jeri.rewards.list({ scope: "customer" }),
        jeri.discounts.list({ page: 1, limit: 20 }),
        jeri.wallet.getLinks(),
      ]);
      await renderCustomer(customer);
      renderRewards(rewards);
      renderCodes({ discounts });
      if (wallet) {
        $("[data-jl-wallet]").hidden = false;
        $("[data-jl-wallet-ios]").href = wallet.ios;
        $("[data-jl-wallet-android]").href = wallet.android;
      }
      // After a claim or an earning, the SDK reads these again and fires the events.
      jeri.on("customer:updated", renderCustomer);
      jeri.on("rewards:updated", ({ scope, rewards }) => scope === "customer" && renderRewards(rewards));
      jeri.on("discounts:updated", renderCodes);
    } else {
      renderRewards(await jeri.rewards.list({ scope: "public" }));
    }
  }

  if (!window.JeriCommerce) {
    notice("Loyalty is not available right now. Turn on the SDK app embed in your theme.");
  } else {
    start().catch(error => notice(errorText(error)));
  }
</script>

{% schema %}
{
  "name": "JeriCommerce loyalty",
  "settings": [
    { "type": "text", "id": "title", "label": "Title", "default": "Rewards club" }
  ],
  "presets": [{ "name": "JeriCommerce loyalty" }]
}
{% endschema %}

Ideas to extend it

All of these are in the SDK reference:

  • Points history with history.list().
  • Referral link with referrals.getLink().
  • Tier list and benefits with tiers.list() and each tier's content. content is HTML, so sanitize it before you insert it.
  • Active boosts with boosts.list({ scope: "public" }).
  • Open the widget on a page with widget.open({ page: "rewards" }).

Build it with an AI assistant

The SDK documentation is written so an AI coding assistant can build a page from it. Give the assistant these two guides, Storefront SDK and this page, or the full text of the site at /llms-full.txt. Then describe what you want, for example:

Build a Shopify section called jeri-loyalty for my theme using the JeriCommerce
Storefront SDK (window.JeriCommerce). Follow the JeriCommerce SDK guides I attached.
Match the look of https://www.my-store.com: fonts, colors and buttons.
Show the customer's points and tier progress, the rewards with a claim button,
their discount codes and the ways to earn. Visitors see the rewards and a sign-in button.
Only use SDK methods and fields listed in the reference, handle errors by their code,
and update the page from the SDK events instead of calling refresh().

Check the result as a visitor and as a customer before you publish the page.