@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
text/typescript
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;
})
);
}
}