Vue Storefront is now Alokai! Learn More
SearchProducts

SearchProducts

Implements SearchProducts Unified Method.

Source

/* eslint-disable no-fallthrough */
/* eslint-disable complexity */
import type { Context } from "@vue-storefront/magento-api";
import { defineApi, query } from "@vsf-enterprise/unified-api-magento";
import { getRawCategory } from "@/methods/category/getCategory/getCategory";
import type { ProductWithTypeName } from "@/normalizers/__internal__";
import {
  SortEnum,
  type Aggregation,
  type CategoryInterface,
  type ProductAttributeFilterInput,
  type ProductAttributeSortInput,
} from "@vue-storefront/magento-types";
import {
  getNormalizers,
  type SearchProductsArgs,
  type Maybe,
  type SfFacet,
  type SearchProducts,
} from "@vue-storefront/unified-data-model";

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: Context,
  {
    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 facetsResult = await buildFacets(context, category, aggregations ?? []);

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

async function buildFacets(
  context: Context,
  category: string | undefined,
  facets: Maybe<Aggregation>[],
): Promise<SfFacet[]> {
  const { filterFacets = () => true } = context.config.unified;
  const { normalizeFacet } = getNormalizers(context);

  let normalizedFacets = facets
    .filter((facet) => facet && filterFacets(facet))
    .map((facet) => normalizeFacet(facet!));

  if (category) {
    const currentCategory = await context.api.categorySearch({
      filters: { parent_category_uid: { eq: category } },
    });
    const categories = (currentCategory.data.categoryList ?? []).filter(Boolean);
    normalizedFacets = normalizedFacets.map((facet) => buildCategoryFacet(facet, categories));
  }

  return normalizedFacets.filter(Boolean);
}

function buildCategoryFacet(
  facet: SfFacet | null,
  categories: CategoryInterface[] = [],
): SfFacet | null {
  if (!facet || facet.name !== "category_uid") {
    return facet;
  }

  const categoriesUids = new Set(categories.map((category) => category.uid));

  return {
    ...facet,
    // filter out categories that are not in the current category
    values: facet.values.filter((facetValue) => categoriesUids.has(facetValue.value)),
  };
}

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: Context, categoryIdOrSlug: string) {
  const category = await getRawCategory(context, { id: categoryIdOrSlug });
  return category.category.uid!;
}