"Add To Wishlist Button" Snippet

Read this article to learn more about the snippet usage.


Usage:

{% render 'wl-add-button' %}

Code:

{% liquid
  assign is_blocked = shop.metafields.wishlist.app_blocked
  assign product = wish_product | default: product

  assign show_popup = show_popup | default: true
  assign slide_right = slide_right | default: false

  assign button_position = button_position | default: 'static'
  comment
    Options: 'static', 'absolute'
  endcomment

  assign link_url = link_url | default: ''

  assign view = view | default: 'icon_and_text'
  comment
    Options: 'icon_and_text', 'icon_only', 'text_only'
  endcomment

  assign content_align = content_align | default: 'left'
  comment
    Options: 'left', 'center', 'right'
  endcomment

  assign popup_position = popup_position | default: 'bottom-left'
  comment
    Options: 'bottom-left', 'bottom-right', 'top-left', 'top-right'
  endcomment

  assign icon_text_gap = icon_text_gap | default: 6
  assign max_width = max_width | default: 0
  comment
    0 — no width limit
  endcomment

  assign icon_color = icon_color | default: '#000000'
  assign icon_size = icon_size | default: 20
  assign icon_size_mobile = icon_size_mobile | default: 20

  assign text_color = text_color | default: '#000000'
  assign text_size = text_size | default: 14
  assign text_size_mobile = text_size_mobile | default: 14

  assign margin_top = margin_top | default: 0
  assign margin_bottom = margin_bottom | default: 0
  assign margin_left = margin_left | default: 0
  assign margin_right = margin_right | default: 0

  assign popup_bg_color = popup_bg_color | default: '#ffffff'
  assign popup_text_color = popup_text_color | default: '#000000'
  assign popup_font_size = popup_font_size | default: 16
  assign popup_font_size_mobile = popup_font_size_mobile | default: 14
  assign popup_padding_x = popup_padding_x | default: 20
  assign popup_padding_y = popup_padding_y | default: 20
  assign popup_border_radius = popup_border_radius | default: 4

  assign popup_close_btn_color = popup_close_btn_color | default: '#1b1b1b'
  assign popup_close_btn_size = popup_close_btn_size | default: 14
  assign popup_close_btn_size_mobile = popup_close_btn_size_mobile | default: 12

  assign popup_btn_bg_color = popup_btn_bg_color | default: '#000000'
  assign popup_btn_text_color = popup_btn_text_color | default: '#ffffff'
  assign popup_btn_font_size = popup_btn_font_size | default: 14
  assign popup_btn_font_size_mobile = popup_btn_font_size_mobile | default: 13
  assign popup_btn_border_radius = popup_btn_border_radius | default: 4
  assign popup_btn_padding_x = popup_btn_padding_x | default: 16
  assign popup_btn_padding_y = popup_btn_padding_y | default: 10

  assign show_icon = false
  assign show_text = false
  if view == 'icon_and_text' or view == 'icon_only'
    assign show_icon = true
  endif
  if view == 'icon_and_text' or view == 'text_only'
    assign show_text = true
  endif

  assign align_val = 'flex-start'
  if content_align == 'center'
    assign align_val = 'center'
  elsif content_align == 'right'
    assign align_val = 'flex-end'
  endif
%}
{% unless is_blocked %}
  <style>
    .hidden {
      display: none !important;
    }

    .wl-add a,
    .wl-page a,
    a.wl-go {
      text-decoration: none;
    }

    button::before,
    button::after {
      content: none !important;
    }

    :root {
      --atw-modal-offset: 20px;
      --atw-modal-transition-duration: 0.35s;
      --atw-modal-width: 357px;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add {
      position: {{ button_position }};
      z-index: 100;
      display: inline-block;
      cursor: pointer;
      margin: var(--wl-add-margin-top, 0) var(--wl-add-margin-right, 0)
        var(--wl-add-margin-bottom, 0) var(--wl-add-margin-left, 0);
      width: 100%;
      max-width: var(--wl-add-max-width, none);
      color: var(--wl-add-text-color, #000);
      font-size: var(--wl-add-text-size, 14px);
      user-select: none;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger {
      display: flex;
      justify-content: var(--wl-add-align, flex-start);
      align-items: center;
      gap: var(--wl-add-gap, 8px);
      cursor: pointer;
      margin: 0;
      border: none;
      background: none;
      padding: 0;
      width: 100%;
      color: inherit;
      font: inherit;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger svg {
      width: 24px;
      height: 24px;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger span {
      text-wrap: nowrap;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger--add {
      display: flex;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger--remove {
      display: none;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add--active .wl-add__trigger--add {
      display: none;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add--active .wl-add__trigger--remove {
      display: flex;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__icon {
      display: flex;
      align-items: center;
      width: var(--wl-add-icon-size, 18px);
      height: var(--wl-add-icon-size, 18px);
      color: var(--wl-add-icon-color, #000);
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__icon {
      display: flex;
      align-items: center;
      width: var(--wl-add-icon-size, 18px);
      height: var(--wl-add-icon-size, 18px);
      color: var(--wl-add-icon-color, #000);
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__trigger svg path {
      stroke: var(--wl-add-icon-color, #000);
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__text {
      color: var(--wl-add-text-color, #000);
      font-size: var(--wl-add-text-size, 14px);
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add__modal {
      display: flex;
      position: fixed;
      flex-direction: column;
      gap: 16px;
      transform: translateX(calc(-100% * var(--direction, 1)));
      visibility: hidden;
      opacity: 0;
      z-index: 1000;
      transition: opacity var(--atw-modal-transition-duration) ease,
        transform var(--atw-modal-transition-duration) ease,
        visibility 0s linear var(--atw-modal-transition-duration);
      cursor: default;
      inset: auto;
      box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
      border-radius: var(--wl-add-modal-radius, 4px);
      background-color: var(--wl-add-modal-bg, #fff);
      padding: var(--wl-add-modal-padding-y, 20px)
        var(--wl-add-modal-padding-x, 20px);
      width: var(--atw-modal-width);
      max-width: calc(100vw - 40px);
      color: var(--wl-add-modal-color, #000);
      font-size: var(--wl-add-modal-font-size, 16px);
    }

    @media (prefers-reduced-motion: reduce) {
      .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add__modal {
        --atw-modal-transition-duration: 0.01s;
      }
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal--slide-right {
      transform: translateX(calc(100% * var(--direction, 1)));
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add__modal.active {
      transform: translateX(0);
      visibility: visible;
      opacity: 1;
      transition-delay: 0s;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal--bottom-left {
      bottom: var(--atw-modal-offset);
      inset-inline-start: var(--atw-modal-offset);
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal--bottom-right {
      bottom: var(--atw-modal-offset);
      inset-inline-end: var(--atw-modal-offset);
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal--top-left {
      top: var(--atw-modal-offset);
      inset-inline-start: var(--atw-modal-offset);
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal--top-right {
      top: var(--atw-modal-offset);
      inset-inline-end: var(--atw-modal-offset);
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-close {
      position: absolute;
      top: 8px;
      cursor: pointer;
      margin: 0;
      inset-inline-end: 8px;
      border: none;
      background: none;
      padding: 0;
      width: var(--wl-add-modal-close-size, 16px);
      height: var(--wl-add-modal-close-size, 16px);
      color: var(--wl-add-modal-close-color, #1b1b1b);
      font: inherit;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-close:hover {
      opacity: 0.8;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-close:focus-visible {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-product,
    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-variant {
      font-weight: 700;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-link {
      display: inline-block;
      transition: opacity 0.2s;
      border-radius: var(--wl-add-modal-btn-radius, 4px);
      background-color: var(--wl-add-modal-btn-bg, #000);
      padding: var(--wl-add-modal-btn-padding-y, 10px)
        var(--wl-add-modal-btn-padding-x, 16px);
      color: var(--wl-add-modal-btn-color, #fff) !important;
      font-weight: 600;
      font-size: var(--wl-add-modal-btn-font-size, 14px);
      text-align: center;
      text-decoration: none;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-link:hover {
      opacity: 0.9;
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }} .wl-add__modal-link:focus-visible {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }

    [dir='rtl'] {
      --direction: -1;
    }

    [dir='ltr'] {
      --direction: 1;
    }

    .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add {
      --wl-add-icon-color: {{ icon_color }};
      --wl-add-text-color: {{ text_color }};
      --wl-add-align: {{ align_val }};
      --wl-add-gap: {{ icon_text_gap }}px;
      --wl-add-margin-top: {{ margin_top }}px;
      --wl-add-margin-right: {{ margin_right }}px;
      --wl-add-margin-bottom: {{ margin_bottom }}px;
      --wl-add-margin-left: {{ margin_left }}px;
      {% if max_width > 0 %}
      --wl-add-max-width: {{ max_width }}px;
      {% endif %}
      --wl-add-icon-size: {{ icon_size_mobile }}px;
      --wl-add-text-size: {{ text_size_mobile }}px;
    }

    @media screen and (min-width: 768px) {
      .wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add {
        --wl-add-icon-size: {{ icon_size }}px;
        --wl-add-text-size: {{ text_size }}px;
      }
    }

    .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add__modal {
      --wl-add-modal-bg: {{ popup_bg_color }};
      --wl-add-modal-color: {{ popup_text_color }};
      --wl-add-modal-padding-x: {{ popup_padding_x }}px;
      --wl-add-modal-padding-y: {{ popup_padding_y }}px;
      --wl-add-modal-radius: {{ popup_border_radius }}px;
      --wl-add-modal-btn-bg: {{ popup_btn_bg_color }};
      --wl-add-modal-btn-color: {{ popup_btn_text_color }};
      --wl-add-modal-btn-radius: {{ popup_btn_border_radius }}px;
      --wl-add-modal-btn-padding-x: {{ popup_btn_padding_x }}px;
      --wl-add-modal-btn-padding-y: {{ popup_btn_padding_y }}px;
      --wl-add-modal-close-color: {{ popup_close_btn_color }};
      --wl-add-modal-font-size: {{ popup_font_size_mobile }}px;
      --wl-add-modal-btn-font-size: {{ popup_btn_font_size_mobile }}px;
      --wl-add-modal-close-size: {{ popup_close_btn_size_mobile }}px;
    }

    @media screen and (min-width: 768px) {
      .wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}.wl-add__modal {
        --wl-add-modal-font-size: {{ popup_font_size }}px;
        --wl-add-modal-btn-font-size: {{ popup_btn_font_size }}px;
        --wl-add-modal-close-size: {{ popup_close_btn_size }}px;
      }
    }
  </style>

  {% liquid
    assign is_rtl = false
    if request.locale.iso_code == 'ar' or request.locale.iso_code == 'he' or request.locale.iso_code == 'fa' or request.locale.iso_code == 'ur'
      assign is_rtl = true
    endif
    assign dir_value = 'ltr'
    if is_rtl
      assign dir_value = 'rtl'
    endif

    assign variant_display = product.selected_or_first_available_variant.title
    if variant_display == 'Default Title'
      assign variant_display = ''
    endif

    assign info_text = 'wishlist.add_to_wishlist_btn.info_text' | t
    assign parts_product = info_text | split: '{product}'
    assign after_product = parts_product[1] | default: info_text
    assign parts_variant = after_product | split: '{variant}'
  %}

  <add-to-wishlist-btn
    class='wl-add wl-add--inline wl-add--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}'
    data-wishlist-root
    dir='{{ dir_value }}'
  >
    <input
      type='hidden'
      name='variantId'
      value='{{ product.selected_or_first_available_variant.id }}'
      data-wishlist-variant-id
    >
    <input
      type='hidden'
      name='variants'
      value='{{ product.variants | json | escape }}'
      data-wishlist-variants
    >
    <input
      type='hidden'
      name='productId'
      value='{{ product.id }}'
      data-wishlist-product-id
    >
    <input
      type='hidden'
      name='productHandle'
      value='{{ product.handle | escape }}'
      data-wishlist-product-handle
    >

    <button
      type='button'
      class='wl-add__trigger wl-add--inline__trigger'
      aria-pressed='false'
      aria-label='{{ 'wishlist.add_to_wishlist_btn.text_add' | t }}'
      data-wishlist-trigger
    >
      <span class='wl-add__trigger--add'>
        {% if show_icon %}
          <svg
            class='icon-heart'
            xmlns='http://www.w3.org/2000/svg'
            fill='none'
            viewBox='0 0 24 24'
          >
            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.87891 3.75c.67013.00152 1.33423.14051 1.9541.40918.61989.26872 1.18439.66253 1.66019 1.16016.1399.14633.3336.22972.5361.23144.2024.00163.3967-.07878.5391-.22266.9658-.9764 2.2584-1.51357 3.5957-1.50488 1.3372.00873 2.6231.5627 3.5771 1.55176.955.98998 1.5 2.33576 1.5088 3.74707.0087 1.41133-.52 2.76473-1.4629 3.76753l-6.6318 6.877c-.3018.3128-.7055.4834-1.1201.4834-.4146-.0001-.8175-.1707-1.1192-.4834l-6.63963-6.8828-.00196-.0029-.17578-.1885c-.3984-.452-.71987-.9742-.94922-1.543-.26198-.6498-.39789-1.34784-.39941-2.0537-.0015-.70608.13124-1.40537.39062-2.05664.25939-.6512.64026-1.24084 1.11817-1.73633.47787-.49543 1.04403-.88648 1.66504-1.15234.62086-.26578 1.28506-.40188 1.95508-.40039"/>
          </svg>
        {% endif %}
        {% if show_text %}
          <span>{{ 'wishlist.add_to_wishlist_btn.text_add' | t }}</span>
        {% endif %}
      </span>
      <span class='wl-add__trigger--remove'>
        {% if show_icon %}
          <svg
            class='icon-heart'
            xmlns='http://www.w3.org/2000/svg'
            fill='currentColor'
            viewBox='0 0 24 24'
          >
            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.87891 3.75c.67013.00152 1.33423.14051 1.9541.40918.61989.26872 1.18439.66253 1.66019 1.16016.1399.14633.3336.22972.5361.23144.2024.00163.3967-.07878.5391-.22266.9658-.9764 2.2584-1.51357 3.5957-1.50488 1.3372.00873 2.6231.5627 3.5771 1.55176.955.98998 1.5 2.33576 1.5088 3.74707.0087 1.41133-.52 2.76473-1.4629 3.76753l-6.6318 6.877c-.3018.3128-.7055.4834-1.1201.4834-.4146-.0001-.8175-.1707-1.1192-.4834l-6.63963-6.8828-.00196-.0029-.17578-.1885c-.3984-.452-.71987-.9742-.94922-1.543-.26198-.6498-.39789-1.34784-.39941-2.0537-.0015-.70608.13124-1.40537.39062-2.05664.25939-.6512.64026-1.24084 1.11817-1.73633.47787-.49543 1.04403-.88648 1.66504-1.15234.62086-.26578 1.28506-.40188 1.95508-.40039"/>
          </svg>
        {% endif %}
        {% if show_text %}
          <span>{{ 'wishlist.add_to_wishlist_btn.text_remove' | t }}</span>
        {% endif %}
      </span>
    </button>

    {% if show_popup %}
      <div
        class='wl-add__modal wl-add__modal--{{ section.id }}--{{ product.id }}--{{  product.selected_or_first_available_variant.id }}'
        role='status'
        aria-live='polite'
        aria-label='{{ 'wishlist.add_to_wishlist_btn.modal_aria_label' | t }}'
        data-wishlist-modal
        dir='{{ dir_value }}'
      >
        <button
          type='button'
          class='wl-add__modal-close'
          aria-label='{{ 'wishlist.add_to_wishlist_btn.modal_close' | t }}'
        >
          <svg
            class='icon-close'
            xmlns='http://www.w3.org/2000/svg'
            fill='none'
            viewBox='0 0 24 24'
          >
            <path fill="currentColor" d="M19.22 2.66c.77-.77 1.14-.82 1.59-.37l.9.9c.47.47.37.85-.36 1.59L14.12 12l7.23 7.23c.72.72.82 1.12.36 1.58l-.9.9c-.47.48-.86.36-1.59-.36L12 14.12l-7.23 7.23c-.72.72-1.1.84-1.58.37l-.9-.91c-.47-.46-.36-.86.36-1.58L9.88 12 2.65 4.78c-.73-.74-.83-1.12-.37-1.58l.91-.91c.45-.45.82-.4 1.58.37L12 9.88z"/>
          </svg>
        </button>

        <div
          class='wl-add__modal-message'
          data-wishlist-product-title='{{ product.title | escape }}'
        >
          {% if parts_product.size > 1 %}
            {{ parts_product[0] -}}
            <strong class='wl-add__modal-product'>
              {{- product.title | escape -}}
            </strong>
          {% endif %}
          {{ parts_variant[0] }}
          {% if parts_variant.size > 1 %}
            <strong class='wl-add__modal-variant'>
              {{- variant_display | escape -}}
            </strong>
          {% endif %}
          {{ parts_variant[1] | default: '' }}
        </div>

        <div class="wl-add__modal-link-container"></div>
      </div>
    {% endif %}
  </add-to-wishlist-btn>

  <script>
    /**
 * Add to Wishlist Button component.
 *
 * Responsibility:
 * - resolve active product/variant from product form context;
 * - toggle variant in default wishlist list via WishlistHelper adapter;
 * - keep button state in sync with current variant and helper data.
 *
 * Behavior notes:
 * - uses optimistic UI for add/remove with rollback on request failure;
 * - debounces variant-change updates to avoid extra reads;
 * - emits `wishlist-updated` after successful or rolled-back mutations.
 */
(() => {
  'use strict';

  const BANNER_HIDE_DELAY_MS = 5000;
  const DEFAULT_WISHLIST_NAME = 'Wishlist';
  const UPDATE_STATE_DEBOUNCE_MS = 100;

  const SELECTORS = {
    cartForm: "form[action*='/cart/add']",
    variantField: "input[name='id'], select[name='id']",
    productId: "input[name='productId']",
    productHandle: "input[name='productHandle']",
    variantId: "input[name='variantId']",
    variants: "input[name='variants']",
    trigger: '[data-wishlist-trigger]',
    banner: '[data-wishlist-modal]',
    bannerClose: '.wl-add__modal-close',
    bannerMessage: '.wl-add__modal-message',
    bannerProduct: '.wl-add__modal-product',
    bannerVariant: '.wl-add__modal-variant',
    bannerLinkContainer: '.wl-add__modal-link-container'
  };

  const EVENTS = {
    variantChange: 'variant:change',
    themeVariantChange: 'theme:variant:change',
  };

  const namespace = (window.__refactorWishlistApp =
    window.__refactorWishlistApp || {});

  // ---------------------------------------------------------------------------
  // Diagnostics + shared guards (aligned with WishlistHelper)
  // ---------------------------------------------------------------------------
  const logger = {
    warn(...args) {
      console.warn('[Wishlist]', ...args);
    },
    error(...args) {
      console.error('[Wishlist]', ...args);
    },
  };

  const getWishlistRuntimeHelpers = () => {
    if (!namespace.runtimeHelpers) {
      namespace.runtimeHelpers = {
        getErrorMessage(error) {
          return error instanceof Error
            ? error.message
            : String(error ?? 'Unknown error');
        },
        isFunction(value) {
          return typeof value === 'function';
        },
        createDebouncedFunction(callback, delayMs) {
          let timerId = null;
          return (...args) => {
            clearTimeout(timerId);
            timerId = setTimeout(() => callback(...args), delayMs);
          };
        },
        resolveWishlistInstance() {
          const helper = window.refactor_apps?.wishlist ?? null;
          if (helper) return { helper, error: null };
          return {
            helper: null,
            error: new Error('WishlistHelper is not available'),
          };
        },
        ensureHelperContractOrThrow(helper, methods) {
          for (const methodName of methods) {
            if (typeof helper?.[methodName] === 'function') continue;
            console.error('[Wishlist App] Helper contract mismatch', {
              methodName,
            });
            throw new Error('Wishlist helper contract mismatch');
          }
        },
      };
    }
    return namespace.runtimeHelpers;
  };

  const {
    getErrorMessage,
    createDebouncedFunction,
    resolveWishlistInstance,
    ensureHelperContractOrThrow,
    isFunction,
  } = getWishlistRuntimeHelpers();

  /**
   * Normalizes a raw variant id from number/string/gid into numeric id.
   *
   * @param {unknown} value
   * @returns {number | null}
   */
  const parseVariantId = (value) => {
    if (value == null) return null;
    if (typeof value === 'number' && Number.isFinite(value)) return value;

    const normalizedValue = String(value).trim();
    if (!normalizedValue) return null;

    const gidMatch = normalizedValue.match(/ProductVariant[/](\d+)/);
    const parsedValue = gidMatch
      ? Number(gidMatch[1])
      : Number(normalizedValue);
    return Number.isFinite(parsedValue) ? parsedValue : null;
  };

  const createWishlistAdapter = () => {
    let readyPromise = null;

    const getHelper = async () => {
      if (readyPromise) return readyPromise;

      readyPromise = (async () => {
        const { helper, error } = resolveWishlistInstance();
        if (error) throw error;
        if (isFunction(helper.ready)) {
          await helper.ready();
          return helper;
        }
        return helper;
      })().catch((error) => {
        readyPromise = null;
        throw error;
      });

      return readyPromise;
    };

    return {
      ready: async () => {
        await getHelper();
      },
      getDefaultListId: async () => {
        const helper = await getHelper();
        ensureHelperContractOrThrow(helper, ['getAllLists', 'createList']);

        const findDefaultListId = (lists) =>
          lists.find((list) => list?.name === DEFAULT_WISHLIST_NAME)?.id ??
          null;

        const lists = (await helper.getAllLists({ forceSync: true })) ?? [];
        const existingDefaultListId = findDefaultListId(lists);
        if (existingDefaultListId) return existingDefaultListId;

        await helper.createList(DEFAULT_WISHLIST_NAME);
        const nextLists = (await helper.getAllLists({ forceSync: true })) ?? [];
        return findDefaultListId(nextLists);
      },
      hasVariantInList: async (listId, variantId) => {
        const helper = await getHelper();
        ensureHelperContractOrThrow(helper, ['hasVariantInList']);
        return await helper.hasVariantInList(listId, variantId);
      },
      addProduct: async (listId, productId, variantId, handle) => {
        const helper = await getHelper();
        ensureHelperContractOrThrow(helper, ['addProduct']);
        return helper.addProduct(listId, {
          id: productId,
          variantId,
          handle,
        });
      },
      removeProduct: async (listId, productId, variantId, handle) => {
        const helper = await getHelper();
        ensureHelperContractOrThrow(helper, ['removeProduct']);
        return helper.removeProduct(listId, {
          id: productId,
          variantId,
          handle,
        });
      },
    };
  };

  document.addEventListener('DOMContentLoaded', () => {
    if (customElements.get('add-to-wishlist-btn')) return;

    class AddToWishlistBtn extends HTMLElement {
      #hideBannerTimeoutId = null;
      #isPending = false;
      #variantStateSequence = 0;
      #debouncedVariantStateUpdate = null;
      #wishlistAdapter = createWishlistAdapter();

      /**
       * Bootstraps DOM refs, list id, and subscriptions.
       *
       * @returns {Promise<void>}
       */
      async connectedCallback() {
        await this.#initializeState();
        this.#debouncedVariantStateUpdate = createDebouncedFunction(
          (variantId) => this.#updateVariantState(variantId),
          UPDATE_STATE_DEBOUNCE_MS,
        );
        await this.#updateVariantState();

        this.#initializeBanner();
        this.#bindEvents();
      }

      /**
       * Cleans observers/listeners created during mount.
       *
       * @returns {void}
       */
      /** @returns {void} */
      disconnectedCallback() {
        clearTimeout(this.#hideBannerTimeoutId);
        this.#unbindEvents();
        this.#removeBannerFromBody();
      }

      /**
       * Resolves DOM data required for wishlist actions.
       *
       * @returns {Promise<void>}
       */
      async #initializeState() {
        this.wishlistButton = this.querySelector(SELECTORS.trigger);
        this.productId = this.#readProductId();
        this.handle = this.#readProductHandle();
        this.variants = this.#readVariants();
        this.#resolveVariantField();

        if (!Number.isFinite(this.productId)) return;

        try {
          this.defaultListId = await this.#wishlistAdapter.getDefaultListId();
        } catch (error) {
          logger.warn('AddToWishlistBtn: failed to resolve default list', {
            error: getErrorMessage(error),
          });
          this.defaultListId = null;
        }
      }

      /**
       * Reads product handle from hidden input.
       *
       * @returns {string | null}
       */
      #readProductHandle() {
        const productHandleInput = this.querySelector(SELECTORS.productHandle);
        const handle = String(productHandleInput?.value ?? '').trim();
        return handle || null;
      }

      /**
       * Reads product id from hidden input.
       *
       * @returns {number | null}
       */
      #readProductId() {
        const productIdInput = this.querySelector(SELECTORS.productId);
        if (!productIdInput?.value) return null;
        const numericProductId = Number(productIdInput.value);
        return Number.isFinite(numericProductId) ? numericProductId : null;
      }

      /**
       * Reads serialized variants from hidden input.
       *
       * @returns {Array<Record<string, unknown>>}
       */
      #readVariants() {
        try {
          const variantsInput = this.querySelector(SELECTORS.variants);
          return variantsInput ? JSON.parse(variantsInput.value) : [];
        } catch (error) {
          logger.warn('AddToWishlistBtn: failed to parse variants', {
            error: getErrorMessage(error),
          });
          return [];
        }
      }

      /**
       * Finds product form and active variant field in current section.
       *
       * @returns {void}
       */
      #resolveVariantField() {
        const section = this.closest('section.shopify-section') ?? document;
        this.productForm =
          section.querySelector(SELECTORS.cartForm);
        this.variantField =
          this.productForm?.querySelector(SELECTORS.variantField) ??
          section.querySelector(SELECTORS.variantField);
      }

      /** Binds UI and variant-change subscriptions. */
      #bindEvents() {
        this.wishlistButton?.addEventListener('click', this.#handleButtonClick);
        this.bannerCloseButton?.addEventListener(
          'click',
          this.#handleBannerCloseClick,
        );

        this.onVariantChange = (event) => {
          const candidateVariantId =
            event?.detail?.variant?.id ?? event?.detail?.variantId;
          this.#debouncedVariantStateUpdate?.(
            parseVariantId(candidateVariantId),
          );
        };

        document.addEventListener(EVENTS.variantChange, this.onVariantChange);
        document.addEventListener(
          EVENTS.themeVariantChange,
          this.onVariantChange,
        );
        this.variantField?.addEventListener('change', this.onVariantChange);

        this.#startVariantObserver();
      }

      /** Unbinds all event subscriptions created on mount. */
      #unbindEvents() {
        this.wishlistButton?.removeEventListener(
          'click',
          this.#handleButtonClick,
        );
        this.bannerCloseButton?.removeEventListener(
          'click',
          this.#handleBannerCloseClick,
        );

        if (this.onVariantChange) {
          document.removeEventListener(
            EVENTS.variantChange,
            this.onVariantChange,
          );
          document.removeEventListener(
            EVENTS.themeVariantChange,
            this.onVariantChange,
          );
          this.variantField?.removeEventListener(
            'change',
            this.onVariantChange,
          );
        }

        this.#stopVariantObserver();
      }

      /** Observes form mutations to track theme-driven variant changes. */
      #startVariantObserver() {
        this.#stopVariantObserver();
        if (!this.productForm) return;

        this.lastObservedVariantValue = this.variantField?.value ?? '';
        this.variantObserver = new MutationObserver(() => {
          const field = this.productForm?.querySelector(SELECTORS.variantField);
          if (!field?.value || field.value === this.lastObservedVariantValue)
            return;
          this.lastObservedVariantValue = field.value;
          this.#debouncedVariantStateUpdate?.(parseVariantId(field.value));
        });

        this.variantObserver.observe(this.productForm, {
          childList: true,
          subtree: true,
          attributes: true,
        });
      }

      /** Stops active mutation observer if present. */
      #stopVariantObserver() {
        this.variantObserver?.disconnect();
        this.variantObserver = null;
      }

      /**
       * Handles button click and routes to add/remove flow.
       *
       * @param {MouseEvent} event
       * @returns {void}
       */
      #handleButtonClick = (event) => {
        event.preventDefault();
        if (this.#isPending) return;

        const variantId = this.#getSelectedVariantId();
        if (
          !this.defaultListId ||
          !Number.isFinite(this.productId) ||
          !variantId ||
          !this.handle
        )
          return;

        if (this.isAddedToWishlist) {
          this.#toggleWishlistProduct({
            productId: this.productId,
            variantId,
            shouldAdd: false,
          });
          return;
        }
        this.#toggleWishlistProduct({
          productId: this.productId,
          variantId,
          shouldAdd: true,
        });
      };

      async #toggleWishlistProduct({ productId, variantId, shouldAdd }) {
        this.#isPending = true;
        this.#setPendingUi(true);
        this.#setOptimisticState(shouldAdd);

        try {
          if (shouldAdd) {
            await this.#wishlistAdapter.addProduct(
              this.defaultListId,
              productId,
              variantId,
              this.handle,
            );
          } else {
            await this.#wishlistAdapter.removeProduct(
              this.defaultListId,
              productId,
              variantId,
              this.handle,
            );
          }
        } catch (error) {
          logger.error('AddToWishlistBtn: failed to toggle wishlist product', {
            error: getErrorMessage(error),
            productId,
            variantId,
            shouldAdd,
          });
          this.#setOptimisticState(!shouldAdd);
        } finally {
          this.#isPending = false;
          this.#setPendingUi(false);
          window.dispatchEvent(new CustomEvent('wishlist-updated'));
        }
      }

      /**
       * Updates local UI state and notification banner.
       *
       * @param {boolean} isAdded
       * @returns {void}
       */
      #setOptimisticState(isAdded) {
        this.isAddedToWishlist = isAdded;
        this.classList.toggle('wl-add--active', isAdded);

        if (isAdded) {
          this.#showBanner();
          clearTimeout(this.#hideBannerTimeoutId);
          this.#hideBannerTimeoutId = setTimeout(
            () => this.#hideBanner(),
            BANNER_HIDE_DELAY_MS,
          );
          return;
        }

        this.#hideBanner();
      }

      /**
       * Toggles pending state to prevent double-submits.
       *
       * @param {boolean} isPending
       * @returns {void}
       */
      #setPendingUi(isPending) {
        this.wishlistButton?.toggleAttribute('disabled', isPending);
        this.wishlistButton?.setAttribute('aria-busy', String(isPending));
      }

      /**
       * Resolves current variant id from form/url/fallback input.
       *
       * @returns {number | null}
       */
      #getSelectedVariantId() {
        const fieldVariantId = this.variantField?.value;
        const queryVariantId = new URLSearchParams(window.location.search).get(
          'variant',
        );
        const blockVariantId = this.querySelector(SELECTORS.variantId)?.value;
        return parseVariantId(
          fieldVariantId || queryVariantId || blockVariantId,
        );
      }

      /**
       * Syncs button state with helper for currently selected variant.
       * Sequence guard ensures stale async responses do not overwrite UI.
       *
       * @param {number | null | undefined} nextVariantId
       * @returns {Promise<void>}
       */
      #updateVariantState = async (nextVariantId) => {
        this.variantId =
          nextVariantId != null ? nextVariantId : this.#getSelectedVariantId();

        const variantIdInput = this.querySelector(SELECTORS.variantId);
        if (variantIdInput && this.variantId) {
          variantIdInput.value = String(this.variantId);
        }

        if (!this.defaultListId || !this.variantId) return;

        const sequence = ++this.#variantStateSequence;
        try {
          const isInList = await this.#wishlistAdapter.hasVariantInList(
            this.defaultListId,
            this.variantId,
          );
          if (sequence !== this.#variantStateSequence) return;
          this.isAddedToWishlist = isInList;
          this.classList.toggle('wl-add--active', isInList);
        } catch (error) {
          if (sequence !== this.#variantStateSequence) return;
          logger.warn('AddToWishlistBtn: failed to sync variant state', {
            error: getErrorMessage(error),
          });
        }
      };

      /**
       * Moves modal banner to body to avoid section re-render clipping.
       *
       * @returns {void}
       */
      #initializeBanner() {
        this.bannerElement = this.querySelector(SELECTORS.banner);
        if (!this.bannerElement) return;

        {% comment %} document
          .querySelectorAll('body > [data-wishlist-modal]')
          .forEach((element) => element.remove()); {% endcomment %}
        document.body.appendChild(this.bannerElement);
        
        const { linkUrl, linkText } = window.refactor_apps?.wishlist;

        if (linkUrl.length) {
          this.bannerLinkContainer = this.bannerElement.querySelector(SELECTORS.bannerLinkContainer);
          const linkEl = document.createElement('a');
          linkEl.href = linkUrl;
          linkEl.innerText = linkText;
          linkEl.target = '_blank';
          linkEl.classList.add('wl-add__modal-link');
          this.bannerLinkContainer?.replaceWith(linkEl);
          this.bannerLinkContainer = null;
        }

        this.bannerCloseButton = this.bannerElement.querySelector(
          SELECTORS.bannerClose,
        );
        this.bannerMessageElement = this.bannerElement.querySelector(
          SELECTORS.bannerMessage,
        );
        this.bannerProductElement = this.bannerMessageElement?.querySelector(
          SELECTORS.bannerProduct,
        );
        this.bannerVariantElement = this.bannerMessageElement?.querySelector(
          SELECTORS.bannerVariant,
        );
      }

      /** Removes detached banner node from body on teardown. */
      #removeBannerFromBody() {
        if (this.bannerElement?.parentNode === document.body) {
          this.bannerElement.remove();
        }
      }

      /** Renders and opens add-to-wishlist confirmation banner. */
      #showBanner() {
        if (!this.bannerElement || !this.variants?.length || !this.variantId)
          return;

        const selectedVariant = this.variants.find(
          (variant) => parseVariantId(variant?.id) === this.variantId,
        );
        if (!selectedVariant) return;

        const productTitle =
          this.bannerMessageElement?.dataset?.wishlistProductTitle ?? '';
        const rawVariantTitle =
          selectedVariant.title ?? selectedVariant.name ?? '';
        const variantTitle =
          rawVariantTitle === 'Default Title' ? '' : rawVariantTitle;

        if (this.bannerProductElement) {
          this.bannerProductElement.textContent = productTitle;
        }
        if (this.bannerVariantElement) {
          this.bannerVariantElement.textContent = variantTitle;
        }

        this.bannerElement.classList.add('active');
      }

      /** Closes banner from explicit close action. */
      #handleBannerCloseClick = () => this.#hideBanner();

      /** Hides add-to-wishlist confirmation banner. */
      #hideBanner() {
        this.bannerElement?.classList.remove('active');
      }
    }

    customElements.define('add-to-wishlist-btn', AddToWishlistBtn);
  });
})();

  </script>
{% endunless %}

Do you need help?

If you have any questions or run into issues, please contact us — we’re happy to help

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.

Still need help? Contact Us Contact Us