commercetools-8.x-1.2-alpha1/modules/commercetools_decoupled/js/libs/commercetoolsApi.js
modules/commercetools_decoupled/js/libs/commercetoolsApi.js
/**
* @file
* Contains the Commercetools API integration.
*/
const config = drupalSettings.commercetoolsDecoupled;
if (!config.projectKey) {
const message =
'The commercetools project key is not configured on the <a href="/admin/config/system/commercetools">commercetools settings page</a>.';
new Drupal.Message().add(`Error: ${message}`, { type: 'error' });
throw new Error(message);
}
const { ClientBuilder } = window['@commercetools/sdk-client-v2'];
const { createApiBuilderFromCtpClient } = window['@commercetools/platform-sdk'];
const { GraphQLToolkit } = window;
const enabledClientApiFE = !!config?.feApiAuthorizationHeader;
// The commercetools API client for Drupal-handled requests (back-end).
let apiClientBE;
const buildApiClientBE = () => {
const client = new ClientBuilder()
.withProjectKey(config.projectKey)
.withHttpMiddleware({
host: Drupal.url(config.apiUrl.substring(1)),
})
.build();
return createApiBuilderFromCtpClient(client).withProjectKey({
projectKey: config.projectKey,
});
};
// The commercetools API client for direct front-end requests.
let apiClientFE;
const buildApiClientFE = () => {
const client = new ClientBuilder()
.withProjectKey(config.projectKey)
.withExistingTokenFlow(config.feApiAuthorizationHeader, { force: true })
.withHttpMiddleware({
host: `https://api.${config.apiRegion}.commercetools.com`,
httpClient: fetch,
})
.build();
return createApiBuilderFromCtpClient(client).withProjectKey({
projectKey: config.projectKey,
});
};
const fetchFeAuthAndRebuildClient = async () => {
const res = await fetch('/api/commercetools/fe-auth');
const data = await res.json();
config.feApiAuthorizationHeader = data.feApiAuthorizationHeader;
buildApiClientFE();
};
/**
* Get a commercetools API instance.
* mode: 'fe' | 'be'
*/
const getApiClient = (mode) => {
if (mode === 'be') {
if (!apiClientBE) apiClientBE = buildApiClientBE();
return apiClientBE;
}
if (mode === 'fe') {
if (!apiClientFE) apiClientFE = buildApiClientFE();
return apiClientFE;
}
};
const commercetools = {
/**
* A configuration settings from back-end.
*
* @type {Object}
*/
settings: config,
/**
* Product list results.
*
* @type {Object}
*/
productListResult: {},
/**
* A list of URL GET parameters managed by commercetools related components.
*
* @type {Array}
*/
urlParamsList: [
'text',
'filters',
'queryFilters',
'facetFilters',
'sorts',
'search',
'facets',
'category',
],
/**
* Executes a GraphQL operation.
*
* @param {string} operation
* A GraphQL operation: query, mutation, etc.
* @param {object} variables
* A GraphQL operation variables.
* @param {object} options
* Additional options.
*
* @return {Promise<Object>}
* The result of the GraphQL operation
*/
executeGraphqlOperation(
operation,
variables,
options = { allowErrors: false },
allowDirectRequest = false,
) {
const isDirectRequest = allowDirectRequest === true && enabledClientApiFE;
const api = isDirectRequest ? getApiClient('fe') : getApiClient('be');
const result = api
.graphql()
.post({
body: { query: operation, variables },
})
.execute()
.catch(async (error) => {
if (isDirectRequest && this.isAuthError(error)) {
await fetchFeAuthAndRebuildClient();
return this.executeGraphqlOperation(
operation,
variables,
options,
allowDirectRequest,
);
}
const message =
error.body?.errors?.[0]?.message ||
'An unexpected error occurred when rendering the commercetools component';
new Drupal.Message().add(message.replace(/\n/g, '<br/>'), {
type: 'error',
});
throw error;
})
.then((result) => {
if (!options.allowErrors && result.errors) {
result.errors.forEach((error) => {
// We need to output to console here.
// eslint-disable-next-line no-console
console.error(error);
new Drupal.Message().add(error.replace(/\n/g, '<br/>'), {
type: 'error',
});
});
result.body.errors = result.errors;
}
if (result.warnings) {
result.warnings.forEach((warning) => {
// We need to output to console here.
// eslint-disable-next-line no-console
console.warn(warning);
});
result.body.warnings = result.warnings;
}
return result.body;
});
return result;
},
/**
* Checks whether an error represents an authorization/authentication failure.
*/
isAuthError(error) {
const code = error?.statusCode ?? error?.code;
return code === 401 || code === 403;
},
/**
* Retrieves a list of products.
*
* @param {string} queryType
* There are two types for getting products:
* - products: use products query type.
* - productProjectionSearch: use product search query type.
* @param {object} args
* Here the following object properties are used:
* (Only for productProjectionSearch).
* - sorts: An associative array of sort options to apply to the query.
* - facets: An associative array of facets to add to the query.
* - queryFilters: An associative array of filters to apply to the query.
* (Only for products).
* - sort: An associative array of sort options to apply to the query.
* - where: An associative array of filters to apply to the query.
* (For all types)
* - offset: The number of products to skip before starting to collect the
* result set. Defaults to 0.
* - limit: The maximum number of products to return. Defaults to 10.
* @param {bool} includeDetails
* Whether to include detailed product information. Defaults to NULL.
*
* @return {Array} An array of products.
*/
getProducts(queryType = 'products', args = {}, includeDetails = false) {
const query =
queryType === 'products'
? this.getProductsQuery()
: this.getProductsSearchQuery();
this.addProductFieldsToQuery(query[queryType], includeDetails, queryType);
query.$variables = {
...query.$variables,
...{
$languages: '[Locale!]',
$country: 'Country',
$currency: 'Currency!',
$limit: 'Int',
$offset: 'Int',
},
};
const variables = {
languages: this.settings.languages,
country: this.settings.priceCountry,
currency: this.settings.priceCurrency,
limit: args.limit || 9,
offset: args.offset || 0,
facets: args.facets || [],
sort: args.sort || [],
sorts: args.sorts || [],
queryFilters: args.queryFilters || [],
where: args.where || null,
text: args.text || null,
locale: args.text ? args.locale || this.settings.locale : null,
};
const operation = GraphQLToolkit.generateQuery({ query });
return this.executeGraphqlOperation(operation, variables, {}, true).then(
(result) => {
console.log(result);
return {
total: result.data[queryType].total,
facets: result.data[queryType].facets,
products: result.data[queryType].results.map((productData) => {
return this.masterDataToProduct(productData, queryType);
}),
};
},
);
},
/**
* Retrieves a product by its slug.
*
* @param {string} slug
* The slug of the product.
*
* @return {Object|null}
* The product object.
*/
getProductBySlug(slug) {
const { languages } = this.settings;
const predicate = `${languages.join(`="${slug}" or `)}="${slug}"`;
const where = `masterData(current(slug(${predicate})))`;
return this.getProducts('products', { where, limit: 1 }, true).then(
(result) => {
return result.products.length ? result.products[0] : null;
},
);
},
/**
* Retrieves a list of categories.
*
* @param {Number} limit
* The maximum results number.
* @param {Array} sort
* The sort order predicate.
*
* @return {Promise<{total: Number, categories: Array}>}
* A list of categories as array.
*/
getCategories(limit = 500, sort = ['orderHint ASC']) {
const query = {
$name: 'categories',
categories: {
$args: {
limit: { $var: '$limit' },
sort: { $var: '$sort' },
},
total: true,
results: {
id: true,
key: true,
orderHint: true,
name: { $args: { acceptLanguage: { $var: '$languages' } } },
parent: { id: true },
},
},
};
query.$variables = {
$limit: 'Int',
$sort: '[String!]',
$languages: '[Locale!]',
};
const variables = {
limit,
sort,
languages: this.settings.languages,
};
const operation = GraphQLToolkit.generateQuery({ query });
return this.executeGraphqlOperation(operation, variables, {}, true).then(
(result) => {
return {
total: result.data.categories.total,
categories: result.data.categories.results,
};
},
);
},
/**
* Builds product categories tree.
*
* @param {Array} categories
* The list of categories.
* @param {Array|null} activeCategoryId
* Active category id.
*
* @return {Array}
* A tree of categories.
*/
buildCategoriesTree(categories, activeCategoryId = null) {
if (activeCategoryId) {
let activeCategoryIndex = categories.findIndex(
(category) => category.id === activeCategoryId,
);
if (activeCategoryIndex !== -1) {
categories[activeCategoryIndex].is_active = true;
do {
categories[activeCategoryIndex].in_active_trail = true;
activeCategoryId =
categories[activeCategoryIndex]?.parent?.id ?? null;
activeCategoryIndex = categories.findIndex(
// eslint-disable-next-line no-loop-func
(category) => category.id === activeCategoryId,
);
} while (activeCategoryIndex !== -1);
}
}
const fillChildren = (parentId = null) =>
categories
.filter((category) => (category.parent?.id ?? null) === parentId)
.map((category) => ({
...category,
children: fillChildren(category.id),
}));
return Object.values(fillChildren()).filter((cat) => !cat.parent?.id);
},
/**
* Constructs a GraphQL query for retrieving products.
*
* @return {Object} The constructed GraphQL query.
*/
getProductsQuery: () => {
const query = {
$name: 'products',
products: {
$args: {
limit: { $var: '$limit' },
offset: { $var: '$offset' },
where: { $var: '$where' },
sort: { $var: '$sort' },
},
total: true,
},
};
query.$variables = {
$where: 'String',
$sort: '[String!]',
};
return query;
},
/**
* Constructs a GraphQL query for the search products.
*
* @return {Object}
* The constructed GraphQL query.
*/
getProductsSearchQuery: () => {
const query = {
$name: 'productsProjectionSearch',
productProjectionSearch: {
$args: {
limit: { $var: '$limit' },
offset: { $var: '$offset' },
sorts: { $var: '$sorts' },
facets: { $var: '$facets' },
queryFilters: { $var: '$queryFilters' },
text: { $var: '$text' },
locale: { $var: '$locale' },
},
total: true,
facets: {
facet: true,
value: {
$fragments: [
{
inline: {
$on: 'TermsFacetResult',
terms: {
term: true,
productCount: true,
},
},
},
{
inline: {
$on: 'RangeFacetResult',
ranges: {
$fragments: [
{
inline: {
$on: 'RangeCountDouble',
min: true,
max: true,
},
},
],
},
},
},
],
},
},
},
};
query.$variables = {
$facets: '[SearchFacetInput!]',
$queryFilters: '[SearchFilterInput!]',
$text: 'String',
$locale: 'Locale',
$sorts: '[String!]',
};
return query;
},
/**
* Adds product fields to a GraphQL object.
*
* @param {object} queryProductsSection
* The GraphQL object.
* @param {boolean} details
* Whether to include detailed product information
* @param {string} queryType
* The query type of GraphQL object.
*/
addProductFieldsToQuery(
queryProductsSection,
details = false,
queryType = 'products',
) {
let root = {};
let product = root;
if (queryType === 'products') {
root = { masterData: { current: {} } };
product = root.masterData.current;
}
root.id = true;
root.productType = {
id: true,
key: true,
name: true,
};
Object.assign(product, {
name: { $args: { acceptLanguage: { $var: '$languages' } } },
slug: { $args: { acceptLanguage: { $var: '$languages' } } },
masterVariant: this.addProductVariantFields(),
});
if (details) {
product.description = {
$args: { acceptLanguage: { $var: '$languages' } },
};
product.metaTitle = {
$args: { acceptLanguage: { $var: '$languages' } },
};
product.metaDescription = {
$args: { acceptLanguage: { $var: '$languages' } },
};
product.metaKeywords = {
$args: { acceptLanguage: { $var: '$languages' } },
};
product.variants = this.addProductVariantFields();
}
queryProductsSection.results = root;
},
/**
* Adds variant fields to a product variant node.
*
* @return {Object}
* Product variant fields.
*/
addProductVariantFields: () => {
const includeAttributes = [];
Object.keys(drupalSettings.commercetoolsDecoupled.attributes).forEach(
(productType) => {
drupalSettings.commercetoolsDecoupled.attributes[productType].forEach(
(attribute) => {
includeAttributes.push(attribute.name);
},
);
},
);
return {
id: true,
sku: true,
images: {
url: true,
label: true,
},
attributesRaw: includeAttributes.length
? {
$args: {
includeNames: includeAttributes,
},
name: true,
value: true,
}
: false,
availability: {
noChannel: { availableQuantity: true },
},
price: {
$args: {
country: { $var: '$country' },
currency: { $var: '$currency' },
},
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
discounted: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
},
};
},
/**
* Converts the masterData object to a product object.
*
* @param {Object} productData
* The product data object.
* @param {string} queryType
* The query type of GraphQL object.
*
* @return {Object}
* The product object.
*/
masterDataToProduct(productData, queryType = 'products') {
if (!['products', 'productProjectionSearch'].includes(queryType)) {
throw new Error('The query type is not supported.');
}
if (queryType === 'products') {
const masterData = productData.masterData.current;
delete productData.masterData;
productData = { ...productData, ...masterData };
}
productData.masterVariant = this.variantDataToVariant(
productData.masterVariant,
productData.productType.key,
);
if (productData.variants) {
productData.variants = productData.variants.map((variant) =>
this.variantDataToVariant(variant, productData.productType.key),
);
}
return productData;
},
/**
* Converts the raw variant data response to a simplified variant array.
*
* @param {array} variant
* The variant data variant array.
* @param {string} productType
* The product type.
*
* @return {array}
* The converted variant array.
*/
variantDataToVariant(variant, productType) {
const price = !variant.price
? {
localizedPrice:
window.drupalSettings.commercetoolsDecoupled.unavailableDataText,
}
: {
...variant.price.value,
...{ localizedPrice: this.formatPrice(variant.price.value) },
};
if (variant?.price?.discounted) {
price.discounted = {
...variant.price.discounted.value,
...{ localizedPrice: this.formatPrice(variant.price.discounted.value) },
};
}
variant.price = price;
variant.attributes = this.attributesDataToAttributes(
variant.attributesRaw || [],
productType,
);
delete variant.attributesRaw;
return variant;
},
/**
* Builds a filter object for Commercetools queries.
* @param {string} path
* The path to filter on (e.g., 'categories.id', 'variants.sku').
* @param {Array} values
* The values to filter by.
* @param {string} type
* The filter option. Optional. Default is 'value'.
*
* @return {Object}
* The filter structure compatible with Commercetools.
*/
buildFilter(path, values, type = 'value') {
// @todo: Add support for other matching options: range, missing, exists, string.
switch (type) {
case 'tree':
return {
model: {
tree: {
path,
rootValues: [],
subTreeValues: Array.isArray(values) ? [...values] : [values],
},
},
};
case 'range': {
return {
model: {
range: {
path,
ranges: values,
},
},
};
}
default:
return {
model: {
value: {
path,
values: Array.isArray(values) ? [...values] : [values],
},
},
};
}
},
/**
* Converts the attributes data response to a simplified attribute array.
*
* @param {array} attrs
* The attributes data attributes array.
* @param {string} productType
* The product type.
*
* @return {array}
* The converted attributes array.
*/
attributesDataToAttributes(attrs, productType) {
const productTypeDefinition =
drupalSettings.commercetoolsDecoupled.attributes[productType] || [];
const variantAttrs = {};
attrs.forEach((attr) => {
const attrDefinition = productTypeDefinition.find(
(attrItem) => attrItem.name === attr.name,
);
switch (attrDefinition.type) {
case 'enum':
attr.labelValue = attr.value.label;
attr.value = attr.value.key;
break;
case 'lenum':
attr.labelValue =
typeof attr.value === 'undefined'
? null
: this.getTranslationValue(attr.value);
attr.value = attr.value.key;
break;
case 'ltext': {
const value =
typeof attr.value === 'undefined'
? null
: this.getTranslationValue(attr.value);
attr.value = value;
attr.labelValue = value;
break;
}
default:
attr.labelValue = attr.value;
break;
}
attr.label = attrDefinition.label;
variantAttrs[attr.name] = attr;
});
return variantAttrs;
},
/**
* Get the cart object
*
* @param {object} args
* Here the following object properties are used:
* - id: Cart ID
*
* @return {Promise<Array>}
* The cart object.
*/
getCart(args = {}) {
const query = {
$name: 'getCart',
cart: {
$args: {
id: { $var: '$id' },
},
...this.addCartFieldsToQuery(),
},
};
query.$variables = {
$id: 'String!',
$languages: '[Locale!]',
$country: 'Country',
$currency: 'Currency!',
};
const variables = {
id: args.id || '[current_cart:id]',
languages: this.settings.languages,
country: this.settings.priceCountry,
currency: this.settings.priceCurrency,
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ query }),
variables,
)
.then((result) => {
return !result?.data.cart ? {} : this.cartDataToCart(result.data.cart);
});
},
/**
* Update cart.
*
* @param {object} args
* Here the following object properties are used:
* - id: Cart ID
* - version: Cart version
* - actions: An array of actions.
*
* @return {Promise<Array>}
* The cart object.
*/
updateCart(args = { allowErrors: false }) {
const mutation = {
$name: 'updateCart',
updateCart: {
$args: {
id: { $var: '$id' },
version: { $var: '$version' },
actions: { $var: '$actions' },
},
...this.addCartFieldsToQuery(),
},
};
mutation.$variables = {
$id: 'String',
$version: 'Long!',
$actions: '[CartUpdateAction!]!',
$languages: '[Locale!]',
$country: 'Country',
$currency: 'Currency!',
};
const variables = {
id: args.id || '[current_cart_or_create:id]',
version: args.version || '[current_cart_or_create:version]',
actions: args.actions || [],
languages: this.settings.languages,
country: this.settings.priceCountry,
currency: this.settings.priceCurrency,
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ mutation }),
variables,
{ allowErrors: args.allowErrors },
)
.then((result) => {
if (args.allowErrors && result?.errors) {
return result;
}
if (result?.data?.updateCart) {
return this.cartDataToCart(result.data.updateCart);
}
return {};
});
},
/**
* Create order form the cart.
*
* @param {object} args
* Here the following object properties are used:
* - id: Cart ID
* - version: Cart version
*
* @return {Promise<Array>}
* The order object.
*/
createOrderFromCart(args = {}) {
const mutation = {
$name: 'createOrderFromCart',
createOrderFromCart: {
$args: {
draft: { $var: '$draft' },
},
...this.addOrderFieldsToQuery(false),
},
};
mutation.$variables = {
$draft: 'OrderCartCommand!',
$languages: '[Locale!]',
$country: 'Country',
$currency: 'Currency!',
};
const variables = {
draft: {
id: args.id || '[current_cart:id]',
version: args.version || '[current_cart:version]',
orderNumber: '[order_number:next]',
orderState: 'Open',
paymentState: 'Pending',
shipmentState: 'Pending',
},
languages: this.settings.languages,
country: this.settings.priceCountry,
currency: this.settings.priceCurrency,
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ mutation }),
variables,
)
.then((result) => {
return !result?.data.createOrderFromCart
? {}
: this.orderDataToOrder(result.data.createOrderFromCart);
});
},
/**
* Return the cart fields used in the request.
*
* @return {Promise<Object>}
* An object of cart fields.
*/
addCartFieldsToQuery() {
return {
id: true,
cartState: true,
version: true,
customerId: true,
totalLineItemQuantity: true,
totalPrice: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
discountOnTotalPrice: {
includedDiscounts: {
discount: {
id: true,
name: { $args: { acceptLanguage: { $var: '$languages' } } },
},
discountedAmount: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
},
discountCodes: {
discountCode: {
id: true,
code: true,
name: { $args: { acceptLanguage: { $var: '$languages' } } },
cartDiscounts: {
id: true,
},
},
},
lineItems: {
id: true,
name: { $args: { acceptLanguage: { $var: '$languages' } } },
productSlug: { $args: { acceptLanguage: { $var: '$languages' } } },
quantity: true,
productType: {
id: true,
key: true,
name: true,
},
variant: this.addProductVariantFields(),
price: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
discounted: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
},
totalPrice: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
discountedPricePerQuantity: {
quantity: true,
discountedPrice: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
includedDiscounts: {
discount: {
id: true,
},
discountedAmount: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
},
},
},
shippingAddress: this.addAddressNodeFields(),
billingAddress: this.addAddressNodeFields(),
};
},
/**
* Converts the raw cart data response to a simplified cart array.
*
* @param {array} cartData
* The array of the cart response data.
*
* @return {array}
* The simplified cart array.
*/
cartDataToCart(cartData) {
cartData.totalLineItemQuantity = cartData.totalLineItemQuantity || 0;
if (cartData.lineItems) {
cartData.lineItems = cartData.lineItems.map((lineItem) => {
this.addDiscountDataToLineItem(lineItem);
lineItem.totalPrice.localizedPrice = this.formatPrice(
lineItem.totalPrice,
);
lineItem.variant = this.variantDataToVariant(
lineItem.variant,
lineItem.productType.key,
);
return lineItem;
});
}
this.enhanceTotalPrice(cartData);
this.enhanceDiscounts(cartData);
return cartData;
},
enhanceTotalPrice(cartData) {
let originalTotal = 0;
let discount = 0;
const items = cartData.lineItems;
for (let i = 0; i < items.length; i++) {
const item = items[i];
originalTotal += item.originalTotalPrice?.centAmount ?? 0;
if (item.price.discounted?.value?.centAmount != null) {
discount +=
(item.price.value.centAmount -
item.price.discounted.value.centAmount) *
item.quantity;
}
}
cartData.originalTotalPrice = this.mergePriceData(
{ centAmount: originalTotal },
cartData.totalPrice,
);
cartData.discount = this.mergePriceData(
{ centAmount: discount },
cartData.totalPrice,
);
cartData.totalPrice.localizedPrice = this.formatPrice(cartData.totalPrice);
cartData.originalTotalPrice.localizedPrice = this.formatPrice(
cartData.originalTotalPrice,
);
cartData.discount.localizedPrice = this.formatPrice(cartData.discount);
cartData.isDiscounted = originalTotal !== cartData.totalPrice.centAmount;
},
enhanceDiscounts(cartData) {
cartData.discountCodes = cartData.discountCodes.map((entry) => {
const { discountCode } = entry;
const refIds = discountCode.cartDiscounts.map((d) => d.id);
let totalCentAmount = 0;
cartData.lineItems.forEach((item) => {
item.discountedPricePerQuantity.forEach((dp) => {
dp.discountedPrice.includedDiscounts.forEach((inc) => {
const id = inc.discount?.id;
if (refIds.includes(id)) {
totalCentAmount +=
(inc.discountedAmount?.centAmount || 0) * dp.quantity;
}
});
});
});
const onTotal = cartData.discountOnTotalPrice?.includedDiscounts;
if (Array.isArray(onTotal)) {
onTotal.forEach((inc) => {
const id = inc.discount?.id;
if (refIds.includes(id)) {
totalCentAmount += inc.discountedAmount?.centAmount || 0;
}
});
}
discountCode.amount = this.mergePriceData(
{ centAmount: totalCentAmount },
cartData.totalPrice,
);
discountCode.amount.localizedPrice = this.formatPrice(
discountCode.amount,
);
return discountCode;
});
},
/**
* Add discounted fields to line items.
*/
addDiscountDataToLineItem(lineItemData) {
const originalCent =
(lineItemData.price.value.centAmount || 0) * lineItemData.quantity;
const mergedOriginal = this.mergePriceData(
{ centAmount: originalCent },
lineItemData.price.value,
);
lineItemData.originalTotalPrice = mergedOriginal;
lineItemData.originalTotalPrice.localizedPrice =
this.formatPrice(mergedOriginal);
lineItemData.isDiscounted =
lineItemData.totalPrice.centAmount !== originalCent;
},
/**
* Retrieves orders based on the provided parameters.
*
* @param {Array} args
* Here the following array values are used:
* - sort: An associative array of sort options to apply to the query.
* - where: An string of filters to apply to the query.
* - offset: The number of orders to skip before starting to collect the
* result set. Defaults to 0.
* - limit: The maximum number of orders to return. Defaults to 10.
* @param {bool} includeDetails
* Whether to include detailed order information. Defaults to false.
*
* @return {Promise<Array>} the orders array.
*/
getOrders(args = {}, includeDetails = false) {
const query = {
$name: 'getOrders',
orders: {
$args: {
where: { $var: '$where' },
sort: { $var: '$sort' },
limit: { $var: '$limit' },
offset: { $var: '$offset' },
},
total: true,
results: {
...this.addOrderFieldsToQuery(includeDetails),
},
},
};
query.$variables = {
$where: 'String',
$sort: '[String!]',
$limit: 'Int',
$offset: 'Int',
$languages: '[Locale!]',
$country: 'Country',
$currency: 'Currency!',
};
const variables = {
where: args.where || null,
sort: args.sort || [],
limit: args.limit || 9,
offset: args.offset || 0,
languages: this.settings.languages,
country: this.settings.priceCountry,
currency: this.settings.priceCurrency,
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ query }),
variables,
)
.then((result) => {
return {
total: result.data.orders.total,
orders: result.data.orders.results.map((orderData) => {
return this.orderDataToOrder(orderData);
}),
};
});
},
/**
* Return the order fields used in the request.
*
* @param {bool} includeDetails
* Whether to include detailed order information. Defaults to false.
*
* @return {Object} An object of order fields.
*/
addOrderFieldsToQuery(includeDetails = false) {
const query = {
id: true,
orderNumber: true,
orderState: true,
customerId: true,
lineItems: {
id: true,
name: { $args: { acceptLanguage: { $var: '$languages' } } },
productSlug: { $args: { acceptLanguage: { $var: '$languages' } } },
quantity: true,
productType: {
id: true,
key: true,
name: true,
},
variant: this.addProductVariantFields(),
price: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
discounted: {
value: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
},
totalPrice: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
totalPrice: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
};
if (includeDetails) {
query.shippingInfo = {
shippingMethodName: true,
price: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
};
query.shippingAddress = this.addAddressNodeFields();
query.billingAddress = this.addAddressNodeFields();
query.taxedPrice = {
totalGross: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
taxPortions: {
name: true,
rate: true,
amount: {
centAmount: true,
currencyCode: true,
fractionDigits: true,
},
},
};
}
return query;
},
/**
* Converts the raw order data response to a simplified order array.
*
* @param {array} orderData - The array of the order response data.
*
* @return {array} The simplified order array.
*/
orderDataToOrder(orderData) {
//
orderData.totalPrice.localizedPrice = this.formatPrice(
orderData.totalPrice,
);
if (orderData.lineItems) {
orderData.lineItems = orderData.lineItems.map((lineItem) => {
this.addDiscountDataToLineItem(lineItem);
lineItem.totalPrice.localizedPrice = this.formatPrice(
lineItem.totalPrice,
);
lineItem.variant = this.variantDataToVariant(
lineItem.variant,
lineItem.productType.key,
);
return lineItem;
});
orderData.subtotalPrice = orderData.lineItems.reduce(
(carry, item) => {
carry.centAmount += item.totalPrice.centAmount;
return carry;
},
{ ...orderData.totalPrice, centAmount: 0 },
);
orderData.subtotalPrice.localizedPrice = this.formatPrice(
orderData.subtotalPrice,
);
}
if (orderData.taxedPrice) {
orderData.taxedPrice.totalGross.localizedPrice = this.formatPrice(
orderData.taxedPrice.totalGross,
);
orderData.taxedPrice.taxPortions = orderData.taxedPrice.taxPortions.map(
(portion) => {
portion.amount.localizedPrice = this.formatPrice(portion.amount);
return portion;
},
);
}
if (orderData.shippingInfo) {
orderData.shippingInfo.price.localizedPrice = this.formatPrice(
orderData.shippingInfo.price,
);
}
this.enhanceTotalPrice(orderData);
return orderData;
},
/**
* Update customer.
*
* @param {object} args
* Here the following object properties are used:
* - id: Customer ID
* - version: Customer version
* - actions: An array of actions.
*
* @return {Promise<Array>}
* The customer data object.
*/
updateCustomer(args = {}) {
const mutation = {
$name: 'updateCustomer',
updateCustomer: {
$args: {
id: { $var: '$id' },
version: { $var: '$version' },
actions: { $var: '$actions' },
},
id: true,
addresses: { ...this.addAddressNodeFields() },
},
};
mutation.$variables = {
$id: 'String',
$version: 'Long!',
$actions: '[CustomerUpdateAction!]!',
};
const variables = {
id: args.id || '[current_customer:id]',
version: args.version || '[current_customer:version]',
actions: args.actions || [],
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ mutation }),
variables,
)
.then((result) => {
return !result.data ? {} : result.data;
});
},
/**
* Add address to customer.
*
* @param {object} args
* Here the following object properties are used:
* - id: Customer ID
* - type: Address type (shipping or billing)
* - setDefault: Set address as default
*
* @return {object}
* The added address data.
*/
async addAddressCustomer(args = {}) {
const result = await this.updateCustomer({
id: args.id || '[current_customer:id]',
version: args.version || '[current_customer:version]',
actions: [
{
addAddress: { address: args.addressData },
},
],
});
const address = result.updateCustomer.addresses.at(-1) || null;
const addressId = address.id;
if (args.type) {
let action =
args.type === 'shippingAddress'
? 'addShippingAddressId'
: 'addBillingAddressId';
const actions = [
{
[action]: { addressId },
},
];
if (args.setDefault) {
action =
args.type === 'shippingAddress'
? 'setDefaultShippingAddress'
: 'setDefaultBillingAddress';
actions.push({
[action]: { addressId },
});
}
await this.updateCustomer({
id: args.id || '[current_customer:id]',
version: args.version || '[current_customer:version]',
actions,
});
}
return address;
},
/**
* Get user`s addresses.
*
* @param {object} args
* Here the following object properties are used:
* - id: Customer ID
*
* @return {Promise<Array>}
* The customer data object.
*/
getUserAddressData(args = {}) {
const query = {
$name: 'customer',
customer: {
$args: {
id: { $var: '$id' },
},
defaultBillingAddressId: true,
defaultShippingAddressId: true,
shippingAddresses: this.addAddressNodeFields(),
billingAddresses: this.addAddressNodeFields(),
},
};
query.$variables = {
$id: 'String!',
};
const variables = {
id: args.id || '[current_customer:id]',
};
// @todo Get rid of this await and return a promise.
return commercetools
.executeGraphqlOperation(
GraphQLToolkit.generateQuery({ query }),
variables,
)
.then((result) => {
return result.data.customer;
});
},
/**
* Format a price array from the given array.
*
* @param {array} price
* The price array, with the "currencyCode", "centAmount"
* and "fractionDigits" keys.
*
* @return {string} Formatted price.
*/
formatPrice(price) {
return Intl.NumberFormat(drupalSettings.commercetoolsDecoupled.locale, {
style: 'currency',
currency: price.currencyCode,
}).format(price.centAmount / 10 ** price.fractionDigits);
},
/**
* Get translation value.
*
* @param {array} value
* An array of language values.
*
* @return {any}
* Translated value.
*/
getTranslationValue(value) {
const lang = this.settings.languages.find((itemLang) => !!value[itemLang]);
return value[lang];
},
addAddressNodeFields() {
return {
id: true,
key: true,
title: true,
postalCode: true,
streetName: true,
streetNumber: true,
city: true,
region: true,
state: true,
country: true,
phone: true,
mobile: true,
firstName: true,
lastName: true,
email: true,
};
},
/**
* Get component param name according to component index.
*
* @param {string} paramName
* The parameter name.
* @param {number} index
* The component index number.
*
* @return {string}
* A component name with component suffix.
*/
getParamNameByIndex(paramName, index) {
return index ? `${paramName}_${index}` : paramName;
},
/**
* Get query param value according to component index.
*
* @param {string} paramName
* The parameter name.
* @param {number} index
* The component index number.
*
* @return {*}
* A component name with component suffix.
*/
getQueryParamByIndex(paramName, index) {
return (
new URL(window.location).searchParams.get(
this.getParamNameByIndex(paramName, index),
) || null
);
},
/**
* Extracts valid query parameters from the current URL.
*
* @param {number} productListIndex
* The product list index.
* @return {Object}
* An object containing all available valid query parameters.
*/
getRequestQueryParams(productListIndex) {
const params = {};
const searchParams = new URLSearchParams(window.location.search);
const nestedSearchParams = this.parseParams(searchParams);
// eslint-disable-next-line no-restricted-syntax
for (const key of this.urlParamsList) {
const paramName = this.getParamNameByIndex(key, productListIndex);
if (paramName in nestedSearchParams) {
params[paramName] = nestedSearchParams[paramName];
}
}
return params;
},
/**
* Parses URLSearchParams into a map:
* {
* root: {
* originalKeys: [rawKey1, rawKey2, ...],
* originalValues: [rawValue1, rawValue2, ...],
* originalEntries: [[rawKey1, rawValue1], [rawKey2, rawValue2], ...],
* parsed: { root: { …nested… } }
* },
* …
* }
*/
parseParams(data) {
const out = {};
// eslint-disable-next-line no-restricted-syntax
for (const [rk, rv] of data) {
const root = rk.split('[')[0];
if (out[root] == null) {
out[root] = {
originalKeys: [],
originalValues: [],
originalEntries: [],
parsed: {},
};
}
// Store all original keys and values for this root
out[root].originalKeys.push(rk);
out[root].originalValues.push(rv);
out[root].originalEntries.push([rk, rv]);
const isArr = rk.endsWith('[]');
const segments = (isArr ? rk.slice(0, -2) : rk).match(/[^[\]]+/g) || [];
let cur = out[root].parsed;
segments.forEach((seg, i) => {
seg = decodeURIComponent(seg);
const last = i === segments.length - 1;
if (last) {
if (isArr) {
if (!cur[seg]) cur[seg] = [];
else if (!Array.isArray(cur[seg])) cur[seg] = [cur[seg]];
cur[seg].push(rv);
} else {
// eslint-disable-next-line
cur[seg] = cur[seg] === undefined ? rv : Array.isArray(cur[seg]) ? [...cur[seg], rv] : [cur[seg], rv];
}
} else {
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg]))
cur[seg] = {};
cur = cur[seg];
}
});
}
return out;
},
/**
* Generate a new url according to the form.
*
* @param {element} form
* The form to retrieve values from.
*
* @return {URL}
* A generated URL.
*/
generateNewUrl(form) {
const formData = new FormData(form);
const formKeys = [
...new Set(
Array.from(form.elements)
.filter((el) => el.name)
.map((el) => el.name),
),
];
const currentUrl = new URL(window.location);
formKeys.forEach((key) => {
const values = formData.getAll(key);
currentUrl.searchParams.delete(key);
if (values.length) {
values.forEach((value) => {
value = value.trim();
if (value) {
currentUrl.searchParams.append(key, value);
}
});
}
});
return currentUrl;
},
/**
* Apply an image style to the image.
*
* @param {string} imageUrl
* Image url.
* @param {string|null} style
* The name of the style. All possible styles are
* listed in https://docs.commercetools.com/api/projects/products#image.
*
* @return {string}
* URL of the image with the applied style.
*/
applyImageStyle: (imageUrl, style = null) => {
style = style || drupalSettings.commercetoolsDecoupled.cardImageStyle;
return style ? imageUrl.replace(/(\.[^/.]+)$/, `-${style}$1`) : imageUrl;
},
/**
* Provides a component configuration from its data attribute.
*
* @param {HTMLElement} component
* The DOM element to collect data attributes from.
* @return {Object}
* A component configuration.
*/
getComponentConfig(component) {
if (!component?.dataset?.ct) {
new Drupal.Message().add(
`${component.tagName} component configuration is missing.`,
{
type: 'error',
},
);
throw new Error(
`${component.tagName} component configuration is missing.`,
);
}
// Configuration must be JSON-encoded.
const encodedConfiguration = component.dataset.ct;
try {
return JSON.parse(encodedConfiguration);
} catch (e) {
new Drupal.Message().add(
`${component.tagName} component configuration is invalid.`,
{
type: 'error',
},
);
throw new Error(
`${component.tagName} component configuration is invalid.`,
);
}
},
/**
* Get product list result.
*
* Collect configuration from all product list component,
* do request and save result in local cache.
*
* @param {number} productListIndex
* The component index number.
*
* @return {Array} An array of products.
*/
async getProductListResult(productListIndex) {
if (this.productListResult?.[productListIndex]) {
return this.productListResult[productListIndex];
}
let productListConfig = { offset: 0, limit: 0 };
document
.querySelectorAll('[data-ct-list-component]')
.forEach((component) => {
const componentConfig = this.getComponentConfig(component);
if (componentConfig.productListIndex === productListIndex) {
productListConfig =
component.applyProductListConfiguration(productListConfig);
}
});
this.productListResult[productListIndex] =
await window.commercetools.getProducts(
'productProjectionSearch',
productListConfig,
false,
);
return this.productListResult[productListIndex];
},
/**
* Reset product list result.
*
* @param {number} productListIndex
* The component index number.
*/
resetProductListResult(productListIndex) {
delete this.productListResult?.[productListIndex];
},
throwCommercetoolsException(error) {
error = Array.isArray(error) ? error.join(' ') : String(error);
Drupal.Message().add(
Drupal.t('The commercetools API operation failed: @error'),
{ type: 'error', '@error': error },
);
},
/**
* Merges calculated price values with data fields from the original price.ё
*
* When computing a partial price, typically only `centAmount` is recalculated.
* This method pulls all other data fields (e.g., `currencyCode`,
* `fractionDigits`, etc.) from the original price and
* combines them with the calculated price array, producing a complete
* price structure.
*
* @param {Object} calculatedPrice
* An associative array containing the newly calculated price values,
* at minimum:
* - centAmount (int): the recalculated amount in the smallest currency unit.
* It may also include other computed fields.
* @param {Object} sourcePrice
* The source price array, which must include data fields such as:
* - currencyCode (string): currency code.
* - fractionDigits (int): number of decimal places.
* - any other display.
*
* @return {Object}
* A merged price array with:
* - centAmount: from $calculatedPrice
* - currencyCode, fractionDigits, and other data: from $originalPrice
* - any additional computed fields: from $calculatedPrice
*/
mergePriceData(calculatedPrice, sourcePrice) {
return {
...sourcePrice,
...calculatedPrice,
};
},
/**
* Converts price values between display format and API format.
*
* @param {*} priceValue - The price value (can be string, float, int, or null).
* @param {number} fractionDigits - The number of fraction digits.
* @param {string} operation - Either 'multiply' (to cents) or 'divide' (from cents).
* @return {string|null} The converted price or '*' for null values.
*/
convertPrice(priceValue, fractionDigits = 0, operation = 'multiply') {
// Handle null, empty, or '*' values
if (
priceValue === null ||
priceValue === undefined ||
priceValue === '' ||
priceValue === '*'
) {
return operation === 'multiply' ? '*' : null;
}
const priceString = String(priceValue).trim();
if (priceString === '') {
return operation === 'multiply' ? '*' : null;
}
const factor = 10 ** fractionDigits;
if (operation === 'multiply') {
const priceFloat = parseFloat(priceString);
if (Number.isNaN(priceFloat)) {
return '*';
}
const result = priceFloat * factor;
return String(Math.round(result));
}
if (operation === 'divide') {
const priceInt = parseInt(priceString, 10);
if (Number.isNaN(priceInt)) {
return null;
}
if (factor === 1) {
return String(priceInt);
}
const result = priceInt / factor;
return result.toFixed(fractionDigits);
}
throw new Error("Operation must be 'multiply' or 'divide'");
},
};
window.commercetools = commercetools;
