This is a basic client~side wishlist in around 2kb-ish for usage within Shopify themes (though it can be appropriated outside of that context). It uses localstorage to keep track of liked products and allows for shared URL to be generated for cross device support.
Open the flems example (click the badge) to see a live demo of it and play around.
If you subscribe to the idea that having a cross-device wishlist is an essential, then try something else. In most cases, shoppers do not switch between devices when purchasing online (take it from me, I act on behalf of enterprise brands), typically, customer purchases start and conclude on the same device and thus this implementation will suffice for the vast majority of webshops. You can configure it to apply additional capabilities like tracking etc.
The wishlist is function that invokes as an IIFE. It is exposed in globalThis scope (i.e, window.wishlist
or simply, wishlist
). This means that you can drop it in via a <script>
but of course you bundle it too. The solution provides several methods for applied usage, most of which will allow you to do simple to more difficult operations. By default, it will not do anything until you wire it up.
First, let me explain the main methods available:
// use render method to optionally create a virtual wishlist, more on this later
wishlist.render((product) => `<a href="#"><img class="img" src="${product.img}"></a>`);
// If you need to listen for wishes
wishlist.on('wished', (args) => console.log(args));
// If you need to listen for unwishes
wishlist.on('unwish', (args) => console.log(args));
// This returns the number of items in wishlist
wishlist.count
There are additional methods such as
update
andinit
but you probably won't need them, feel free to adjust if you do.
Next, let's look at how we can leverage this in our Shopify store/s. The script works rather simple, it looks for tag elements named <wish-button>
and inspects data attributes contained within. The only expectation is that you provide an identifier annotation of either id=""
or data-id=""
and product id the value.
Using the data-
attribute annotations, you can compose reference objects of products and they will be saved to the browsers LocalStorage, here is a "wish" button typical setup:
<wish-button
id="{{ product.id }}"
data-url="{{ product.url }}"
data-cost-price="{{ product.cost_price }}"
data-sale-price="{{ product.sale_price }}"
data-title="{{ product.title }}"
data-img="{{ product.featured.image }}"></wish-button>
Whenever a merchant presses a <wish-button>
element the data-
attributes will be saved to localstorage as an object structure. The id
(or data-id
) attribute will be used as a key reference in the structure for quick lookups and easier reasoning about with wishes. There is no limit to the amount of data attributes you apply, the script will parse and handle them accrdingly, converting kebab-case
attributes names to camelCase
format. When an items has been wished and exists in the storage the <wish-button>
will add an active
class.
An example of how the above <wish-button>
will be transformed and saved:
{
123456789: {
id: 123456789,
url: '/some-url/',
title: 'Sissel'
img: 'https://cdn.shopify.com/xxx',
costPrice: '100.00',
salePrice: '200.00'
}
}
Each key is a product
id
(or variantid
) depending on what you'll provide.
Using the wishlist.render()
method, you can compose a template in your DOM. Consult the the flems example for more context here as it is VERY easy to understand. Click the .js
tab and the .css
tab where I provide you nerds with a demo for how things might look.
The wishlist script is around 1kb-ish and provides a number of different methods and functions, you can find a mostly complete reference below:
The wishlist.on
method is a subscriber function that be used to listen for wishes. This will allow you to call an external API or execute some task when a customer wishes or unwishes an item.
// The wished event will provide the following arugments in the callback
// The product will be an object representing the parsed attributes of <wish-button>
//
wishlist.on('wished', ({ id, node, product }) => {});
wishlist.on('unwish', ({ id, node, product }) => {});
// Optionally provide a this scope binding as third argument
// This is helpful when working in a class
//
wishlist.on('wished', function({ id, node, product }) { console.log(this) }, { foo: 'bar' })
wishlist.on('unwish', function({ id, node, product }) { console.log(this) }, { foo: 'bar' })
// There is also an update event subscription you can leverage
// This will be called upon every change or interaction with the localstorage wishlist model
//
wishlist.on('update', wish => {
wish.toString() // => Returns the model as an LZ compressed string
wish.url() // => Return a search parameter value for appending to url
wish.products() // => Returns the current wishlist storage model (immutable)
})
The wishlist.render
function can be used to compose a template from the wishlist data. It allows you to generate custom markup in a dynamic manner and mount it to an element in the dom. The argument of the callback
will be the parsed product data provided to <wish-button>
, using the example above, here is how you'd leverage this method:
// Return a template literal string and optionally provide some addition options
// in the 2nd parameter if you need more control of rendering.
wishlist.render(({ img, title, url }) => `
<div class="col-2">
<a href="${url}">
<img class="img" src="${img}">
</a>
</div>
`,
{
selector: '#wishlist', // optional, defaults to id="wishlist" (also accepts an HTMLElement),
wishlist: {} // optional, defaults to the current wishlist in localStorage
})
The wishlist.query
can be used to interface with the current window.location
URL. Use this method to determine whether a shared wishlist was provided to search parameters and then, from here perform an action.
// Check is the url has a wishlist, e.g: ?wishlist=eyI0NTYiOsSAaWTEhcSBxIMiLCJ1cmzEint7IHByb2R1Y3QuxJB
// When a wishlist parameter exists is means a shared wishlist has been passed
//
wishlist.query() // Returns a boolean false if no wishlist param exists or wishlist string
// The method accepts an options object and defaults to the following
//
wishlist.query({
save: false, // when true, the value of wishlist param will save to localStorage
url: location.search, // Location URL Params to query
param: 'wishlist' // The name of parameter holding wishlist string
})
The wishlist.url
can be used to general a URL that contains the ?wishlist=
parameter and string value.
wishlist.url() // => Returns a query parameter string, e.g: e.g: ?wishlist=eyI0NTYiOsSAaWTEhcSB
// You can cutomise the return string with options:
wishlist.url({
prefix: '', // optionally provide a pathname or full url
param: 'wishlist' // customize the query parameter name
})
Clears the current wishlist, removing all records and resetting expiration.
wishlist.clear() // programmatic removal of references
Soft refresh and re-connection of wishes in the DOM.
wishlist.connect() // connect wishes in the DOM
A readonly getter that returns the current number of products in the wishlist
wishlist.amount // => 100
A readonly getter that returns the current model of wishes in localStorage
wishlist.wishes // => { /* wishlist items */}
You may prefer to expose custom LocalStroage key name and adjust the expiration time for records. This can acheived by calling the wishlist default function:
wishlist({
key: 'wishlist', // changes the localStorage key name
expiry: 60 // The number of days the wishlist exists before applying purge and reset
})
Follow me on X if you like Shopify and banter.
You are not allowed to use this in themes published to theme store. Everyone else, you are fine, DWTFYW.
window.wishlist = (function Wishlist ({ key = 'wishes', expire = 60 } = {}) {
const events = { wished: [], unwish: [], update: [] };
const age = () => (Date.now() + (expire * 24 * 60 * 60 * 1000));
let wishes = localStorage.getItem(key);
let template = null;
let amount = 0;
if (wishes) {
wishes = JSON.parse(wishes);
if (Date.now() > wishes._age) {
clear();
} else if (!('_age' in wishes)) {
wishes._age = age();
localStorage.setItem(key, JSON.stringify(wishes));
}
} else {
localStorage.setItem(key, `{"_age":${age()}}`);
wishes = JSON.parse(localStorage.getItem(key));
}
const prop = attr => attr === 'id' ? attr : attr.slice(5).replace(/-./g, m => m[1].toUpperCase());
const nodes = (el = 'wish-button') => typeof el === 'string' ? document.body.querySelectorAll(el) : el;
const att = (k, e) => k.reduce((a, n) => (/^data-|^id$/.test(n) ? { ...a, [prop(n)]: e.getAttribute(n) } : a), {});
const fire = args => cb => cb[0].call(cb[1], args);
const change = cb => cb[0].call(cb[1], { toString: share, url, products: () => wishes });
const count = (selector = '[data-wishlist=count]') => {
amount = Object.keys(wishes).length - 1;
nodes(selector).forEach(c => c.innerText = amount);
};
const storage = (id, product, node, event = [ 'wished', product ]) => {
if (typeof product !== 'object') return;
if (id in wishes) event[0] = 'unwish', delete wishes[id]; else wishes[id] = product;
localStorage.setItem(key, JSON.stringify(wishes));
update(id, node);
count();
events[event[0]].forEach(fire({ id, node, product: event[1] }));
if (template) render(template);
};
Object.defineProperties(Wishlist, {
amount: { get: () => amount },
wishes: { get: () => wishes }
});
Wishlist.clear = clear;
Wishlist.render = render;
Wishlist.query = query;
Wishlist.url = url;
Wishlist.on = on;
Wishlist.connect = connect();
return Wishlist;
function clear () {
localStorage.setItem(key, `{"_age":${age()}}`);
wishes = JSON.parse(localStorage.getItem(key));
if (template) render(template);
connect()
}
function url ({ prefix = '', param = 'wishlist' } = {}) {
return `${prefix}?${param}=${share(wishes)}`;
}
function on (name, cb, scope = null) {
const idx = events[name].push([ cb, scope ]) - 1;
return () => events[name].splice(idx, 1);
};
function query ({ save = false, url = location.search, param = 'wishlist' } = {}) {
const uri = new URLSearchParams(url);
const get = uri.has(param) ? url.get(param) : false;
if (get && save) {
wishes = share(get);
localStorage.setItem(key, JSON.stringify(wishes));
}
return get;
};
function render (dom, o = { selector: '#wishlist', wishlist: undefined }) {
if (!template) template = dom;
const items = { ...(typeof o.list === 'string' ? share(o.list) : wishes) };
delete items._age;
const markup = Object.values(items).map((product, idx) => template(product)).join('');
(typeof o.selector === 'string' ? nodes(o.selector) : o.selector).forEach(el => el.innerHTML = markup);
}
function connect () {
count();
nodes().forEach(node => {
const id = node.id || node.dataset.id;
update(id, node);
node.onclick = () => storage(id, att(node.getAttributeNames(), node), node);
});
nodes('[data-wishlist=clear]').forEach((button) => button.onclick = () => clear())
setTimeout(() => events.update.forEach(change));
return connect;
};
function update (id, node = null) {
events.update.forEach(change);
if ((typeof id !== 'string' && typeof id !== 'number') || id === '') return;
if (node === null) return nodes().forEach(node => update(id, node));
if (!(id in wishes) && node.classList.contains('active')) node.classList.remove('active');
else if (id in wishes && !node.classList.contains('active')) node.classList.add('active');
}
function share (param) {
const dict = {};
const out = [];
let code = 256;
let phrase;
let currChar;
if (typeof param === 'string') {
try {
const data = decodeURIComponent(escape(window.atob(param))).split('');
let oldPhrase = currChar = data[0];
out.push(currChar);
for (let i = 1, s = data.length; i < s; i++) {
const currCode = data[i].charCodeAt(0);
if (currCode < 256) phrase = data[i];
else phrase = dict[currCode] ? dict[currCode] : oldPhrase + currChar;
out.push(phrase);
currChar = phrase.charAt(0);
dict[code] = oldPhrase + currChar;
code++;
oldPhrase = phrase;
}
return JSON.parse(out.join(''));
} catch (e) {
console.log('Failed decompress wishes', e);
return wishes;
}
} else {
try {
const data = JSON.stringify(wishes).split('');
let phrase = currChar = data[0];
for (let i = 1, s = data.length; i < s; i++) {
currChar = data[i];
if (dict[phrase + currChar] != null) {
phrase += currChar;
} else {
out.push(phrase.length > 1 ? dict[phrase] : phrase.charCodeAt(0));
dict[phrase + currChar] = code;
code++;
phrase = currChar;
}
}
out.push(phrase.length > 1 ? dict[phrase] : phrase.charCodeAt(0));
for (let j = 0, s = out.length; j < s; j++) out[j] = String.fromCharCode(out[j]);
return window.btoa(unescape(encodeURIComponent(out.join(''))));
} catch (e) {
console.log('Failed to compress wishlist', e);
return '';
}
}
};
})();