UNPKG

@ribajs/shopify

Version:

Shopify extension for Riba.js

542 lines (486 loc) 16.8 kB
import { HttpService } from "@ribajs/core"; import { EventDispatcher } from "@ribajs/events"; import { isObject, clone, getNumber } from "@ribajs/utils/src/type.js"; import { PQueue } from "./p-queue.service.js"; // https://github.com/sindresorhus/p-queue import { ShopifyCartLineItem, ShopifyCartUpdateProperty, ShopifyCartAddError, ShopifyCartObject, ShopifyCustomerAddress, ShopifyShippingRates, ShopifyShippingRate, ShopifyShippingRatesNormalized, } from "../interfaces/index.js"; export interface ShopifyCartRequestOptions { triggerOnStart: boolean; triggerOnComplete: boolean; triggerOnChange: boolean; } export class ShopifyCartService { public static queue = new PQueue({ concurrency: 1 }); public static cart: ShopifyCartObject | null = null; public static shopifyCartEventDispatcher = new EventDispatcher("ShopifyCart"); /** * Use this method to force an update of the shopping cart object, e.g. if the shopping cart was updated outside Riba. * @param options */ public static async updateExtern( options: ShopifyCartRequestOptions = this.requestOptionDefaults, ) { if (options.triggerOnStart) { this.triggerOnStart(); } const newCart = await this.refresh(); const oldCart = this.cart; this.cart = newCart; if (options.triggerOnChange) { if ( oldCart?.total_price !== newCart?.total_price || oldCart?.item_count !== oldCart?.item_count ) { this.triggerOnChange(this.cart); } } if (options.triggerOnComplete) { this.triggerOnComplete(); } } /** * Use this to add a variant to the cart. * @param id Variant id * @param quantity Quantity * @param properties Additional properties * @return Response if successful, the JSON of the line item associated with the added variant. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#add-to-cart */ public static async add( id: number, quantity = 1, properties = {}, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartLineItem | ShopifyCartAddError> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(async () => { const body: any = { id, quantity }; if (Object.keys(properties).length !== 0) { body.properties = properties; } const lineItemRes = await HttpService.post< ShopifyCartLineItem | ShopifyCartAddError >(this.CART_POST_ADD_URL, body, "json"); if (lineItemRes.status >= 400) { throw lineItemRes.body as ShopifyCartAddError; } const lineItem = lineItemRes.body; // Force update cart object const cartRes = await HttpService.get<ShopifyCartObject>( this.CART_GET_URL, {}, "json", ); if (cartRes.status >= 400) { throw lineItemRes.body as ShopifyCartAddError; } const cart = cartRes.body; if (options.triggerOnChange) { this.triggerOnChange(cart); } this.triggerAdd(id, quantity, properties); return lineItem; // return original response }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } public static async refresh(): Promise<ShopifyCartObject> { const cartRes = await HttpService.get<ShopifyCartObject>( this.CART_GET_URL, {}, "json", ); let cart: ShopifyCartObject; if (typeof cartRes.body === "string") { cart = JSON.parse(cartRes.body); } else { cart = cartRes.body; } return cart; } public static _get(): Promise<ShopifyCartObject> { if (ShopifyCartService.cart !== null) { return new Promise((resolve /*, reject*/) => { setTimeout(() => { if (ShopifyCartService.cart !== null) { return resolve(ShopifyCartService.cart); } else { return this._get(); } }, 0); }); } return ShopifyCartService.refresh(); } /** * Use this to get the cart as JSON. * @param data * @return The JSON of the cart. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#get-cart */ public static get( options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(() => { return this._get(); }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } /** * Use this to change cart attributes, the cart note, and quantities of line items in the cart. * @param id Variant ID * @param quantity Quantity * @param properties Additional properties * @return Response The JSON of the cart. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#update-cart */ public static update( id: number | number, quantity: number, properties = {}, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue .add(() => { const body: any = { id, quantity }; if (Object.keys(properties).length !== 0) { body.properties = properties; } return HttpService.post(this.CART_POST_UPDATE_URL, body, "form"); }) // because type is form we need to parse the json response by self .then((cart: string) => { return JSON.parse(cart); }) .then((cart: ShopifyCartObject) => { if (options.triggerOnChange) { this.triggerOnChange(cart); } return cart; }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } /** * Use this to change cart attributes, the cart note, and quantities of line items in the cart. * @param id Variant ID * @param quantity Quantity * @param properties Additional properties * @return Response The JSON of the cart. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#update-cart */ public static updates( updates: ShopifyCartUpdateProperty | Array<number>, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue .add(() => { return HttpService.post( this.CART_POST_UPDATE_URL, { updates, }, "form", ); }) // because type is form we need to parse the json response by self .then((cart: string) => { return JSON.parse(cart); }) .then((cart: ShopifyCartObject) => { if (options.triggerOnChange) { this.triggerOnChange(cart); } return cart; }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } /** * This call sets the quantity of an item already in the cart. * * Although /cart/update.js and /cart/change.js may seem like they accomplish the same function, * they truly are quite different. The /cart/update.js controller allows updates to several items * at once, including items that may not yet be in the cart (it will add them), and it also allows * updates of cart attributes and the cart note. The /cart/change.js controller is only able to * update the quantity of one item at a time, and that item must be in the cart already. If the * item is not in the cart, /cart/change.js will not add it and it will then return a 404 error. * Whereas the /cart/update.js controller updates no quantity when any of the requested update * cannot be met, the /cart/change.js controller, on the other hand, will adjust the quantity to * add all items in stock if what is requested is greater than what's available. Use your browser's * JavaScript console to test things out if you're not sure about the behavior of the different request URLs. * * @param id Variant ID * @param quantity Quantity * @param properties Additional properties * @return Response The JSON of the cart. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#change-cart */ public static async change( id: number | number, quantity: number, properties = {}, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(async () => { const body: any = { id, quantity }; if (Object.keys(properties).length !== 0) { body.properties = properties; } const cartRes = await HttpService.post<ShopifyCartObject | string>( this.CART_POST_CHANGE_URL, body, "form", ); let cart: ShopifyCartObject; // Because type is form we need to parse the json response by self if (typeof cartRes.body === "string") { cart = JSON.parse(cartRes.body); } else { cart = cartRes.body; } if (options.triggerOnChange) { this.triggerOnChange(cart); } return cart; }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } /** * If you use Line Item Properties you may end up with several items in the cart that share the same variant ID. * How do you update the quantity of an item in the cart that has specific line item properties? * Once you have identified the 1-based index of the item in the cart, you can use the line property instead of id. * @param line -based index of the item in the cart * @param quantity Quantity * @param properties Additional properties * @return Response The JSON of the cart. */ public static async changeLine( line: string | number, quantity: number, properties = {}, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(async () => { const body: any = { line, quantity }; if (Object.keys(properties).length !== 0) { body.properties = properties; } const cartRes = await HttpService.post<ShopifyCartObject | string>( this.CART_POST_CHANGE_URL, body, "form", ); let cart: ShopifyCartObject; // Because type is form we need to parse the json response by self if (typeof cartRes.body === "string") { cart = JSON.parse(cartRes.body); } else { cart = cartRes.body; } if (options.triggerOnChange) { this.triggerOnChange(cart); } return cart; }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise as Promise<ShopifyCartObject>; } /** * This call sets all quantities of all line items in the cart to zero. * @return The JSON of an empty cart. This does not remove cart attributes nor the cart note. * @return Response The JSON of an empty cart. This does not remove cart attributes nor the cart note. * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#clear-cart */ public static async clear( options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyCartObject> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(async () => { const cartRes = await HttpService.post<ShopifyCartObject>( this.CART_POST_CLEAR_URL, {}, "form", ); let cart: ShopifyCartObject; // Because type is form we need to parse the json response by self if (typeof cartRes.body === "string") { cart = JSON.parse(cartRes.body); } else { cart = cartRes.body; } if (options.triggerOnChange) { this.triggerOnChange(cart); } return cart; }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } public static async _getShippingRates( shippingAddress: ShopifyCustomerAddress, normalize = true, ): Promise<ShopifyShippingRates | ShopifyShippingRatesNormalized> { const res = await HttpService.get<{ shipping_rates: ShopifyShippingRates }>( this.CART_GET_SHIPPING_RATES_URL, { shipping_address: shippingAddress }, "json", ); const shippingRates = res.body; if (isObject(shippingRates) && isObject(shippingRates.shipping_rates)) { if (normalize) { return this.normalizeShippingRates(shippingRates.shipping_rates); } return shippingRates.shipping_rates as ShopifyShippingRates; } else { throw new Error( "shipping_rates property not found: " + JSON.stringify(shippingRates), ); } } /** * Get estimated shipping rates. * @param shippingAddress TODO: /cart/shipping_rates.json?shipping_address[zip]=K1N 5T2&shipping_address[country]=Canada&shipping_address[province]=Ontario * @see https://help.shopify.com/en/themes/development/getting-started/using-ajax-api#get-shipping-rates */ public static getShippingRates( shippingAddress: ShopifyCustomerAddress, normalize = true, options: ShopifyCartRequestOptions = this.requestOptionDefaults, ): Promise<ShopifyShippingRates | ShopifyShippingRatesNormalized> { if (options.triggerOnStart) { this.triggerOnStart(); } const promise = this.queue.add(() => { return this._getShippingRates(shippingAddress, normalize); }); if (options.triggerOnComplete) { this.triggerOnComplete(); } return promise; } protected static CART_POST_ADD_URL = "/cart/add.js"; protected static CART_GET_URL = "/cart.js"; protected static CART_POST_UPDATE_URL = "/cart/update.js"; protected static CART_POST_CHANGE_URL = "/cart/change.js"; protected static CART_POST_CLEAR_URL = "/cart/clear.js"; protected static CART_GET_SHIPPING_RATES_URL = "/cart/shipping_rates.json"; protected static requestOptionDefaults = { triggerOnStart: true, triggerOnComplete: true, triggerOnChange: true, }; protected static waitForComplete = false; /** * Trigger `ShopifyCart:request:complete`, if queue is already pending no noting (in this case we already looking for onIdle) */ protected static triggerOnComplete() { if (!this.waitForComplete) { this.waitForComplete = true; return this.queue.onIdle().then(() => { ShopifyCartService.shopifyCartEventDispatcher.trigger( "ShopifyCart:request:complete", this.cart, ); this.waitForComplete = false; }); } } /** * TODO check if cart values are changed * @param cart The cart object */ protected static triggerOnChange(cart: ShopifyCartObject) { this.cart = cart; ShopifyCartService.shopifyCartEventDispatcher.trigger( "ShopifyCart:request:changed", this.cart, ); } /** * Trigger `ShopifyCart:request:start`, if not already triggered */ protected static triggerOnStart() { if (this.queue.pending > 0) { return; } ShopifyCartService.shopifyCartEventDispatcher.trigger( "ShopifyCart:request:start", ); } /** * Trigger `ShopifyCart:add` */ protected static triggerAdd(id: number, quantity: number, properties: any) { ShopifyCartService.shopifyCartEventDispatcher.trigger("ShopifyCart:add", { id, quantity, properties, }); } protected static normalizeShippingRates( shippingRates: ShopifyShippingRates, ): ShopifyShippingRatesNormalized { const normalized = new Array<any>(shippingRates.length); for (const i in shippingRates) { if (shippingRates[i]) { const shippingRate = shippingRates[i]; normalized[i] = clone<ShopifyShippingRate>(false, shippingRate); if (normalized[i] && normalized[i].price) { normalized[i].price = getNumber(normalized[i].price); if (normalized[i].price) { normalized[i].price *= 100; } else { console.warn(`Can't parse "${normalized[i].price}" to number`); } } else { console.warn(`price property not defined`, normalized[i]); } } } return normalized as ShopifyShippingRatesNormalized; } }