Skip to content

Instantly share code, notes, and snippets.

@panoply
Last active August 27, 2024 16:04
Show Gist options
  • Save panoply/3656a32f1936a556d02f4f20bd082870 to your computer and use it in GitHub Desktop.
Save panoply/3656a32f1936a556d02f4f20bd082870 to your computer and use it in GitHub Desktop.
Client Slide Wishlist for Shopify

Shopify Client-Side Wishlist

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.

Flems

FWIW

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.

Introduction

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 and init 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 variant id) depending on what you'll provide.

Virtual DOM list

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.

API

The wishlist script is around 1kb-ish and provides a number of different methods and functions, you can find a mostly complete reference below:

wishlist.on(event, callback, binding?)

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)
})

wishlist.render(callback, options?)

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
})

wishlist.query(options?)

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
})

wishlist.url(options?)

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
})

wishlist.clear()

Clears the current wishlist, removing all records and resetting expiration.

wishlist.clear() // programmatic removal of references

wishlist.connect()

Soft refresh and re-connection of wishes in the DOM.

wishlist.connect() // connect wishes in the DOM

wishlist.amount

A readonly getter that returns the current number of products in the wishlist

wishlist.amount // => 100

wishlist.wishes

A readonly getter that returns the current model of wishes in localStorage

wishlist.wishes // => { /* wishlist items */}

Additional Customizations

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
})

Author

Follow me on X if you like Shopify and banter.

License

You are not allowed to use this in themes published to theme store. Everyone else, you are fine, DWTFYW.

Wishlist Code

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 '';
      }
    }
  };

})();
window.wishlist=function e({key:t="wishes",expire:o=60}={}){const n={wished:[],unwish:[],update:[]},s=()=>Date.now()+24*o*60*60*1e3;let r=localStorage.getItem(t),i=null,a=0;r?(r=JSON.parse(r),Date.now()>r._age?d():"_age"in r||(r._age=s(),localStorage.setItem(t,JSON.stringify(r)))):(localStorage.setItem(t,`{"_age":${s()}}`),r=JSON.parse(localStorage.getItem(t)));const c=(e="wish-button")=>"string"==typeof e?document.body.querySelectorAll(e):e,l=(e,t)=>e.reduce(((e,o)=>{return/^data-|^id$/.test(o)?{...e,[(n=o,"id"===n?n:n.slice(5).replace(/-./g,(e=>e[1].toUpperCase())))]:t.getAttribute(o)}:e;var n}),{}),u=e=>e[0].call(e[1],{toString:S,url:p,products:()=>r}),h=(e="[data-wishlist=count]")=>{a=Object.keys(r).length-1,c(e).forEach((e=>e.innerText=a))},g=(e,o,s,a=["wished",o])=>{var c;"object"==typeof o&&(e in r?(a[0]="unwish",delete r[e]):r[e]=o,localStorage.setItem(t,JSON.stringify(r)),m(e,s),h(),n[a[0]].forEach((c={id:e,node:s,product:a[1]},e=>e[0].call(e[1],c))),i&&f(i))};return Object.defineProperties(e,{amount:{get:()=>a},wishes:{get:()=>r}}),e.clear=d,e.render=f,e.query=function({save:e=!1,url:o=location.search,param:n="wishlist"}={}){const s=new URLSearchParams(o),i=!!s.has(n)&&o.get(n);i&&e&&(r=S(i),localStorage.setItem(t,JSON.stringify(r)));return i},e.url=p,e.on=function(e,t,o=null){const s=n[e].push([t,o])-1;return()=>n[e].splice(s,1)},e.connect=w(),e;function d(){localStorage.setItem(t,`{"_age":${s()}}`),r=JSON.parse(localStorage.getItem(t)),i&&f(i),w()}function p({prefix:e="",param:t="wishlist"}={}){return`${e}?${t}=${S(r)}`}function f(e,t={selector:"#wishlist",wishlist:void 0}){i||(i=e);const o={..."string"==typeof t.list?S(t.list):r};delete o._age;const n=Object.values(o).map(((e,t)=>i(e))).join("");("string"==typeof t.selector?c(t.selector):t.selector).forEach((e=>e.innerHTML=n))}function w(){return h(),c().forEach((e=>{const t=e.id||e.dataset.id;m(t,e),e.onclick=()=>g(t,l(e.getAttributeNames(),e),e)})),c("[data-wishlist=clear]").forEach((e=>e.onclick=()=>d())),setTimeout((()=>n.update.forEach(u))),w}function m(e,t=null){if(n.update.forEach(u),("string"==typeof e||"number"==typeof e)&&""!==e)return null===t?c().forEach((t=>m(e,t))):void(!(e in r)&&t.classList.contains("active")?t.classList.remove("active"):e in r&&!t.classList.contains("active")&&t.classList.add("active"))}function S(e){const t={},o=[];let n,s,i=256;if("string"==typeof e)try{const r=decodeURIComponent(escape(window.atob(e))).split("");let a=s=r[0];o.push(s);for(let e=1,c=r.length;e<c;e++){const c=r[e].charCodeAt(0);n=c<256?r[e]:t[c]?t[c]:a+s,o.push(n),s=n.charAt(0),t[i]=a+s,i++,a=n}return JSON.parse(o.join(""))}catch(e){return console.log("Failed decompress wishes",e),r}else try{const e=JSON.stringify(r).split("");let n=s=e[0];for(let r=1,a=e.length;r<a;r++)s=e[r],null!=t[n+s]?n+=s:(o.push(n.length>1?t[n]:n.charCodeAt(0)),t[n+s]=i,i++,n=s);o.push(n.length>1?t[n]:n.charCodeAt(0));for(let e=0,t=o.length;e<t;e++)o[e]=String.fromCharCode(o[e]);return window.btoa(unescape(encodeURIComponent(o.join(""))))}catch(e){return console.log("Failed to compress wishlist",e),""}}}();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment