Vue Storefront is now Alokai! Learn More
SearchProducts

SearchProducts

Implements SearchProducts Unified Method.

Source

/* eslint-disable no-fallthrough */
/* eslint-disable complexity */
import { type InternalContext, defineApi, getNormalizerContext, query } from "@vsf-enterprise/unified-api-magento";
import { getRawCategory } from "@/methods/category/getCategory/getCategory";
import { maybe } from "@shared/utils";
import {
  type Aggregation,
  type CategoryFilterInput,
  type CategoryInterface,
  type ProductAttributeFilterInput,
  type ProductAttributeSortInput,
  SortEnum,
} from "@vue-storefront/magento-types";
import {
  type SfFacet,
  type SearchProductsArgs,
  type Maybe,
  type SfFacetItem,
  type NormalizersLike,
  getNormalizers,
  SearchProducts,
} from "@vue-storefront/unified-data-model";

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

async function searchProductsByQuery(
  context: InternalContext,
  {
    search,
    sortBy,
    facets,
    category: categoryIdOrSlug,
    currentPage = 1,
    pageSize,
  }: SearchProductsArgs,
): ReturnType<SearchProducts> {
  const category = categoryIdOrSlug ? await getCategoryId(context, categoryIdOrSlug) : undefined;
  const filter = buildFilter({ category, facets });
  const sort = translateSort(sortBy);

  /*
   * Magento2 Aggregations are recalculated on each request,
   * to keep facets unchanged for the same category, we fetching them independenty
   */
  const productsPromise = query(
    context.api.products({
      pageSize,
      search,
      currentPage: currentPage ?? 1,
      sort,
      filter,
    }),
  );
  const facetsPromise = query(
    context.api.products({
      pageSize: 0,
      filter: buildFilter({ category }),
    }),
  );

  const [productsData, facetsData] = await Promise.all([productsPromise, facetsPromise]);

  const { items, page_info, total_count } = productsData.products ?? {};
  const { aggregations } = facetsData.products ?? {};

  const { normalizeProductCatalogItem, normalizePagination } = getNormalizers(context);
  const normalizerContext = getNormalizerContext(context);
  const facetsResult = await buildFacets(context, category, aggregations ?? []);

  return {
    products: (items ?? [])
      ?.filter(Boolean)
      .map((product) => normalizeProductCatalogItem(product, normalizerContext)),
    facets: facetsResult,
    pagination: normalizePagination(
      { ...page_info, total_results: total_count },
      normalizerContext,
    ),
  };
}

async function buildFacets(
  context: InternalContext,
  category: string | undefined,
  facets: Maybe<Aggregation>[],
): Promise<ReturnType<NormalizersLike["normalizeFacet"]>[]> {
  // using rootCategoryId instead of rootCategoryUid, because id value can be retrieved directly from Magento Admin

  const { rootCategoryId = 2 } = context.config;
  const filters: CategoryFilterInput = category
    ? { parent_category_uid: { eq: category } }
    : { parent_id: { eq: rootCategoryId.toString() } };

  const currentCategory = await context.api.categorySearch({ filters });
  const categories = (currentCategory.data.categoryList ?? []).filter(Boolean);

  const { filterFacets = () => true } = context.config;
  const { normalizeFacet } = getNormalizers(context);

  return facets
    .filter(Boolean)
    .filter((facet) => filterFacets(facet))
    .map((facet) => normalizeFacet(facet, getNormalizerContext(context)))
    .map((facet) => buildCategoryFacet(facet, categories))
    .filter(Boolean);
}

/*
 * Build category facet from current categories
 *
 * Note: Aggregations don't contain 'category_uid' option, so we need to manually build correct category child data
 * https://github.com/magento/magento2/issues/32557
 */
function buildCategoryFacet(
  facet: SfFacet | null,
  categories: CategoryInterface[] = [],
): SfFacet | null {
  if (!facet || facet.name !== "category_id") {
    return facet;
  }

  const values: SfFacetItem[] = categories.map((category) => ({
    label: category.name ?? category.uid,
    value: category.uid,
    productCount: maybe(category.product_count),
  }));

  return {
    ...facet,
    values,
  };
}

function buildFilterValue<Key extends keyof ProductAttributeFilterInput & string>(
  filterKey: Key,
  value: string | string[],
): ProductAttributeFilterInput[Key] {
  switch (filterKey) {
    case "name":
    case "description": {
      if (typeof value === "string") {
        return { match: value };
      }
    }
    case "price": {
      if (typeof value === "string") {
        const [from, to] = value.split("_");
        return { from, to };
      }
      if (Array.isArray(value) && typeof value.at(-1) === "string") {
        const [from, to] = value.at(-1)?.split("_") as [string, string];
        return { from, to };
      }
    }
    default: {
      return Array.isArray(value) ? { in: value } : { eq: value };
    }
  }
}

function buildFilter(
  query: Pick<SearchProductsArgs, "category" | "facets">,
): ProductAttributeFilterInput {
  const { facets, category } = query;

  const facetsFilter = Object.fromEntries(
    Object.entries(facets ?? {}).map(([key, value]: any[]) => [key, buildFilterValue(key, value)]),
  );

  return { ...(category && { category_uid: { eq: category } }), ...facetsFilter };
}

function translateSort(sortBy: SearchProductsArgs["sortBy"]): ProductAttributeSortInput {
  switch (sortBy) {
    case "relevant": {
      return {
        relevance: SortEnum.Desc,
      };
    }
    case "price-low-to-high": {
      return {
        price: SortEnum.Asc,
      };
    }
    case "price-high-to-low": {
      return {
        price: SortEnum.Desc,
      };
    }
    default: {
      return {};
    }
  }
}

async function getCategoryId(context: InternalContext, categoryIdOrSlug: string) {
  const category = await getRawCategory(context, { id: categoryIdOrSlug });
  return category.category.uid!;
}