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;

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc