From b8cb7d6797c71b2697dcc719dcdf2510734fed6d Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Fri, 10 Jun 2022 12:36:52 -0300 Subject: [PATCH 1/4] accept slug on Query.product --- .../platforms/vtex/clients/commerce/index.ts | 17 +++++++++++- .../api/src/platforms/vtex/loaders/sku.ts | 19 +++----------- .../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/facets.ts | 6 +++++ .../test/__snapshots__/queries.test.ts.snap | 22 ++++++++-------- 10 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 packages/api/src/platforms/vtex/utils/canonical.ts diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index c6894a2f99..af9ba70537 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,19 @@ export const VtexCommerce = ( body: '{}', }) }, + search: { + slug: ( + slug: string, + options?: { simulation: boolean } + ): Promise => { + const params = new URLSearchParams({ + simulation: `${options?.simulation ?? false}`, // skip simulation for faster queries + }) + + return fetchAPI( + `${base}/api/catalog_system/pub/products/search/${slug}/p?${params.toString()}` + ) + }, + }, } } 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..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..145a602331 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 !== null) { + 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/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": "", From 03d8bf28eaef9d7bf86e0311e56ebcd38763b6d9 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Sat, 11 Jun 2022 11:47:17 -0300 Subject: [PATCH 2/4] pick best sku --- .../platforms/vtex/clients/commerce/index.ts | 15 ------ .../vtex/clients/commerce/types/Portal.ts | 2 +- .../src/platforms/vtex/resolvers/product.ts | 2 +- .../api/src/platforms/vtex/resolvers/query.ts | 49 +++++++++++++------ .../platforms/vtex/utils/orderStatistics.ts | 29 +++++++++++ packages/api/src/platforms/vtex/utils/sku.ts | 26 ++++++++++ 6 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 packages/api/src/platforms/vtex/utils/orderStatistics.ts create mode 100644 packages/api/src/platforms/vtex/utils/sku.ts diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index af9ba70537..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,4 @@ 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' @@ -153,19 +152,5 @@ export const VtexCommerce = ( body: '{}', }) }, - search: { - slug: ( - slug: string, - options?: { simulation: boolean } - ): Promise => { - const params = new URLSearchParams({ - simulation: `${options?.simulation ?? false}`, // skip simulation for faster queries - }) - - return fetchAPI( - `${base}/api/catalog_system/pub/products/search/${slug}/p?${params.toString()}` - ) - }, - }, } } 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/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index e0b197ab4d..216bf7d0cb 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -87,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 145a602331..24af719693 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from '../../errors' +import { NotFoundError, BadRequestError } from '../../errors' import { mutateChannelContext, mutateLocaleContext } from '../utils/contex' import { enhanceSku } from '../utils/enhanceSku' import { @@ -19,6 +19,7 @@ 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) => { @@ -38,28 +39,46 @@ export const Query = { const { loaders: { skuLoader }, - clients: { commerce }, + clients: { commerce, search }, } = ctx - const skuIdFromSlug = async (s: string) => { - // Standard VTEX PDP routes does not contain skuIds. - const [product] = await commerce.search.slug(s).catch(() => []) + try { + const skuId = id ?? slug?.split('-').pop() ?? '' - if (product) { - return product.items[0].itemId + if (!isValidSkuId(skuId)) { + throw new Error('Invalid SkuId') } - // We are not in a standard VTEX PDP route, this means we are in a /slug-skuId/p route - return s?.split('-').pop() ?? '' - } + const sku = await skuLoader.load(skuId) + + return sku + } catch (err) { + if (slug == null) { + throw new BadRequestError(`Missing slug or id`) + } - const skuId = slug ? await skuIdFromSlug(slug) : id + const route = await commerce.catalog.portal.pagetype(`${slug}/p`) - if (skuId !== null) { - return skuLoader.load(skuId) - } + if (route.pageType !== 'Product' || !route.id) { + throw new NotFoundError(`No product found for slug ${slug}`) + } - throw new BadRequestError(`Missing slug or id`) + 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/utils/orderStatistics.ts b/packages/api/src/platforms/vtex/utils/orderStatistics.ts new file mode 100644 index 0000000000..06dd6508a9 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/orderStatistics.ts @@ -0,0 +1,29 @@ +/** + * More info at: https://en.wikipedia.org/wiki/Order_statistic + */ + +// O(n) search to find the max of an array +export const max = (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] +} + +// 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)) From 7f8aafa209e931085fe85b8c9bc81674683de7a5 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 13 Jun 2022 14:16:06 -0300 Subject: [PATCH 3/4] Update packages/api/src/platforms/vtex/resolvers/query.ts Co-authored-by: Emerson Laurentino --- packages/api/src/platforms/vtex/resolvers/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 24af719693..875ca3240c 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -54,7 +54,7 @@ export const Query = { return sku } catch (err) { if (slug == null) { - throw new BadRequestError(`Missing slug or id`) + throw new BadRequestError('Missing slug or id') } const route = await commerce.catalog.portal.pagetype(`${slug}/p`) From 8e617fabed4b636002e4e6ca80e50d0e3e655577 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 13 Jun 2022 14:16:53 -0300 Subject: [PATCH 4/4] apply requested changes --- .../api/src/platforms/vtex/utils/orderStatistics.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/api/src/platforms/vtex/utils/orderStatistics.ts b/packages/api/src/platforms/vtex/utils/orderStatistics.ts index 06dd6508a9..84fb6b1827 100644 --- a/packages/api/src/platforms/vtex/utils/orderStatistics.ts +++ b/packages/api/src/platforms/vtex/utils/orderStatistics.ts @@ -2,19 +2,6 @@ * More info at: https://en.wikipedia.org/wiki/Order_statistic */ -// O(n) search to find the max of an array -export const max = (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] -} - // O(n) search to find the max of an array export const min = (array: T[], cmp: (a: T, b: T) => number) => { let best = 0