diff --git a/packages/store-api/package.json b/packages/store-api/package.json index a397e9cefb..0172d59373 100644 --- a/packages/store-api/package.json +++ b/packages/store-api/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "@graphql-tools/schema": "^8.2.0", - "graphql": "^15.5.3", + "dataloader": "^2.0.0", + "fast-deep-equal": "^3.1.3", "isomorphic-unfetch": "^3.1.0", "rollup-plugin-graphql": "^0.1.0", "slugify": "^1.6.0" @@ -36,5 +37,8 @@ "tsdx": "^0.14.1", "tslib": "^2.3.1", "typescript": "^4.4.2" + }, + "peerDependencies": { + "graphql": "^15.6.0" } } diff --git a/packages/store-api/src/__generated__/schema.ts b/packages/store-api/src/__generated__/schema.ts index 279ea4f070..e1cde348dd 100644 --- a/packages/store-api/src/__generated__/schema.ts +++ b/packages/store-api/src/__generated__/schema.ts @@ -11,6 +11,48 @@ export type Scalars = { Float: number; }; +export type IStoreCart = { + order: IStoreOrder; +}; + +export type IStoreImage = { + alternateName: Scalars['String']; + url: Scalars['String']; +}; + +export type IStoreOffer = { + itemOffered: IStoreProduct; + listPrice: Scalars['Float']; + price: Scalars['Float']; + quantity: Scalars['Int']; + seller: IStoreOrganization; +}; + +export type IStoreOrder = { + acceptedOffer: Array; + orderNumber: Scalars['String']; +}; + +export type IStoreOrganization = { + identifier: Scalars['String']; +}; + +export type IStoreProduct = { + image: Array; + name: Scalars['String']; + sku: Scalars['String']; +}; + +export type Mutation = { + __typename?: 'Mutation'; + validateCart?: Maybe; +}; + + +export type MutationValidateCartArgs = { + cart: IStoreCart; +}; + export type Query = { __typename?: 'Query'; allCollections: StoreCollectionConnection; @@ -76,6 +118,18 @@ export type StoreBreadcrumbList = { numberOfItems: Scalars['Int']; }; +export type StoreCart = { + __typename?: 'StoreCart'; + messages: Array; + order: StoreOrder; +}; + +export type StoreCartMessage = { + __typename?: 'StoreCartMessage'; + status: StoreStatus; + text: Scalars['String']; +}; + export type StoreCollection = { __typename?: 'StoreCollection'; breadcrumbList: StoreBreadcrumbList; @@ -154,14 +208,22 @@ export type StoreOffer = { __typename?: 'StoreOffer'; availability: Scalars['String']; itemCondition: Scalars['String']; + itemOffered: StoreProduct; listPrice: Scalars['Float']; price: Scalars['Float']; priceCurrency: Scalars['String']; priceValidUntil: Scalars['String']; + quantity: Scalars['Int']; seller: StoreOrganization; sellingPrice: Scalars['Float']; }; +export type StoreOrder = { + __typename?: 'StoreOrder'; + acceptedOffer: Array; + orderNumber: Scalars['String']; +}; + export type StoreOrganization = { __typename?: 'StoreOrganization'; identifier: Scalars['String']; @@ -264,3 +326,9 @@ export const enum StoreSort { ReleaseDesc = 'release_desc', ScoreDesc = 'score_desc' }; + +export const enum StoreStatus { + Error = 'ERROR', + Info = 'INFO', + Warning = 'WARNING' +}; diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/index.ts b/packages/store-api/src/platforms/vtex/clients/commerce/index.ts index b39485ecf6..c761a60142 100644 --- a/packages/store-api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/store-api/src/platforms/vtex/clients/commerce/index.ts @@ -3,16 +3,25 @@ import type { Simulation, SimulationArgs, SimulationOptions, -} from './types/Checkout' +} from './types/Simulation' import type { CategoryTree } from './types/CategoryTree' import type { Options } from '../..' import type { Brand } from './types/Brand' +import type { OrderForm, OrderFormInputItem } from './types/OrderForm' + +const BASE_INIT = { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, +} const getBase = ({ account, environment }: Options) => `http://${account}.${environment}.com.br` -export const VtexCommerce = (opts: Options) => { - const base = getBase(opts) +export const VtexCommerce = (options: Options) => { + const { channel } = options + const base = getBase(options) return { catalog: { @@ -28,18 +37,61 @@ export const VtexCommerce = (opts: Options) => { checkout: { simulation: ( args: SimulationArgs, - options: SimulationOptions = { sc: '1' } + { salesChannel }: SimulationOptions = { salesChannel: channel } ): Promise => { - const params = new URLSearchParams({ ...options }) + const params = new URLSearchParams({ + sc: salesChannel, + }) return fetchAPI( `${base}/api/checkout/pub/orderForms/simulation?${params.toString()}`, { - method: 'POST', + ...BASE_INIT, body: JSON.stringify(args), - headers: { - 'content-type': 'application/json', - }, + } + ) + }, + orderForm: ({ + id, + refreshOutdatedData = true, + salesChannel = channel, + }: { + id: string + refreshOutdatedData?: boolean + salesChannel?: string + }): Promise => { + const params = new URLSearchParams({ + refreshOutdatedData: refreshOutdatedData.toString(), + sc: salesChannel, + }) + + return fetchAPI( + `${base}/api/checkout/pub/orderForm/${id}?${params.toString()}`, + BASE_INIT + ) + }, + updateOrderFormItems: ({ + id, + orderItems, + allowOutdatedData = 'paymentData', + salesChannel = channel, + }: { + id: string + orderItems: OrderFormInputItem[] + allowOutdatedData?: 'paymentData' + salesChannel?: string + }): Promise => { + const params = new URLSearchParams({ + allowOutdatedData, + sc: salesChannel, + }) + + return fetchAPI( + `${base}/api/checkout/pub/orderForm/${id}/items?${params}`, + { + ...BASE_INIT, + body: JSON.stringify({ orderItems }), + method: 'PATCH', } ) }, diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/types/OrderForm.ts b/packages/store-api/src/platforms/vtex/clients/commerce/types/OrderForm.ts new file mode 100644 index 0000000000..ebb3d66d39 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/commerce/types/OrderForm.ts @@ -0,0 +1,371 @@ +export interface OrderFormInputItem { + id: string + quantity: number + seller: string + index?: number +} + +export interface OrderFormItem { + id: string + name: string + detailUrl: string + imageUrl: string + skuName: string + quantity: number + uniqueId: string + productId: string + refId: string + ean: string + priceValidUntil: string + price: number + tax: number + listPrice: number + sellingPrice: number + rewardValue: number + isGift: boolean + parentItemIndex: number | null + parentAssemblyBinding: string | null + productCategoryIds: string + priceTags: string[] + manualPrice: number + measurementUnit: string + additionalInfo: { + brandName: string + brandId: string + offeringInfo: any | null + offeringType: any | null + offeringTypeId: any | null + } + productCategories: Record + productRefId: string + seller: string + sellerChain: string[] + availability: string + unitMultiplier: number + skuSpecifications: SKUSpecification[] + priceDefinition: { + calculatedSellingPrice: number + sellingPrices: SellingPrice[] + total: number + } +} + +export interface SKUSpecification { + fieldName: string + fieldValues: string[] +} + +export interface AdditionalInfo { + dimension: null + brandName: string + brandId: string + offeringInfo: null + offeringType: null + offeringTypeId: null +} + +export interface PriceDefinition { + calculatedSellingPrice: number + total: number + sellingPrices: SellingPrice[] +} + +export interface SellingPrice { + value: number + quantity: number +} + +export interface PriceTag { + name: string + value: number + rawValue: number + isPercentual: boolean + identifier: string +} + +export interface OrderForm { + orderFormId: string + salesChannel: string + loggedIn: boolean + isCheckedIn: boolean + storeId: string | null + checkedInPickupPointId: string | null + allowManualPrice: boolean + canEditData: boolean + userProfileId: string | null + userType: string | null + ignoreProfileData: boolean + value: number + messages: any[] + items: OrderFormItem[] + selectableGifts: any[] + totalizers: Array<{ + id: string + name: string + value: number + }> + shippingData: ShippingData | null + clientProfileData: ClientProfileData | null + paymentData: PaymentData + marketingData: OrderFormMarketingData | null + sellers: Array<{ + id: string + name: string + logo: string + }> + clientPreferencesData: { + locale: string + optinNewsLetter: any | null + } + commercialConditionData: any | null + storePreferencesData: { + countryCode: string + currencyCode: string + currencyFormatInfo: { + currencyDecimalDigits: number + currencyDecimalSeparator: string + currencyGroupSeparator: string + currencyGroupSize: number + startsWithCurrencySymbol: boolean + } + currencyLocale: string + currencySymbol: string + saveUserData: boolean + timeZone: string + } + giftRegistryData: any | null + openTextField: any | null + invoiceData: any | null + customData: any | null + itemMetadata: { + items: MetadataItem[] + } + hooksData: any | null + ratesAndBenefitsData: { + rateAndBenefitsIdentifiers: any[] + teaser: any[] + } + subscriptionData: SubscriptionData | null + itemsOrdination: any | null +} + +export interface OrderFormMarketingData { + utmCampaign?: string + utmMedium?: string + utmSource?: string + utmiCampaign?: string + utmiPart?: string + utmipage?: string + marketingTags?: string + coupon?: string +} + +export interface PaymentData { + installmentOptions: Array<{ + paymentSystem: string + bin: string | null + paymentName: string | null + paymentGroupName: string | null + value: number + installments: Array<{ + count: number + hasInterestRate: false + interestRate: number + value: number + total: number + sellerMerchantInstallments: Array<{ + count: number + hasInterestRate: false + interestRate: number + value: number + total: number + }> + }> + }> + paymentSystems: Array<{ + id: string + name: string + groupName: string + validator: { + regex: string + mask: string + cardCodeRegex: string + cardCodeMask: string + weights: number[] + useCvv: boolean + useExpirationDate: boolean + useCardHolderName: boolean + useBillingAddress: boolean + } + stringId: string + template: string + requiresDocument: boolean + isCustom: boolean + description: string | null + requiresAuthentication: boolean + dueDate: string + availablePayments: any | null + }> + payments: any[] + giftCards: any[] + giftCardMessages: any[] + availableAccounts: any[] + availableTokens: any[] +} + +export interface ClientProfileData { + email: string + firstName: string + lastName: string + document: string + documentType: string + phone: string + corporateName: string + tradeName: string + corporateDocument: string + stateInscription: string + corporatePhone: string + isCorporate: boolean + profileCompleteOnLoading: boolean + profileErrorOnLoading: boolean + customerClass: string +} + +export interface ShippingData { + address: CheckoutAddress | null + logisticsInfo: LogisticsInfo[] + selectedAddresses: CheckoutAddress[] + availableAddresses: CheckoutAddress[] + pickupPoints: PickupPoint[] +} + +export interface PickupPoint { + friendlyName: string + address: CheckoutAddress + additionalInfo: string + id: string + businessHours: BusinessHour[] +} + +export interface BusinessHour { + DayOfWeek: number + ClosingTime: string + OpeningTime: string +} + +export interface LogisticsInfo { + addressId: string | null + deliveryChannels: DeliveryChannel[] + itemId: string + itemIndex: number + shipsTo: string[] + slas: SLA[] + selectedDeliveryChannel: string | null + selectedSla: string | null +} + +export interface SLA { + id: string + deliveryChannel: string + name: string + deliveryIds: DeliveryId[] + shippingEstimate: string + shippingEstimateDate: string | null + lockTTL: string | null + availableDeliveryWindows: any[] + deliveryWindow: string | null + price: number + listPrice: number + tax: number + pickupStoreInfo: { + isPickupStore: boolean + friendlyName: string | null + address: CheckoutAddress | null + additionalInfo: any | null + dockId: string | null + } + pickupPointId: string | null + pickupDistance: number | null + polygonName: string | null + transitTime: string | null +} + +export interface DeliveryId { + courierId: string + warehouseId: string + dockId: string + courierName: string + quantity: number +} + +export interface DeliveryChannel { + id: string +} + +export interface CheckoutAddress { + addressId: string + addressType: string + city: string | null + complement: string | null + country: string + geoCoordinates: number[] + neighborhood: string | null + number: string | null + postalCode: string | null + receiverName: string | null + reference: string | null + state: string | null + street: string | null + isDisposable: boolean +} + +export interface MetadataItem { + id: string + name: string + imageUrl: string + detailUrl: string + seller: string + assemblyOptions: AssemblyOption[] + skuName: string + productId: string + refId: string + ean: string | null +} + +export interface AssemblyOption { + id: string + name: string + composition: Composition | null +} + +export interface SubscriptionDataEntry { + executionCount: number + itemIndex: number + plan: { + frequency: { + interval: number + periodicity: 'YEAR' | 'MONTH' | 'WEEK' | 'DAY' + } + type: string + validity: unknown + } +} + +export interface CompositionItem { + id: string + minQuantity: number + maxQuantity: number + initialQuantity: number + priceTable: string + seller: string +} + +export interface Composition { + minQuantity: number + maxQuantity: number + items: CompositionItem[] +} + +export interface SubscriptionData { + subscriptions: SubscriptionDataEntry[] +} diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts b/packages/store-api/src/platforms/vtex/clients/commerce/types/Simulation.ts similarity index 99% rename from packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts rename to packages/store-api/src/platforms/vtex/clients/commerce/types/Simulation.ts index 3dd23440b9..d203ab41bc 100644 --- a/packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts +++ b/packages/store-api/src/platforms/vtex/clients/commerce/types/Simulation.ts @@ -21,7 +21,7 @@ export interface SimulationArgs { } export interface SimulationOptions { - sc: string + salesChannel: string } export interface Simulation { diff --git a/packages/store-api/src/platforms/vtex/clients/common.ts b/packages/store-api/src/platforms/vtex/clients/common.ts index 6fe8d4170f..7020c11dba 100644 --- a/packages/store-api/src/platforms/vtex/clients/common.ts +++ b/packages/store-api/src/platforms/vtex/clients/common.ts @@ -9,6 +9,5 @@ export const fetchAPI = async (info: RequestInfo, init?: RequestInit) => { const text = await response.text() - console.error(text) throw new Error(text) } diff --git a/packages/store-api/src/platforms/vtex/clients/search/index.ts b/packages/store-api/src/platforms/vtex/clients/search/index.ts index 667ddae167..0e5eaaea6c 100644 --- a/packages/store-api/src/platforms/vtex/clients/search/index.ts +++ b/packages/store-api/src/platforms/vtex/clients/search/index.ts @@ -33,16 +33,17 @@ export interface ProductLocator { value: string } -// TODO: change here once supporting sales channel -const defaultFacets = [ - { - key: 'trade-policy', - value: '1', - }, -] +export const IntelligentSearch = (options: Options) => { + const { channel } = options + const base = `http://search.biggylabs.com.br/search-api/v1/${options.account}` -export const IntelligentSearch = (opts: Options) => { - const base = `http://search.biggylabs.com.br/search-api/v1/${opts.account}` + // TODO: change here once supporting sales channel + const defaultFacets = [ + { + key: 'trade-policy', + value: channel, + }, + ] const search = ({ query = '', diff --git a/packages/store-api/src/platforms/vtex/index.ts b/packages/store-api/src/platforms/vtex/index.ts index 5a0d28a490..3faaa809ac 100644 --- a/packages/store-api/src/platforms/vtex/index.ts +++ b/packages/store-api/src/platforms/vtex/index.ts @@ -1,26 +1,32 @@ -import { StoreSearchResult } from './resolvers/searchResult' import { getClients } from './clients' +import { getLoaders } from './loaders' import { StoreAggregateOffer } from './resolvers/aggregateOffer' import { StoreAggregateRating } from './resolvers/aggregateRating' import { StoreCollection } from './resolvers/collection' import { StoreFacet } from './resolvers/facet' import { StoreFacetValue } from './resolvers/facetValue' +import { Mutation } from './resolvers/mutation' import { StoreOffer } from './resolvers/offer' import { StoreProduct } from './resolvers/product' import { StoreProductGroup } from './resolvers/productGroup' import { Query } from './resolvers/query' import { StoreReview } from './resolvers/review' +import { StoreSearchResult } from './resolvers/searchResult' import { StoreSeo } from './resolvers/seo' +import type { Loaders } from './loaders' import type { Clients } from './clients' export interface Options { platform: 'vtex' account: string environment: 'vtexcommercestable' | 'vtexcommercebeta' + // Default sales channel to use for fetching products + channel: string } export interface Context { clients: Clients + loaders: Loaders } export type Resolver = ( @@ -43,10 +49,12 @@ const Resolvers = { StoreProductGroup, StoreSearchResult, Query, + Mutation, } export const getContextFactory = (options: Options) => (ctx: any) => { ctx.clients = getClients(options) + ctx.loaders = getLoaders(options, ctx.clients) return ctx } diff --git a/packages/store-api/src/platforms/vtex/loaders/index.ts b/packages/store-api/src/platforms/vtex/loaders/index.ts new file mode 100644 index 0000000000..e05ad0fcab --- /dev/null +++ b/packages/store-api/src/platforms/vtex/loaders/index.ts @@ -0,0 +1,16 @@ +import { getSimulationLoader } from './simulation' +import { getSkuLoader } from './sku' +import type { Options } from '..' +import type { Clients } from '../clients' + +export type Loaders = ReturnType + +export const getLoaders = (options: Options, clients: Clients) => { + const skuLoader = getSkuLoader(options, clients) + const simulationLoader = getSimulationLoader(options, clients) + + return { + skuLoader, + simulationLoader, + } +} diff --git a/packages/store-api/src/platforms/vtex/loaders/simulation.ts b/packages/store-api/src/platforms/vtex/loaders/simulation.ts new file mode 100644 index 0000000000..2f13abbd30 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/loaders/simulation.ts @@ -0,0 +1,42 @@ +import DataLoader from 'dataloader' + +import type { + PayloadItem, + Simulation, +} from '../clients/commerce/types/Simulation' +import type { Options } from '..' +import type { Clients } from '../clients' + +export const getSimulationLoader = (_: Options, clients: Clients) => { + const loader = async (items: readonly PayloadItem[][]) => { + const simulated = await clients.commerce.checkout.simulation({ + items: [...items.flat()], + }) + + const itemsIndices = items.reduce( + (acc, curr) => [...acc, curr.length + acc[acc.length - 1]], + [0] + ) + + if (simulated.items.length !== itemsIndices[itemsIndices.length - 1]) { + const askedItems = itemsIndices[itemsIndices.length - 1] + const returnedItems = simulated.items.length + + throw new Error( + `Simulation asked for ${askedItems}, but received ${returnedItems} items` + ) + } + + return items.map((__, index) => ({ + ...simulated, + items: simulated.items.slice( + itemsIndices[index], + itemsIndices[index + 1] + ), + })) + } + + return new DataLoader(loader, { + maxBatchSize: 20, + }) +} diff --git a/packages/store-api/src/platforms/vtex/loaders/sku.ts b/packages/store-api/src/platforms/vtex/loaders/sku.ts new file mode 100644 index 0000000000..e5e7ad1118 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/loaders/sku.ts @@ -0,0 +1,48 @@ +import DataLoader from 'dataloader' + +import { enhanceSku } from '../utils/enhanceSku' +import type { EnhancedSku } from '../utils/enhanceSku' +import type { Options } from '..' +import type { Clients } from '../clients' + +export const getSkuLoader = (_: Options, clients: Clients) => { + const loader = async (skuIds: readonly string[]) => { + const indexById = skuIds.reduce( + (acc, id, index) => ({ ...acc, [id]: index }), + {} as Record + ) + + const { products } = await clients.search.products({ + query: `sku:${skuIds.join(';')}`, + page: 0, + count: skuIds.length, + }) + + if (products.length !== skuIds.length) { + throw new Error( + `Sku batching went wrong. Asked for ${skuIds.length} sku(s) but search api returned ${products.length} sku(s)` + ) + } + + const sorted = new Array(products.length) + + // O(n*m) sort, where n = skuIds.length and m is the number of skus per product + for (const product of products) { + const sku = product.skus.find((item) => indexById[item.id] != null) + + if (sku == null) { + throw new Error(`Could not find sku for product ${product.id}`) + } + + const index = indexById[sku.id] + + sorted[index] = enhanceSku(sku, product) + } + + return sorted + } + + return new DataLoader(loader, { + maxBatchSize: 50, // Warning: Don't change this value, this the max allowed batch size of Search API + }) +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts b/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts index bc8a8c5cf7..8acfe39e4a 100644 --- a/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts +++ b/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts @@ -1,6 +1,7 @@ -import type { Simulation } from '../clients/commerce/types/Checkout' +import type { EnhancedSku } from '../utils/enhanceSku' +import type { Simulation } from '../clients/commerce/types/Simulation' -type Resolvers = (root: Simulation) => unknown +type Resolvers = (root: Simulation & { product: EnhancedSku }) => unknown export const StoreAggregateOffer: Record = { highPrice: ({ items }) => @@ -15,5 +16,5 @@ export const StoreAggregateOffer: Record = { ) / 1e2, offerCount: ({ items }) => items.length, priceCurrency: () => '', - offers: ({ items }) => items, + offers: ({ items, product }) => items.map((item) => ({ ...item, product })), } diff --git a/packages/store-api/src/platforms/vtex/resolvers/mutation.ts b/packages/store-api/src/platforms/vtex/resolvers/mutation.ts new file mode 100644 index 0000000000..1a60c6717d --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/mutation.ts @@ -0,0 +1,5 @@ +import { validateCart } from './validateCart' + +export const Mutation = { + validateCart, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/offer.ts b/packages/store-api/src/platforms/vtex/resolvers/offer.ts index 69b2797f09..3d74691978 100644 --- a/packages/store-api/src/platforms/vtex/resolvers/offer.ts +++ b/packages/store-api/src/platforms/vtex/resolvers/offer.ts @@ -1,7 +1,13 @@ +import type { EnhancedSku } from '../utils/enhanceSku' import type { Resolver } from '..' -import type { Item } from '../clients/commerce/types/Checkout' +import type { Item } from '../clients/commerce/types/Simulation' +import type { OrderFormItem } from '../clients/commerce/types/OrderForm' -export const StoreOffer: Record> = { +type Root = + | (Item & { product: EnhancedSku }) // when querying search/product + | (OrderFormItem & { product: Promise }) // when querying order + +export const StoreOffer: Record> = { priceCurrency: () => '', priceValidUntil: ({ priceValidUntil }) => priceValidUntil ?? '', itemCondition: () => 'https://schema.org/NewCondition', @@ -15,4 +21,6 @@ export const StoreOffer: Record> = { price: ({ sellingPrice }) => sellingPrice / 1e2, // TODO add spot price calculation sellingPrice: ({ sellingPrice }) => sellingPrice / 1e2, listPrice: ({ listPrice }) => listPrice / 1e2, + itemOffered: ({ product }) => product, + quantity: ({ quantity }) => quantity, } diff --git a/packages/store-api/src/platforms/vtex/resolvers/product.ts b/packages/store-api/src/platforms/vtex/resolvers/product.ts index d923e50942..cdf9932d6e 100644 --- a/packages/store-api/src/platforms/vtex/resolvers/product.ts +++ b/packages/store-api/src/platforms/vtex/resolvers/product.ts @@ -52,11 +52,13 @@ export const StoreProduct: Record> = { gtin: ({ reference }) => reference ?? '', review: () => [], aggregateRating: () => ({}), - offers: async ({ sellers, id }, _, ctx) => { + offers: async (product, _, ctx) => { const { - clients: { commerce }, + loaders: { simulationLoader }, } = ctx + const { sellers, id } = product + // Unique seller ids const sellerIds = sellers.map((seller) => seller.id) const items = Array.from(new Set(sellerIds)).map((seller) => ({ @@ -65,9 +67,9 @@ export const StoreProduct: Record> = { id, })) - return commerce.checkout.simulation({ - items, - }) + const simulation = await simulationLoader.load(items) + + return { ...simulation, product } }, isVariantOf: ({ isVariantOf }) => isVariantOf, } diff --git a/packages/store-api/src/platforms/vtex/resolvers/query.ts b/packages/store-api/src/platforms/vtex/resolvers/query.ts index 4e21c439d8..8a9fe2a9e6 100644 --- a/packages/store-api/src/platforms/vtex/resolvers/query.ts +++ b/packages/store-api/src/platforms/vtex/resolvers/query.ts @@ -32,7 +32,7 @@ export const Query = { ctx: Context ) => { const { - clients: { search }, + loaders: { skuLoader }, } = ctx const skuId = @@ -40,19 +40,7 @@ export const Query = { ? locator.value : locator.value.split('-').reverse()[0] - const { - products: [product], - } = await search.products({ query: `sku:${skuId}`, page: 0, count: 1 }) - - const sku = product.skus.find((x) => x.id === skuId) - - if (sku == null) { - throw new Error( - `Could not find sku of id: ${skuId} for locator field: ${locator.field}, value: ${locator.value}` - ) - } - - return enhanceSku(sku, product) + return skuLoader.load(skuId) }, search: async ( _: unknown, diff --git a/packages/store-api/src/platforms/vtex/resolvers/validateCart.ts b/packages/store-api/src/platforms/vtex/resolvers/validateCart.ts new file mode 100644 index 0000000000..5db58583c0 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/validateCart.ts @@ -0,0 +1,166 @@ +import deepEquals from 'fast-deep-equal' + +import type { IStoreCart, IStoreOffer } from '../../../__generated__/schema' +import type { + OrderForm, + OrderFormItem, + OrderFormInputItem, +} from '../clients/commerce/types/OrderForm' +import type { Context } from '..' + +type Indexed = T & { index?: number } + +const getId = (item: IStoreOffer) => + [item.itemOffered.sku, item.seller.identifier, item.price].join('::') + +const orderFormItemToOffer = ( + item: OrderFormItem, + index?: number +): Indexed => ({ + listPrice: item.listPrice / 100, + price: item.sellingPrice / 100, + quantity: item.quantity, + seller: { identifier: item.seller }, + itemOffered: { + sku: item.id, + image: [], + name: item.name, + }, + index, +}) + +const offerToOrderItemInput = ( + offer: Indexed +): OrderFormInputItem => ({ + quantity: offer.quantity, + seller: offer.seller.identifier, + id: offer.itemOffered.sku, + index: offer.index, +}) + +const groupById = (offers: IStoreOffer[]): Map => + offers.reduce((acc, item) => { + const id = getId(item) + + acc.set(id, acc.get(id) ?? item) + + return acc + }, new Map()) + +const equals = (of1: OrderForm, of2: OrderForm) => { + const pick = ({ orderFormId, messages, items, salesChannel }: OrderForm) => ({ + orderFormId, + messages, + salesChannel, + items: items.map( + ({ uniqueId, quantity, seller, sellingPrice, availability }) => ({ + uniqueId, + quantity, + seller, + sellingPrice, + availability, + }) + ), + }) + + return deepEquals(pick(of1), pick(of2)) +} + +/** + * This resolver implements the optimistic cart behavior. The main idea in here + * is that we receive a cart from the UI (as query params) and we validate it with + * the commerce platform. If the cart is valid, we return null, if the cart is + * invalid according to the commerce platform, we return the new cart the UI should use + * instead + * + * The algoritm is something like: + * 1. Fetch orderForm from VTEX + * 2. Compute delta changes between the orderForm and the UI's cart + * 3. Update the orderForm in VTEX platform accordingly + * 4. If any chages were made, send to the UI the new cart. Null otherwise + */ +export const validateCart = async ( + _: unknown, + { + cart: { + order: { orderNumber, acceptedOffer }, + }, + }: { cart: IStoreCart }, + ctx: Context +) => { + const { + clients: { commerce }, + loaders: { skuLoader }, + } = ctx + + // Step1: Get OrderForm from VTEX Commerce + const orderForm = await commerce.checkout.orderForm({ + id: orderNumber, + }) + + // Step2: Process items from both browser and checkout so they have the same shape + const browserItemsById = groupById(acceptedOffer) + const originItemsById = groupById(orderForm.items.map(orderFormItemToOffer)) + const browserItems = Array.from(browserItemsById.values()) // items on the user's browser + const originItems = Array.from(originItemsById.values()) // items on the VTEX platform backend + + // Step3: Compute delta changes + const { itemsToAdd, itemsToUpdate } = browserItems.reduce( + (acc, item) => { + const maybeOriginItem = originItemsById.get(getId(item)) + + if (!maybeOriginItem) { + acc.itemsToAdd.push(item) + } else { + acc.itemsToUpdate.push({ + ...maybeOriginItem, + quantity: item.quantity, + }) + } + + return acc + }, + { + itemsToAdd: [] as IStoreOffer[], + itemsToUpdate: [] as IStoreOffer[], + } + ) + + const itemsToDelete = originItems + .filter((item) => !browserItemsById.has(getId(item))) + .map((item) => ({ ...item, quantity: 0 })) + + const changes = [...itemsToAdd, ...itemsToUpdate, ...itemsToDelete].map( + offerToOrderItemInput + ) + + if (changes.length === 0) { + return null + } + + // Step4: Apply delta changes to order form + const updatedOrderForm = await commerce.checkout.updateOrderFormItems({ + id: orderForm.orderFormId, + orderItems: changes, + }) + + // Step5: If no changes detected before/after updating orderForm, the order is validated + if (equals(orderForm, updatedOrderForm)) { + return null + } + + // Step6: There were changes, convert orderForm to StoreOrder + return { + order: { + orderNumber: updatedOrderForm.orderFormId, + acceptedOffer: updatedOrderForm.items.map((item) => ({ + ...item, + product: skuLoader.load(item.id), + })), + }, + messages: updatedOrderForm.messages.map(({ text, status }) => ({ + text, + status: status.toUpperCase(), + })), + } +} diff --git a/packages/store-api/src/typeDefs/cart.graphql b/packages/store-api/src/typeDefs/cart.graphql new file mode 100644 index 0000000000..8719740674 --- /dev/null +++ b/packages/store-api/src/typeDefs/cart.graphql @@ -0,0 +1,13 @@ +type StoreCartMessage { + text: String! + status: StoreStatus! +} + +type StoreCart { + order: StoreOrder! + messages: [StoreCartMessage!]! +} + +input IStoreCart { + order: IStoreOrder! +} diff --git a/packages/store-api/src/typeDefs/image.graphql b/packages/store-api/src/typeDefs/image.graphql index f14908085b..8d0af62928 100644 --- a/packages/store-api/src/typeDefs/image.graphql +++ b/packages/store-api/src/typeDefs/image.graphql @@ -2,3 +2,8 @@ type StoreImage { url: String! alternateName: String! } + +input IStoreImage { + url: String! + alternateName: String! +} diff --git a/packages/store-api/src/typeDefs/index.ts b/packages/store-api/src/typeDefs/index.ts index 5f55589919..4f20a70d33 100644 --- a/packages/store-api/src/typeDefs/index.ts +++ b/packages/store-api/src/typeDefs/index.ts @@ -8,7 +8,9 @@ import Breadcrumb from './breadcrumb.graphql' import Collection from './collection.graphql' import Facet from './facet.graphql' import Image from './image.graphql' +import Mutation from './mutation.graphql' import Offer from './offer.graphql' +import Order from './order.graphql' import Organization from './organization.graphql' import PageInfo from './pageInfo.graphql' import Product from './product.graphql' @@ -16,9 +18,12 @@ import ProductGroup from './productGroup.graphql' import Query from './query.graphql' import Review from './review.graphql' import Seo from './seo.graphql' +import Cart from './cart.graphql' +import Status from './status.graphql' export const typeDefs = [ Query, + Mutation, Brand, Breadcrumb, Collection, @@ -34,6 +39,9 @@ export const typeDefs = [ ProductGroup, Organization, AggregateOffer, + Order, + Cart, + Status, ] .map(print) .join('\n') diff --git a/packages/store-api/src/typeDefs/mutation.graphql b/packages/store-api/src/typeDefs/mutation.graphql new file mode 100644 index 0000000000..f1a68a6b72 --- /dev/null +++ b/packages/store-api/src/typeDefs/mutation.graphql @@ -0,0 +1,4 @@ +type Mutation { + # Returns the order if anything changed with the order. Null if the order is valid + validateCart(cart: IStoreCart!): StoreCart +} diff --git a/packages/store-api/src/typeDefs/offer.graphql b/packages/store-api/src/typeDefs/offer.graphql index c158866c0f..c7b41e493e 100644 --- a/packages/store-api/src/typeDefs/offer.graphql +++ b/packages/store-api/src/typeDefs/offer.graphql @@ -8,4 +8,14 @@ type StoreOffer { itemCondition: String! availability: String! seller: StoreOrganization! + itemOffered: StoreProduct! + quantity: Int! +} + +input IStoreOffer { + price: Float! + listPrice: Float! + seller: IStoreOrganization! + itemOffered: IStoreProduct! + quantity: Int! } diff --git a/packages/store-api/src/typeDefs/order.graphql b/packages/store-api/src/typeDefs/order.graphql new file mode 100644 index 0000000000..86fbbc2912 --- /dev/null +++ b/packages/store-api/src/typeDefs/order.graphql @@ -0,0 +1,9 @@ +type StoreOrder { + orderNumber: String! + acceptedOffer: [StoreOffer!]! +} + +input IStoreOrder { + orderNumber: String! + acceptedOffer: [IStoreOffer!]! +} diff --git a/packages/store-api/src/typeDefs/organization.graphql b/packages/store-api/src/typeDefs/organization.graphql index c2659284c8..6682dfa40f 100644 --- a/packages/store-api/src/typeDefs/organization.graphql +++ b/packages/store-api/src/typeDefs/organization.graphql @@ -1,3 +1,7 @@ type StoreOrganization { identifier: String! } + +input IStoreOrganization { + identifier: String! +} diff --git a/packages/store-api/src/typeDefs/product.graphql b/packages/store-api/src/typeDefs/product.graphql index 91d5cc9e32..74bcdef713 100644 --- a/packages/store-api/src/typeDefs/product.graphql +++ b/packages/store-api/src/typeDefs/product.graphql @@ -17,3 +17,9 @@ type StoreProduct { aggregateRating: StoreAggregateRating! isVariantOf: StoreProductGroup! } + +input IStoreProduct { + sku: String! + name: String! + image: [IStoreImage!]! +} diff --git a/packages/store-api/src/typeDefs/status.graphql b/packages/store-api/src/typeDefs/status.graphql new file mode 100644 index 0000000000..3acab056a7 --- /dev/null +++ b/packages/store-api/src/typeDefs/status.graphql @@ -0,0 +1,5 @@ +enum StoreStatus { + INFO + WARNING + ERROR +} diff --git a/packages/store-api/test/index.test.ts b/packages/store-api/test/index.test.ts index 10081511dc..123edeb679 100644 --- a/packages/store-api/test/index.test.ts +++ b/packages/store-api/test/index.test.ts @@ -6,6 +6,7 @@ describe('Schema', () => { platform: 'vtex', account: 'storecomponents', environment: 'vtexcommercestable', + channel: '1', }) expect(schema).not.toBeNull() diff --git a/yarn.lock b/yarn.lock index 43be6018eb..3564a42e60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6456,27 +6456,28 @@ integrity sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw== "@typescript-eslint/eslint-plugin@^2.12.0", "@typescript-eslint/eslint-plugin@^4", "@typescript-eslint/eslint-plugin@^4.28.1", "@typescript-eslint/eslint-plugin@^4.29.2": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.1.tgz#e938603a136f01dcabeece069da5fb2e331d4498" - integrity sha512-UDqhWmd5i0TvPLmbK5xY3UZB0zEGseF+DHPghZ37Sb83Qd3p8ujhvAtkU4OF46Ka5Pm5kWvFIx0cCTBFKo0alA== + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.32.0.tgz#46d2370ae9311092f2a6f7246d28357daf2d4e89" + integrity sha512-+OWTuWRSbWI1KDK8iEyG/6uK2rTm3kpS38wuVifGUTDB6kjEuNrzBI1MUtxnkneuWG/23QehABe2zHHrj+4yuA== dependencies: - "@typescript-eslint/experimental-utils" "4.31.1" - "@typescript-eslint/scope-manager" "4.31.1" + "@typescript-eslint/experimental-utils" "4.32.0" + "@typescript-eslint/scope-manager" "4.32.0" debug "^4.3.1" functional-red-black-tree "^1.0.1" + ignore "^5.1.8" regexpp "^3.1.0" semver "^7.3.5" tsutils "^3.21.0" -"@typescript-eslint/experimental-utils@4.31.1": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.1.tgz#0c900f832f270b88e13e51753647b02d08371ce5" - integrity sha512-NtoPsqmcSsWty0mcL5nTZXMf7Ei0Xr2MT8jWjXMVgRK0/1qeQ2jZzLFUh4QtyJ4+/lPUyMw5cSfeeME+Zrtp9Q== +"@typescript-eslint/experimental-utils@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.32.0.tgz#53a8267d16ca5a79134739129871966c56a59dc4" + integrity sha512-WLoXcc+cQufxRYjTWr4kFt0DyEv6hDgSaFqYhIzQZ05cF+kXfqXdUh+//kgquPJVUBbL3oQGKQxwPbLxHRqm6A== dependencies: "@types/json-schema" "^7.0.7" - "@typescript-eslint/scope-manager" "4.31.1" - "@typescript-eslint/types" "4.31.1" - "@typescript-eslint/typescript-estree" "4.31.1" + "@typescript-eslint/scope-manager" "4.32.0" + "@typescript-eslint/types" "4.32.0" + "@typescript-eslint/typescript-estree" "4.32.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" @@ -6505,13 +6506,13 @@ eslint-utils "^3.0.0" "@typescript-eslint/parser@^2.12.0", "@typescript-eslint/parser@^4", "@typescript-eslint/parser@^4.28.1", "@typescript-eslint/parser@^4.29.2": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.31.1.tgz#8f9a2672033e6f6d33b1c0260eebdc0ddf539064" - integrity sha512-dnVZDB6FhpIby6yVbHkwTKkn2ypjVIfAR9nh+kYsA/ZL0JlTsd22BiDjouotisY3Irmd3OW1qlk9EI5R8GrvRQ== + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.32.0.tgz#751ecca0e2fecd3d44484a9b3049ffc1871616e5" + integrity sha512-lhtYqQ2iEPV5JqV7K+uOVlPePjClj4dOw7K4/Z1F2yvjIUvyr13yJnDzkK6uon4BjHYuHy3EG0c2Z9jEhFk56w== dependencies: - "@typescript-eslint/scope-manager" "4.31.1" - "@typescript-eslint/types" "4.31.1" - "@typescript-eslint/typescript-estree" "4.31.1" + "@typescript-eslint/scope-manager" "4.32.0" + "@typescript-eslint/types" "4.32.0" + "@typescript-eslint/typescript-estree" "4.32.0" debug "^4.3.1" "@typescript-eslint/scope-manager@4.19.0": @@ -6530,13 +6531,13 @@ "@typescript-eslint/types" "4.29.2" "@typescript-eslint/visitor-keys" "4.29.2" -"@typescript-eslint/scope-manager@4.31.1": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.31.1.tgz#0c21e8501f608d6a25c842fcf59541ef4f1ab561" - integrity sha512-N1Uhn6SqNtU2XpFSkD4oA+F0PfKdWHyr4bTX0xTj8NRx1314gBDRL1LUuZd5+L3oP+wo6hCbZpaa1in6SwMcVQ== +"@typescript-eslint/scope-manager@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.32.0.tgz#e03c8668f8b954072b3f944d5b799c0c9225a7d5" + integrity sha512-DK+fMSHdM216C0OM/KR1lHXjP1CNtVIhJ54kQxfOE6x8UGFAjha8cXgDMBEIYS2XCYjjCtvTkjQYwL3uvGOo0w== dependencies: - "@typescript-eslint/types" "4.31.1" - "@typescript-eslint/visitor-keys" "4.31.1" + "@typescript-eslint/types" "4.32.0" + "@typescript-eslint/visitor-keys" "4.32.0" "@typescript-eslint/types@4.19.0": version "4.19.0" @@ -6548,10 +6549,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.29.2.tgz#fc0489c6b89773f99109fb0aa0aaddff21f52fcd" integrity sha512-K6ApnEXId+WTGxqnda8z4LhNMa/pZmbTFkDxEBLQAbhLZL50DjeY0VIDCml/0Y3FlcbqXZrABqrcKxq+n0LwzQ== -"@typescript-eslint/types@4.31.1": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.31.1.tgz#5f255b695627a13401d2fdba5f7138bc79450d66" - integrity sha512-kixltt51ZJGKENNW88IY5MYqTBA8FR0Md8QdGbJD2pKZ+D5IvxjTYDNtJPDxFBiXmka2aJsITdB1BtO1fsgmsQ== +"@typescript-eslint/types@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.32.0.tgz#52c633c18da47aee09449144bf59565ab36df00d" + integrity sha512-LE7Z7BAv0E2UvqzogssGf1x7GPpUalgG07nGCBYb1oK4mFsOiFC/VrSMKbZQzFJdN2JL5XYmsx7C7FX9p9ns0w== "@typescript-eslint/typescript-estree@4.19.0": version "4.19.0" @@ -6579,13 +6580,13 @@ semver "^7.3.5" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@4.31.1": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.1.tgz#4a04d5232cf1031232b7124a9c0310b577a62d17" - integrity sha512-EGHkbsUvjFrvRnusk6yFGqrqMBTue5E5ROnS5puj3laGQPasVUgwhrxfcgkdHNFECHAewpvELE1Gjv0XO3mdWg== +"@typescript-eslint/typescript-estree@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.32.0.tgz#db00ccc41ccedc8d7367ea3f50c6994b8efa9f3b" + integrity sha512-tRYCgJ3g1UjMw1cGG8Yn1KzOzNlQ6u1h9AmEtPhb5V5a1TmiHWcRyF/Ic+91M4f43QeChyYlVTcf3DvDTZR9vw== dependencies: - "@typescript-eslint/types" "4.31.1" - "@typescript-eslint/visitor-keys" "4.31.1" + "@typescript-eslint/types" "4.32.0" + "@typescript-eslint/visitor-keys" "4.32.0" debug "^4.3.1" globby "^11.0.3" is-glob "^4.0.1" @@ -6608,12 +6609,12 @@ "@typescript-eslint/types" "4.29.2" eslint-visitor-keys "^2.0.0" -"@typescript-eslint/visitor-keys@4.31.1": - version "4.31.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.1.tgz#f2e7a14c7f20c4ae07d7fc3c5878c4441a1da9cc" - integrity sha512-PCncP8hEqKw6SOJY+3St4LVtoZpPPn+Zlpm7KW5xnviMhdqcsBty4Lsg4J/VECpJjw1CkROaZhH4B8M1OfnXTQ== +"@typescript-eslint/visitor-keys@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.32.0.tgz#455ba8b51242f2722a497ffae29313f33b14cb7f" + integrity sha512-e7NE0qz8W+atzv3Cy9qaQ7BTLwWsm084Z0c4nIO2l3Bp6u9WIgdqCgyPyV5oSPDMIW3b20H59OOCmVk3jw3Ptw== dependencies: - "@typescript-eslint/types" "4.31.1" + "@typescript-eslint/types" "4.32.0" eslint-visitor-keys "^2.0.0" "@vtex/prettier-config@^0.3.5": @@ -10345,7 +10346,7 @@ data-urls@^1.1.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" -dataloader@2.0.0: +dataloader@2.0.0, dataloader@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ== @@ -14270,11 +14271,6 @@ graphql@^15.4.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== -graphql@^15.5.3: - version "15.5.3" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.3.tgz#c72349017d5c9f5446a897fe6908b3186db1da00" - integrity sha512-sM+jXaO5KinTui6lbK/7b7H/Knj9BpjGxZ+Ki35v7YbUJxxdBCUqNM0h3CRVU1ZF9t5lNiBzvBCSYPvIwxPOQA== - growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -14924,7 +14920,7 @@ ignore@^4.0.3, ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.1, ignore@^5.1.4: +ignore@^5.1.1, ignore@^5.1.4, ignore@^5.1.8: version "5.1.8" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==