This guide is aimed at developers building custom storefront experiences. If you just want to tweak colors and copy, use the default template guide instead.

For merchants who need full control over the HTML layout or want to build a completely custom loyalty page, JeriCommerce provides standalone Liquid section templates that load the loyalty page engine directly from the CDN.

Want to skip the manual setup? The loyalty-template-creator AI skill can generate a complete custom .liquid template that matches your store's visual style. Give it a reference URL and it analyzes colors, fonts, layout, and assets to produce a ready-to-use template built on the same CDN engine documented below.

Install it with: npx skills add jericommerce/skills --skill loyalty-template-creator

Source: github.com/JeriCommerce/skills


When to Use CDN Mode

  • The merchant wants a completely custom layout that goes beyond what the theme editor settings allow.
  • The development team prefers to own the HTML directly in the theme code.
  • You need to integrate the loyalty page into an existing page template alongside other sections.

Setup Steps

Get the Template

JeriCommerce provides two ready-made CDN templates. Copy the one that best fits your needs:

  • Visual template (templates/visual.liquid) — Full-featured: hero with CTA, how-it-works steps, earning rows with per-network SVG icons, tier cards, and loading skeleton. Same look as the theme block.
  • Minimal template (templates/minimal.liquid) — Compact list layout: title, earnings as a simple list, tiers as rows. Inline CSS, no external stylesheet needed.

You can obtain these from the JeriCommerce admin under Loyalty Page > Get template code, or copy directly from the repository files.

Add to Your Theme

  1. Go to Shopify Admin > Online Store > Themes > Edit code.
  2. In the sections/ directory, click Add a new section.
  3. Name it (e.g., jeri-loyalty) and paste the template code.

Create a Page Template

Create a JSON page template that references your section:

```json title="templates/page.loyalty.json" { "sections": { "loyalty": { "type": "jeri-loyalty" } }, "order": ["loyalty"] }

Alternatively, add the section to an existing page template via the theme editor: **Customize > Add section**.

### Create the Page

Go to **Shopify Admin > Pages > Add page** and select the "loyalty" template.

</Steps>

---

## How CDN Mode Works

CDN templates load the same JavaScript that powers the theme app extension block — bundled, minified, and always up to date:

```html
<!-- Always the latest version -->
<script src="https://cdn.jericommerce.com/shopify/loyalty-page.js" defer></script>
<link rel="stylesheet" href="https://cdn.jericommerce.com/shopify/loyalty-page.css" />

The engine auto-detects the shop domain from the data-shop attribute on the root element, or falls back to window.Shopify.shop.


Template System: How Data Binding Works

The template engine uses two systems to bind API data to your HTML.

1. Variable Interpolation — {varName}

Write {varName} tokens anywhere in your HTML text content. The JS engine walks all text nodes and replaces them with API data.

<h3>{tier.name}</h3>
<span>{tier.requiredPoints}</span>
<span>{earning.points}</span>

Inside repeating templates (tiers, earnings), variables are scoped to the template data. Syntax uses single curly braces {key} — no conflict with Liquid ({{ }}), JS template literals (${ }), or CSS.

There are also global variables (like {programName}, {currencySymbol}) that work in any text node across the entire template — not just inside repeating templates. See the Global Template Variables section below for the full list.

2. Data Attributes — data-jeri

Used for non-text operations that can't be expressed as text interpolation:

Attribute Purpose Example
data-jeri="name" on <template> Defines a repeating row template <template data-jeri="tiers">
data-jeri="name" on container Receives cloned template rows <div data-jeri="tiers">
data-jeri="key" on <img> Sets src attribute (hides if empty) <img data-jeri="earning.iconUrl">
data-jeri-hide-if="value" Hide element when data matches value data-jeri-hide-if="1"
data-jeri-html="key" Inject raw HTML content <p data-jeri-html="tier.content">
data-jeri-group="name" Group container (hidden when empty) data-jeri-group="purchases"

Quick Reference: When to Use Which

Need Use
Display text/numbers {varName} in text content
Mix variables with custom copy Earn {earning.points} with {earning.title}
Set image src data-jeri="key" on <img>
Hide based on value data-jeri-hide-if
Inject rich HTML content data-jeri-html="key"
Define a repeating row <template data-jeri="name"> + <div data-jeri="name">

Global Template Variables

These 7 global variables can be used in any text node inside #jeri-loyalty-page-root. Write them with {variableName} syntax — the engine replaces them automatically with data from the program configuration.

Variable Type Description Example
{programName} string Name of the loyalty program "My Rewards"
{currencySymbol} string Currency symbol, resolved via Intl.NumberFormat "$", "€", "£"
{currencyCode} string ISO currency code "USD", "EUR", "GBP"
{balanceName} string Plural name for points "Points", "Stars"
{singularBalanceName} string Singular name for points "Point", "Star"
{tierBalanceName} string Plural name for tier points "Points"
{singularTierBalanceName} string Singular name for tier points "Point"

Usage Example

<h1>Welcome to {programName}!</h1>
<p>Earn {balanceName} with every {currencySymbol} you spend</p>
<p>Prices are in {currencyCode}</p>

Global variables work alongside scoped variables ({earning.*}, {tier.*}). Inside repeating templates, both global and scoped variables are resolved.

The {currencySymbol} variable uses the browser's native Intl.NumberFormat to resolve the correct symbol for the program's currency code. This means the symbol adapts to the actual program currency — $ for USD, for EUR, £ for GBP, and so on.

Migration note: The variable {hero.programName} has been removed. Use {programName} instead — it works everywhere in the template, not just in the hero section.


Available Data Fields

Earning Fields (earning.*)

Used inside the <template data-jeri="earning-row"> template.

Field Type Description
earning.type string Flow type: purchase, social, referral, profile, verify, wallet, scan, link
earning.title string Human-readable title for the earning rule
earning.description string Detailed description of how to earn
earning.points string Compact points display: +100 for fixed, 10% for rate < 1, x2 for multiplier >= 1
earning.iconUrl string Icon URL (set via data-jeri on <img>, empty if no icon)

Tier Fields (tier.*)

Used inside the <template data-jeri="tiers"> template.

Field Type Description
tier.name string Tier name (e.g., "Gold", "VIP")
tier.content string Tier description/benefits — may contain HTML, use data-jeri-html="tier.content"
tier.image string Tier image URL (set via data-jeri on <img>)
tier.requiredPoints string Points needed (e.g., "1,000 tier points"). Uses tierBalanceName with singular/plural
tier.factor string Points multiplier (e.g., "2"). Auto-hidden when "1" via data-jeri-hide-if
tier.id string Internal tier ID

Required Root Element

Every custom template must include this root element with the required attributes:

<div
  id="jeri-loyalty-page-root"
  class="jeri-loyalty-page jeri-loyalty-page--loading"
  data-shop="{{ shop.permanent_domain }}"
  data-icon-base="https://cdn.jericommerce.com/shopify/"
  data-locale="{{ request.locale.iso_code }}"
>
  <!-- Your custom HTML here -->
</div>
Attribute Required Purpose
id="jeri-loyalty-page-root" Yes JS entry point — the engine looks for this ID
class="jeri-loyalty-page" Yes CSS scoping + CSS variables
data-shop Yes Shop domain for API calls
data-icon-base Recommended URL prefix for earning icons
data-locale Recommended Store locale for i18n (falls back to "en")

The jeri-loyalty-page--loading class shows a skeleton and hides content until data loads. JS removes it after data is fetched.

The CSS stylesheet must be loaded before the HTML content (inside the root element) to prevent a flash of unstyled content on first load.


Template Rules

The 6 rules for custom templates:

  1. Keep id="jeri-loyalty-page-root" on the root element — JS needs it to initialize.
  2. Keep class="jeri-loyalty-page" on root — CSS variables are defined here.
  3. Keep data-shop — required for API calls.
  4. Keep template/container pairs — A <template data-jeri="X"> needs a matching <div data-jeri="X"> as its sibling.
  5. Use {varName} freely in any text node — the engine replaces all tokens it recognizes.
  6. Use data-jeri-html="key" for HTML content (not {var}, which would be escaped).

Default Template Structure

Hero Section

<section class="jeri-loyalty-page__hero">
  <h1 class="jeri-loyalty-page__hero-title">Our Loyalty Program</h1>
  <p class="jeri-loyalty-page__hero-subtitle">Join our loyalty program...</p>

  <!-- CTA adapts to login state via Liquid -->
  <div class="jeri-loyalty-page__hero-cta">
    {% if customer %}
      {% if block.settings.cta_action == 'widget_rewards' %}
        <a href="#jeri=loyalty/rewards" class="jeri-loyalty-page__cta-button jeri-loyalty-page__cta-button--primary">
          {{ block.settings.logged_in_cta_label | default: 'See rewards' }}
        </a>
      {% else %}
        <a href="{{ block.settings.logged_in_cta_url | default: '/account' }}" class="jeri-loyalty-page__cta-button jeri-loyalty-page__cta-button--primary">
          {{ block.settings.logged_in_cta_label | default: 'See rewards' }}
        </a>
      {% endif %}
    {% else %}
      <a href="{{ block.settings.guest_cta_url | default: '/account/login' }}" class="jeri-loyalty-page__cta-button jeri-loyalty-page__cta-button--primary">
        {{ 'loyalty_page.hero_cta' | t }}
      </a>
    {% endif %}
  </div>
</section>

Tiers Section

<template data-jeri="tiers">
  <div class="jeri-tier-card">
    <div class="jeri-tier-card__body">
      <div class="jeri-tier-card__top">
        <h3 class="jeri-tier-card__name">{tier.name}</h3>
        <span class="jeri-tier-card__factor" data-jeri="tier.factor" data-jeri-hide-if="1">
          {tier.factor}x
        </span>
      </div>
      <div class="jeri-tier-card__meta">
        <span class="jeri-tier-card__points">{tier.requiredPoints}</span>
      </div>
      <p class="jeri-tier-card__content" data-jeri-html="tier.content"></p>
    </div>
  </div>
</template>

<!-- Container: cloned cards are appended here -->
<div data-jeri="tiers" class="jeri-tier-grid"></div>

Earnings Section

<!-- Purchase earnings group -->
<div class="jeri-earning-group" data-jeri-group="purchases">
  <h3 class="jeri-earning-group__title">Purchases</h3>
  <div class="jeri-earning-list" data-jeri="earnings-purchases"></div>
</div>

<!-- Action earnings group -->
<div class="jeri-earning-group" data-jeri-group="actions">
  <h3 class="jeri-earning-group__title">Actions</h3>
  <div class="jeri-earning-list" data-jeri="earnings-actions"></div>
</div>

<!-- Shared template for both groups -->
<template data-jeri="earning-row">
  <div class="jeri-earning-row">
    <img class="jeri-earning-row__icon" data-jeri="earning.iconUrl" alt="" width="48" height="48" loading="lazy" />
    <div class="jeri-earning-row__content">
      <h3 class="jeri-earning-row__title">{earning.title}</h3>
      <p class="jeri-earning-row__description">{earning.description}</p>
    </div>
    <span class="jeri-earning-row__points">{earning.points}</span>
  </div>
</template>

Full Custom Template Example

Here is a complete minimal custom template to use as a starting point:

<!-- CDN Scripts -->
<script src="https://cdn.jericommerce.com/shopify/loyalty-page.js" defer></script>
<link rel="stylesheet" href="https://cdn.jericommerce.com/shopify/loyalty-page.css" />

<div
  id="jeri-loyalty-page-root"
  class="jeri-loyalty-page jeri-loyalty-page--loading"
  data-shop="{{ shop.permanent_domain }}"
  data-icon-base="https://cdn.jericommerce.com/shopify/"
  data-locale="{{ request.locale.iso_code }}"
>

  <!-- Hero -->
  <section class="jeri-loyalty-page__hero">
    <h1 class="jeri-loyalty-page__hero-title">Our Loyalty Program</h1>
    <p class="jeri-loyalty-page__hero-subtitle">Join and start earning rewards today.</p>
    <div class="jeri-loyalty-page__hero-cta">
      {% if customer %}
        <a href="/account" class="jeri-loyalty-page__cta-button jeri-loyalty-page__cta-button--primary">
          See rewards
        </a>
      {% else %}
        <a href="/account/login" class="jeri-loyalty-page__cta-button jeri-loyalty-page__cta-button--primary">
          Join now
        </a>
      {% endif %}
    </div>
  </section>

  <!-- Tiers -->
  <template data-jeri="tiers">
    <div>
      <h3>{tier.name}</h3>
      <p>{tier.requiredPoints}</p>
      <span data-jeri="tier.factor" data-jeri-hide-if="1">
        Multiplier: {tier.factor}x
      </span>
      <div data-jeri-html="tier.content"></div>
    </div>
  </template>
  <div data-jeri="tiers"></div>

  <!-- Earnings -->
  <template data-jeri="earning-row">
    <div>
      <img data-jeri="earning.iconUrl" alt="" width="32" height="32" />
      <strong>{earning.title}</strong>
      <span>{earning.points}</span>
      <p>{earning.description}</p>
    </div>
  </template>
  <div data-jeri-group="purchases">
    <h3>Purchases</h3>
    <div data-jeri="earnings-purchases"></div>
  </div>
  <div data-jeri-group="actions">
    <h3>Actions</h3>
    <div data-jeri="earnings-actions"></div>
  </div>

  <!-- Loading skeleton -->
  <div class="jeri-loyalty-page__skeleton">
    <div class="jeri-loyalty-page__skeleton-hero"></div>
  </div>

</div>

Visual vs Minimal Template

Visual template — Best for brands that want a polished, marketing-style loyalty page with hero section, how-it-works steps, earning rows with SVG icons, tier cards, and full loading skeleton.

Minimal template — Best for stores that want a clean, data-first approach with compact list layout, inline CSS, and no external stylesheet dependency beyond the JS engine.

Both templates use the same JS engine and data binding system. The only difference is the HTML structure and visual design. You can start from either one and customize freely.


Icon System

Earning row icons are SVG files named by type. The icon URL is computed as data-icon-base + name + .svg.

Icon File Used For
purchase.svg Purchase earning flows
facebook.svg Follow on Facebook
instagram.svg Follow on Instagram
tiktok.svg Follow on TikTok
x.svg Follow on X
youtube.svg Follow on YouTube
referral.svg Refer a friend
profile.svg Complete profile
wallet.svg Install wallet pass
scan.svg Scan in store
verify.svg Verify account
link.svg Visit link

In CDN mode, the icon base URL is hardcoded to https://cdn.jericommerce.com/shopify/. If data-icon-base is missing or an icon doesn't exist, the <img> element is hidden automatically.


Points Display Format

The earning.points field uses a compact format depending on the earning type:

Earning Type Format Example
Purchase (rate < 1) Percentage return 10%
Purchase (rate >= 1) Multiplier x2
All others (social, referral, profile, etc.) Fixed points +100

For referral, only the referrer's points are shown in the badge. The friend's reward is mentioned in the description text.

The purchase earning description uses the program's actual currency symbol dynamically (e.g., "Earn x3 Points per $ spent" for USD, "Earn x3 Points per € spent" for EUR). The currency is resolved from the program configuration using the browser's Intl.NumberFormat.


Wallet Names Resolution

Four naming properties are resolved from the program config:

Name Used For Example
balanceName / singularBalanceName Earning descriptions, titles "points" / "point"
tierBalanceName / singularTierBalanceName Tier required points "tier points" / "tier point"

These are read from program.configurations.wallet (base config). Singular/plural is applied automatically based on the amount (1 = singular, else plural).

These four names are also available as global template variables — {balanceName}, {singularBalanceName}, {tierBalanceName}, and {singularTierBalanceName} — so you can reference them directly in any text node.


Theme Editor Settings

Setting Type Default Description
Enable Loyalty Page Checkbox true Show/hide the entire block
Hero Title Text "Our Loyalty Program" Main heading
Hero Subtitle Textarea "Join our loyalty..." Description below heading
Guest CTA URL URL /account/login Where to send guests
Logged-in CTA Action Select account account = go to account page, widget_rewards = open rewards widget
Logged-in CTA Label Text "See rewards" Button text shown to logged-in customers
Logged-in CTA URL URL /account URL when action is "Go to account page"
Show How It Works Checkbox true Toggle the 3-step section
Show Earning Rules Checkbox true Toggle the earnings section
Show VIP Tiers Checkbox true Toggle the tiers section
Primary Color Color #0f0f0f Buttons, accents, badges
Primary Contrast Color #fcfcfc Text on primary backgrounds
Secondary Color Color #f5f5f5 Card backgrounds
Text Color Color #0f0f0f Headings and body text
Muted Color Color #888888 Subtitles, descriptions
Custom CSS Textarea Additional CSS overrides

Widget Integration

The "See rewards" CTA (when cta_action is set to widget_rewards) uses #jeri=loyalty/rewards as its href. When the JeriCommerce widget is active on the page, clicking this link opens the widget's rewards panel. This means:

  • The loyalty page explains the program (public info)
  • The widget handles rewards, transactions, and account management (authenticated)
  • Both work independently but complement each other via the #jeri= URL pattern

Conditional Visibility

The page automatically hides elements based on program configuration:

Element Condition
VIP Tiers section Hidden when program has no tiers
Ways to Earn section Hidden when program has no active engagement flows
Purchases group Hidden when no purchase-type flows exist
Actions group Hidden when no action-type flows exist

CSS Variables

Merchant-Facing Variables

These are set on :root by the Liquid template based on theme editor color settings:

Variable Default Set By
--jeri-loyalty-primary #0f0f0f Primary Color setting
--jeri-loyalty-primary-contrast #fcfcfc Primary Contrast setting
--jeri-loyalty-secondary #f5f5f5 Secondary Color setting
--jeri-loyalty-text #0f0f0f Text Color setting
--jeri-loyalty-muted #888888 Muted Color setting

Internal Variables

These resolve the merchant variables and add layout/structural tokens. Override them in Custom CSS for fine-grained control:

Variable Default Purpose
--jlp-primary var(--jeri-loyalty-primary, #0f0f0f) Primary color
--jlp-primary-contrast var(--jeri-loyalty-primary-contrast, #fcfcfc) Text on primary
--jlp-text var(--jeri-loyalty-text, #0f0f0f) Body text color
--jlp-muted var(--jeri-loyalty-muted, #888888) Secondary text color
--jlp-font var(--font-body-family, system stack) Font family
--jlp-border-radius 16px Border radius for cards
--jlp-space 16px Base spacing unit
--jlp-border-color #e8e8e8 Card/list border color
--jlp-card-bg #fcfcfc Card background color
--jlp-divider #f0f0f0 Row divider color
--jlp-skeleton-bg #f5f5f5 Skeleton loader color
--jlp-icon-size 48px Earning row icon size
--jlp-max-width 1200px Page max-width

Example: Override Spacing

.jeri-loyalty-page {
  --jlp-space: 12px;
  --jlp-border-radius: 8px;
}

Example: Dark Theme

.jeri-loyalty-page {
  --jlp-card-bg: #1a1a1a;
  --jlp-border-color: #333;
  --jlp-divider: #2a2a2a;
  --jlp-skeleton-bg: #222;
}

CSS Classes Reference

Layout

Class Element
.jeri-loyalty-page Root container
.jeri-loyalty-page--loading Loading state (shows skeleton, hides content)
.jeri-loyalty-page__hero Hero section
.jeri-loyalty-page__section Content section
.jeri-loyalty-page__section-title Section heading
.jeri-loyalty-page__skeleton Skeleton wrapper

Hero

Class Element
.jeri-loyalty-page__hero-title Hero heading
.jeri-loyalty-page__hero-subtitle Hero description
.jeri-loyalty-page__hero-cta CTA button wrapper
.jeri-loyalty-page__cta-button CTA button base
.jeri-loyalty-page__cta-button--primary Primary CTA variant

How It Works

Class Element
.jeri-loyalty-page__steps Steps grid (3-column)
.jeri-loyalty-page__step Individual step
.jeri-loyalty-page__step-number Circle with number
.jeri-loyalty-page__step-title Step heading
.jeri-loyalty-page__step-text Step description

Earnings

Class Element
.jeri-earning-group Group wrapper (Purchases/Actions)
.jeri-earning-group__title Group heading
.jeri-earning-list Bordered list container
.jeri-earning-row Individual earning row
.jeri-earning-row__icon Row icon (<img>)
.jeri-earning-row__content Row text wrapper
.jeri-earning-row__title Row title
.jeri-earning-row__description Row description
.jeri-earning-row__points Points display

Tiers

Class Element
.jeri-tier-grid Tier cards flex container
.jeri-tier-card Individual tier card
.jeri-tier-card__body Card body
.jeri-tier-card__top Name + factor row
.jeri-tier-card__name Tier name
.jeri-tier-card__factor Multiplier badge (hidden when 1x)
.jeri-tier-card__content Description (rendered as HTML)
.jeri-tier-card__points Required points

Internationalization (i18n)

The loyalty page supports multiple languages based on the store's current locale:

  • English (en) — default
  • Spanish (es)
  • French (fr)
  • German (de)
  • Italian (it)

Unsupported locales fall back to English automatically.

How It Works

There are two layers of i18n:

  • Liquid-side strings (server-rendered) — Uses Shopify's {{ 'key' | t }} filter with locale JSON files in locales/. These translate static text like "Purchases", "Actions", "How It Works" steps, and the guest CTA button.
  • TypeScript-side strings (client-rendered) — Uses the shared i18n.ts utility with locale .ts files bundled into the JS. These translate dynamic earning flow titles and descriptions.

Locale Detection

The store locale is passed to JavaScript via the data-locale attribute on the root element:

<div id="jeri-loyalty-page-root" data-locale="{{ request.locale.iso_code }}" ...>
</div>

Adding a New Language

  1. Create theme-extensions/loyalty-page/locales/{lang}.ts (copy en.ts and translate)
  2. Create theme-extensions/loyalty-page/locales/{lang}.json (copy en.default.json and translate)
  3. Import the new locale in loyalty-page.ts and add it to initFromSession(locale, { en, es, fr, de, it, {lang} })
  4. Run npm run build:theme-extensions to rebuild

Translation Files

File Purpose
locales/en.ts English translations for JS-side strings (bundled into JS)
locales/es.ts Spanish translations for JS-side strings (bundled into JS)
locales/fr.ts French translations for JS-side strings (bundled into JS)
locales/de.ts German translations for JS-side strings (bundled into JS)
locales/it.ts Italian translations for JS-side strings (bundled into JS)
locales/en.default.json English translations for Liquid-side strings (copied to output locales/)
locales/es.json Spanish translations for Liquid-side strings (copied to output locales/)
locales/fr.json French translations for Liquid-side strings (copied to output locales/)
locales/de.json German translations for Liquid-side strings (copied to output locales/)
locales/it.json Italian translations for Liquid-side strings (copied to output locales/)