Yotpo — How To Watch For Changes

I needed to customize the reviews UI on a product detail page for a Shopify site whenever there were no reviews. However, I ran into a series of unexpected problems, thinking naively that I could just execute a callback when the page was ready, query the DOM as needed to make my change. Unfortunately, the markup did not have any review information on DOMContentLoaded , and I had to find a more creative solution to the problem. After looking through a lot of different options via a google search and yotpo’s very own documentation, the forward for me here was leveraging something I had never used before: MutationObserver.

The Problem

Initially, as I mentioned above, I thought I’d be able to query the Yotpo markup once DOMContentLoaded event fired. However, to my dismay the Yotpo javascript had not initialized at that time and so the markup on the page was not ready for any customization. In case you hadn’t already guessed it, Yotpo was installed on the store manually (See the section, Installing Yotpo Manually). We were not using any Yotpo APIs. All Yotpo features were loaded via the scripted and HTML markup provided in the installation instructions.

After looking at the HTML markup that Yotpo provided in their installation instructions, I was hoping that I could perhaps reference metafield data which could at least inform me on a review quantity. Alas, strike two — no was luck there either. I could not find a metafield on the shopify object in liquid which would inform me on the amount of reviews the product had on Yotpo. Unfortunately, I would have to use DOM traversal to know if there were reviews present.

I also tried to find a feature of JS which would allow me to watch the Window object for an added, Yotpo key. I figured that if I could detect when this precise change happened on the Window then I would be in the clear and ready to query the DOM for the yotpo markup. I found this article, but I could not get any of the examples to work — well that for me was strike three.

In addition to all the above attempts, I even tried to find a native Yotpo event that would give me a callback to run whenever Yotpo was initialized. I found this post, which has the following code which was promising:

yotpo.onAllWidgetsReady(function(){
  if (!$(Yotpo.getStars()).find("span:not('.yotpo-icon-empty-star')").length) 
    $(Yotpo.widgets.main.selector).hide();
})

While I’m glad that potentially the above code worked for some people, the above code for me, however, did not work. I would see this error in the DOMContentLoaded event:

Uncaught ReferenceError: yotpo is not defined
    at HTMLDocument.eval (product.js?681f:92)

wow, so I guess that’s strike four.

The Solution

Luckily I came across an SO post, which then directed me to this blog post about MutationObserver. After reading the post I was able to come up with a solution that looks like this.

// scripts/templates/product.js

import YotpoNoReviews from "../models/YotpoNoReviews";
import YotpoObserver from "../models/YotpoObserver";

document.addEventListener("DOMContentLoaded", () => {
 let target = document.querySelector('[data-yotpo-reviews]');

  let yotpoNoReviews = new YotpoNoReviews({
    target
  })

  let watchYotpoMarkup = new YotpoObserver({
    target,
    yotpoNoReviews
  })

  watchYotpoMarkup.observe()

  yotpoNoReviews.apply()
})

In the above code I load two JS classes, YotpoNoReviews and YotpoObserver. In YotpoObserver I watch for DOM changes on an HTML element (document.querySelector('[data-yotpo-reviews]')) whose markup will change once Yotpo initializes. Once the markup changes I then execute yotpoNoReviews.apply() which queries the DOM to change the markup as I need it to. Let’s have a look at these two classes:

// scripts/models/YotpoObserver.js
export default class YotpoObserver {
  constructor(options = {}) {
    this.target = options.target
    this.yotpoNoReviews = options.yotpoNoReviews
  }

  observe() {
    // create an observer instance
    this.observer = new MutationObserver(this.observerCallback.bind(this));

    // configuration of the observer:
    let config = { attributes: true, childList: true, characterData: true }

    // pass in the target node, as well as the observer options
    this.observer.observe(this.target, config);
  }

  observerCallback(mutations) {
    mutations.forEach(this.mutationsCallback.bind(this));
  }

  mutationsCallback(mutation) {
    if (mutation.type === 'childList') {

      this.yotpoNoReviews.apply()

      this.observer.disconnect();
    }
  }
}

// scripts/models/YotpoNoReviews.js

export default class YotpoNoReviews {
  constructor(options = {}) {
    this.target = options.target
  }

  apply() {
    let starContainerElements = document.querySelector('.yotpo-stars-and-sum-reviews')

    if (starContainerElements) {
      let emptyStars = starContainerElements.querySelectorAll('.yotpo-icon-empty-star')

      if (emptyStars.length === 5) {
        this.target.classList.add('reviews-absent')
        this.target.querySelector('.yotpo-icon-button-text').innerHTML = "No Reviews"
      }
    }
  }
}

In YotpoObserver I setup the custom event I need when the markup containing this data attribute changes data-yotpo-reviews. The moment the markup changes in a way that informs me that all the review data is present I execute this.yotpoNoReviews.apply()

Inside YotpoNoReviews I check to see if the stars have 5 empty stars. If they do, then I add the class I need to enforce the styling I want as well as the text for the button.

Avoid Race Conditions

You may be wondering why I run this.yotpoNoReviews.apply() inside YotpoObserver and yotpoNoReviews.apply() inside the DOMContentLoaded callback. On initial page load the call yotpoNoReviews.apply() inside DOMContentLoaded event indeed will not have the markup the function call needs on the page to make the changes it needs to do potentially. However, in the event that Yotpo markup so happens to arrive on the page before the mutation observer callback is setup, then yotpoNoReviews.apply() inside the DOM ready event will make sure that the markup will be taken care of without the need for mutation observer callback to run.

As some of you may already have guessed — the potential problem I outlined above is a race condition. Practically speaking, I come across this race condition whenever I make a code change locally and slate reloads the theme’s JS in the browser. When this happens the markup is present but no mutation will take place. In this event, yotpoNoReviews.apply() inside DOMContentLoaded comes in handy so that the HTML can change as I needed.

Also, do you happen to need to reinitialize Yotpo after making an AJAX request to the server and updating the HTML markup on the product detail page? That is no problem at all. Yotpo documents a solution here:

var api = new Yotpo.API(yotpo);
api.refreshWidgets();

While the truth is that I’d rather use the Yotpo API, the above was a good solution for the near term.