/**
* Observer for the {@link UpsellStore} that updates the data layer for Google Tag Manager.
* Stores a copy of the state so it can compare the previous and updated state.
* Dispatches custom events when the user takes an action, such as viewing, accepting or declining an offer.
* Events contain metadata about the post-purchase flow and offer.
* Implements the Observer design pattern. See {@link https://en.wikipedia.org/wiki/Observer_pattern}
* @class AnalyticsObserver
* @param {Object[]} dataLayer - Data layer, as specified by Google Tag Manager {@link https://developers.google.com/tag-manager/devguide}
* @param {Object} state - The initial state of the {@link UpsellStore}
*/
export default class AnalyticsObserver {
constructor(dataLayer, state) {
this.dataLayer = dataLayer;
if (state.flow === null) return;
this.state = this.copyState(state);
const { completeFlow: flow } = state;
this.flowName = flow.name;
this.flowID = flow.id;
/**
* An initial "view" event is dispatched when the observer is created.
* This is necessary because the observer is created after the initial flow is loaded.
*/
this.addViewEvent(state);
}
/**
* Update the data layer with the latest flow data.
* Dispatches events for the following: Viewed offer, Accepted offer, and Declined offer, and Upsell Completed.
* Intended to be used as the callback for a Vue watcher. See {@link https://vuejs.org/guide/essentials/watchers.html}
* @public
* @method update
* @memberof AnalyticsObserver
* @param {Object} newState - The new state of the {@link UpsellStore}
* @param {Object} oldState - The previous state of the {@link UpsellStore}
* @returns {void}
*/
async update(newState) {
if (this.isNewAcceptedOffer(newState)) {
await this.addAcceptEvent(newState);
}
if (this.isNewDeclinedOffer(newState)) {
this.addDeclineEvent(newState);
}
if (this.isNewFlow(newState)) {
this.addViewEvent(newState);
}
if (this.isFlowCompleted(newState)) {
await this.addCompleteEvent(newState);
}
this.state = this.copyState(newState);
}
/**
* Checks if the upsell flow is completed. ie, if the current flow is null.
* @private
* @param {Object} newState - The new state of the {@link UpsellStore}
* @returns {boolean} - True if all offers have been accepted or declined
*/
isFlowCompleted(newState) {
return newState.flow === null && this.state.flow !== null;
}
/**
* Calculates the final order value and pushes it to the data layer.
* This is triggered after the final offer is accepted or declined.
* @private
* @param {Object} newState - The new state of the {@link UpsellStore}
*/
async addCompleteEvent(newState) {
const value = await this.calculateFinalOrderValue(newState);
this.dataLayer.push({
event: "post_purchase_upsell_completed",
flow_name: this.flowName,
value
});
}
/**
* Calculates the final order value, which is used by the "complete" event.
* It subtracts the value of any items removed from the cart, then adds the value of each accepted offer.
* This temporarily necessary because we can't get the final order value from ReCharge.
* @param {Object} newState - the new state of the {@link UpsellStore}
* @returns {number} - the final order value
*/
async calculateFinalOrderValue(newState) {
let value = newState.orderTotal;
for (const { offer, responseBody: responseBodyPromise } of newState.acceptedOffers) {
const offerIDs = offer.filter(({ original_external_variant_id }) => original_external_variant_id).map(({ original_external_variant_id }) => original_external_variant_id);
const relevantCartItems = newState.cartItems.filter(item => offerIDs.includes(item.variantID));
const deduction = relevantCartItems.reduce((total, item) => total + (item.price * item.quantity), 0);
value -= deduction;
const resBody = await responseBodyPromise;
if (resBody) {
const addition = resBody.result.reduce((total, { price, quantity }) => total + (price * quantity), 0);
value += addition;
}
}
return value;
}
/**
* Creates a partial copy of a state object. This is used to compare the previous and updated state.
* @private
* @method copyState
* @param {Object} state - The state of the {@link UpsellStore}
*/
copyState(state) {
return {
...state,
// The flow object is a shallow copy because it is not expected to be mutated.
// This allows us to use !== to determine if the flow has changed.
flow: state.flow || null,
// The offer arrays are deep copies because they are expected to be mutated.
acceptedOffers: state.acceptedOffers ? [...state.acceptedOffers] : [],
declinedOffers: state.declinedOffers ? [...state.declinedOffers] : []
};
}
/**
* Handles the "accepted offer" event. Fires when a new offer is accepted.
* @private
* @method addAcceptEvent
*/
async addAcceptEvent(newState) {
const { acceptedOffers } = newState;
const { offer, responseBody: responseBodyPromise } = acceptedOffers[acceptedOffers.length - 1];
// If the ReCharge POST request was successful, we can use the response to calculate the value added to the order.
// Otherwise, the value is 0.
let value = 0;
const resBody = await responseBodyPromise;
if (resBody) {
for (const { price } of resBody.result) {
value += parseFloat(price, 10);
}
}
this.dataLayer.push({
event: 'post_purchase_offer_accepted',
flow_name: this.flowName,
offer_view: this.state.flow.offer.view,
offer_type: getOfferType(offer),
variants: offer.map(({ new_external_variant_id: variant }) => variant),
value
});
}
/**
* Handles the "declined offer" event. Fires when a new offer is declined.
* @private
* @method addDeclineEvent
*/
addDeclineEvent(newState) {
const { declinedOffers } = newState;
const { offer } = declinedOffers[declinedOffers.length - 1];
this.dataLayer.push({
event: 'post_purchase_offer_declined',
flow_name: this.flowName,
offer_view: this.state.flow.offer.view,
offer_type: getOfferType(offer),
variants: offer.map(({ new_external_variant_id: variant }) => variant),
});
}
/**
* Handles the "viewed offer" event. Fires when a new offer is viewed.
* @private
* @method addViewEvent
*/
addViewEvent(newState) {
const { flow } = newState;
const payload = flow.offer.options[0].payload;
this.addEvent('ga4_view_post_purchase_offer', flow.offer.view, payload);
}
/**
* Compares new state to current state to determine if the flow has changed.
* @private
* @method isNewFlow
* @param {Object} state - The new state of the {@link UpsellStore}
* @returns {boolean} - True if the flow has changed, false otherwise.
*/
isNewFlow({ flow }) {
const oldState = this.state;
return flow && oldState.flow && flow !== oldState.flow;
}
/**
* Compares new state to current state to determine if a new offer has been accepted.
* @private
* @method isNewAcceptedOffer
* @param {Object} state - The new state of the {@link UpsellStore}
* @returns {boolean} - True if a new offer has been accepted, false otherwise.
*/
isNewAcceptedOffer({ acceptedOffers }) {
const oldState = this.state;
return acceptedOffers && oldState.acceptedOffers && acceptedOffers.length > oldState.acceptedOffers.length;
}
/**
* Compares new state to current state to determine if a new offer has been declined.
* @private
* @method isNewDeclinedOffer
* @param {Object} state - The new state of the {@link UpsellStore}
* @returns {boolean} - True if a new offer has been declined, false otherwise.
*/
isNewDeclinedOffer({ declinedOffers }) {
const oldState = this.state;
return declinedOffers && oldState.declinedOffers && declinedOffers.length > oldState.declinedOffers.length;
}
/**
* Adds an event to the data layer. Automatically includes metadata about the flow and the current offer.
* @private
* @method addEvent
* @param {string} event - The name of the event to add to the data layer
* @param {string} view - The name of the view that the event is associated with
* @param {Object} payload - The payload of the offer that the event is associated with
* @returns {void}
*/
addEvent(event, view, payload) {
this.dataLayer.push({
event,
flow_name: this.flowName,
flow_id: this.flowID,
offer_view: view,
offer_type: getOfferType(payload),
offer_payload: payload
});
}
}
/**
* Takes a payload from a post-purchase offer and returns the offer type.
* Offer type may be one of the following:
* - upsell: all products are upsell products
* - cross-sell: no products are upsell products
* - upsell/cross-sell: some products are upsell products
*
* A product is an upsell product if it has an `original_external_variant_id` property.
* @private
* @function getOfferType
* @param {Array<UpsellConfig>} products The payload from a post-purchase offer (which will be sent to ReCharge if the user accepts the offer)
* @returns {string} The offer type
*/
function getOfferType(products) {
if (products.every(isUpsellProduct)) return "upsell";
if (!products.some(isUpsellProduct)) return "cross-sell";
return "upsell/cross-sell";
}
function isUpsellProduct(product) {
return !!product.original_external_variant_id;
}