# How to make a coupon reminder using Joy SDK

### Introduction

If customers can’t *see* their reward, they won’t use it. In a few lines, the Joy SDK gives you the signals to surface a tiny, on-brand reminder—right where it helps. Under the hood, we call `rewardList` (optionally with filters like `isAvailableCoupon: true`), sort for the newest unused coupon, and render a small UI.

<figure><img src="https://99037881-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLCQVRKtqOD25VSLWDI7U%2Fuploads%2Fgit-blob-18bcf30624b0a1022856f0cb54f29a7ea5174d57%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

### What we’re building (tl;dr)

* A small, **dismissible** UI that shows the **latest unused** coupon for the logged-in customer
* **No duplicates**: if the same code is already in the cart, we don’t render
* Looks native to the theme (rounded card, soft shadow, single CTA)
* **Theme Editor preview**: shows a mock coupon so merchants can style it (even without live data)

{% hint style="info" %}
This is one of many “micro-UIs” you can craft with the Joy SDK. Start here, then remix it for cart drawers, price chips, or toasts.
{% endhint %}

### Get our hand on

To demo what’s possible with the Joy JS SDK, let’s craft a **Shopify** implementation that uses `joyInstance.rewardList` to show a helpful coupon reminder.

### As a custom liquid block

This example adds a coupon reminder to your Online Store. It checks whether the customer has **unused** coupons in Joy; if so, it surfaces a reminder. If the customer already has **that same code** applied in the cart, it stays hidden.

Steps to take:

1. In the Theme Editor, add a **Custom liquid** block where you want the reminder to appear.
2. Set the block’s top/bottom padding to **0** so the fixed card doesn’t add extra white space.
3. Paste the script scaffold below and replace the placeholders with your utilities/styles.

```
```

<figure><img src="https://99037881-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLCQVRKtqOD25VSLWDI7U%2Fuploads%2Fgit-blob-0e63a5fb0b91850f520b87b087efad8bebbf172c%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

```liquid
{% if customer or request.design_mode %}
<script>
(function () {
  'use strict';

  // -------- small logger (never throws) --------
  const LOG_TAG = 'Joy coupon bar';
  const inEditor = !!(window.Shopify && window.Shopify.designMode);
  const log = (...args) => { try { console.warn(LOG_TAG + ':', ...args); } catch (_) {} };

  // -------- helpers --------
  async function fetchCart() {
    try {
      const res = await fetch('/cart.js', { credentials: 'same-origin' });
      if (!res || !res.ok) return null;
      return await res.json();
    } catch (e) {
      log('fetchCart error', e);
      return null;
    }
  }

  async function getAppliedDiscountCodes() {
    try {
      const cart = await fetchCart();
      if (!cart) return [];
      return (cart.discount_codes || [])
        .map(dc => String(dc?.code || '').toUpperCase())
        .filter(Boolean);
    } catch (e) {
      log('getAppliedDiscountCodes error', e);
      return [];
    }
  }

  function createStylesOnce() {
    try {
      if (document.getElementById('joy-announce-styles')) return;
      const css = `
        @keyframes joySlideUp { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
        #joy-announce-wrap{position:fixed;left:0;right:0;bottom:8px;z-index:9999;pointer-events:none}
        #joy-announce {
          pointer-events:auto;
          margin:0 auto; max-width:980px;
          background: linear-gradient(180deg, #ffffff, #fbfbfb);
          border:1px solid rgba(0,0,0,.08);
          box-shadow: 0 8px 24px rgba(0,0,0,.12);
          border-radius:16px;
          padding:12px 16px; gap:10px;
          display:flex; align-items:center; justify-content:center; flex-wrap:wrap;
          font-size:14px; line-height:1.35; color:#2b2b2b;
          animation: joySlideUp .22s ease-out; position:relative;
        }
        #joy-announce .joy-emoji{font-size:18px; margin-right:4px}
        #joy-announce .joy-strong{font-weight:700}
        #joy-announce .joy-code{font-family: ui-monospace, SFMono-Regular, Menlo, monospace}
        #joy-announce .joy-cta{
          display:inline-block; padding:8px 14px; border-radius:999px; font-weight:600; text-decoration:none;
          border:1px solid rgba(0,0,0,.08); background:#111; color:#fff;
        }
        #joy-announce .joy-cta:focus{outline:2px solid #111; outline-offset:2px}
        #joy-announce .joy-dismiss{
          position:absolute; right:8px; top:8px; width:28px; height:28px; border:none; background:transparent; cursor:pointer;
          border-radius:6px; color:#666; font-size:18px; line-height:1;
        }
        #joy-announce .joy-dismiss:hover{background:rgba(0,0,0,.06)}
        @media (max-width: 640px){
          #joy-announce{margin:0 8px; padding:10px 12px; border-radius:14px}
          #joy-announce .joy-cta{width:100%; text-align:center}
        }
        @media (prefers-reduced-motion: reduce){ #joy-announce{animation:none} }
      `;
      const style = document.createElement('style');
      style.id = 'joy-announce-styles';
      style.textContent = css;
      document.head.appendChild(style);
    } catch (e) {
      log('createStylesOnce error', e);
    }
  }

  function mountBar(code, label, benefit, { respectDismiss = true } = {}) {
    try {
      if (!code) return null;
      if (document.getElementById('joy-announce-wrap')) return null;
      if (respectDismiss && sessionStorage.getItem('joy_announcement_dismissed') === '1') return null;

      createStylesOnce();

      const wrap = document.createElement('div');
      wrap.id = 'joy-announce-wrap';
      const bar = document.createElement('div');
      bar.id = 'joy-announce';
      bar.setAttribute('role', 'region');
      bar.setAttribute('aria-label', 'Loyalty coupon announcement');

      bar.innerHTML = `
        <button type="button" class="joy-dismiss" aria-label="Dismiss">×</button>
        <span class="joy-emoji" aria-hidden="true">🎉</span>
        <span><span class="joy-strong">Unused ${label} coupon:</span> 
          <span>Use <span class="joy-strong">${benefit}</span> with code 
            <span class="joy-code joy-strong">${code}</span>.
          </span>
        </span>
        <a class="joy-cta" href="/discount/${encodeURIComponent(code)}">Apply now</a>
      `;

      wrap.appendChild(bar);
      document.body.appendChild(wrap);

      // add bottom padding while visible
      const paddingEl = document.documentElement;
      const prevPad = paddingEl.style.paddingBottom;
      paddingEl.style.paddingBottom = '64px';

      // dismiss handler
      try {
        bar.querySelector('.joy-dismiss')?.addEventListener('click', () => {
          try { if (respectDismiss) sessionStorage.setItem('joy_announcement_dismissed', '1'); } catch(_) {}
          try { wrap.remove(); } catch(_) {}
          try { paddingEl.style.paddingBottom = prevPad || ''; } catch(_) {}
        });
      } catch (e) { log('dismiss binding error', e); }

      return {
        wrap,
        bar,
        cleanup: () => {
          try { wrap?.remove(); } catch (_) {}
          try { paddingEl.style.paddingBottom = prevPad || ''; } catch (_) {}
        }
      };
    } catch (e) {
      log('mountBar error', e);
      return null;
    }
  }

  // -------- Theme Editor preview (mock coupon) --------
  (function editorPreview() {
    try {
      if (!inEditor) return;

      const renderPreview = () => {
        try {
          document.getElementById('joy-announce-wrap')?.remove();
          mountBar('JOY-PREVIEW10', 'reward', '10% off', { respectDismiss: false });
        } catch (e) { log('renderPreview error', e); }
      };

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', renderPreview);
      } else {
        renderPreview();
      }
      document.addEventListener('shopify:section:load', renderPreview);
      document.addEventListener('shopify:section:select', renderPreview);
    } catch (e) {
      log('editorPreview error', e);
    }
  })();

  // -------- Live logic (Joy) --------
  window.addEventListener('joy:ready', async () => {
    if (inEditor) return; // preview handles editor
    try {
      const ji = window.joyInstance || window.joy;
      if (!ji) return;

      // avoid dupes / respect dismiss
      if (document.getElementById('joy-announce-wrap')) return;
      if (sessionStorage.getItem('joy_announcement_dismissed') === '1') return;

      const appliedCodes = await getAppliedDiscountCodes();

      let resp;
      try {
        resp = await ji.rewardList({ isAvailableCoupon: true });
      } catch (e) {
        log('rewardList error', e);
        return;
      }

      const rewards = Array.isArray(resp?.data) ? resp.data : [];
      const unused = rewards
        .filter(r => r && r.couponCode && !r.isUsedCode)
        .sort((a, b) => {
          try { return new Date(b.createdAt) - new Date(a.createdAt); }
          catch (_) { return 0; }
        });

      if (!unused.length) return;

      const latest = unused[0];
      const code   = String(latest?.couponCode || '');
      const label  = String(latest?.eventRule || 'reward').replace(/_/g, ' ');
      const benefit= latest?.programDescription || 'your coupon';

      if (!code) return;
      if (appliedCodes.includes(code.toUpperCase())) return;

      const mounted = mountBar(code, label, benefit, { respectDismiss: true });
      if (!mounted) return;

      // auto-hide if code becomes applied
      const maybeHide = async () => {
        try {
          const nowCodes = await getAppliedDiscountCodes();
          if (nowCodes.includes(code.toUpperCase())) {
            try { sessionStorage.setItem('joy_announcement_dismissed', '1'); } catch (_) {}
            mounted.cleanup();
          }
        } catch (e) {
          log('maybeHide error', e);
        }
      };

      try {
        mounted.bar.querySelector('.joy-cta')?.addEventListener('click', () => {
          try { setTimeout(maybeHide, 1200); } catch (_) {}
        });
      } catch (e) { log('cta binding error', e); }

      let checks = 0;
      const iv = setInterval(async () => {
        try {
          checks++;
          await maybeHide();
          if (checks > 10 || !document.getElementById('joy-announce-wrap')) clearInterval(iv);
        } catch (e) {
          clearInterval(iv);
          log('interval error', e);
        }
      }, 1000);

    } catch (e) {
      log('joy:ready handler error', e);
    }
  });

})(); // end IIFE
</script>
{% endif %}

```

### More flexibility with a Shopify **section** block

Prefer a reusable, configurable section? Create `sections/loyalty-rewards-bar.liquid`. Expose a few settings so merchants can edit copy, colors, and layout without touching code.

Steps to take:

1. Create a new section file and add **style** rules for the fixed wrapper and card.
2. Add settings for message, button text, colors, max width, padding, bottom offset, and z-index.
3. Implement the same `joy:ready → rewardList → filter unused → de-dupe /cart.js → render` logic.
4. In **Theme Editor** (`Shopify.designMode === true`), render a **mock** coupon (e.g., `JOY-PREVIEW10`) so styling is easy.

```liquid
{% comment %}
  Section: Loyalty rewards reminder
  - Previewable in Theme Editor (mock coupon) even if not logged in
  - Live logic runs only when a customer is logged in
  - Uses Joy SDK rewardList to surface newest unused coupon and avoids duplicates with /cart.js
{% endcomment %}

{% if customer or request.design_mode %}
{% style %}
  @keyframes joySlideUp { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

  #joy-announce-wrap{
    position:fixed;
    left:0; right:0;
    bottom: {{ section.settings.bottom_offset | default: 8 }}px;
    z-index: {{ section.settings.z_index | default: 2147483646 }};
    pointer-events:none;
  }
  #joy-announce{
    pointer-events:auto; position:relative;
    margin:0 auto;
    max-width: {{ section.settings.max_width | default: 980 }}px;
    background:
      {% if section.settings.use_gradient %}
        linear-gradient(180deg, {{ section.settings.bg_color }}, {{ section.settings.bg_color_2 }})
      {% else %}
        {{ section.settings.bg_color }}
      {% endif %};
    color: {{ section.settings.text_color }};
    border:1px solid rgba(0,0,0,.08);
    box-shadow:0 8px 24px rgba(0,0,0,.12);
    border-radius:16px;
    padding: {{ section.settings.padding | default: 16 }}px;
    gap:10px;
    display:flex; align-items:center; justify-content:center; flex-wrap:wrap;
    font-size:14px; line-height:1.35;
    animation: joySlideUp .22s ease-out;
  }
  #joy-announce .joy-emoji{
    font-size:18px; margin-right:4px;
    display: {% if section.settings.show_emoji %}inline{% else %}none{% endif %};
  }
  #joy-announce .joy-strong{ font-weight:700; }
  #joy-announce .joy-code{
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    display:inline-block; padding:2px 8px; border-radius:8px; white-space:nowrap;
    background: {{ section.settings.code_bg }}; color: {{ section.settings.code_text }};
    border:1px solid rgba(0,0,0,.08);
  }
  #joy-announce .joy-cta{
    display:inline-block; padding:8px 14px; border-radius:999px; font-weight:600; text-decoration:none;
    border:1px solid rgba(0,0,0,.08);
    background: {{ section.settings.btn_bg }}; color: {{ section.settings.btn_text }};
  }
  #joy-announce .joy-cta:hover{ background: {{ section.settings.btn_bg_hover }}; }
  #joy-announce .joy-cta:focus{ outline:2px solid {{ section.settings.btn_bg }}; outline-offset:2px; }
  #joy-announce .joy-dismiss{
    position:absolute; right:8px; top:8px;
    width:28px; height:28px; border:none; background:transparent; cursor:pointer;
    border-radius:6px; color:#666; font-size:18px; line-height:1;
  }
  #joy-announce .joy-dismiss:hover{ background:rgba(0,0,0,.06); }

  @media (max-width:640px){
    #joy-announce{ margin:0 8px; padding: {{ section.settings.padding | default: 16 | minus: 2 }}px; border-radius:14px }
    #joy-announce .joy-cta{ width:100%; text-align:center }
  }
  @media (prefers-reduced-motion:reduce){ #joy-announce{ animation:none } }
{% endstyle %}

{%- assign __template_msg = section.settings.message | default: "Unused {label} coupon: Use {discount} with code {code}." -%}
{%- assign __button_text = section.settings.button_text | default: "Apply now" -%}

<script>
(function(){
  'use strict';

  // ===== Liquid → JS (safe) =====
  const TEMPLATE_MSG = {{ __template_msg | json }};
  const BUTTON_TEXT  = {{ __button_text  | json }};
  const IS_CUSTOMER  = {% if customer %}true{% else %}false{% endif %};
  const IN_EDITOR    = !!(window.Shopify && window.Shopify.designMode);

  const log = (...args) => { try { console.warn('[Joy coupon bar]:', ...args); } catch(_){} };

  // ===== helpers (shared, guarded) =====
  async function fetchCart() {
    try {
      const res = await fetch('/cart.js', { credentials: 'same-origin' });
      if (!res || !res.ok) return null;
      return await res.json();
    } catch(e) {
      log('fetchCart error', e); return null;
    }
  }
  async function getAppliedDiscountCodes() {
    try {
      const cart = await fetchCart();
      if (!cart) return [];
      return (cart.discount_codes || [])
        .map(dc => String(dc?.code || '').toUpperCase())
        .filter(Boolean);
    } catch(e) {
      log('getAppliedDiscountCodes error', e); return [];
    }
  }

  function createBar(code, label, benefit, messageTemplate, buttonText) {
    const wrap = document.createElement('div');
    wrap.id = 'joy-announce-wrap';
    const bar = document.createElement('div');
    bar.id = 'joy-announce';
    bar.setAttribute('role','region');
    bar.setAttribute('aria-label','Loyalty coupon announcement');

    const msg = (messageTemplate || 'Unused {label} coupon: Use {discount} with code {code}.')
      .replace('{label}', label)
      .replace('{discount}', benefit)
      .replace('{code}', code);

    bar.innerHTML = `
      <button type="button" class="joy-dismiss" aria-label="Dismiss">×</button>
      <span class="joy-emoji" aria-hidden="true">🎉</span>
      <span>${msg}</span>
      <a class="joy-cta" href="/discount/${encodeURIComponent(code)}">${buttonText || 'Apply now'}</a>
    `;
    wrap.appendChild(bar);
    return { wrap, bar };
  }

  function addBottomPaddingWhileVisible() {
    try {
      const el = document.documentElement;
      const prev = el.style.paddingBottom;
      el.style.paddingBottom = '64px';
      return () => { try { el.style.paddingBottom = prev || ''; } catch(_){} };
    } catch(_) { return () => {}; }
  }

  // ===== Theme Editor preview (works even if not logged in) =====
  (function editorPreview(){
    try {
      if (!IN_EDITOR) return;

      const render = () => {
        try {
          document.getElementById('joy-announce-wrap')?.remove();
          const { wrap, bar } = createBar('JOY-PREVIEW10', 'reward', '10% off', TEMPLATE_MSG, BUTTON_TEXT);
          document.body.appendChild(wrap);

          const restorePad = addBottomPaddingWhileVisible();
          bar.querySelector('.joy-dismiss')?.addEventListener('click', () => {
            try { wrap.remove(); } catch(_){}
            restorePad();
          });
        } catch(e) { log('render preview error', e); }
      };

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', render);
      } else {
        render();
      }
      document.addEventListener('shopify:section:load', render);
      document.addEventListener('shopify:section:select', render);
    } catch(e) { log('editorPreview error', e); }
  })();

  // ===== Live logic (runs only when not in editor AND a customer is logged in) =====
  window.addEventListener('joy:ready', async () => {
    if (IN_EDITOR) return;
    if (!IS_CUSTOMER) return;
    try {
      if (document.getElementById('joy-announce-wrap')) return;
      if (sessionStorage.getItem('joy_announcement_dismissed') === '1') return;

      const ji = window.joyInstance || window.joy;
      if (!ji) return;

      const appliedCodes = await getAppliedDiscountCodes();

      let resp;
      try {
        resp = await ji.rewardList({ isAvailableCoupon: true });
      } catch(e) { log('rewardList error', e); return; }

      const rewards = Array.isArray(resp?.data) ? resp.data : [];
      const unused = rewards
        .filter(r => r?.couponCode && !r?.isUsedCode)
        .sort((a,b) => { try { return new Date(b.createdAt) - new Date(a.createdAt); } catch(_) { return 0; } });

      if (!unused.length) return;

      const latest  = unused[0];
      const code    = String(latest?.couponCode || '');
      const label   = String(latest?.eventRule || 'reward').replace(/_/g,' ');
      const benefit = latest?.programDescription || 'your coupon';

      if (!code) return;
      if (appliedCodes.includes(code.toUpperCase())) return;

      const { wrap, bar } = createBar(code, label, benefit, TEMPLATE_MSG, BUTTON_TEXT);
      document.body.appendChild(wrap);

      const restorePad = addBottomPaddingWhileVisible();

      // dismiss (persist for session)
      try {
        bar.querySelector('.joy-dismiss')?.addEventListener('click', () => {
          try { sessionStorage.setItem('joy_announcement_dismissed','1'); } catch(_){}
          try { wrap.remove(); } catch(_){}
          restorePad();
        });
      } catch(e) { log('dismiss binding error', e); }

      // auto-hide if code becomes applied
      const maybeHide = async () => {
        try {
          const nowCodes = await getAppliedDiscountCodes();
          if (nowCodes.includes(code.toUpperCase())) {
            try { sessionStorage.setItem('joy_announcement_dismissed','1'); } catch(_){}
            try { wrap.remove(); } catch(_){}
            restorePad();
          }
        } catch(e) { log('maybeHide error', e); }
      };

      try {
        bar.querySelector('.joy-cta')?.addEventListener('click', () => {
          try { setTimeout(maybeHide, 1200); } catch(_){}
        });
      } catch(e) { log('cta binding error', e); }

      {% if section.settings.auto_check %}
      // brief polling window for {{ section.settings.check_interval }}s
      try {
        let elapsed = 0;
        const stepMs = 1000;
        const limitMs = {{ section.settings.check_interval | default: 60 | times: 1000 }};
        const iv = setInterval(async () => {
          try {
            elapsed += stepMs;
            await maybeHide();
            if (elapsed >= limitMs || !document.getElementById('joy-announce-wrap')) clearInterval(iv);
          } catch(e) {
            clearInterval(iv); log('poll error', e);
          }
        }, stepMs);
      } catch(e) { log('poll setup error', e); }
      {% endif %}
    } catch(e) { log('joy:ready handler error', e); }
  });
})(); // end IIFE
</script>
{% endif %}

{% schema %}
{
  "name": "Loyalty rewards reminder",
  "settings": [
    { "type": "header", "content": "Content" },
    { "type": "text", "id": "message", "label": "Message", "default": "Unused {label} coupon: Use {discount} with code {code}.", "info": "Placeholders: {label}, {discount}, {code}" },
    { "type": "text", "id": "button_text", "label": "Button text", "default": "Apply now" },
    { "type": "checkbox", "id": "show_emoji", "label": "Show emoji", "default": true },

    { "type": "header", "content": "Colors" },
    { "type": "checkbox", "id": "use_gradient", "label": "Use gradient background", "default": true },
    { "type": "color", "id": "bg_color", "label": "Background / Gradient start", "default": "#ffffff" },
    { "type": "color", "id": "bg_color_2", "label": "Gradient end", "default": "#fbfbfb" },
    { "type": "color", "id": "text_color", "label": "Text color", "default": "#2b2b2b" },
    { "type": "color", "id": "code_bg", "label": "Code pill background", "default": "#f6f6f6" },
    { "type": "color", "id": "code_text", "label": "Code pill text", "default": "#111111" },
    { "type": "color", "id": "btn_bg", "label": "Button background", "default": "#111111" },
    { "type": "color", "id": "btn_bg_hover", "label": "Button hover background", "default": "#333333" },
    { "type": "color", "id": "btn_text", "label": "Button text color", "default": "#ffffff" },

    { "type": "header", "content": "Layout" },
    { "type": "range", "id": "max_width", "min": 680, "max": 1200, "step": 20, "unit": "px", "label": "Card max width", "default": 980 },
    { "type": "range", "id": "padding", "min": 12, "max": 32, "step": 4, "unit": "px", "label": "Card padding", "default": 16 },
    { "type": "range", "id": "bottom_offset", "min": 0, "max": 40, "step": 2, "unit": "px", "label": "Distance from bottom", "default": 8 },

    { "type": "header", "content": "Behavior" },
    { "type": "checkbox", "id": "auto_check", "label": "Auto-hide if code gets applied (poll for a short time)", "default": true },
    { "type": "range", "id": "check_interval", "min": 30, "max": 300, "step": 30, "unit": "s", "label": "Auto-hide window", "default": 60 },

    { "type": "header", "content": "Advanced" },
    { "type": "text", "id": "z_index", "label": "z-index", "default": "2147483646", "info": "Only change if another widget overlaps." }
  ],
  "presets": [{ "name": "Loyalty rewards announcement" }]
}
{% endschema %}
```

### What’s more from here

You can use the same recipe to craft any custom section, block, or inline UI:

* **Cart drawer row:** “You have **{discount}** — code **{code}** \[Apply]”
* **Price chip on PDP:** subtle pill next to the price; tap to copy/apply
* **Add-to-cart toast:** quick reminder with an **Apply** action
* **Account rewards list / VIP tier blocks:** show all unused coupons, expiry, and actions

**Pattern to remember:** `rewardList` → filter **unused** → de-dupe against `/cart.js` → render a tiny, on-brand UI → dismiss/auto-hide politely.
