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