brandlabs-bigcommerce-wishlist
Version:
Easy-to-use wishlist functionality for BigCommerce themes.
566 lines (485 loc) • 18.8 kB
JavaScript
/**
* Wishlist API functionality in the front-end, for Stencil themes
*/
/**
* WishlistManager class provides easy-to-use methods
* to retrieve, edit, delete and create wishlists
* directly from the store front-end.
*/
class WishlistManager {
/**
* Constructs a new WishlistManager instance.
*
* @param {object} options The options argument
* @param {boolean} options.useCache Use cached data when possible
*/
constructor({ useCache = true, cacheDuration = 60 } = {}) {
// Whether to use cache or not
this.useCache = Boolean(useCache);
// Cache entry expiration time, in minutes
this.cacheDuration = Number(cacheDuration);
// List of URL paths
this.urls = {
base: window.location.origin,
all: '/wishlist.php',
add: '/wishlist.php?action=addwishlist',
edit: '/wishlist.php?action=editwishlist',
delete: '/wishlist.php?action=deletewishlist',
view: '/wishlist.php?action=viewwishlistitems',
add_item: '/wishlist.php?action=add',
delete_item: '/wishlist.php?action=remove',
share: '/wishlist.php?action=sharewishlist',
};
// List of events
this.events = {
createWishlist: 'createWishlist',
deleteWishlist: 'deleteWishlist',
updateWishlist: 'updateWishlist',
addWishlistItem: 'addWishlistItem',
deleteWishlistItem: 'deleteWishlistItem',
};
// List of event listeners - keyed by event name
this.eventListeners = {};
// Use "makeRequest" from stencil-utils to make AJAX requests
// eslint-disable-next-line no-undef
this.makeRequest = stencilUtils.api.search.makeRequest;
// Whether the list of all wishlists has been set once
this.wishlistsLoaded = false;
// Wishlists data - keyed by wishlist ID
this.wishlists = {};
}
/**
* Cache warming and initial wishlists fetch
*
* @param {object} context Stencil context
* @param {array} context.wishlists Wishlists
* @param {object} context.urls URLs
*/
async init({ wishlists, urls } = {}) {
// Use wishlists URLs from context
if (urls && urls.account && urls.account.wishlists) {
Object.assign(this.urls, urls.account.wishlists);
}
// Cache warming
if (this.useCache) {
this.getAllWishlistsFromCache();
}
// Initial wishlists fetch, if not available in context
if (Array.isArray(wishlists)) {
this.setWishlists(wishlists);
} else {
await this.getAllWishlists(true);
}
}
/* --- API --- */
/**
* Gets all wishlists
*
* @param {boolean} forceFetch True to ignore the cache, False otherwise
* @return {Promise<array>} All wishlists
*/
async getAllWishlists(forceFetch = false) {
// Try loading from cache
if (!forceFetch && this.useCache && this.wishlistsLoaded) {
const wishlists = this.getAllWishlistsFromCache();
if (wishlists) {
return wishlists;
}
}
const content = await this.request('all');
return content && content.customer && content.customer.wishlists;
}
/**
* Gets a wishlist
*
* @param {integer} wishlistid Wishlist ID
* @param {boolean} forceFetch True to ignore the cache, False otherwise
* @return {Promise<object>} The wishlist
*/
async getWishlist(wishlistid, forceFetch = false) {
// Try loading from cache
if (!forceFetch && this.useCache) {
const wishlist = this.getWishlistFromCache(wishlistid);
if (wishlist && Array.isArray(wishlist.items)) {
return wishlist;
}
}
const content = await this.request(['view', { wishlistid }]);
return content && content.wishlist;
}
/**
* Creates a wishlist
*
* @param {object} params The wishlist attributes
* @param {string} params.name Wishlist name (required)
* @param {boolean} params.is_public Indicates if wishlist is public (required)
* @param {integer} params.product_id ID of a product to be added to the wishlist (optional)
* @return {Promise<object>} The created wishlist
* @fires createWishlist
*/
async createWishlist({ name, is_public, product_id }) {
const urlInfo = ['add'];
if (product_id) {
urlInfo.push({ product_id });
}
const payload = {
submit: '',
wishlistname: name,
};
if (is_public) {
payload.publicwishlist = 'on';
}
const content = await this.request(urlInfo, payload);
let wishlist;
// The response varies according to the presence or absence of product ID in the request
if (product_id) {
wishlist = content && content.wishlist;
} else {
// Get all wishlists with the provided name (the newly created wishlist is not provided separately)
const wishlists = content && content.customer && Array.isArray(content.customer.wishlists)
&& content.customer.wishlists.filter(item => (item.name === name));
// From these, pick the wishlist with highest ID
wishlist = Array.isArray(wishlists)
&& wishlists.reduce((current, next) => (current.id > next.id ? current : next));
}
this.emitEvent(this.events.createWishlist, { wishlist });
return wishlist;
}
/**
* Deletes a wishlist
*
* @param {integer} wishlistid Wishlist ID
* @return {Promise<undefined>}
* @fires deleteWishlist
*/
async deleteWishlist(wishlistid) {
await this.request(['delete', { wishlistid }], {});
this.emitEvent(this.events.deleteWishlist, { wishlistid });
}
/**
* Updates a wishlist
*
* @param {integer} wishlistid Wishlist ID
* @param {object} params The wishlist attributes
* @param {string} params.name Wishlist name (required)
* @param {boolean} params.is_public Indicates if the wishlist is public (required)
* @return {Promise<wishlist>} The updated wishlist
* @fires updateWishlist
*/
async updateWishlist(wishlistid, { name, is_public }) {
const payload = {
submit: '',
wishlistname: name,
};
if (is_public) {
payload.publicwishlist = 'on';
}
const content = await this.request(['edit', { wishlistid }], payload);
const wishlist = content && content.customer && Array.isArray(content.customer.wishlists)
&& content.customer.wishlists.find(item => (item.id === Number(wishlistid)));
this.emitEvent(this.events.updateWishlist, { wishlist });
return wishlist;
}
/**
* Adds a product to a wishlist
*
* @param {integer} wishlistid Wishlist ID
* @param {integer} product_id ID of the product to be added
* @return {Promise<item>} The added item
* @fires addWishlistItem
*/
async addWishlistItem(wishlistid, product_id) {
const content = await this.request(['add_item', { wishlistid, product_id }], {});
const wishlist = content && content.wishlist;
const item = wishlist && Array.isArray(wishlist.items)
&& wishlist.items.find(wishlistItem => (wishlistItem.product_id === Number(product_id)));
this.emitEvent(this.events.addWishlistItem, { wishlist, item });
return item;
}
/**
* Removes a product from a wishlist
*
* @param {integer} wishlistid Wishlist ID
* @param {integer} item_id Wishlist item ID
* @return {Promise<undefined>}
* @fires deleteWishlistItem
*/
async deleteWishlistItem(wishlistid, item_id) {
await this.request(['delete_item', { wishlistid, item_id }], {});
this.emitEvent(this.events.deleteWishlistItem, { wishlistid, item_id });
}
/* --- EXTENDED API --- */
/**
* Gets all wishlists details:
* - retrieves the items for all wishlists
* - provides an object keyed by the wishlists IDs
* - this object is also available as this.wishlists
*
* @return {Promise<object>} All wishlists details.
*/
async getAllWishlistsDetails() {
const wishlists = await this.getAllWishlists();
await Promise.all(wishlists.map(wishlist => this.getWishlist(wishlist.id)));
return Object.values(this.wishlists);
}
/**
* Determines whether the specified product identifier belongs to any wishlist items
*
* @param {integer} product_id Product ID
* @return {Promise<boolean>} True if the specified product ID belongs to any wishlist, False otherwise
*/
async isProductInAnyWishlist(product_id) {
const wishlists = await this.getAllWishlistsDetails();
for (let wishlist, item, i = 0; i < wishlists.length; i++) {
wishlist = wishlists[i];
item = wishlist.items.find(wishlistItem => wishlistItem.product_id === Number(product_id));
if (item) {
return true;
}
}
return false;
}
/* --- REMOTE REQUEST --- */
/**
* Builds URL for the remote requests
* It uses this.urls.base as base, and this.urls[key] as path
*
* Both arguments can be passed as an array, in the first argument
*
* @param {string} key URL key in this.urls object
* @param {object} params Query string parameters to be added
* @return {string} Full URL
*/
getUrl(key, params) {
if (Array.isArray(key)) {
// eslint-disable-next-line no-param-reassign
[key, params] = key;
}
const url = new URL(this.urls[key], this.urls.base);
if (params) {
Object.keys(params).forEach(name => {
url.searchParams.append(name, params[name]);
});
}
return url.toString();
}
/**
* Wrapper around stencil-utils "makeRequest" to perform the remote calls:
* - urlInfo is passed as argument to this.getUrl
* - if payload is undefined, 'GET' method will be used; otherwise, 'POST'
* - templates/components/custom/json-this.html file is required in the theme
* - makeRequest takes care of stencil headers, including the XSRF token
* - returned wishlists data is automatically used to populate this.wishlists
*
* @param {string|array} urlInfo The url information (key and params)
* @param {object} payload The request payload
* @return {Promise<object>} The response object (parsed JSON)
*/
async request(urlInfo, payload) {
const url = this.getUrl(urlInfo);
const method = (typeof payload === 'undefined') ? 'GET' : 'POST';
const options = {
template: 'custom/json-this',
};
if (method === 'POST') {
options.params = payload;
}
return new Promise((resolve, reject) => {
this.makeRequest(url, method, options, false, (error, response) => {
if (error) {
reject(error);
} else {
try {
const content = JSON.parse(response);
if (content && content.customer && Array.isArray(content.customer.wishlists)) {
this.setWishlists(content.customer.wishlists);
}
if (content && content.wishlist && Array.isArray(content.wishlist.items)) {
this.setWishlistDetails(content.wishlist);
}
resolve(content);
} catch (contentError) {
reject(contentError);
}
}
});
});
}
/* --- LOCAL DATA --- */
/**
* Updates this.wishlists with the provided wishlists information
*
* @param {array} wishlists The wishlists data
*/
setWishlists(wishlists) {
// Update this.wishlists
wishlists.forEach(wishlist => this.setWishlistDetails(wishlist));
// Pluck the wishlists IDs as strings
const wishlistids = wishlists.reduce((ids, wishlist) => {
ids.push(wishlist.id);
return ids;
}, []);
// Remove the entries in this.wishlists which does not exist in wishlists
Object.keys(this.wishlists).forEach(wishlistid => {
if (wishlistids.indexOf(Number(wishlistid)) === -1) {
delete this.wishlists[wishlistid];
}
});
this.purgeWishlistsCache(wishlistids);
this.wishlistsLoaded = true;
}
/**
* Updates this.wishlists with the information provided for a single wishlist
*
* @param {object} wishlist The wishlist data
*/
setWishlistDetails(wishlist) {
if (typeof this.wishlists[wishlist.id] === 'undefined') {
this.wishlists[wishlist.id] = wishlist;
} else {
Object.assign(this.wishlists[wishlist.id], wishlist);
}
// If the items array is updated, we take care of updating num_items accordingly
if (Array.isArray(wishlist.items) && (typeof this.wishlists[wishlist.id].num_items === 'number')) {
this.wishlists[wishlist.id].num_items = wishlist.items.length;
}
this.saveWishlistInCache(this.wishlists[wishlist.id]);
}
/* --- CACHE --- */
/**
* Removes deleted entries from wishlists cache
*
* @param {array} wishlistids IDs of the current wishlists to keep
*/
purgeWishlistsCache(wishlistids) {
const keys = Object.keys(localStorage).filter(key => key.startsWith('wishlist-'));
const keyRegex = /^wishlist-(\d+)$/;
keys.forEach(key => {
const match = key.match(keyRegex);
if (match) {
const wishlistid = match[1];
// Remove the cached entry which does not exist in wishlists anymore
if (wishlistids.indexOf(Number(wishlistid)) === -1) {
localStorage.removeItem(key);
}
}
});
}
/**
* Store a wishlist in cache
*
* @param {object} wishlist Wishlist object
*/
saveWishlistInCache(wishlist) {
// Add wishlist to cache
const key = `wishlist-${wishlist.id}`;
const created_at = Date.now();
localStorage.setItem(key, JSON.stringify({ created_at, wishlist }));
}
/**
* Retrieves all stored wishlists from cache
*
* @return {array|undefined} Wishlists
*/
getAllWishlistsFromCache() {
const keys = Object.keys(localStorage).filter(key => key.startsWith('wishlist-'));
const keyRegex = /^wishlist-(\d+)$/;
const wishlists = [];
keys.forEach(key => {
const match = key.match(keyRegex);
if (match) {
const wishlistid = match[1];
const wishlist = this.getWishlistFromCache(wishlistid);
if (wishlist) {
wishlists.push(wishlist);
}
}
});
if (wishlists.length > 0) {
return wishlists;
}
}
/**
* Retrieves a wishlist from cache
*
* @param {integer} wishlistid Wishlist ID
* @return {object|undefined} Wishlist
*/
getWishlistFromCache(wishlistid) {
const key = `wishlist-${wishlistid}`;
const value = localStorage.getItem(key);
let entry;
if (typeof value !== 'string') {
return;
}
try {
entry = JSON.parse(value);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Invalid '${key}' entry at localStorage`);
}
if (entry && entry.created_at && !this.isExpired(entry) && entry.wishlist && entry.wishlist.id) {
this.setWishlistDetails(entry.wishlist);
return entry.wishlist;
}
}
/**
* Determines whether the cache entry is expired
*
* @param {object} entry Cache entry
* @param {number} entry.created_at Timestamp of cache entry creation time
* @return {boolean} True if cache entry is expired, False otherwise
*/
isExpired({ created_at }) {
const now = Date.now();
const ellapsed = now - created_at; // milliseconds since entry creation
const limit = this.cacheDuration * 60 * 1000; // converts from minutes to milliseconds
return (ellapsed > limit);
}
/* --- EVENTS --- */
/**
* Register an event listener
*
* @param {string} name Event name (one of this.events values)
* @param {function} callback The function to run when the event is triggered
*/
on(name, callback) {
if (!Array.isArray(this.eventListeners[name])) {
this.eventListeners[name] = [];
}
if (typeof callback !== 'function') {
throw new Error('Callback must be a function.');
}
this.eventListeners[name].push(callback);
}
/**
* Unregister an event listener
*
* @param {string} name Event name (one of this.events values)
* @param {function} callback The function to be removed from listeners
*/
off(name, callback) {
if (!Array.isArray(this.eventListeners[name])) {
return;
}
const index = this.eventListeners[name].indexOf(callback);
if (index > -1) {
this.eventListeners[name].splice(index, 1);
}
}
/**
* Call the listeners registered for an event
*
* @param {string} name Event name (one of this.events values)
* @param {*} payload The event payload (passed as a single argument)
*/
emitEvent(name, payload) {
if (!Array.isArray(this.eventListeners[name])) {
return;
}
this.eventListeners[name].forEach(listener => {
listener.call(this, payload);
});
}
}
export default WishlistManager;