Esta guía es para desarrolladores. Necesita acceso al código de tu tema. Para una página de fidelización que configuras sin código, usa el Shopify Loyalty Hub.

Esta guía crea una página de fidelización completa con el Storefront SDK: puntos y progreso de nivel, recompensas que el cliente puede reclamar, códigos de descuento, formas de ganar puntos y la tarjeta de Wallet, además de una vista de inicio de sesión para visitantes. Tú controlas todo el HTML y el CSS, así que puedes cambiar el diseño con libertad.

Antes de empezar

  • El app embed SDK está activado en Shopify Admin > Online Store > Themes > Customize > App embeds.
  • Tu programa de fidelización tiene al menos una recompensa y una forma de ganar puntos en JeriCommerce, para que la página tenga algo que mostrar.

Añade la página a tu tema

1

Crea la sección

Ve a Shopify Admin > Online Store > Themes > Edit code. En sections/, haz clic en Add a new section, ponle el nombre jeri-loyalty y pega la plantilla de abajo.

2

Crea una plantilla de página

En templates/, añade una plantilla JSON que use la sección:

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

También puedes añadir la sección a una plantilla existente desde el editor del tema con Add section.

3

Crea la página

Ve a Shopify Admin > Online Store > Pages > Add page, ponle un título como "Recompensas", elige la plantilla page.loyalty y guarda.

4

Compruébala como visitante y como cliente

Abre la página sin haber iniciado sesión: verás las recompensas públicas y un botón para iniciar sesión. Inicia sesión con un cliente de prueba: verás sus puntos, su progreso de nivel y sus códigos, y podrás reclamar una recompensa.

La plantilla

  • Visitante o cliente: auth.getSession() decide qué partes se muestran. Los visitantes ven las recompensas públicas y un botón que llama a auth.login().
  • Primera carga: la página lee las formas de ganar puntos y después, en paralelo, el cliente, las recompensas, los códigos y los enlaces de Wallet. La sección de Wallet solo aparece cuando wallet.getLinks() devuelve enlaces.
  • Reclamar: rewards.claim() se resuelve con el código, que se muestra al instante. Después el SDK vuelve a leer el cliente, las recompensas y los códigos y lanza customer:updated, rewards:updated y discounts:updated, que actualizan el saldo, los botones de las recompensas y la lista de códigos.
  • Formas de ganar puntos: las que tienen un botón llaman a earnings.execute() con action: "flow" y abren su enlace, si tienen uno. El botón se vuelve a activar mientras remainingExecutions sea mayor que 0. Las que solo abren un enlace lo muestran como botón. Los enlaces de Follow us abren la página de la red social y registran el clic con action: "click". Las de referidos y de completar el perfil no tienen ese botón, porque nunca se ejecutan así.
  • Errores: cada llamada está envuelta para que el cliente vea un mensaje breve. La plantilla muestra un mensaje específico para NOT_ENOUGH_POINTS. Si el SDK o la sesión del cliente no se pueden cargar, la página muestra un mensaje en lugar de quedarse en blanco.

La plantilla reclama las recompensas Points as cash con el mínimo de puntos. Para que los clientes elijan, añade un campo numérico entre minPoints y maxPoints y pasa su valor como 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 para ampliarla

Todas están en la referencia del SDK:

  • Historial de puntos con history.list().
  • Enlace de referido con referrals.getLink().
  • Lista de niveles y beneficios con tiers.list() y el content de cada nivel. content es HTML, así que sanitízalo antes de insertarlo.
  • Boosts activos con boosts.list({ scope: "public" }).
  • Abrir el widget en una página con widget.open({ page: "rewards" }).

Créala con un asistente de IA

La documentación del SDK está escrita para que un asistente de código con IA pueda crear una página a partir de ella. Dale al asistente estas dos guías, Storefront SDK y esta página, o el texto completo del sitio en /llms-full.txt. Después describe lo que quieres, por ejemplo:

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

Comprueba el resultado como visitante y como cliente antes de publicar la página.