diff --git a/framework/bigcommerce/api/utils/fetch-graphql-api.ts b/framework/bigcommerce/api/utils/fetch-graphql-api.ts index a449b81e0..81eb2841e 100644 --- a/framework/bigcommerce/api/utils/fetch-graphql-api.ts +++ b/framework/bigcommerce/api/utils/fetch-graphql-api.ts @@ -27,7 +27,7 @@ const fetchGraphqlApi: GraphQLFetcher = async ( const json = await res.json() if (json.errors) { throw new FetcherError({ - errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }], + errors: json.errors ?? [{ message: 'Failed to fetch Vendure API' }], status: res.status, }) } diff --git a/framework/vendure/common/get-site-info.ts b/framework/vendure/common/get-site-info.ts index 07e8d8ea4..579814abc 100644 --- a/framework/vendure/common/get-site-info.ts +++ b/framework/vendure/common/get-site-info.ts @@ -1,106 +1,117 @@ -import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../schema' import type { RecursivePartial, RecursiveRequired } from '../api/utils/types' import filterEdges from '../api/utils/filter-edges' import { VendureConfig, getConfig } from '../api' -import { categoryTreeItemFragment } from '../api/fragments/category-tree' -// Get 3 levels of categories -export const getSiteInfoQuery = /* GraphQL */ ` - query getSiteInfo { - site { - categoryTree { - ...categoryTreeItem - children { - ...categoryTreeItem - children { - ...categoryTreeItem +export const getCollectionsQuery = /* GraphQL */ ` + query getCollections { + collections { + items { + id + name + description + slug + productVariants { + totalItems } - } - } - brands { - pageInfo { - startCursor - endCursor - } - edges { - cursor - node { - entityId - name - defaultImage { - urlOriginal - altText - } - pageTitle - metaDesc - metaKeywords - searchKeywords - path + parent { + id + } + children { + id } } } } - } - ${categoryTreeItemFragment} -` - -export type CategoriesTree = NonNullable< - GetSiteInfoQuery['site']['categoryTree'] -> - -export type BrandEdge = NonNullable< - NonNullable[0] -> - -export type Brands = BrandEdge[] - -export type GetSiteInfoResult< - T extends { categories: any[]; brands: any[] } = { - categories: CategoriesTree - brands: Brands - } -> = T - -async function getSiteInfo(opts?: { - variables?: GetSiteInfoQueryVariables - config?: VendureConfig - preview?: boolean -}): Promise - -async function getSiteInfo< - T extends { categories: any[]; brands: any[] }, - V = any ->(opts: { - query: string - variables?: V - config?: VendureConfig - preview?: boolean -}): Promise> +`; async function getSiteInfo({ - query = getSiteInfoQuery, + query = getCollectionsQuery, variables, config, }: { query?: string - variables?: GetSiteInfoQueryVariables + variables?: any config?: VendureConfig preview?: boolean -} = {}): Promise { +} = {}): Promise { config = getConfig(config) // RecursivePartial forces the method to check for every prop in the data, which is // required in case there's a custom `query` - const { data } = await config.fetch>( + const { data } = await config.fetch( query, { variables } ) - const categories = data.site?.categoryTree + const categories = arrayToTree(data.collections?.items.map(i => ({ + ...i, + entityId: i.id, + name: i.name, + path: i.slug, + description: i.description, + productCount: i.productVariants.totalItems, + }))).children; const brands = data.site?.brands?.edges return { categories: (categories as RecursiveRequired) ?? [], - brands: filterEdges(brands as RecursiveRequired), + brands: [], } } export default getSiteInfo + +export type HasParent = { id: string; parent?: { id: string } | null }; +export type TreeNode = T & { children: Array>; expanded: boolean }; +export type RootNode = { id?: string; children: Array> }; + +export function arrayToTree(nodes: T[], currentState?: RootNode): RootNode { + const topLevelNodes: Array> = []; + const mappedArr: { [id: string]: TreeNode } = {}; + const currentStateMap = treeToMap(currentState); + + // First map the nodes of the array to an object -> create a hash table. + for (const node of nodes) { + mappedArr[node.id] = { ...(node as any), children: [] }; + } + + for (const id of nodes.map(n => n.id)) { + if (mappedArr.hasOwnProperty(id)) { + const mappedElem = mappedArr[id]; + mappedElem.expanded = currentStateMap.get(id)?.expanded ?? false; + const parent = mappedElem.parent; + if (!parent) { + continue; + } + // If the element is not at the root level, add it to its parent array of children. + const parentIsRoot = !mappedArr[parent.id]; + if (!parentIsRoot) { + if (mappedArr[parent.id]) { + mappedArr[parent.id].children.push(mappedElem); + } else { + mappedArr[parent.id] = { children: [mappedElem] } as any; + } + } else { + topLevelNodes.push(mappedElem); + } + } + } + // tslint:disable-next-line:no-non-null-assertion + const rootId = topLevelNodes.length ? topLevelNodes[0].parent!.id : undefined; + return { id: rootId, children: topLevelNodes }; +} + +/** + * Converts an existing tree (as generated by the arrayToTree function) into a flat + * Map. This is used to persist certain states (e.g. `expanded`) when re-building the + * tree. + */ +function treeToMap(tree?: RootNode): Map> { + const nodeMap = new Map>(); + function visit(node: TreeNode) { + nodeMap.set(node.id, node); + node.children.forEach(visit); + } + if (tree) { + visit(tree as TreeNode); + } + return nodeMap; +} diff --git a/framework/vendure/product/use-search.tsx b/framework/vendure/product/use-search.tsx index ade0bbca2..961f4335b 100644 --- a/framework/vendure/product/use-search.tsx +++ b/framework/vendure/product/use-search.tsx @@ -2,11 +2,33 @@ import type { HookFetcher } from '@commerce/utils/types' import type { SwrOptions } from '@commerce/utils/use-data' import useCommerceSearch from '@commerce/products/use-search' import type { SearchProductsData } from '../api/catalog/products' +import useResponse from '@commerce/utils/use-response' -const defaultOpts = { - url: '/api/bigcommerce/catalog/products', - method: 'GET', -} +export const searchQuery = /* GraphQL */ ` + query search($input: SearchInput!) { + search(input: $input) { + items { + productId + currencyCode + productName + description + priceWithTax { + ...on SinglePrice { + value + } + ...on PriceRange { + min max + } + } + productAsset { + preview + } + slug + } + totalItems + } + } +` export type SearchProductsInput = { search?: string @@ -20,40 +42,57 @@ export const fetcher: HookFetcher = ( { search, categoryId, brandId, sort }, fetch ) => { - // Use a dummy base as we only care about the relative path - const url = new URL(options?.url ?? defaultOpts.url, 'http://a') - - if (search) url.searchParams.set('search', search) - if (Number.isInteger(categoryId)) - url.searchParams.set('category', String(categoryId)) - if (Number.isInteger(categoryId)) - url.searchParams.set('brand', String(brandId)) - if (sort) url.searchParams.set('sort', sort) - return fetch({ - url: url.pathname + url.search, - method: options?.method ?? defaultOpts.method, + query: searchQuery, + variables: { + input: { + term: search, + collectionId: categoryId, + groupByProduct: true + } + } }) } export function extendHook( customFetcher: typeof fetcher, - swrOptions?: SwrOptions + swrOptions?: SwrOptions ) { const useSearch = (input: SearchProductsInput = {}) => { const response = useCommerceSearch( - defaultOpts, + {}, [ ['search', input.search], ['categoryId', input.categoryId], ['brandId', input.brandId], - ['sort', input.sort], + ['sort', input.sort] ], customFetcher, { revalidateOnFocus: false, ...swrOptions } ) - return response + return useResponse(response, { + normalizer: data => { + return { + found: data?.search.totalItems > 0, + products: data?.search.items.map((item: any) => ({ + id: item.productId, + name: item.productName, + description: item.description, + slug: item.slug, + path: item.slug, + images: [{ url: item.productAsset?.preview }], + variants: [], + price: { + value: (item.priceWithTax.min / 100), + currencyCode: item.currencyCode + }, + options: [], + sku: item.sku + })) ?? [], + } + } + }) } useSearch.extend = extendHook