Wishlist Curations Page

Build a store-curated product page where tagged "creator" accounts publish named Swym lists as scrollable carousels — no custom coding required after initial setup.

Wishlist Curation Page

A dedicated page that renders one or more curated Swym lists as scrollable product carousels. A tagged "curation creator" account builds the lists normally across the store, then copies the list IDs into the section settings — no code changes needed to publish new curations.

What you'll build: A Shopify page with one carousel per curated Swym list, each titled and populated from a named list built by a designated curator account. Shoppers browse products, curators manage lists from the storefront, and merchants publish them by pasting list IDs into a section setting — no redeployment required.


Curator's Panel

Curator's Panel - visible to user with the configured curation creator tag


Curated Lists View

Curated Lists View

Setup

Step 1: Complete the prerequisites

Before writing any code, confirm all of the following are in place:

  • The Swym Wishlist Plus app is installed and active on your Shopify store, and its embed snippet is present in your theme layout
  • Your theme uses OS 2.0 JSON templates (templates/page.*.json). Vintage Liquid-only themes require adaptation
  • At least one Shopify customer account exists that you can tag as curation-creator — this account will operate the Curator Panel

Note: The search endpoint and product card snippet created in Steps 3 and 4 are new files you will add. They do not need to exist beforehand.

Step 2: Add the section file

In your theme code editor (Online Store > Themes > Edit code), create sections/swym-curation.liquid:

{% comment %} Swym Curation Page — Multiple curated lists, one product grid per LID block {% endcomment %}
<style>
.swym-curation-list {
  margin-bottom: 3.5rem;
}
.swym-curation-loading {
  padding: 2rem 0;
}
.swym-curation-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 3rem 0;
}

#swym-creator-panel {
  margin-bottom: 3rem;
  padding-bottom: 2rem;
  border-bottom: 1px solid #e0e0e0;
}
#swym-creator-lists {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1rem;
  margin-top: 1rem;
}
.swym-creator-list-tile {
  border: 1px solid #e0e0e0;
  background: #f9f9f9;
  overflow: hidden;
}
.swym-creator-tile-images {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  height: 120px;
}
.swym-creator-tile-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.swym-creator-tile-img--empty {
  background: #e8e8e8;
}
.swym-creator-tile-body {
  padding: 0.75rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.swym-creator-list-name {
  font-weight: 600;
  margin: 0;
}
.swym-creator-list-count {
  color: #666;
  font-size: 0.875rem;
  margin: 0;
}
.swym-creator-lid {
  font-family: monospace;
  font-size: 0.75rem;
  background: #fff;
  border: 1px solid #ddd;
  padding: 3px 8px;
  border-radius: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: block;
}
.swym-copy-lid-btn {
  white-space: nowrap;
  align-self: flex-start;
  margin-top: 0.25rem;
}

.swym-carousel .product-grid {
  display: flex !important;
  flex-wrap: nowrap !important;
  overflow-x: auto;
  scroll-behavior: smooth;
  scroll-snap-type: x mandatory;
  min-width: 0;
  gap: 1rem;
  cursor: grab;
}
.swym-carousel .product-grid:active {
  cursor: grabbing;
}
.swym-carousel .product-grid::-webkit-scrollbar {
  display: none;
}
.swym-carousel .product-grid {
  -ms-overflow-style: none;
  scrollbar-width: none;
}
.swym-carousel .product-grid > * {
  flex: 0 0 var(--carousel-item-width, 260px);
  max-width: var(--carousel-item-width, 260px);
  scroll-snap-align: start;
}
.swym-carousel .product-grid[data-product-card-size="small"]       { --carousel-item-width: 180px; }
.swym-carousel .product-grid[data-product-card-size="large"]       { --carousel-item-width: 365px; }
.swym-carousel .product-grid[data-product-card-size="extra-large"] { --carousel-item-width: 530px; }
@media screen and (max-width: 749px) {
  .swym-carousel .product-grid > * {
    flex: 0 0 calc(50% - 0.5rem);
    max-width: calc(50% - 0.5rem);
  }
  .swym-carousel .product-grid-mobile--large > * {
    flex: 0 0 100%;
    max-width: 100%;
  }
}
</style>

<script>
  window.swymCurationSearchUrl = "{{ shop.url | append: routes.search_url }}";
  window.swymCuratorTag = {{ section.settings.curator_tag | default: 'curation-creator' | json }};
</script>

<div id="swym-curation-page" class="section section--page-width" style="min-height: 60vh;">

  {% assign curator_tag = section.settings.curator_tag | default: 'curation-creator' %}
  {% if customer and customer.tags contains curator_tag %}
  <div id="swym-creator-panel">
    <h3>Curator's Panel</h3>
    <p class="small" style="margin-top: 0.5rem; margin-bottom: 0;">
      {{ 'content.curations.curator_info' | t }}
    </p>
    <div id="swym-creator-lists-loading" aria-live="polite">{{ 'content.curations.curator_loading' | t }}</div>
    <div id="swym-creator-lists" role="list" aria-label="Your Swym lists"></div>
  </div>
  {% endif %}

  {% assign lids_raw = section.settings.lids %}
  {% if lids_raw != blank %}
    {% assign lids = lids_raw | split: ',' %}
    {% for lid_raw in lids %}
      {% assign lid = lid_raw | strip %}
      {% if lid != blank %}
        <section class="swym-curation-list" data-lid="{{ lid }}">
          <h4 style="margin-bottom: 1rem;"></h4>
          <div class="swym-curation-loading" aria-live="polite">{{ 'content.curations.loading' | t }}</div>
          <div class="swym-carousel">
            <ul
              class="product-grid product-grid--{{ section.id }} product-grid--grid{% if section.settings.mobile_product_card_size == 'large' %} product-grid-mobile--large{% endif %}"
              role="list"
              aria-busy="true"
              data-product-card-size="{{ section.settings.product_card_size }}"
              style="--mobile-columns: {% if section.settings.mobile_product_card_size == 'large' %}1{% else %}2{% endif %}; display: none;"
            ></ul>
          </div>
          <div class="swym-curation-empty" style="display:none;">
            <h4>{{ 'content.curations.empty_heading' | t }}</h4>
            {% if section.settings.empty_browse_url %}
              <a href="{{ section.settings.empty_browse_url }}">{{ 'content.curations.empty_browse' | t }}</a>
            {% endif %}
          </div>
        </section>
      {% endif %}
    {% endfor %}
  {% else %}
    <div id="swym-wishlist-empty" style="display: flex; flex-direction: column; align-items: center; margin: 100px auto 100px;">
      <h3>{{ 'content.curations.empty_heading' | t }}</h3>
      <h4>{{ 'content.curations.empty_subheading' | t }}</h4>
      <a href="{{ section.settings.empty_browse_url | default: '/collections/all' }}">{{ 'content.curations.empty_browse' | t }}</a>
    </div>
  {% endif %}

</div>

{% liquid
  case section.settings.product_card_size
    when 'small'
      assign product_card_size = '180px'
    when 'large'
      assign product_card_size = '365px'
    when 'extra-large'
      assign product_card_size = '530px'
    else
      assign product_card_size = '260px'
  endcase
%}

{% render 'product-badges-styles' %}
{% render 'theme-styles-variables' %}
{% style %}
  @media screen and (min-width: 750px) {
    .product-grid--{{ section.id }}:is(.product-grid--grid) {
      --product-grid-columns-desktop: repeat(auto-fill, minmax({{ product_card_size }}, 1fr));
    }
  }
{% endstyle %}

<script src="{{ 'swym-curation.js' | asset_url }}" defer></script>

{% schema %}
{
  "name": "Swym - Curation Lists",
  "limit": 1,
  "settings": [
    {
      "type": "text",
      "id": "lids",
      "label": "List IDs (LIDs)",
      "info": "Comma-separated Swym list IDs to display. Log in as the curator account on this page and use the 'Copy LID' button to get each ID."
    },
    {
      "type": "url",
      "id": "empty_browse_url",
      "label": "Empty state browse link"
    },
    {
      "type": "select",
      "id": "product_card_size",
      "label": "Product card size",
      "options": [
        { "value": "small", "label": "Small" },
        { "value": "medium", "label": "Medium" },
        { "value": "large", "label": "Large" },
        { "value": "extra-large", "label": "Extra large" }
      ],
      "default": "medium"
    },
    {
      "type": "select",
      "id": "mobile_product_card_size",
      "label": "Mobile product card size",
      "options": [
        { "value": "standard", "label": "Standard (2 columns)" },
        { "value": "large", "label": "Large (1 column)" }
      ],
      "default": "standard"
    },
    {
      "type": "text",
      "id": "curator_tag",
      "label": "Curator customer tag",
      "default": "curation-creator",
      "info": "Shopify customer tag that unlocks the Curator Panel and hides the wishlist button on curation cards. Must match exactly (case-sensitive)."
    }
  ]
}
{% endschema %}

Note: The <script> block at the top of this section sets window.swymCurationSearchUrl to your store's search route. The JavaScript in Step 3 reads this global to build the card-fetch URL — do not remove it.

Step 3: Add the JavaScript asset

Create assets/swym-curation.js:

(function () {
  'use strict';

  function initCurationPage(swat) {
    var listEls = document.querySelectorAll('.swym-curation-list[data-lid]');
    var total = listEls.length;
    var rendered = 0;

    function onListRendered() {
      rendered++;
      if (rendered === total) {
        swat.initializeActionButtons('#swym-curation-page');
      }
    }

    listEls.forEach(function (section) {
      var lid = section.dataset.lid;
      if (!lid) return;

      var loadingEl = section.querySelector('.swym-curation-loading');
      var grid = section.querySelector('ul.product-grid');
      var emptyEl = section.querySelector('.swym-curation-empty');

      swat.fetchListDetails({ lid: lid }, function (res) {
        if (loadingEl) loadingEl.style.display = 'none';
        var list = res && res.list ? res.list : null;
        var products = list ? (list.listcontents || []) : [];

        var titleEl = section.querySelector('h4');
        if (titleEl) titleEl.textContent = (list && list.lname) ? list.lname : lid;

        if (!products.length) {
          if (emptyEl) emptyEl.style.display = 'flex';
          onListRendered();
          return;
        }

        renderProducts(products, grid, emptyEl, lid, onListRendered);
      }, function (err) {
        console.error('[Swym Curation] fetchListDetails failed for LID ' + lid, err);
        if (loadingEl) loadingEl.style.display = 'none';
        if (emptyEl) emptyEl.style.display = 'flex';
        onListRendered();
      });
    });

    var creatorPanel = document.getElementById('swym-creator-panel');
    if (creatorPanel) {
      initCreatorPanel(swat);
    }
  }

  // _gens persists for the script's lifetime. In a SPA theme where the page re-renders
  // without a full reload, _gens[lid] carries over and the first render of the next visit
  // is silently discarded as stale. Acceptable for standard Shopify navigation.
  var _gens = {};

  function renderProducts(products, container, emptyEl, lid, onDone) {
    _gens[lid] = (_gens[lid] || 0) + 1;
    var gen = _gens[lid];

    container.setAttribute('aria-busy', 'true');
    container.style.display = 'none';
    container.innerHTML = '';

    if (!products || !products.length) {
      if (emptyEl) emptyEl.style.display = 'flex';
      container.setAttribute('aria-busy', 'false');
      return;
    }

    if (emptyEl) emptyEl.style.display = 'none';

    var variantIds = products.map(function (p) { return p.epi; }).join('-');
    var productIds = Array.from(
      new Set(products.map(function (p) { return p.empi; }))
    ).map(function (id) { return 'id:' + id; }).join(' OR ');

    var base = window.swymCurationSearchUrl || window.wishListRouteSearchUrl || '/search';
    // Parameters are parsed by name in search.wishlist.liquid — keep key names in sync if changed.
    var url = base + '?type=product'
      + '&variant_ids=' + variantIds
      + '&lid=' + lid
      + '&q=' + encodeURIComponent(productIds)
      + '&view=wishlist'
      + '&curator_tag=' + encodeURIComponent(window.swymCuratorTag || 'curation-creator');

    fetch(url)
      .then(function (res) { return res.json(); })
      .then(function (data) {
        if (_gens[lid] !== gen) return;
        var items = data.items || [];

        // Shopify search returns results in relevance order, not list order.
        // Build a map from variant id to html, then insert in the curator's original order.
        var htmlByVariantId = {};
        items.forEach(function (item) {
          if (item && item.html) htmlByVariantId[String(item.id)] = item.html;
        });
        products.forEach(function (p) {
          var html = htmlByVariantId[String(p.epi)];
          if (!html) return;
          var wrap = document.createElement('div');
          wrap.innerHTML = html;
          while (wrap.firstChild) container.appendChild(wrap.firstChild);
        });

        container.style.display = '';
        container.setAttribute('aria-busy', 'false');
        if (onDone) onDone();
      })
      .catch(function () {
        if (_gens[lid] !== gen) return;
        container.style.display = '';
        container.setAttribute('aria-busy', 'false');
        if (onDone) onDone();
      });
  }

  function initCreatorPanel(swat) {
    var loadingEl = document.getElementById('swym-creator-lists-loading');
    var listsEl = document.getElementById('swym-creator-lists');
    var _publicised = new Set();

    swat.fetchLists({
      callbackFn: function (lists) {
        if (loadingEl) loadingEl.style.display = 'none';
        if (!lists || !lists.length) {
          if (listsEl) listsEl.innerHTML = '<p>No lists found in your account.</p>';
          return;
        }

        lists.forEach(function (list) {
          var contents = list.listcontents || [];
          var count = contents.length;

          var imgHtml = '';
          for (var j = 0; j < 3; j++) {
            var p = contents[j];
            if (p && p.iu) {
              imgHtml += '<img class="swym-creator-tile-img" src="' + esc(p.iu) + '" alt="" loading="lazy">';
            } else {
              imgHtml += '<div class="swym-creator-tile-img swym-creator-tile-img--empty"></div>';
            }
          }

          var tile = document.createElement('div');
          tile.className = 'swym-creator-list-tile';
          tile.setAttribute('role', 'listitem');
          tile.innerHTML =
            '<div class="swym-creator-tile-images">' + imgHtml + '</div>' +
            '<div class="swym-creator-tile-body">' +
              '<p class="swym-creator-list-name">' + esc(list.lname || 'Untitled') + '</p>' +
              '<p class="swym-creator-list-count">' + count + ' item' + (count !== 1 ? 's' : '') + '</p>' +
              '<code class="swym-creator-lid" title="' + esc(list.lid) + '">' + esc(list.lid) + '</code>' +
              '<button class="swym-copy-lid-btn button" type="button" data-lid="' + esc(list.lid) + '">Copy LID</button>' +
            '</div>';
          if (listsEl) listsEl.appendChild(tile);
        });

        if (listsEl) {
          listsEl.addEventListener('click', function (e) {
            var btn = e.target.closest('.swym-copy-lid-btn');
            if (!btn) return;
            var lid = btn.dataset.lid;
            if (_publicised.has(lid)) {
              copyText(lid, btn);
              return;
            }
            btn.disabled = true;
            swat.markListPublic(lid, function () {
              _publicised.add(lid);
              btn.disabled = false;
              copyText(lid, btn);
            }, function () {
              btn.disabled = false;
              copyText(lid, btn);
            });
          });
        }
      },
      errorFn: function () {
        if (loadingEl) loadingEl.style.display = 'none';
        if (listsEl) listsEl.innerHTML = '<p>Could not load your lists.</p>';
      }
    });
  }

  function copyText(text, btn) {
    var orig = btn ? btn.textContent : '';
    var done = function () {
      if (!btn) return;
      btn.textContent = 'Copied!';
      setTimeout(function () { btn.textContent = orig; }, 1500);
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(done).catch(function () { fallback(text); done(); });
    } else {
      fallback(text);
      done();
    }
  }

  function fallback(text) {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none;';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } catch (e) {}
    document.body.removeChild(ta);
  }

  function esc(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  window.SwymCallbacks = window.SwymCallbacks || [];
  window.SwymCallbacks.push(initCurationPage);
})();

Step 4: Add the search endpoint

Create templates/search.wishlist.liquid. This template outputs raw JSON consumed only by swym-curation.js — it is never rendered as a visible page.

Warning: The very first line must be {%- layout none -%}. If this line is missing or misplaced, Shopify wraps the output in your theme's full HTML layout and the JavaScript fetch call will receive HTML instead of JSON, causing all carousels to silently fail.

{%- layout none -%}
{%- liquid
  # theme-check-disable
  capture content_query
    echo content_for_header
  endcapture
  # theme-check-enable
-%}
{%- assign page_url = content_query
  | split: '"pageurl":"'
  | last
  | split: '"'
  | first
  | split: '.myshopify.com'
  | last
  | replace: '\/', '/'
  | replace: '%20', ' '
  | replace: '&', '&'
-%}
{%- assign params = page_url | split: '&' -%}
{%- comment -%} Parse params by name rather than position — key names must match swym-curation.js URL builder {%- endcomment -%}
{%- assign variant_ids_raw = '' -%}
{%- assign lid = '' -%}
{%- assign curator_tag = 'curation-creator' -%}
{%- for param in params -%}
  {%- if param contains 'variant_ids=' -%}
    {%- assign variant_ids_raw = param | replace_first: 'variant_ids=', '' -%}
  {%- elsif param contains 'lid=' -%}
    {%- assign lid = param | replace_first: 'lid=', '' -%}
  {%- elsif param contains 'curator_tag=' -%}
    {%- assign curator_tag = param | replace_first: 'curator_tag=', '' -%}
  {%- endif -%}
{%- endfor -%}
{%- assign variant_ids = variant_ids_raw | split: '-' -%}
{%- paginate search.results by 24 -%}
  { "items": [
  {%- for product in search.results -%}
    {%- assign current_index = forloop.index0 -%}
    {%- for product_variant in product.variants -%}
      {%- assign variant_id = product_variant.id | append: '' -%}
      {% if variant_ids contains variant_id %}
        {%- capture html -%}
          {%- render 'wishlist-product-card',
              product: product,
              selected_variant_id: variant_id,
              lid: lid,
              index: current_index,
              curator_tag: curator_tag
          -%}
        {%- endcapture -%}
        { "id": {{ product_variant.id | json }}, "html": {{ html | json }} }
      {%- else -%}
        null
      {%- endif -%}
      {% unless forloop.last %},{% endunless %}
    {%- endfor -%}
    {% unless forloop.last %},{% endunless %}
  {%- endfor -%}
  ], "variant_ids": {{ variant_ids | json }}, "total": {{ variant_ids.size | json }}
  {%- comment -%} total = variant_ids count, not rendered card count — null entries are included in this figure {%- endcomment -%}
  }
{% endpaginate %}

Step 5: Create the wishlist product card snippet

Horizon theme: This implementation is optimised for the Horizon theme. snippets/wishlist-product-card.liquid is a direct adaptation of Horizon's snippets/product-card.liquid, borrowing its <product-card> custom element, card layout classes, view-transition wiring, and {% render 'price' %} call. If you are on a different theme, use this snippet as a reference and align the markup to your own product card structure.

Create snippets/wishlist-product-card.liquid. This snippet is a copy of Horizon's snippets/product-card.liquid with the following modifications applied on top:

  • Block context stripped — the standard product card is block-driven (it reads block.settings for sizing, spacing, borders, and variant linking). This snippet has no block context, so those dynamic settings are removed and their CSS variables are set to safe defaults directly on the .card-gallery element.
  • Card children hardcoded — the standard card delegates its inner layout to block children rendered via {{ children }}. Here the image, badge, title, and price are inlined directly since there is no block rendering path.
  • Swym wishlist button added — a swym-add-to-wishlist-view-product button is injected into the card. It is suppressed for curation-creator customers so the curator's own browsing does not alter the curated lists.
{%- doc -%}
  Wishlist product card rendered via search.wishlist.liquid JSON endpoint.
  Children are hardcoded — no block context available in this render path.

  @param {object} product - The product object
  @param {string} [selected_variant_id] - The wishlisted variant id
  @param {string} [curator_tag] - Customer tag that hides the wishlist button; defaults to 'curation-creator'
{%- enddoc -%}

{% liquid
  assign variant_to_link = product.selected_or_first_available_variant

  if settings.transition_to_main_product
    assign featured_image = variant_to_link.featured_image
    if featured_image == blank
      assign featured_image = product.featured_media.preview_image
    endif
    if featured_image != blank
      assign featured_media_url = featured_image | image_url: width: 500
    endif
  endif

  assign onboarding = false
  if product.id == empty or product == blank
    assign onboarding = true
  endif

  assign selected_variant_id = selected_variant_id | default: product.selected_or_first_available_variant.id
  assign curator_tag = curator_tag | default: 'curation-creator'
%}

<product-card
  class="product-card"
  data-product-id="{{ product.id }}"
  id="product-card-{{ product.id }}"
  {% if settings.transition_to_main_product %}
    data-product-transition="true"
    data-featured-media-url="{{ featured_media_url }}"
    on:click="/handleViewTransition"
  {% endif %}
  {% if onboarding %}
    data-placeholder="true"
  {% endif %}
>
  {% unless customer and customer.tags contains curator_tag %}
  <button
    class="swym-button swym-add-to-wishlist-view-product product_{{ product.id }}"
    data-with-epi="true"
    data-swaction="addToWishlist"
    data-product-id="{{product.id | json}}"
    data-variant-id="{{ selected_variant_id }}"
    data-product-url="{{ shop.url }}{{ product.url }}"
    aria-label="Add to your Wishlist"
  >
  </button>
  {% endunless %}
  <a
    {% unless onboarding %}
      href="{{ variant_to_link.url }}"
    {% endunless %}
    class="product-card__link"
    ref="productCardLink"
  >
    <span class="visually-hidden">{{ product.title }}</span>
  </a>
  <div class="product-card__content layout-panel-flex layout-panel-flex--column product-grid__card gap-style">
    <div
      class="card-gallery"
      style="--border-radius: {{ settings.border_radius }}px; --padding-block-start: 0px; --padding-block-end: 0px; --padding-inline-start: 0px; --padding-inline-end: 0px;"
    >
      {%- if product.featured_media -%}
        {{- product.featured_media | image_url: width: 600 | image_tag:
          loading: 'lazy',
          widths: '300, 400, 600',
          sizes: '(min-width: 1000px) 25vw, (min-width: 750px) 33vw, 50vw'
        -}}
      {%- endif -%}
      {%- if product.available == false or product.compare_at_price > product.price -%}
        <div class="product-badges product-badges--{{ settings.badge_position }}" style="--badge-border-radius: {{ settings.badge_corner_radius }}px;">
          <div class="product-badges__badge product-badges__badge--rectangle {% if product.available == false %}color-{{ settings.badge_sold_out_color_scheme }}{% else %}color-{{ settings.badge_sale_color_scheme }}{% endif %}">
            {%- if product.available == false -%}
              {{ 'content.product_badge_sold_out' | t }}
            {%- else -%}
              {{ 'content.product_badge_sale' | t }}
            {%- endif -%}
          </div>
        </div>
      {%- endif -%}
    </div>
    <a href="{{ variant_to_link.url }}" class="product-card__title paragraph">
      <p>{{ product.title }}</p>
    </a>
    <product-price data-product-id="{{ product.id }}">
      {% render 'price', product_resource: product, show_sale_price_first: true %}
    </product-price>
  </div>
</product-card>

Horizon styles note: The {% render 'product-badges-styles' %} and {% render 'theme-styles-variables' %} lines already included in the Step 2 section file are required for badge positioning to work. product-badges-styles provides the position: absolute rule and --badge-inset definition. theme-styles-variables provides --padding-xs, --layer-flat, and other design tokens those rules depend on. Without them, the badge renders in normal document flow below the product image instead of overlaid at the configured corner.

Step 6: Create the page template

Create templates/page.swym-curation.json:

{
  "sections": {
    "curation-lists": {
      "type": "swym-curation",
      "settings": {
        "lids": "",
        "empty_browse_url": "/collections/all",
        "product_card_size": "medium",
        "mobile_product_card_size": "standard"
      }
    }
  },
  "order": [
    "curation-lists"
  ]
}

Step 7: Create a Shopify page and assign the template

In your Shopify admin: Online Store > Pages > Add page.

  1. Set the page title (e.g. "Our Picks" or "Staff Picks" or "Curations").
  2. In the Theme template dropdown in the right sidebar, select page.swym-curation.
  3. Save the page.

Step 8: Tag your curator account

In your Shopify admin: Customers > [select the curator's account] > Tags.

Add the tag that matches the Curator customer tag setting in your section. The default is curation-creator.

Warning: The tag is an exact, case-sensitive match against whatever value is in the section setting. If you change the setting to curator but tag the customer as Curator or curation-creator, the panel will not appear. Keep the customer tag and the section setting in sync.

The Curator Panel section at the top of the page is rendered conditionally by Liquid and is only visible to this customer when logged in. All other visitors see only the public carousels.

Step 9 (Curator/Merchant): Build your lists

This step is performed by the curator account on the storefront, not in the Theme Editor.

  1. Log in to the storefront as the curation-creator customer.
  2. Browse the store and use the wishlist heart button to add products to named lists as normal. Rename lists descriptively — the list name becomes the carousel heading on the curation page.
  3. Navigate to the curation page. The Curator's Panel appears at the top, showing all your Swym lists as tiles. Each tile displays a 3-image mosaic, the list name, item count, and the list ID.
  4. Click Copy LID on any list tile. This does two things: marks the list as publicly accessible, then copies its ID to your clipboard.

Warning: You must click Copy LID (which calls markListPublic) before pasting a LID into the section settings. A LID that has never been made public will cause its carousel to silently render as empty, with no error displayed.

Step 10 (Curator/Merchant): Publish LIDs to the page

This step is performed in the Shopify Theme Editor.

  1. Go to Online Store > Themes > Customize.
  2. Navigate to the curation page.
  3. Click on the Swym - Curation Lists section in the left sidebar.
  4. Paste the copied LID into the List IDs (LIDs) field.
  5. To display multiple lists, enter them comma-separated: abc-123, def-456, ghi-789. Each LID renders as its own titled carousel, in the order listed.
  6. Save. The page now renders one carousel per LID, each titled with the list's name from Swym.

Tip: To update a curation, the curator edits their list on the storefront (adding or removing products). Changes are reflected immediately the next time the curation page loads — no LID change or Theme Editor update is needed.

How It Works

  1. On page load, the Liquid template splits the lids setting on commas and renders one <section class="swym-curation-list" data-lid="..."> block per LID. Each block contains a loading indicator and an empty carousel container.
  2. Once the Swym SDK fires SwymCallbacks, initCurationPage queries all .swym-curation-list[data-lid] elements and calls swat.fetchListDetails({ lid }) for each, in parallel, to retrieve the list name and product contents.
  3. The list name (lname) is written into the section's <h4> heading. If the list is empty, the section shows an empty state with a browse CTA.
  4. For non-empty lists, renderProducts builds a search URL: /search?type=product&variant_ids=...&lid=...&q=id:...&view=wishlist. The view=wishlist parameter routes the request through templates/search.wishlist.liquid, which returns a JSON array of rendered product-card HTML — one fragment per wishlisted variant. Parameters are parsed by name in the Liquid template, so parameter order in the JS URL builder is not load-bearing.
  5. The returned HTML fragments are keyed by variant ID and re-inserted into the carousel <ul> in the curator's original list order, not Shopify's search relevance order. The grid is then revealed.
  6. A generation counter (_gens[lid]) guards against stale fetches. If a LID's fetch is triggered a second time while the first is still in flight, the older response is discarded when it resolves. The counter is module-scoped and persists for the script's lifetime; in SPA themes without a full reload between navigations this means the first render of a return visit is silently dropped.
  7. After every list has rendered, swat.initializeActionButtons is called once to activate Swym's add-to-wishlist buttons across the entire page. Waiting until all lists are done prevents a partial initialization where buttons on late-rendering carousels are missed.
  8. If the logged-in customer has the curation-creator tag, initCreatorPanel runs concurrently with the list fetches. It calls swat.fetchLists() to load all the creator's lists, renders them as tiles, and attaches a delegated click listener. Clicking Copy LID checks a session-scoped _publicised Set — if the list was already made public during this visit, it skips the API call and copies immediately. On the first click, it calls swat.markListPublic(lid), adds the LID to the set on success, then copies the LID to the clipboard using the Clipboard API with a textarea fallback.

Section Settings Reference

SettingTypeDescription
List IDs (LIDs)TextComma-separated Swym list IDs. Each ID renders as a separate titled carousel in the order listed. Leave blank to show the global empty state.
Empty state browse linkURLThe CTA link shown when no LIDs are configured, or a specific list has no products. Defaults to /collections/all.
Product card sizeSelectControls --carousel-item-width: Small (180 px), Medium (260 px), Large (365 px), Extra Large (530 px).
Mobile product card sizeSelectStandard renders a 2-column grid on mobile. Large renders 1 full-width column.
Curator customer tagTextShopify customer tag that unlocks the Curator Panel and hides the wishlist button on carousel cards. Defaults to curation-creator. Must match the tag on the curator's customer account exactly (case-sensitive).

Styling

The section ships with minimal structural styles only — enough to establish the carousel layout, tile grid, and loading states. All visual styling (colours, typography, border radius, spacing, button appearance) is intentionally left to you and should be matched to your theme.

Style them to match your collection page using the classes below:

Element / ClassPurpose
#swym-curation-pagePage root — scopes all curation styles
.swym-curation-listOne section per LID — controls vertical spacing between carousels
.swym-carouselWrapper for the scrollable product rail
ul.product-gridThe scrollable product rail — overflow-x:auto, hidden scrollbar, grab cursor on hover
.swym-curation-loadingPer-list loading indicator — replace the text content with a spinner if your theme uses one
.swym-curation-emptyPer-list empty state — centered column layout with an optional browse CTA link
#swym-creator-panelCurator Panel container — only present in the DOM for curation-creator customers
#swym-creator-listsAuto-fill grid of list tiles inside the Curator Panel
.swym-creator-list-tileIndividual list tile: 3-image mosaic, list name, item count, LID display, Copy LID button
.swym-copy-lid-btnCopy LID button — label briefly changes to "Copied!" on success

Note: Product cards are rendered by snippets/wishlist-product-card.liquid and injected into the carousel at runtime. This snippet is a direct adaptation of Horizon's snippets/product-card.liquid — it shares the same <product-card> element, layout classes, view-transition wiring, and {% render 'price' %} call, so cards match the rest of the store automatically on Horizon. If you are on a different theme, align wishlist-product-card.liquid to your own collection card markup.

Troubleshooting

Carousels load but show as empty

The most common cause is a LID that was never made public. The curator must click Copy LID in the Curator Panel (which calls markListPublic) before the LID is usable. Pasting the raw LID from the Swym dashboard without going through the panel will not work. Ask the curator to visit the curation page while logged in and click Copy LID on the affected list, then re-paste the LID into section settings.

Fetch returns a JSON parse error in the browser console

search.wishlist.liquid is returning HTML instead of JSON. Check that {%- layout none -%} is the very first line of the template with no whitespace or blank lines before it.

Curator Panel does not appear

The logged-in customer does not have the curation-creator tag, or has a variation of it (wrong case, underscore instead of hyphen). Verify the exact tag in Shopify Admin > Customers > [account] > Tags.

"Add to Wishlist" buttons on carousel cards do not respond

swat.initializeActionButtons runs only after all LID fetches complete. If one LID fails silently (network error, invalid LID), onListRendered may never reach the total count and buttons are never initialized. Open the browser console and look for [Swym Curation] fetchListDetails failed errors to identify the offending LID.

Product cards look different from the collection page

On Horizon, snippets/wishlist-product-card.liquid is a direct adaptation of the built-in product card and should match automatically. If cards look different, verify that {% render 'product-badges-styles' %} and {% render 'theme-styles-variables' %} are present in sections/swym-curation.liquid as described in Step 5. On a non-Horizon theme, the snippet markup will need to be aligned to your own collection card structure.