UNPKG

@nacelle/compatibility-connector

Version:

Connect @nacelle/client-js-sdk to Nacelle's v2 back end with minimal code changes

680 lines (571 loc) 18.8 kB
import { Storefront } from 'storefrontSdkV1'; import { StorefrontClient } from '@nacelle/storefront-sdk'; import { CommerceQueries } from '@nacelle/commerce-queries-plugin'; import { NacelleGraphQLConnector } from '@nacelle/client-js-sdk'; import { checkVariantAvailability, sanitizeIntegerValue as integerValue, toIetfLocale, transformCollections, transformContent, transformProducts, transformSpace, graphqlQuery, checkStorefrontSdkMethod } from '../utils'; import { allProductCollectionsProductHandlesQuery, allProductCollectionsProductsQuery, allProductCollectionsQuery } from '../queries'; import type { FetchArticleParams, FetchArticlesParams, FetchBlogPageParams, FetchBlogParams, FetchCollectionPageParams, FetchCollectionParams, FetchContentParams, FetchPageParams, FetchPagesParams, FetchProductParams, FetchProductsParams } from '@nacelle/client-js-sdk'; import type { NacelleCollection, NacelleContent, NacelleProduct, NacelleShopSpace } from '@nacelle/types'; import type { Content, FetchContentMethodParams, FetchMethodParams, Product, ProductEdge, StorefrontInstance, StorefrontConfig as StorefrontConfigv1 } from 'storefrontSdkV1'; import type { ProductCollectionGraphQLResponse, ProductCollectionNode, ProductCollectionWithProductConnection } from '../utils'; export const notFoundMessages = { product: 'Product was not found.', collection: 'Collection was not found.', content: 'Content was not found.' }; const ClientWithCommerceQueries = CommerceQueries(StorefrontClient); export type StorefrontInstanceV2 = InstanceType< typeof ClientWithCommerceQueries >; export interface CompatibilityConnectorParams { /** * Your Nacelle v2 Storefront Endpoint. The `token` parameter is also required if the `endpoint` parameter is provided. */ endpoint?: string; /** * Your Nacelle v2 Public Storefront Token. The `endpoint` parameter is also required if the `token` parameter is provided. */ token?: string; /** * A Nacelle v2 Storefront client. A `client` parameter or `endpoint` and `token` parameters must be provided. */ client?: StorefrontInstance | StorefrontInstanceV2; /** * The default locale used by the various `client.data` methods. * * @defaultValue `'en-US'` * * @example * locale: 'en-US' * * @example * locale: 'es-MX' */ locale?: string; } type WithEntryDepth<T> = T & { entryDepth?: number; }; export interface FetchAllContentParams { limit?: number; locale?: string; type?: string; /** * @deprecated For best results, leave `queryLimit` unset. */ queryLimit?: number; } export interface FetchAllProductsParams { limit?: number; locale?: string; /** * @deprecated For best results, leave `queryLimit` unset. */ queryLimit?: number; } export interface FetchAllCollectionsParams { limit?: number; locale?: string; /** * @deprecated For best results, leave `queryLimit` unset. */ queryLimit?: number; } export default class NacelleCompatibilityConnector extends NacelleGraphQLConnector { client: StorefrontInstance | StorefrontInstanceV2; locale: string; spaceId: string; constructor(params: CompatibilityConnectorParams) { let token: string; let endpoint: string; if (params.client) { token = (params.client.getConfig() as StorefrontConfigv1).token ?? '<no-token>'; endpoint = params.client.getConfig().storefrontEndpoint; } else { if (!params.token) { throw new Error( '@nacelle/compatibility-connector requires a valid Nacelle Public Storefront token.' ); } if (!params.endpoint) { throw new Error( '@nacelle/compatibility-connector requires a valid Nacelle Storefront Endpoint.' ); } token = params.token; endpoint = params.endpoint; } const endpointRegex = /\/spaces\/([a-z0-9-]+)\/?/i; const endpointMatch = endpointRegex.exec(endpoint); if (!endpointMatch) { throw new Error( '@nacelle/compatibility-connector requires a valid Nacelle Storefront Endpoint.' ); } super({ endpoint, token, spaceId: endpointMatch[1] }); this.locale = params.client?.getConfig().locale || params.locale || 'en-us'; this.spaceId = endpointMatch[1]; this.client = params.client || Storefront({ token, storefrontEndpoint: endpoint, locale: toIetfLocale(this.locale) }); (['content', 'navigation', 'products', 'spaceProperties'] as const).forEach( (methodName) => checkStorefrontSdkMethod(this.client, methodName) ); } // Gets article data async article( params: WithEntryDepth<FetchArticleParams> ): Promise<NacelleContent> { const contentParams: FetchContentMethodParams = { handles: [params.handle], type: 'article', locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: 1 }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } const articles = (await this.client.content(contentParams)) as Content[]; if (!articles.length) { throw new Error(notFoundMessages.content); } return transformContent(articles, params.locale || this.locale)[0]; } // Gets articles data async articles( params: WithEntryDepth<FetchArticlesParams> ): Promise<NacelleContent[]> { const contentParams: FetchContentMethodParams = { handles: params.handles, type: 'article', locale: toIetfLocale(params.locale || this.locale) }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } let articles = (await this.client.content(contentParams)) as Content[]; if (params.blogHandle) { articles = articles.filter( (article) => article.fields?.blogHandle === params.blogHandle ); } return transformContent(articles, params.locale || this.locale); } // Gets content data async content( params: WithEntryDepth<FetchContentParams> ): Promise<NacelleContent> { const contentParams: FetchContentMethodParams = { handles: [params.handle], type: params.type, locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: 1 }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } const content = (await this.client.content(contentParams)) as Content[]; if (!content.length) { throw new Error(notFoundMessages.content); } return transformContent(content, params.locale || this.locale)[0]; } // Gets all content data async allContent( params?: WithEntryDepth<FetchAllContentParams> ): Promise<NacelleContent[]> { const entryDepth = integerValue(params?.entryDepth, 0); const contentParams: FetchContentMethodParams = { maxReturnedEntries: -1, // Fetch all content entries type: params?.type }; if (params?.queryLimit) { const entriesPerPage = integerValue(params.queryLimit, 50, 1, 250); contentParams.advancedOptions = { entriesPerPage }; } if (entryDepth) { contentParams.entryDepth = entryDepth; } const content = (await this.client.content(contentParams)) as Content[]; const results = transformContent(content, params?.locale || this.locale); // Limit the length of the content array that's returned from `transformContent` if (params?.limit && params.limit > 0) { return results.slice(0, params.limit); } return results; } // Gets content page data async page(params: WithEntryDepth<FetchPageParams>): Promise<NacelleContent> { const contentParams: FetchContentMethodParams = { handles: [params.handle], type: 'page', locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: 1 }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } const content = (await this.client.content(contentParams)) as Content[]; if (!content.length) { throw new Error(notFoundMessages.content); } return transformContent(content, params.locale || this.locale)[0]; } // Gets content pages data async pages( params: WithEntryDepth<FetchPagesParams> ): Promise<NacelleContent[]> { const contentParams: FetchContentMethodParams = { handles: params.handles, type: 'page', locale: toIetfLocale(params.locale || this.locale) }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } const content = (await this.client.content(contentParams)) as Content[]; return transformContent(content, params.locale || this.locale); } // Gets blog data async blog(params: WithEntryDepth<FetchBlogParams>): Promise<NacelleContent> { const contentParams: FetchContentMethodParams = { handles: [params.handle], type: 'blog', locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: 1 }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } const blogs = (await this.client.content(contentParams)) as Content[]; if (!blogs.length) { throw new Error(notFoundMessages.content); } return transformContent(blogs, params.locale || this.locale)[0]; } // Gets some or all articles belonging to a blog async blogPage( params: WithEntryDepth<FetchBlogPageParams> ): Promise<NacelleContent[]> { const { handle, locale, index = 0, itemsPerPage = 30, list = 'default', paginate } = params; let { blog } = params; let articles: Content[] = []; let handles: string[] = []; if (!blog) { if (!handle) { throw new Error('A blog or handle is required'); } blog = await this.blog({ handle, locale, entryDepth: params.entryDepth }); } if (Array.isArray(blog.articleLists)) { const blogArticleHandles = blog.articleLists.find( (articleList) => articleList.slug === list )?.handles; if (Array.isArray(blogArticleHandles)) { handles = blogArticleHandles; } } if (paginate && handles.length) { handles = handles.slice(index, index + itemsPerPage); } const contentParams: FetchContentMethodParams = { handles, type: 'article', locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: handles.length }; const entryDepth = integerValue(params?.entryDepth, 0); if (entryDepth) { contentParams.entryDepth = entryDepth; } articles = handles.length ? ((await this.client.content(contentParams)) as Content[]) : []; return transformContent(articles, params.locale || this.locale); } // Gets product data async product(params: FetchProductParams): Promise<NacelleProduct> { const products = (await this.client.products({ handles: [params.handle], locale: toIetfLocale(params.locale || this.locale), maxReturnedEntries: 1 })) as Product[]; if (!products.length) { throw new Error(notFoundMessages.product); } return transformProducts(products, params.locale || this.locale)[0]; } // Gets products data async products(params: FetchProductsParams): Promise<NacelleProduct[]> { const products = (await this.client.products({ handles: params.handles, locale: toIetfLocale(params.locale || this.locale) })) as Product[]; return transformProducts(products, params.locale || this.locale); } // Gets all products data async allProducts( params?: FetchAllProductsParams ): Promise<NacelleProduct[]> { const maxReturnedEntries = integerValue(params?.limit, -1, -1); const productsParams: FetchMethodParams = { maxReturnedEntries }; if (params?.queryLimit) { const entriesPerPage = integerValue(params.queryLimit, 50, 1, 250); productsParams.advancedOptions = { entriesPerPage }; } const products = (await this.client.products(productsParams)) as Product[]; return transformProducts(products, params?.locale || this.locale); } async isVariantAvailable(params: { productId: string; variantId: string; }): Promise<boolean> { const [product] = (await this.client.products({ nacelleEntryIds: [params.productId], locale: toIetfLocale(this.locale), maxReturnedEntries: 1 })) as Product[]; return checkVariantAvailability({ product, variantId: params.variantId }); } // Gets space data async space(): Promise<NacelleShopSpace> { const spaceProperties = await this.client.spaceProperties(); const navigation = await this.client.navigation(); return transformSpace({ spaceProperties, navigation, spaceId: this.spaceId }); } // Get collection data with product handles, not full products. async collection(params: FetchCollectionParams): Promise<NacelleCollection> { const response = await graphqlQuery<ProductCollectionGraphQLResponse>( allProductCollectionsQuery, { filter: { handles: [params.handle], locale: toIetfLocale(params.locale || this.locale) } }, this.client ); const collections = await this.paginateProducts( response.allProductCollections.edges, { locale: toIetfLocale(params.locale || this.locale) } ); if (!collections.length) { throw new Error(notFoundMessages.collection); } return transformCollections(collections, params.locale || this.locale)[0]; } // Get only products within a collection // `productLists` don't exist in W2, so `list` parameter is ignored. async collectionPage({ handle, locale, collection, paginate, index = 0, itemsPerPage = 30 }: FetchCollectionPageParams): Promise<NacelleProduct[]> { if (typeof collection === 'undefined' && !handle) { throw new Error('A collection or handle is required'); } if (collection) { let handles: string[] = []; if ( collection.productLists?.length && collection.productLists[0].handles ) { handles.push(...collection.productLists[0].handles); } if (!handles.length) { throw new Error('Selected list does not have array of handles'); } if (paginate) { handles = handles.slice(index, index + itemsPerPage); } const collectionProducts = (await this.client.products({ handles, locale: toIetfLocale(locale || this.locale), maxReturnedEntries: -1 })) as Product[]; return transformProducts(collectionProducts, locale || this.locale); } const response = await graphqlQuery<ProductCollectionGraphQLResponse>( allProductCollectionsProductsQuery, { filter: { handles: [handle], locale: toIetfLocale(locale || this.locale) } }, this.client ); const collections = await this.paginateProducts( response.allProductCollections.edges, { locale: toIetfLocale(locale || this.locale) }, true ); let products: Product[] = []; if (collections.length && collections[0].productConnection.edges) { const edges: ProductEdge[] = collections[0].productConnection.edges; products = edges.map((product) => product.node); } if (paginate && products) { products = products.slice(index, index + itemsPerPage); } return transformProducts(products, locale || this.locale); } async allCollections( params?: FetchAllCollectionsParams ): Promise<NacelleCollection[]> { let allCollections: ProductCollectionWithProductConnection[] = []; let keepFetching = true; let nextAfter; do { const first = integerValue(params?.queryLimit, 50, 1, 250); const response: ProductCollectionGraphQLResponse = await graphqlQuery<ProductCollectionGraphQLResponse>( allProductCollectionsQuery, { filter: { first, after: nextAfter } }, this.client ); const collections = await this.paginateProducts( response.allProductCollections.edges ); allCollections = allCollections.concat(collections); nextAfter = response.allProductCollections.pageInfo.endCursor; keepFetching = response.allProductCollections.pageInfo.hasNextPage; } while (keepFetching); if (params?.limit) { allCollections = allCollections.slice(0, params.limit); } return transformCollections(allCollections, params?.locale || this.locale); } paginateProducts( collections: ProductCollectionNode[], filter?: { locale: string }, fullProducts?: boolean ): Promise<ProductCollectionWithProductConnection[]> { return Promise.all( collections.map(async (collection) => { let productConnection = collection.node.productConnection.edges; let hasNextPage = collection.node.productConnection.pageInfo?.hasNextPage; let endCursor = collection.node.productConnection.pageInfo?.endCursor; while (hasNextPage) { const paginatedResponse = await graphqlQuery<ProductCollectionGraphQLResponse>( fullProducts ? allProductCollectionsProductsQuery : allProductCollectionsProductHandlesQuery, { filter: { nacelleEntryIds: [collection.node.nacelleEntryId], ...filter }, after: endCursor }, this.client ); const paginatedCollection = paginatedResponse.allProductCollections.edges[0]; const edges = paginatedCollection ? paginatedCollection.node.productConnection.edges : []; productConnection = [...productConnection, ...edges]; hasNextPage = paginatedCollection && paginatedCollection.node.productConnection.pageInfo?.hasNextPage; endCursor = paginatedCollection && paginatedCollection.node.productConnection.pageInfo?.endCursor; } collection.node.productConnection.edges = productConnection; return collection.node; }) ); } }