@ribajs/shopify
Version:
Shopify extension for Riba.js
396 lines (345 loc) • 11.1 kB
text/typescript
import { Component, ScopeBase } from "@ribajs/core";
import {
ShopifyProductVariant,
ShopifyProduct,
ShopifyProductVariantOption,
ShopifyCartService,
ShopifyProductService,
} from "@ribajs/shopify";
import { hasChildNodesTrim } from "@ribajs/utils/src/dom.js";
export interface PreparedProductVariant extends ShopifyProductVariant {
images?: string[];
}
export interface Scope extends ScopeBase {
handle: string | null;
product: ShopifyProduct | null;
variant: PreparedProductVariant | null;
quantity: number;
showDetailMenu: boolean;
// showAddToCartButton: boolean;
chooseOption: ShopifyProductComponent["chooseOption"];
addToCart: ShopifyProductComponent["addToCart"];
toggleDetailMenu: ShopifyProductComponent["toggleDetailMenu"];
decrease: ShopifyProductComponent["decrease"];
increase: ShopifyProductComponent["increase"];
/**
* If the variant is available, used to disable the add to cart button
*/
available: boolean;
}
export class ShopifyProductComponent extends Component {
public static tagName = "shopify-product";
protected autobind = true;
/**
* handle is the product handle to get the product json object
* extras are product data which is only available over liquid and not over the product json object
*/
static get observedAttributes(): string[] {
return ["handle", "extras"];
}
public scope: Scope = {
handle: null,
product: null,
variant: null,
quantity: 1,
showDetailMenu: false,
// showAddToCartButton: false,
chooseOption: this.chooseOption,
addToCart: this.addToCart,
toggleDetailMenu: this.toggleDetailMenu,
decrease: this.decrease,
increase: this.increase,
/**
* If the variant is available, used to disable the add to cart button
*/
available: false,
};
private colorOption: ShopifyProductVariantOption | null = null;
private selectedOptions: string[] = [];
/**
* Is true if the user has chosen an option
*/
private optionChosen = false;
protected set product(product: ShopifyProduct | null) {
if (product) {
this.scope.product = ShopifyProductService.prepare(product);
this.colorOption =
ShopifyProductService.getOption(this.scope.product, "color") || null;
this.variant = this.scope.product.variants[0];
}
}
protected get product(): ShopifyProduct | null {
return this.scope.product;
}
protected set variant(variant: ShopifyProductVariant | null) {
if (variant === null) {
return;
}
this.scope.variant = this.prepareVariant(variant);
if (this.scope.variant) {
this.selectedOptions = this.scope.variant.options.slice();
this.available = this.scope.variant.available;
this.activateOptions();
}
}
protected get variant() {
return this.scope.variant;
}
/**
* available is only true if the variant is available and the user has clicked on an option
*/
protected set available(available: boolean) {
this.scope.available = available && this.optionChosen;
}
constructor() {
super();
this.init(ShopifyProductComponent.observedAttributes);
}
public chooseOption(
optionValue: string | number,
position1: number,
optionName: string,
event: MouseEvent,
) {
if (!this.scope.product) {
throw new Error("Product not set!");
}
optionValue = optionValue.toString();
this.selectedOptions[position1 - 1] = optionValue.toString();
const variant = ShopifyProductService.getVariantOfOptions(
this.scope.product,
this.selectedOptions,
);
if (variant) {
// Option chosen so enable add to cart button
this.optionChosen = true;
this.variant = variant as ShopifyProductVariant;
}
event.stopPropagation();
}
public addToCart() {
if (!this.variant) {
return;
}
ShopifyCartService.add(this.variant.id, this.scope.quantity)
.then((response) => {
console.debug("addToCart response", response);
})
.catch((error) => {
console.error("addToCart error", error);
});
}
public toggleDetailMenu() {
this.scope.showDetailMenu = !this.scope.showDetailMenu;
}
public increase() {
this.scope.quantity++;
}
public decrease() {
this.scope.quantity--;
if (this.scope.quantity <= 0) {
this.scope.quantity = 1;
}
}
/**
* Workaround because `rv-class-active="isOptionActive | call size"` is not updating if selectedOptions changes
* @param optionValue
* @param optionName
*/
protected activateOption(optionValue: string, optionName: string) {
optionValue = optionValue.toString().replace("#", "");
this.querySelector<HTMLElement>(
`.option-${optionName.toLocaleLowerCase()}`,
)?.classList.remove("active");
this.querySelector<HTMLElement>(
`.option-${optionName.toLocaleLowerCase()}-${optionValue}`,
)?.classList.add("active");
}
/**
* Activate option by selected options (scope.selectedOptions)
* This method sets the active class to the options elements
*/
protected activateOptions() {
for (const position0 in this.selectedOptions) {
if (this.selectedOptions[position0]) {
const optionValue = this.selectedOptions[position0];
if (this.scope.product) {
const optionName = this.scope.product.options[position0].name;
// Only activate size if it was clicked by the user
if (optionName === "size") {
if (this.optionChosen) {
this.activateOption(optionValue, optionName);
}
} else {
this.activateOption(optionValue, optionName);
}
}
}
}
}
protected async beforeBind() {
await super.beforeBind();
if (this.scope.handle === null) {
throw new Error("Product handle not set");
}
return ShopifyProductService.get(this.scope.handle).then(
(product: ShopifyProduct) => {
this.product = product;
},
);
}
protected async afterBind() {
this.activateOptions();
await super.afterBind();
}
protected requiredAttributes(): string[] {
return ["handle"];
}
protected async template() {
// Only set the component template if there no childs already
if (this && hasChildNodesTrim(this)) {
return null;
} else {
const { default: template } =
await import("./product.component.html?raw");
return template;
}
}
/**
* custom version of images.indexOf but compares without protocol and query string in url
* @param images
* @param findImage
*/
private indexOfUrl(images: string[], findImage: string) {
let index = -1;
const clearFindImage = findImage
.split("?")[0] // remove query string
.replace(/(^\w+:|^)\/\//, "//"); // remove protocol
images.forEach((image, i) => {
const clearImage = image
.split("?")[0] // remove query string
.replace(/(^\w+:|^)\/\//, "//"); // remove protocol
if (clearImage === clearFindImage) {
index = i;
}
});
return index;
}
/**
* Get images which are not linked to any variant
*/
private getGeneralImages(optionName = "color") {
optionName = optionName.toLowerCase();
const generalImages: string[] = [];
if (this.scope.product) {
// add images without optionName in filename
this.scope.product.images.forEach((image: string) => {
if (!image.toLowerCase().includes(`${optionName}-`)) {
generalImages.push(image);
}
});
// remove variant images from copied array
this.scope.product.variants.forEach((variant: ShopifyProductVariant) => {
let index = -1;
if (variant.featured_image !== null && variant.featured_image.src) {
index = this.indexOfUrl(generalImages, variant.featured_image.src);
}
if (index >= 0) {
generalImages.splice(index, 1);
}
});
}
return generalImages;
}
/**
* Get options images (without featured image) filtered by filename.
* Shopify only supports one image per variant, with this function more images for each variant are possible.
* The image filename must include {optionName}-{optionValue} for that.
*/
private getOptionImages(
option: ShopifyProductVariantOption,
optionValue: string,
) {
optionValue = optionValue.toLowerCase().replace("#", "_");
const optionName = option.name.toLowerCase();
const optionImages: string[] = [];
if (this.scope.product) {
this.scope.product.images.forEach((image: string) => {
if (image.toLowerCase().includes(`${optionName}-${optionValue}`)) {
optionImages.push(image);
}
});
}
return optionImages;
}
/**
* Get featured images of variant, use the first option image or the featured product image as fallback
*/
private getFeaturedImage(variant: PreparedProductVariant) {
if (variant.featured_image !== null) {
variant.featured_image.src = variant.featured_image.src.replace(
/(^\w+:|^)\/\//,
"//",
); // remove protocol
return variant.featured_image;
}
let fallbackImageSrc = "";
if (variant.images && variant.images.length > 0) {
fallbackImageSrc = variant.images[0];
} else if (this.scope.product) {
fallbackImageSrc = this.scope.product.featured_image;
}
if (!fallbackImageSrc) {
return null;
}
// remove protocol for normalization
fallbackImageSrc = fallbackImageSrc.replace(/(^\w+:|^)\/\//, "//");
// If variant has no image use the default product image
if (this.scope.product) {
const featuredImage = {
src: fallbackImageSrc,
position: 0,
product_id: this.scope.product.id,
variant_ids: [] as PreparedProductVariant["id"][],
alt: this.scope.product.title,
created_at: this.scope.product.created_at,
height: 0,
width: 0,
id: 0,
updated_at: this.scope.product.created_at,
};
return featuredImage;
}
throw new Error("image not found");
}
/**
* prepare variant, e.g. fix missing image etc
* @param variant
*/
private prepareVariant(variant: PreparedProductVariant) {
if (variant === null) {
console.warn("Warn: Variant is null!");
return null;
}
if (this.colorOption) {
variant.images = this.getOptionImages(
this.colorOption,
variant.options[this.colorOption.position - 1],
);
} else {
console.warn("Warn: colorOption not defined");
variant.images = [];
}
variant.featured_image = this.getFeaturedImage(variant);
if (variant.images && variant.featured_image) {
// Remove featured image so that it does not appear twice
const i = this.indexOfUrl(variant.images, variant.featured_image.src);
if (i >= 0) {
variant.images.splice(i, 1);
}
// add general images
variant.images = variant.images.concat(this.getGeneralImages());
}
return variant;
}
}