diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index ec9ec66af1..916fd278f7 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -1,4 +1,6 @@ -import { fetchAPI } from '../fetch' +import { FACET_CROSS_SELLING_MAP } from "../../utils/facets" +import { fetchAPI } from "../fetch" +import type { PortalProduct } from "./types/Product" import type { Context, Options } from '../../index' import type { Brand } from './types/Brand' import type { CategoryTree } from './types/CategoryTree' @@ -13,6 +15,8 @@ import type { import type { Session } from './types/Session' import type { Channel } from '../../utils/channel' +type ValueOf = T extends Record ? K : never; + const BASE_INIT = { method: 'POST', headers: { @@ -40,6 +44,24 @@ export const VtexCommerce = ( pagetype: (slug: string): Promise => fetchAPI(`${base}/api/catalog_system/pub/portal/pagetype/${slug}`), }, + products: { + crossselling: ( + { type, productId, groupByProduct = true }: { + type: ValueOf; + productId: string; + groupByProduct?: boolean; + }, + ): Promise => { + const params = new URLSearchParams({ + sc: ctx.storage.channel.salesChannel, + groupByProduct: groupByProduct.toString(), + }) + + return fetchAPI( + `${base}/api/catalog_system/pub/products/crossselling/${type}/${productId}?${params}`, + ) + }, + }, }, checkout: { simulation: ( @@ -120,7 +142,7 @@ export const VtexCommerce = ( ...BASE_INIT, body: JSON.stringify({ value }), method: 'PUT', - } + }, ) }, region: async ({ diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/Product.ts b/packages/api/src/platforms/vtex/clients/commerce/types/Product.ts new file mode 100644 index 0000000000..d6bf1794f1 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/Product.ts @@ -0,0 +1,199 @@ +export interface PortalProduct { + productId: string; + productName: string; + brand: string; + brandId: number; + brandImageUrl: null | string; + linkText: string; + productReference: string; + productReferenceCode: string; + categoryId: string; + productTitle: string; + metaTagDescription: string; + releaseDate: Date; + clusterHighlights: unknown; + productClusters: unknown; + searchableClusters: unknown; + categories: Category[]; + categoriesIds: CategoriesId[]; + link: string; + description: string; + items: Item[]; +} + +enum Category { + Office = "/Office/", + OfficeChairs = "/Office/Chairs/", +} + +enum CategoriesId { + The9282 = "/9282/", + The92829296 = "/9282/9296/", +} + + + +interface Item { + itemId: string; + name: string; + nameComplete: string; + complementName: string; + ean: string; + referenceId: ReferenceId[]; + measurementUnit: MeasurementUnit; + unitMultiplier: number; + modalType: null; + isKit: boolean; + images: Image[]; + sellers: Seller[]; + videos: unknown[]; + estimatedDateArrival: null; +} + +interface Image { + imageId: string; + imageLabel: string; + imageTag: string; + imageUrl: string; + imageText: string; + imageLastModified: Date; +} + +enum MeasurementUnit { + Un = "un", +} + +interface ReferenceId { + key: Key; + value: string; +} + +enum Key { + RefId = "RefId", +} + +interface Seller { + sellerId: string; + sellerName: SellerName; + addToCartLink: string; + sellerDefault: boolean; + commertialOffer: CommertialOffer; +} + +interface CommertialOffer { + deliverySlaSamplesPerRegion: DeliverySlaSamplesPerRegion; + installments: Installment[]; + discountHighLight: unknown[]; + giftSkuIds: unknown[]; + teasers: unknown[]; + buyTogether: unknown[]; + itemMetadataAttachment: unknown[]; + price: number; + listPrice: number; + priceWithoutDiscount: number; + rewardValue: number; + priceValidUntil: Date; + availableQuantity: number; + isAvailable: boolean; + tax: number; + deliverySlaSamples: DeliverySlaSample[]; + getInfoErrorMessage: null; + cacheVersionUsedToCallCheckout: string; + paymentOptions: PaymentOptions; +} + +interface DeliverySlaSample { + deliverySlaPerTypes: unknown[]; + region: null; +} + +interface DeliverySlaSamplesPerRegion { + the0: DeliverySlaSample; +} + +interface Installment { + value: number; + interestRate: number; + totalValuePlusInterestRate: number; + numberOfInstallments: number; + paymentSystemName: PaymentSystemNameEnum; + paymentSystemGroupName: GroupName; + name: Name; +} + +enum Name { + BoletoBancárioÀVista = "Boleto Bancário à vista", + FreeÀVista = "Free à vista", +} + +enum GroupName { + BankInvoicePaymentGroup = "bankInvoicePaymentGroup", + Custom201PaymentGroupPaymentGroup = "custom201PaymentGroupPaymentGroup", +} + +enum PaymentSystemNameEnum { + BoletoBancário = "Boleto Bancário", + Free = "Free", +} + +interface PaymentOptions { + installmentOptions: InstallmentOption[]; + paymentSystems: PaymentSystem[]; + payments: unknown[]; + giftCards: unknown[]; + giftCardMessages: unknown[]; + availableAccounts: unknown[]; + availableTokens: unknown[]; +} + +interface InstallmentOption { + paymentSystem: string; + bin: null; + paymentName: PaymentSystemNameEnum; + paymentGroupName: GroupName; + value: number; + installments: InstallmentElement[]; +} + +interface InstallmentElement { + count: number; + hasInterestRate: boolean; + interestRate: number; + value: number; + total: number; + sellerMerchantInstallments?: InstallmentElement[]; + id?: Id; +} + +enum Id { + Storeframework = "STOREFRAMEWORK", +} + +interface PaymentSystem { + id: number; + name: PaymentSystemNameEnum; + groupName: GroupName; + validator: null; + stringId: string; + template: Template; + requiresDocument: boolean; + isCustom: boolean; + description: Description | null; + requiresAuthentication: boolean; + dueDate: Date; + availablePayments: null; +} + +enum Description { + FreePayToTestCheckoutPayments = "Free pay to test checkout payments", +} + +enum Template { + BankInvoicePaymentGroupTemplate = "bankInvoicePaymentGroup-template", + Custom201PaymentGroupPaymentGroupTemplate = + "custom201PaymentGroupPaymentGroup-template", +} + +enum SellerName { + Vtex = "VTEX", +} diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 875ca3240c..7597fb4f9a 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -1,25 +1,27 @@ -import { NotFoundError, BadRequestError } from '../../errors' -import { mutateChannelContext, mutateLocaleContext } from '../utils/contex' -import { enhanceSku } from '../utils/enhanceSku' +import { FACET_CROSS_SELLING_MAP } from "./../utils/facets" +import { BadRequestError, NotFoundError } from "../../errors" +import { mutateChannelContext, mutateLocaleContext } from "../utils/contex" +import { enhanceSku } from "../utils/enhanceSku" import { findChannel, + findCrossSelling, findLocale, findSkuId, findSlug, transformSelectedFacet, -} from '../utils/facets' -import { SORT_MAP } from '../utils/sort' -import { StoreCollection } from './collection' +} from "../utils/facets" +import { SORT_MAP } from "../utils/sort" +import { StoreCollection } from "./collection" import type { - QueryProductArgs, QueryAllCollectionsArgs, QueryAllProductsArgs, - QuerySearchArgs, QueryCollectionArgs, -} from '../../../__generated__/schema' -import type { CategoryTree } from '../clients/commerce/types/CategoryTree' -import type { Context } from '../index' -import { isValidSkuId, pickBestSku } from '../utils/sku' + QueryProductArgs, + QuerySearchArgs, +} 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) => { @@ -95,6 +97,7 @@ export const Query = { // Insert channel in context for later usage const channel = findChannel(selectedFacets) const locale = findLocale(selectedFacets) + const crossSelling = findCrossSelling(selectedFacets) if (channel) { mutateChannelContext(ctx, channel) @@ -104,11 +107,33 @@ export const Query = { mutateLocaleContext(ctx, locale) } + let query = term + + /** + * In case we are using crossSelling, we need to modify the search + * we will be performing on our search engine. The idea in here + * is to use the cross selling API for fetching the productIds our + * search will return for us. + * Doing this two request workflow makes it possible to have cross + * selling with Search features, like pagination, internationalization + * etc + */ + if (crossSelling) { + const products = await ctx.clients.commerce.catalog.products.crossselling({ + type: FACET_CROSS_SELLING_MAP[crossSelling.key], + productId: crossSelling.value, + }) + + query = `product:${ + products.map((x) => x.productId).slice(0, first).join(";") + }` + } + const after = maybeAfter ? Number(maybeAfter) : 0 const searchArgs = { - page: Math.ceil(after / first!), + page: Math.ceil(after / first), count: first, - query: term, + query, sort: SORT_MAP[sort ?? 'score_desc'], selectedFacets: selectedFacets?.flatMap(transformSelectedFacet) ?? [], } diff --git a/packages/api/src/platforms/vtex/utils/facets.ts b/packages/api/src/platforms/vtex/utils/facets.ts index 8e9d96f9f9..f908b80b2b 100644 --- a/packages/api/src/platforms/vtex/utils/facets.ts +++ b/packages/api/src/platforms/vtex/utils/facets.ts @@ -1,11 +1,26 @@ import ChannelMarshal from './channel' import type { Maybe } from '../../../__generated__/schema' +import { BadRequestError } from '../../errors' export interface SelectedFacet { key: string value: string } +export interface CrossSellingFacet { + key: keyof typeof FACET_CROSS_SELLING_MAP + value: string +} + +export const FACET_CROSS_SELLING_MAP = { + buy: "whoboughtalsobought", + view: "whosawalsosaw", + similars: "similars", + viewAndBought: "whosawalsobought", + accessories: "accessories", + suggestions: "suggestions", +} as const + /** * Transform facets from the store to VTEX platform facets. * For instance, the channel in Store becomes trade-policy and regionId in VTEX's realm @@ -33,6 +48,15 @@ export const transformSelectedFacet = ({ key, value }: SelectedFacet) => { return { key, value: value.replace('-to-', ':') } } + case "buy": + case "view": + case "similars": + case "viewAndBought": + case "accessories": + case "suggestions": { + return [] // remove this facet from search + } + default: return { key, value } } @@ -52,6 +76,23 @@ export const parseRange = (range: string): [number, number] | null => { return splitted as [number, number] } +export const isCrossSelling = ( + x: string, +): x is CrossSellingFacet['key'] => + typeof (FACET_CROSS_SELLING_MAP as Record)[x] === "string" + +export const findCrossSelling = (facets?: Maybe) => { + const filtered = facets?.filter((x): x is CrossSellingFacet => isCrossSelling(x.key)) + + if (Array.isArray(filtered) && filtered.length > 1) { + throw new BadRequestError( + `You passed ${filtered.length} cross selling facets but only one is allowed. Please leave one of the following facet: ${filtered.map(x => x.key).join(',')}` + ) + } + + return filtered?.[0] ?? null +} + export const findSlug = (facets?: Maybe) => facets?.find((x) => x.key === 'slug')?.value ?? null