diff --git a/CHANGELOG.md b/CHANGELOG.md index f0df855..cb13c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Forward the signed price (`commertialOffer.priceToken`) from the product context as `priceToken` on the `addToCart` payload, so the Checkout can close the cart with the signed price while the Pricing is unavailable (Pricing Fallback V2) + ## [1.3.2] - 2026-03-06 ### Changed diff --git a/react/__tests__/catalogItemToCart.test.ts b/react/__tests__/catalogItemToCart.test.ts new file mode 100644 index 0000000..8cedd4c --- /dev/null +++ b/react/__tests__/catalogItemToCart.test.ts @@ -0,0 +1,89 @@ +import { mapCatalogItemToCart } from '../components/BuyButton/modules/catalogItemToCart' +import { Item, Product, Seller } from '../typings' + +const makeSeller = ( + sellerId: string, + token?: { priceToken?: string; PriceToken?: string } +): Seller => ({ + sellerId, + sellerName: `seller ${sellerId}`, + commertialOffer: { + Price: 10, + ListPrice: 12, + PriceWithoutDiscount: 12, + RewardValue: 0, + AvailableQuantity: 5, + Installments: [], + ...token, + }, +}) + +const makeItem = (sellers: Seller[]): Item => ({ + itemId: '1', + name: 'SKU name', + measurementUnit: 'un', + unitMultiplier: 1, + images: [], + variations: [], + sellers, +}) + +const product: Product = { + productId: '1', + productName: 'Product name', + productReference: 'ref-1', + linkText: 'product-name', + brand: 'Brand', + brandId: 1, + items: [], + categories: ['/Category/'], +} + +const mapWith = (sellers: Seller[], selectedSeller: Seller) => + mapCatalogItemToCart({ + product, + selectedItem: makeItem(sellers), + quantity: 1, + selectedSeller, + })[0] + +describe('mapCatalogItemToCart priceToken', () => { + it('takes the token exposed by search-graphql as `priceToken`', () => { + const seller = makeSeller('1', { priceToken: 'signed-price-token' }) + + expect(mapWith([seller], seller).priceToken).toBe('signed-price-token') + }) + + it('falls back to the Catalog Search API `PriceToken`', () => { + const seller = makeSeller('1', { PriceToken: 'catalog-price-token' }) + + expect(mapWith([seller], seller).priceToken).toBe('catalog-price-token') + }) + + it('leaves the price token undefined when the search does not return one', () => { + const seller = makeSeller('1') + + expect(mapWith([seller], seller).priceToken).toBeUndefined() + }) + + it('does not take the price token of another seller', () => { + const selectedSeller = makeSeller('1') + const otherSeller = makeSeller('2', { priceToken: 'other-seller-token' }) + + expect(mapWith([otherSeller], selectedSeller).priceToken).toBeUndefined() + }) + + it('takes the price token of the selected item, not of the product context item', () => { + const sellerOnSelectedItem = makeSeller('1', { + priceToken: 'selected-item-token', + }) + + const sellerOnContextItem = makeSeller('1', { + priceToken: 'context-item-token', + }) + + expect( + mapWith([sellerOnSelectedItem], sellerOnContextItem).priceToken + ).toBe('selected-item-token') + }) +}) diff --git a/react/components/BuyButton/AddToCartButton.tsx b/react/components/BuyButton/AddToCartButton.tsx index 44faa68..453ffaf 100644 --- a/react/components/BuyButton/AddToCartButton.tsx +++ b/react/components/BuyButton/AddToCartButton.tsx @@ -55,6 +55,9 @@ const adjustItemsForMutationInput = ( seller: item.seller, quantity: item.quantity, options: item.options, + // Only sent when the search actually returned a token, keeping the payload + // unchanged while the field is not exposed by the product context yet + ...(item.priceToken ? { priceToken: item.priceToken } : {}), })) } diff --git a/react/components/BuyButton/modules/catalogItemToCart.ts b/react/components/BuyButton/modules/catalogItemToCart.ts index 116f80d..db5137c 100644 --- a/react/components/BuyButton/modules/catalogItemToCart.ts +++ b/react/components/BuyButton/modules/catalogItemToCart.ts @@ -1,4 +1,4 @@ -import { path } from 'ramda' +import { find, path, pathOr, propEq } from 'ramda' import { transformAssemblyOptions, @@ -6,7 +6,13 @@ import { ParsedAssemblyOptionsMeta, Option, } from './assemblyOptions' -import { AssemblyOptionItem, Item, Maybe, Product } from '../../../typings' +import { + AssemblyOptionItem, + Item, + Maybe, + Product, + Seller, +} from '../../../typings' interface MapCatalogItemToCartArgs { product: Maybe @@ -34,6 +40,7 @@ export interface MapCatalogItemToCartReturn { variant: string skuId: string imageUrl: string | undefined + priceToken: string | undefined sellingPriceWithAssemblies: number options: Option[] assemblyOptions: ParsedAssemblyOptionsMeta @@ -46,6 +53,27 @@ export function mapCatalogItemToCart({ selectedSeller, assemblyOptions, }: MapCatalogItemToCartArgs): MapCatalogItemToCartReturn[] { + // Signed price from the search response, forwarded on addToCart so the + // Checkout can close the cart even while the Pricing is unavailable. It has + // to be read from the very SKU and seller sent on addToCart, since it signs + // that seller's price for that item + const sellersFromSelectedItem: Seller[] = pathOr( + [], + ['sellers'], + selectedItem + ) + + const signedSeller = find( + propEq('sellerId', selectedSeller && selectedSeller.sellerId), + sellersFromSelectedItem + ) + + // `vtex.search-graphql` exposes it as `priceToken` (>= 0.72.0), while the raw + // Catalog Search API returns it as `PriceToken` + const priceToken: string | undefined = + path(['commertialOffer', 'priceToken'], signedSeller) || + path(['commertialOffer', 'PriceToken'], signedSeller) + return ( product && selectedItem && @@ -68,6 +96,7 @@ export function mapCatalogItemToCart({ variant: selectedItem.name, skuId: selectedItem.itemId, imageUrl: path(['images', '0', 'imageUrl'], selectedItem), + priceToken, ...transformAssemblyOptions( path(['items'], assemblyOptions), path(['inputValues'], assemblyOptions), diff --git a/react/typings.ts b/react/typings.ts index 53ddad6..99dcdc3 100644 --- a/react/typings.ts +++ b/react/typings.ts @@ -59,6 +59,12 @@ export interface CommercialOffer { RewardValue: number AvailableQuantity: number Installments: Installment[] + // Signed price generated by the search request that returned this offer. + // Optional because it is only present when price signing is enabled for the + // account. `vtex.search-graphql` exposes it as `priceToken` (>= 0.72.0), + // while the raw Catalog Search API returns it as `PriceToken` + priceToken?: string + PriceToken?: string } export interface Installment { @@ -416,4 +422,5 @@ export interface OrderFormItemInput { seller?: string uniqueId?: string options?: AssemblyOptionInput[] + priceToken?: string }