Ce guide s’adresse aux développeurs. Il nécessite l’accès au code de votre thème. Pour une page de fidélité que vous configurez sans code, utilisez le Shopify Loyalty Hub.

Ce guide construit une page de fidélité complète avec le Storefront SDK : points et progression de niveau, récompenses que le client peut réclamer, codes de réduction, moyens de gagner des points et carte wallet, plus une vue de connexion pour les visiteurs. Vous maîtrisez tout le HTML et le CSS, vous pouvez donc modifier le design librement.

Avant de commencer

  • L’app embed SDK est activé dans Shopify Admin > Online Store > Themes > Customize > App embeds.
  • Votre programme de fidélité compte au moins une récompense et un moyen de gagner des points dans JeriCommerce, pour que la page ait quelque chose à afficher.

Ajouter la page à votre thème

1

Créer la section

Allez dans Shopify Admin > Online Store > Themes > Edit code. Dans sections/, cliquez sur Add a new section, nommez-la jeri-loyalty et collez le modèle ci-dessous.

2

Créer un modèle de page

Dans templates/, ajoutez un modèle JSON qui utilise la section :

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

Vous pouvez aussi ajouter la section à un modèle existant depuis l’éditeur de thème avec Add section.

3

Créer la page

Allez dans Shopify Admin > Online Store > Pages > Add page, donnez-lui un titre comme « Récompenses », choisissez le modèle page.loyalty et enregistrez.

4

Vérifier en tant que visiteur et en tant que client

Ouvrez la page déconnecté : vous voyez les récompenses publiques et un bouton de connexion. Connectez-vous avec un client de test : vous voyez ses points, sa progression de niveau et ses codes, et vous pouvez réclamer une récompense.

Le modèle

  • Visiteur ou client : auth.getSession() décide des parties à afficher. Les visiteurs voient les récompenses publiques et un bouton qui appelle auth.login().
  • Premier chargement : la page lit les moyens de gagner des points, puis le client, les récompenses, les codes et les liens wallet en parallèle. La section wallet n’apparaît que si wallet.getLinks() renvoie des liens.
  • Réclamation : rewards.claim() se résout avec le code, affiché immédiatement. Le SDK relit ensuite le client, les récompenses et les codes et déclenche customer:updated, rewards:updated et discounts:updated, qui mettent à jour le solde, les boutons des récompenses et la liste des codes.
  • Moyens de gagner des points : les gains avec un bouton appellent earnings.execute() avec action: "flow" et ouvrent leur lien, s’ils en ont un. Le bouton se réactive tant que remainingExecutions est supérieur à 0. Les gains qui ouvrent seulement un lien l’affichent comme bouton. Les liens Follow us ouvrent la page du réseau social et enregistrent le clic avec action: "click". Les gains de parrainage et de profil complété n’ont pas ce bouton, car ils ne s’exécutent jamais ainsi.
  • Erreurs : chaque appel est protégé pour que le client voie un court message. Le modèle affiche un message précis pour NOT_ENOUGH_POINTS. Si le SDK ou la session du client ne se charge pas, la page affiche un message au lieu de rester vide.

Le modèle réclame les récompenses Points as cash avec le minimum de points. Pour laisser les clients choisir, ajoutez un champ numérique entre minPoints et maxPoints et passez sa valeur comme 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 %}

Idées pour aller plus loin

Tout cela se trouve dans la référence du SDK :

  • Historique des points avec history.list().
  • Lien de parrainage avec referrals.getLink().
  • Liste des niveaux et avantages avec tiers.list() et le content de chaque niveau. content est du HTML, nettoyez-le donc avant de l’insérer.
  • Boosts actifs avec boosts.list({ scope: "public" }).
  • Ouvrir le widget sur une page avec widget.open({ page: "rewards" }).

Le créer avec un assistant IA

La documentation du SDK est écrite pour qu’un assistant de code IA puisse construire une page à partir de celle-ci. Donnez à l’assistant ces deux guides, Storefront SDK et cette page, ou le texte complet du site à l’adresse /llms-full.txt. Décrivez ensuite ce que vous voulez, par exemple :

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().

Vérifiez le résultat en tant que visiteur et en tant que client avant de publier la page.