Source: upsell-store.js

import { captureException } from "@sentry/vue";

/**
 * Ingests flow data from the backend and makes it easily accessible to the front-end.
 * This class will manage the state for the front-end Upsell App.
 * It should be wrapped in a reactive and provided to the root Vue app.
 * @class UpsellStore
 * @param {Object} flow - A recursive tree structure that defines the sequence of post-purchase offers. This data will be fetched from the /upsell API endpoint
 * @param {UpsellStoreOptions} [options] - Additional config options
 */
export default class UpsellStore {
    /**
     * Internal state of the store.
     * This is totally overwritten by the constructor and fromSnapshot methods.
     * Otherwise it should only be accessed through getters and setters.
     * @private
     */
    _state = {};

    /**
     * Creates a new UpsellStore instance and restores its internal state using a snapshot.
     * The snapshot should be a string created by the createSnapshot method.
     * This allows us to save and resume upsell flows across page reloads.
     * @public
     * @static
     * @memberof UpsellStore
     * @param {String} snapshot - the stringified internal state to be restored
     * @returns {UpsellStore}
     */
    static fromSnapshot(snapshot) {
        const store = new UpsellStore();
        const state = JSON.parse(snapshot);

        // clear viewsLoaded state because the offer views will need to be loaded again
        if (typeof state.viewsLoaded !== 'undefined') {
            delete state.viewsLoaded;
        }

        store._state = state;
        return store;
    }

    /**
     * When the user accepts an offer, the option they selected is stored in this Array.
     * Each object in the Array contains the upsell configuration data that was sent to ReCharge.
     * @public
     * @instance
     * @memberof UpsellStore
     * @type {Array<Array<UpsellConfig>>}
     */
    get acceptedOffers() {
        return this._state.acceptedOffers;
    }

    /**
     * Same as {@link acceptedOffers}, but this Array contains the options the user declined.
     * @public
     * @instance
     * @memberof UpsellStore
     * @type {Array<Array<UpsellConfig>>}
     */
    get declinedOffers() {
        return this._state.declinedOffers;
    }


    constructor(flow, options = {}) {
        this.options = options;
        this._state = {
            ...options,
            flow,
            completeFlow: flow,
            acceptedOffers: [],
            declinedOffers: [],
        };
    }

    /** 
     * Stores the URL of each UI component file and its status.
     * true = loaded, false = not loaded
     * @type {Map<String, Boolean>}
     * @private
     */
    get viewsLoaded() {
        // the viewsLoaded Map is initialized the first time it is accessed
        if (typeof this._state.viewsLoaded === 'undefined') {
            const viewsLoaded = new Map();
            const views = this.getViews(this.flow);
            views.forEach(view => viewsLoaded.set(view, false));
            this._state.viewsLoaded = viewsLoaded;
        }
        return this._state.viewsLoaded;
    }

    /**
     * An array of cart items included in the original order.
     * This is either passed as an option to the constructor, or it is an empty array.
     * @public
     * @readonly
     * @instance
     * @memberof UpsellStore
     * @type {Array<CartItem>}
     */
    get cartItems() {
        return this._state.cartItems || [];
    }

    /**
     * A number representing the total price of the original order.
     * This is either passed as an option to the constructor, or it is 0.
     * @public
     * @readonly
     * @instance
     * @memberof UpsellStore
     * @type {Number}
     */
    get orderTotal() {
        return this._state.orderTotal || 0;
    }

    /**
     * The initial state of the upsell flow, including id, name, and all offers.
     * @public
     * @readonly
     * @instance
     * @memberof UpsellStore
     * @type {Flow}
     */
    get completeFlow() {
        return this._state.completeFlow;
    }

    /**
     * The current state of the upsell flow. This is updated each time an offer is accepted or declined.
     * @public
     * @instance
     * @memberof UpsellStore
     * @type {Flow}
     */
    get flow() {
        return this._state.flow;
    }

    set flow(newValue) {
        this._state.flow = newValue;
    }

    /**
     * @private
     * @readonly
     * @type {String}
     */
    get csrfToken() {
        return this._state.csrfToken || "";
    }

    /**
     * @private
     * @readonly
     * @type {String}
     */
    get assetHostingURL() {
        return this._state.assetHostingURL || "";
    }

    /**
     * @private
     * @readonly
     * @type {String}
     */
    get rechargeUpsellServiceURL() {
        return this._state.rechargeUpsellServiceURL || "";
    }

    /**
     * List of UI component files that need to be loaded.
     * @public
     * @readonly
     * @instance
     * @memberof UpsellStore
     * @type {Array<String>}
     */
    get views() {
        return [...this.viewsLoaded.keys()];
    }

    /** 
     * Completion status of loading the UI component files.
     * true = all loaded, false = not all loaded.
     * If true, it is safe to mount the UI app.
     * @public
     * @readonly
     * @instance
     * @memberof UpsellStore
     * @type {Boolean}
     */
    get allViewsLoaded() {
        return [...this.viewsLoaded.values()].every(Boolean);
    }


    /**
     * The default option is the first option in the flow offer.
     * @private
     */
    get defaultOption() {
        return this.flow.offer.options[0].payload;
    }

    /**
     * Marks a UI component as loaded.
     * This method should be called each time a component is registered in the UI app.
     * @public
     * @method loadView
     * @param {String} viewName - The name of the view to be marked as loaded
     * @returns {void}
     * @instance
     * @memberof UpsellStore
    */
    loadView(viewName) {
        const view = this.buildViewURL(viewName);
        this.viewsLoaded.set(view, true);
    }

    /**
     * Action method that handles the user declining an offer.
     * The state of the store is updated to reflect the user's decision.
     * @public
     * @async
     * @instance
     * @method declineOffer
     * @memberof UpsellStore
     * @param {Array<UpsellConfig>} option - The offer option that the user declined
    
     * @returns {void}
     */
    declineOffer(option) {
        if (!this.flow) throw new Error("Flow is null");

        const selectedOption = option || this.defaultOption;

        const newDeclinedOffer = {
            offer: selectedOption
        };
        this.declinedOffers.push(newDeclinedOffer);

        // Update the flow last so that analytics can be sent before the flow is updated.
        this.flow = this.flow.decline;
    }

    /**
     * Action method that handles the user accepting an offer.
     * The state of the store is updated and a request is made to the ReCharge Upsell Service containing the offer option data.
     * If no option is specified, the default option will be used -- the first option of the current offer.
     * Error messages are returned if the request fails.
     * @public
     * @async
     * @instance
     * @method acceptOffer
     * @memberof UpsellStore
     * @param {Array<UpsellConfig>} option - The offer option that the user accepted
     * @returns {Promise<Object>} - An object with a `success` boolean and an array of `errors`
     */
    async acceptOffer(option) {
        if (!this.flow) throw new Error("Flow is null");
        const selectedOption = option || this.defaultOption;

        const requestPromise = this.addSelectedOptionToOrder(selectedOption);

        // Instead of awaiting the request, which could mess up the order of the acceptedOffers array,
        // we pass a promise that resolves to the response body.
        const newAcceptedOffer = {
            offer: selectedOption,
            responseBody: requestPromise.then(({ responseBody }) => responseBody)
        };
        this.acceptedOffers.push(newAcceptedOffer);

        // Update the flow last so that analytics can be sent before the flow is updated.
        this.flow = this.flow.accept;

        const { errors } = await requestPromise;
        return { success: errors.length === 0, errors };
    }

    /**
     * Encodes the internal state of the UpsellStore into a string.
     * The return value can be used to restore the UpsellStore via the static fromSnapshot method.
     * This is asynchronous because it must wait for all accepted offers to resolve.
     * @public
     * @instance
     * @memberof UpsellStore
     * @returns {String} - a string representing the internal state
     */
    async createSnapshot() {
        const acceptedOffers = await this.getResolvedAcceptedOffers();
        return JSON.stringify({
            ...this._state,
            acceptedOffers
        });
    }

    /**
     * Transforms the acceptedOffers state (which may contain unresolved Promises) into a single Promise.
     * This is useful because it resolves the responseBody of each offer.
     * @private
     * @returns {Promise<Array<Object>>}
     */
    async getResolvedAcceptedOffers() {
        const offers = this.acceptedOffers.map(
            async (offer) => ({
                ...offer,
                responseBody: await offer.responseBody
            })
        );
        return Promise.all(offers);
    }

    /**
     * Sends each product in the offer option to the ReCharge Upsell Service.
     * Catches any errors and returns them in an array.
     * @private
     * @param {Array<UpsellConfig>} option - Array of UpsellConfig objects for each product in the option
     * @returns {Promise<Array<Object>>} - Array of errors, empty if all requests were successful
     */
    async addSelectedOptionToOrder(option) {
        const errors = [];

        try {
            const responseBody = await this.postRechargeUpsellService(option);
            return { responseBody, errors };
        } catch (error) {
            captureException(error);
            const { message } = error;
            errors.push({
                upsellConfig: option,
                message
            });
            return { responseBody: null, errors };
        }
    }

    /**
     * Builds a POST request to the ReCharge Upsell Service containing a single upsell product configuration.
     * ReCharge Upsell Service should respond 200 with a JSON object if successful.
     * An error is thrown if either of these conditions are not met.
     * @private
     * @param {Object} upsellConfig - The configuration object for the upsell service
     * @returns {Promise<void>}
    */
    async postRechargeUpsellService(upsellConfig) {
        const data = upsellConfig.map(item => ({ ...item, csrf_token: this.csrfToken }));
        const response = await fetch(
            this.rechargeUpsellServiceURL,
            {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "accept": "application/json"
                },
                body: JSON.stringify(data)
            }
        );

        if (!response.ok) {
            throw new Error(`Failed to POST ReCharge Upsell Service: ${response.status} ${response.statusText}`);
        }

        try {
            return response.json();
        } catch (error) {
            captureException(error);
            throw new Error(`Failed to POST ReCharge Upsell Service: ${error.message}`);
        }
    }

    /**
     * Takes a flow object and returns a set of unique UI component URLs.
     * Recursively traverses each branch of the flow, building a URL for each "Offer View".
     * @private
     * @param {Object} node
     * @param {Set} [views]
     * @returns {Set}
    */
    getViews(node, views) {
        views ??= new Set();
        if (!node) return views;

        const viewURL = this.buildViewURL(node.offer.view);
        views.add(viewURL);

        this.getViews(node.accept, views);
        this.getViews(node.decline, views);

        return views;
    }

    /**
     * Builds the URL of each UI component JavaScript file.
     * @private
     * @param {String} view
     * @returns {String}
     */
    buildViewURL(view) {
        return `${this.assetHostingURL}/${view}.js`;
    }

    /**
     * Sets a timeout to exit the flow after a specified number of seconds.
     * This allows us to automatically show the Thank You page after the upsell window closes.
     * @public
     * @memberof UpsellStore
     * @param {Number} timeoutSeconds - The number of seconds to wait before exiting the flow
     * @returns {void}
     */
    exitFlowOnTimeout(timeoutSeconds = 300) {
        setTimeout(() => {
            this.flow = null;
        }, timeoutSeconds * 1000);
    }
}

/**
 * @typedef {Object} UpsellStoreOptions
 * @property {String} [assetHostingURL] - The base URL where the assets are hosted. Defaults to the current domain
 * @property {String} [rechargeUpsellServiceURL] - The URL of the ReCharge Upsell Service. Defaults to the current domain
 * @property {String} [csrfToken] - The CSRF token to be sent with each request to the ReCharge Upsell Service. Defaults to an empty string
 * @property {number} [orderTotal] - Total price of the order. Used to calculate analytics event values.
 * @property {Object[]} [cartItems] - Array of cart items. 
 * @memberof UpsellStore
 */