From ebd4b7246a5012079e14a6ec3c546f63e05679bd Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Fri, 10 Jun 2022 11:34:29 -0300 Subject: [PATCH] canonical --- packages/api/src/index.ts | 1 + packages/api/src/platforms/errors.ts | 34 +++++++++++++++++++ .../platforms/vtex/clients/commerce/index.ts | 10 +++++- .../src/platforms/vtex/loaders/collection.ts | 2 +- .../api/src/platforms/vtex/loaders/sku.ts | 23 +++---------- .../api/src/platforms/vtex/resolvers/offer.ts | 9 +++-- .../src/platforms/vtex/resolvers/product.ts | 8 +++-- .../api/src/platforms/vtex/resolvers/query.ts | 26 +++++++++++++- .../api/src/platforms/vtex/resolvers/seo.ts | 4 +-- .../platforms/vtex/resolvers/validateCart.ts | 6 ++-- .../api/src/platforms/vtex/utils/canonical.ts | 3 ++ .../api/src/platforms/vtex/utils/errors.ts | 13 ------- .../api/src/platforms/vtex/utils/facets.ts | 6 ++++ .../test/__snapshots__/queries.test.ts.snap | 22 ++++++------ 14 files changed, 111 insertions(+), 56 deletions(-) create mode 100644 packages/api/src/platforms/errors.ts create mode 100644 packages/api/src/platforms/vtex/utils/canonical.ts delete mode 100644 packages/api/src/platforms/vtex/utils/errors.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4c522a7e18..858cca83b4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,6 +8,7 @@ import { typeDefs } from './typeDefs' import type { Options as OptionsVTEX } from './platforms/vtex' export * from './__generated__/schema' +export * from './platforms/errors' export type Options = OptionsVTEX diff --git a/packages/api/src/platforms/errors.ts b/packages/api/src/platforms/errors.ts new file mode 100644 index 0000000000..97b6a6c1c3 --- /dev/null +++ b/packages/api/src/platforms/errors.ts @@ -0,0 +1,34 @@ +type ErrorType = 'BadRequestError' | 'NotFoundError' | 'RedirectError' + +interface Extension { + type: ErrorType + status: number +} + +class FastStoreError extends Error { + constructor(public extensions: T, message?: string) { + super(message) + this.name = 'FastStoreError' + } +} + +export class BadRequestError extends FastStoreError { + constructor(message?: string) { + super({ status: 400, type: 'BadRequestError' }, message) + } +} + +export class NotFoundError extends FastStoreError { + constructor(message?: string) { + super({ status: 404, type: 'NotFoundError' }, message) + } +} + +export const isFastStoreError = (error: any): error is FastStoreError => + error?.name === 'FastStoreError' + +export const isNotFoundError = (error: any): error is NotFoundError => + error?.extensions?.type === 'NotFoundError' + +export const isBadRequestError = (error: any): error is BadRequestError => + error?.extensions?.type === 'BadRequestError' diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index c6894a2f99..3fe1803439 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -1,5 +1,6 @@ -import type { Context, Options } from '../../index' import { fetchAPI } from '../fetch' +import type { Product } from '../search/types/ProductSearchResult' +import type { Context, Options } from '../../index' import type { Brand } from './types/Brand' import type { CategoryTree } from './types/CategoryTree' import type { OrderForm, OrderFormInputItem } from './types/OrderForm' @@ -152,5 +153,12 @@ export const VtexCommerce = ( body: '{}', }) }, + search: { + slug: (slug: string): Promise => { + return fetchAPI( + `${base}/api/catalog_system/pub/products/search/${slug}/p` + ) + }, + }, } } diff --git a/packages/api/src/platforms/vtex/loaders/collection.ts b/packages/api/src/platforms/vtex/loaders/collection.ts index a1c66d9d10..32fe3ee2f4 100644 --- a/packages/api/src/platforms/vtex/loaders/collection.ts +++ b/packages/api/src/platforms/vtex/loaders/collection.ts @@ -1,7 +1,7 @@ import DataLoader from 'dataloader' import pLimit from 'p-limit' -import { NotFoundError } from '../utils/errors' +import { NotFoundError } from '../../errors' import type { CollectionPageType } from '../clients/commerce/types/Portal' import type { Options } from '..' import type { Clients } from '../clients' diff --git a/packages/api/src/platforms/vtex/loaders/sku.ts b/packages/api/src/platforms/vtex/loaders/sku.ts index 75a98c2afd..dcff5aad9e 100644 --- a/packages/api/src/platforms/vtex/loaders/sku.ts +++ b/packages/api/src/platforms/vtex/loaders/sku.ts @@ -1,26 +1,13 @@ import DataLoader from 'dataloader' -import { BadRequestError } from '../utils/errors' import { enhanceSku } from '../utils/enhanceSku' +import { NotFoundError } from '../../errors' import type { EnhancedSku } from '../utils/enhanceSku' import type { Options } from '..' import type { Clients } from '../clients' -import type { SelectedFacet } from '../utils/facets' export const getSkuLoader = (_: Options, clients: Clients) => { - const loader = async (facetsList: readonly SelectedFacet[][]) => { - const skuIds = facetsList.map((facets) => { - const maybeFacet = facets.find(({ key }) => key === 'id') - - if (!maybeFacet) { - throw new BadRequestError( - 'Error while loading SKU. Needs to pass an id to selected facets' - ) - } - - return maybeFacet.value - }) - + const loader = async (skuIds: readonly string[]) => { const { products } = await clients.search.products({ query: `sku:${skuIds.join(';')}`, page: 0, @@ -39,15 +26,15 @@ export const getSkuLoader = (_: Options, clients: Clients) => { const missingSkus = skus.filter((sku) => !sku) if (missingSkus.length > 0) { - throw new Error( - `Search API did not return the following skus: ${missingSkus.join(',')}` + throw new NotFoundError( + `Search API did not found the following skus: ${missingSkus.join(',')}` ) } return skus } - return new DataLoader(loader, { + return new DataLoader(loader, { maxBatchSize: 99, // Max allowed batch size of Search API }) } diff --git a/packages/api/src/platforms/vtex/resolvers/offer.ts b/packages/api/src/platforms/vtex/resolvers/offer.ts index 4e5a8513b5..03e22084cc 100644 --- a/packages/api/src/platforms/vtex/resolvers/offer.ts +++ b/packages/api/src/platforms/vtex/resolvers/offer.ts @@ -11,7 +11,7 @@ import type { ArrayElementType } from '../../../typings' import type { EnhancedSku } from '../utils/enhanceSku' import type { OrderFormItem } from '../clients/commerce/types/OrderForm' -type OrderFormProduct = OrderFormItem & { product: Promise } +type OrderFormProduct = OrderFormItem & { product: EnhancedSku } type SearchProduct = ArrayElementType< ReturnType > @@ -96,13 +96,16 @@ export const StoreOffer: Record> = { return null }, - itemOffered: async (root) => { + itemOffered: (root) => { if (isSearchItem(root)) { return root.product } if (isOrderFormItem(root)) { - return { ...(await root.product), attachmentsValues: root.attachments } + return { + ...root.product, + attachmentsValues: root.attachments, + } } return null diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 87f66246ee..e0b197ab4d 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -1,3 +1,4 @@ +import { canonicalFromProduct } from '../utils/canonical' import { enhanceCommercialOffer } from '../utils/enhanceCommercialOffer' import { bestOfferFirst } from '../utils/productStock' import { slugify } from '../utils/slugify' @@ -41,9 +42,10 @@ export const StoreProduct: Record> & { name: ({ isVariantOf, name }) => name ?? isVariantOf.productName, slug: ({ isVariantOf: { linkText }, itemId }) => getSlug(linkText, itemId), description: ({ isVariantOf: { description } }) => description, - seo: ({ isVariantOf: { description, productName } }) => ({ - title: productName, - description, + seo: ({ isVariantOf }) => ({ + title: isVariantOf.productName, + description: isVariantOf.description, + canonical: canonicalFromProduct(isVariantOf), }), brand: ({ isVariantOf: { brand } }) => ({ name: brand }), breadcrumbList: ({ diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 1083e1efbf..3217142c88 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -1,8 +1,11 @@ +import { BadRequestError } from '../../errors' import { mutateChannelContext, mutateLocaleContext } from '../utils/contex' import { enhanceSku } from '../utils/enhanceSku' import { findChannel, findLocale, + findSkuId, + findSlug, transformSelectedFacet, } from '../utils/facets' import { SORT_MAP } from '../utils/sort' @@ -22,6 +25,8 @@ export const Query = { // Insert channel in context for later usage const channel = findChannel(locator) const locale = findLocale(locator) + const id = findSkuId(locator) + const slug = findSlug(locator) if (channel) { mutateChannelContext(ctx, channel) @@ -33,9 +38,28 @@ export const Query = { const { loaders: { skuLoader }, + clients: { commerce }, } = ctx - return skuLoader.load(locator) + const skuIdFromSlug = async (s: string) => { + // Standard VTEX PDP routes does not contain skuIds. + const [product] = await commerce.search.slug(s).catch(() => []) + + if (product) { + return product.items[0].itemId + } + + // We are not in a standard VTEX PDP route, this means we are in a /slug-skuId/p route + return s?.split('-').pop() ?? '' + } + + const skuId = slug ? await skuIdFromSlug(slug) : id + + if (skuId) { + return skuLoader.load(skuId) + } + + throw new BadRequestError(`Missing slug or id`) }, collection: (_: unknown, { slug }: QueryCollectionArgs, ctx: Context) => { const { diff --git a/packages/api/src/platforms/vtex/resolvers/seo.ts b/packages/api/src/platforms/vtex/resolvers/seo.ts index 5b569a31d8..e21227cf7f 100644 --- a/packages/api/src/platforms/vtex/resolvers/seo.ts +++ b/packages/api/src/platforms/vtex/resolvers/seo.ts @@ -1,10 +1,10 @@ import type { Resolver } from '..' -type Root = { title?: string; description?: string } +type Root = { title?: string; description?: string; canonical?: string } export const StoreSeo: Record> = { title: ({ title }) => title ?? '', description: ({ description }) => description ?? '', + canonical: ({ canonical }) => canonical ?? '', titleTemplate: () => '', - canonical: () => '', } diff --git a/packages/api/src/platforms/vtex/resolvers/validateCart.ts b/packages/api/src/platforms/vtex/resolvers/validateCart.ts index a37a80a86f..fad69b07c1 100644 --- a/packages/api/src/platforms/vtex/resolvers/validateCart.ts +++ b/packages/api/src/platforms/vtex/resolvers/validateCart.ts @@ -96,16 +96,16 @@ const equals = (storeOrder: IStoreOrder, orderForm: OrderForm) => { return isSameOrder && orderItemsAreSync } -const orderFormToCart = ( +const orderFormToCart = async ( form: OrderForm, skuLoader: Context['loaders']['skuLoader'] ) => { return { order: { orderNumber: form.orderFormId, - acceptedOffer: form.items.map((item) => ({ + acceptedOffer: form.items.map(async (item) => ({ ...item, - product: skuLoader.load([{ key: 'id', value: item.id }]), // TODO: add channel + product: await skuLoader.load(item.id), // TODO: add channel })), }, messages: form.messages.map(({ text, status }) => ({ diff --git a/packages/api/src/platforms/vtex/utils/canonical.ts b/packages/api/src/platforms/vtex/utils/canonical.ts new file mode 100644 index 0000000000..f508e103bf --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/canonical.ts @@ -0,0 +1,3 @@ +import type { Product } from '../clients/search/types/ProductSearchResult' + +export const canonicalFromProduct = ({ linkText }: Product) => `/${linkText}/p` diff --git a/packages/api/src/platforms/vtex/utils/errors.ts b/packages/api/src/platforms/vtex/utils/errors.ts deleted file mode 100644 index ddbcef8b28..0000000000 --- a/packages/api/src/platforms/vtex/utils/errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class BadRequestError extends Error { - constructor(message: string) { - super(message) - this.name = 'BadRequestError' - } -} - -export class NotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'NotFoundError' - } -} diff --git a/packages/api/src/platforms/vtex/utils/facets.ts b/packages/api/src/platforms/vtex/utils/facets.ts index c3e5ce5e64..1fa7a67a17 100644 --- a/packages/api/src/platforms/vtex/utils/facets.ts +++ b/packages/api/src/platforms/vtex/utils/facets.ts @@ -34,6 +34,12 @@ export const transformSelectedFacet = ({ key, value }: SelectedFacet) => { } } +export const findSlug = (facets?: Maybe) => + facets?.find((x) => x.key === 'slug')?.value ?? null + +export const findSkuId = (facets?: Maybe) => + facets?.find((x) => x.key === 'id')?.value ?? null + export const findLocale = (facets?: Maybe) => facets?.find((x) => x.key === 'locale')?.value ?? null diff --git a/packages/api/test/__snapshots__/queries.test.ts.snap b/packages/api/test/__snapshots__/queries.test.ts.snap index 713487a7a0..f2dd88f8ec 100644 --- a/packages/api/test/__snapshots__/queries.test.ts.snap +++ b/packages/api/test/__snapshots__/queries.test.ts.snap @@ -232,7 +232,7 @@ Object { "productID": "99988213", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/4k-philips-monitor/p", "description": "4k Philips Monitor 27\\"", "title": "4k Philips Monitor 27\\"", "titleTemplate": "", @@ -290,7 +290,7 @@ Object { "productID": "99988211", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/aedle-vk1-headphone/p", "description": "Aedle VK-1 L Headphone", "title": "Aedle VK-1 L Headphone", "titleTemplate": "", @@ -348,7 +348,7 @@ Object { "productID": "99988214", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/echo-dot-smart-speaker/p", "description": "Echo Dot Smart Speaker", "title": "Echo Dot Smart Speaker", "titleTemplate": "", @@ -406,7 +406,7 @@ Object { "productID": "99988210", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/oculus-vr-headset/p", "description": "Virtual reality kit", "title": "Oculus VR Headset", "titleTemplate": "", @@ -464,7 +464,7 @@ Object { "productID": "99988212", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/apple-magic-mouse/p", "description": "Apple Magic Mouse", "title": "Apple Magic Mouse", "titleTemplate": "", @@ -601,7 +601,7 @@ Object { "productID": "64953394", "review": Array [], "seo": Object { - "canonical": "", + "canonical": "/unbranded-concrete-table-small/p", "description": "Aut omnis nobis tenetur.", "title": "Unbranded Concrete Table Small", "titleTemplate": "", @@ -771,7 +771,7 @@ Object { "itemCondition": "https://schema.org/NewCondition", "itemOffered": Object { "seo": Object { - "canonical": "", + "canonical": "/licensed-cotton-hat-licensed/p", "description": "Consequatur placeat optio adipisci aut voluptate excepturi.", "title": "Licensed Cotton Hat Licensed", "titleTemplate": "", @@ -843,7 +843,7 @@ Object { "itemCondition": "https://schema.org/NewCondition", "itemOffered": Object { "seo": Object { - "canonical": "", + "canonical": "/handmade-granite-computer-unbranded/p", "description": "Ipsa in sequi incidunt dolores.", "title": "Handmade Granite Computer Unbranded", "titleTemplate": "", @@ -929,7 +929,7 @@ Object { "itemCondition": "https://schema.org/NewCondition", "itemOffered": Object { "seo": Object { - "canonical": "", + "canonical": "/small-cotton-cheese-3325400227651/p", "description": "Dolor harum perferendis voluptatem tempora voluptatum ut et sapiente iure.", "title": "Small Cotton Cheese", "titleTemplate": "", @@ -1001,7 +1001,7 @@ Object { "itemCondition": "https://schema.org/NewCondition", "itemOffered": Object { "seo": Object { - "canonical": "", + "canonical": "/tasty-frozen-tuna-handmade/p", "description": "Recusandae dolores alias.", "title": "Tasty Frozen Tuna Handmade", "titleTemplate": "", @@ -1077,7 +1077,7 @@ Object { "itemCondition": "https://schema.org/NewCondition", "itemOffered": Object { "seo": Object { - "canonical": "", + "canonical": "/sleek-metal-pizza/p", "description": "Aliquam a cumque ratione voluptatem in.", "title": "Sleek Metal Pizza", "titleTemplate": "",