"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