Vue Storefront is now Alokai! Learn More
SearchProducts

SearchProducts

Implements SearchProducts Unified Method.

Source

// Splitting this functions into smaller ones actually may make it less readable
// in this scenario
/* eslint-disable max-lines-per-function */
import { defineApi } from "@vsf-enterprise/unified-api-sfcc";
import type { SFCCIntegrationContext } from "@vsf-enterprise/sfcc-api";
import type { ProductSearchResult } from "@vsf-enterprise/sfcc-types";
import {
  type SearchProducts,
  type SearchProductsArgs,
  getNormalizers,
} from "@vue-storefront/unified-data-model";
import type { Search } from "commerce-sdk";
import "./extended.d";
import { findRefinementCategory, mapFacetsToRefinements, mapSort } from "./helpers";

const CATEGORY_REFINEMENT_ID = "cgid";

export const searchProducts = defineApi.searchProducts(async (context, args) => {
  try {
    return await searchProductsByQuery(context, args);
  } catch (error) {
    console.error("/searchProducts", error);
    return {
      products: [],
      pagination: {
        currentPage: 0,
        pageSize: 0,
        totalResults: 0,
        totalPages: 0,
      },
      facets: [],
    };
  }
});

async function searchProductsByQuery(
  context: SFCCIntegrationContext,
  { search, sortBy, facets: facetsQuery, category, currentPage = 1, pageSize }: SearchProductsArgs,
): ReturnType<SearchProducts> {
  const { filterFacets = () => true, rootCategoryId = "root" } = context.config.unified;
  const shouldReturnWholeCatalog = !category;
  // if no params are passed then searchProduct returns no results
  // UDM predicts that in case of no params we should return all results
  // root is the main category in sfcc so we effectively query all products here
  // in case when no params are passed
  const categoryToSearchBy = shouldReturnWholeCatalog ? rootCategoryId : category;

  // UST-706: we temporarily don't support sets, bundles and variation groups
  // Note: Salesforce TsDocs claim that the correct refinement to filter by the
  // product type is `htypes` but it doesn't work when actually attempting to use it.
  // `htype` works fine though.
  const SUPPORTED_PRODUCT_TYPES = ["master", "product"];

  // TS thinks that categorRefinement may be { cgid: undefined | string}
  // So we cast it to a Record<string, string>
  const categoryRefinement: Record<string, string> = categoryToSearchBy
    ? { cgid: categoryToSearchBy }
    : {};

  const facets = {
    htype: SUPPORTED_PRODUCT_TYPES,
    ...categoryRefinement,
    ...facetsQuery,
  };

  const paginationOffset = Math.max(currentPage - 1, 0) * (pageSize ?? 0);

  const refine = mapFacetsToRefinements(facets);

  const searchProductsResults = await context.api.searchProducts({
    q: search,
    limit: pageSize,
    offset: paginationOffset,
    refine,
    sort: mapSort(sortBy),
  });

  const productsHits = searchProductsResults.hits ?? [];

  const { normalizePagination, normalizeFacet, normalizeProductCatalogItem } =
    getNormalizers(context);

  const sfProductCatalogItems = await Promise.all(
    productsHits.map(async (product) => {
      const representedProductId = product?.representedProduct?.id;
      if (!representedProductId) {
        return normalizeProductCatalogItem(product);
      }

      // We need to fetch tieredPrices separately because they are not included in the
      // searchProducts response
      const { tieredPrices } = await context.api.getProduct({
        id: representedProductId,
        perPricebook: true,
      });

      return normalizeProductCatalogItem({ ...product, tieredPrices });
    }),
  );

  const paginationNormaliserParams = {
    limit: searchProductsResults.limit,
    offset: searchProductsResults.offset,
    total: searchProductsResults.total,
  } satisfies Pick<Search.ShopperSearch.SimpleSearchResult, "limit" | "offset" | "total">;

  const sfPagination = normalizePagination(paginationNormaliserParams);

  const sfFacets = searchProductsResults.refinements
    .filter((refinement) => {
      // We filter out 'htype' to let through only supported products. Other products are not supported
      // and we do not let user request them here.
      const isSystemFilter = ["htype"].includes(refinement.attributeId);

      return !isSystemFilter;
    })
    .filter((refinement) => filterFacets(refinement))
    .map((refinement) =>
      transformCategoryRefinement(
        refinement,
        searchProductsResults.selectedRefinements,
        rootCategoryId,
      ),
    )
    .map((refinement) => normalizeFacet(refinement))
    .filter(Boolean);

  return {
    products: sfProductCatalogItems,
    pagination: sfPagination,
    facets: sfFacets,
  };
}

function transformCategoryRefinement(
  refinement: ProductSearchResult["refinements"][number],
  selectedRefinements: ProductSearchResult["selectedRefinements"],
  rootCategoryId: string,
) {
  if (refinement.attributeId !== CATEGORY_REFINEMENT_ID) {
    return refinement;
  }
  const refinedCategory = selectedRefinements?.[CATEGORY_REFINEMENT_ID];
  if (refinedCategory === rootCategoryId) {
    return refinement;
  }
  const currentCategory = findRefinementCategory(refinement.values ?? [], refinedCategory);
  if (currentCategory) {
    return {
      ...refinement,
      values: currentCategory?.values ?? [],
    };
  }
  return refinement;
}