diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index c6894a2f99..ec9ec66af1 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -1,5 +1,5 @@ -import type { Context, Options } from '../../index' import { fetchAPI } from '../fetch' +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' diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/Portal.ts b/packages/api/src/platforms/vtex/clients/commerce/types/Portal.ts index b6cee9ae6c..c6a16394f1 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/types/Portal.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/types/Portal.ts @@ -6,7 +6,7 @@ export interface CollectionPageType { url: string title: string metaTagDescription: string - pageType: 'Brand' | 'Category' | 'Department' | 'Subcategory' + pageType: 'Brand' | 'Category' | 'Department' | 'Subcategory' | 'Product' } export interface FallbackPageType { diff --git a/packages/api/src/platforms/vtex/loaders/sku.ts b/packages/api/src/platforms/vtex/loaders/sku.ts index 6c813e1a3a..e68e530f06 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 { enhanceSku } from '../utils/enhanceSku' -import { BadRequestError, NotFoundError } from '../../errors' +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, @@ -47,7 +34,7 @@ export const getSkuLoader = (_: Options, clients: Clients) => { 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..216bf7d0cb 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: ({ @@ -85,7 +87,7 @@ export const StoreProduct: Record> & { aggregateRating: () => ({}), offers: (root) => root.sellers - .flatMap((seller) => + .map((seller) => enhanceCommercialOffer({ offer: seller.commertialOffer, seller, diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 1083e1efbf..875ca3240c 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -1,8 +1,11 @@ +import { NotFoundError, 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' @@ -16,12 +19,15 @@ import type { } from '../../../__generated__/schema' import type { CategoryTree } from '../clients/commerce/types/CategoryTree' import type { Context } from '../index' +import { isValidSkuId, pickBestSku } from '../utils/sku' export const Query = { product: async (_: unknown, { locator }: QueryProductArgs, ctx: Context) => { // 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 +39,46 @@ export const Query = { const { loaders: { skuLoader }, + clients: { commerce, search }, } = ctx - return skuLoader.load(locator) + try { + const skuId = id ?? slug?.split('-').pop() ?? '' + + if (!isValidSkuId(skuId)) { + throw new Error('Invalid SkuId') + } + + const sku = await skuLoader.load(skuId) + + return sku + } catch (err) { + if (slug == null) { + throw new BadRequestError('Missing slug or id') + } + + const route = await commerce.catalog.portal.pagetype(`${slug}/p`) + + if (route.pageType !== 'Product' || !route.id) { + throw new NotFoundError(`No product found for slug ${slug}`) + } + + const { + products: [product], + } = await search.products({ + page: 0, + count: 1, + query: `product:${route.id}`, + }) + + if (!product) { + throw new NotFoundError(`No product found for id ${route.id}`) + } + + const sku = pickBestSku(product.items) + + return enhanceSku(sku, product) + } }, 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/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/src/platforms/vtex/utils/orderStatistics.ts b/packages/api/src/platforms/vtex/utils/orderStatistics.ts new file mode 100644 index 0000000000..84fb6b1827 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/orderStatistics.ts @@ -0,0 +1,16 @@ +/** + * More info at: https://en.wikipedia.org/wiki/Order_statistic + */ + +// O(n) search to find the max of an array +export const min = (array: T[], cmp: (a: T, b: T) => number) => { + let best = 0 + + for (let curr = 1; curr < array.length; curr++) { + if (cmp(array[best], array[curr]) > 0) { + best = curr + } + } + + return array[best] +} diff --git a/packages/api/src/platforms/vtex/utils/sku.ts b/packages/api/src/platforms/vtex/utils/sku.ts new file mode 100644 index 0000000000..dc0bd7fe34 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/sku.ts @@ -0,0 +1,26 @@ +import { min } from './orderStatistics' +import { bestOfferFirst } from './productStock' +import type { Item } from '../clients/search/types/ProductSearchResult' + +/** + * This function implements Portal heuristics for returning the best sku for a product. + * + * The best sku is the one with the best (cheapest available) offer + * */ +export const pickBestSku = (skus: Item[]) => { + const offersBySku = skus.flatMap((sku) => + sku.sellers.map((seller) => ({ + offer: seller.commertialOffer, + sku, + })) + ) + + const best = min(offersBySku, ({ offer: o1 }, { offer: o2 }) => + bestOfferFirst(o1, o2) + ) + + return best.sku +} + +export const isValidSkuId = (skuId: string) => + skuId !== '' && !Number.isNaN(Number(skuId)) 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": "",