@ribajs/shopify
Version:
Shopify extension for Riba.js
542 lines (486 loc) • 16.8 kB
text/typescript
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;
}
}