Source: analytics-observer.js

/**
 * 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;
}