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.
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, but I was not out yet.
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.
You may be wondering why I run this.yotpoNoReviews.apply()
inside YotpoObserver
and inside the DOMContentLoaded
callback. The reason is that while on a page refresh yotpoNoReviews.apply()
inside DOMContentLoaded
callback will not have the markup it needs to potentially make the changes it needs to do whenever I make a code change locally however, 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 need it.
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.