From 6c426a80bd3e408eac989b00d4b10d45b74f2967 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 11:49:16 -0300 Subject: [PATCH 01/30] feat(core): add RecommendationShelf section with personalization tracking Adds a CMS-configurable RecommendationShelf section that renders VTEX personalization recommendations using the shared ProductCard, plus a global useStartSession hook (mounted in Layout) that starts the anonymous personalization session once per browser session and reports PDP product views. --- packages/core/@generated/gql.ts | 18 ++ packages/core/@generated/graphql.ts | 244 +++++++++++++++++- .../cms_component__RecommendationShelf.jsonc | 19 ++ packages/core/src/Layout.tsx | 5 + .../RecommendationShelf.module.scss | 49 ++++ .../RecommendationShelf.tsx | 171 ++++++++++++ .../RecommendationShelf.types.ts | 14 + .../components/RecommendationShelf/index.ts | 1 + .../mapRecommendationToProductCard.ts | 69 +++++ .../RecommendationShelf/queries/request.ts | 111 ++++++++ .../queries/useLazyQuery.ts | 33 +++ .../RecommendationShelf/queries/useQuery.ts | 42 +++ .../RecommendationShelf/useRecommendations.ts | 174 +++++++++++++ .../components/RecommendationShelf/utils.ts | 43 +++ .../src/components/RecommendationShelf/vrn.ts | 71 +++++ .../src/components/cms/global/Components.ts | 10 + .../sdk/analytics/hooks/useStartSession.ts | 139 ++++++++++ 17 files changed, 1211 insertions(+), 2 deletions(-) create mode 100644 packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc create mode 100644 packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss create mode 100644 packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx create mode 100644 packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts create mode 100644 packages/core/src/components/RecommendationShelf/index.ts create mode 100644 packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts create mode 100644 packages/core/src/components/RecommendationShelf/queries/request.ts create mode 100644 packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts create mode 100644 packages/core/src/components/RecommendationShelf/queries/useQuery.ts create mode 100644 packages/core/src/components/RecommendationShelf/useRecommendations.ts create mode 100644 packages/core/src/components/RecommendationShelf/utils.ts create mode 100644 packages/core/src/components/RecommendationShelf/vrn.ts create mode 100644 packages/core/src/sdk/analytics/hooks/useStartSession.ts diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 2b4a413e0b..5dbc6c3f96 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -15,6 +15,7 @@ import * as types from './graphql'; * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { + "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": typeof types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": typeof types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": typeof types.ProductDetailsFragment_ProductFragmentDoc, @@ -40,6 +41,8 @@ type Documents = { "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": typeof types.CancelOrderMutationDocument, "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": typeof types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": typeof types.ValidateUserDocument, + "\n mutation StartSession {\n startSession\n }\n": typeof types.StartSessionDocument, + "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n": typeof types.SendProductViewEventDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": typeof types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": typeof types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": typeof types.SubscribeToNewsletterDocument, @@ -62,6 +65,7 @@ type Documents = { "\n query ServerManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n": typeof types.ServerManyProductsQueryDocument, }; const documents: Documents = { + "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": types.ProductDetailsFragment_ProductFragmentDoc, @@ -87,6 +91,8 @@ const documents: Documents = { "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": types.CancelOrderMutationDocument, "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": types.ValidateUserDocument, + "\n mutation StartSession {\n startSession\n }\n": types.StartSessionDocument, + "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n": types.SendProductViewEventDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": types.SubscribeToNewsletterDocument, @@ -109,6 +115,10 @@ const documents: Documents = { "\n query ServerManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n": types.ServerManyProductsQueryDocument, }; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').FetchRecommendationsQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -209,6 +219,14 @@ export function gql(source: "\n mutation ProcessOrderAuthorizationMutation($dat * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n"): typeof import('./graphql').ValidateUserDocument; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation StartSession {\n startSession\n }\n"): typeof import('./graphql').StartSessionDocument; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n"): typeof import('./graphql').SendProductViewEventDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index e1aaee5da1..bf37b2b348 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -132,6 +132,21 @@ export type AvailableDeliveryWindows = { tax: Maybe; }; +export type Benefit = { + featured: Maybe; + id: Maybe; + items: Maybe>; + name: Maybe; + teaserType: Maybe; +}; + +export type BenefitItem = { + benefitProduct: Maybe; + benefitSKUIds: Maybe>; + discount: Maybe; + minQuantity: Maybe; +}; + export type BusinessHour = { /** Business hour closing time. */ closingTime: Maybe; @@ -141,6 +156,11 @@ export type BusinessHour = { openingTime: Maybe; }; +export type ClusterHighlight = { + id: Maybe; + name: Maybe; +}; + /** Commercial Authorization dimension status. */ export type CommercialAuthorizationDimensionStatus = { /** Creation date. */ @@ -343,6 +363,10 @@ export type DeliveryPromiseBadge = { typeName: Maybe; }; +export type Discount = { + name: Maybe; +}; + /** Input to get commercial authorizations by order ID. */ export type ICommercialAuthorizationByOrderId = { /** Order ID to get commercial authorizations for. */ @@ -358,7 +382,7 @@ export type IGeoCoordinates = { export type IOrderEntryOperation = { objectKey: Scalars['String']['input']; - orderFormId?: InputMaybe; + orderFormId: InputMaybe; sessionToken: InputMaybe; }; @@ -601,6 +625,25 @@ export type IUserOrderCancel = { reason: InputMaybe; }; +export type Image = { + cacheId: Maybe; + imageId: Maybe; + imageLabel: Maybe; + imageTag: Maybe; + imageText: Maybe; + imageUrl: Scalars['String']['output']; +}; + +export type Installment = { + InterestRate: Maybe; + Name: Maybe; + NumberOfInstallments: Maybe; + PaymentSystemGroupName: Maybe; + PaymentSystemName: Maybe; + TotalValuePlusInterestRate: Maybe; + Value: Maybe; +}; + export type LogisticsInfo = { /** LogisticsInfo itemIndex. */ itemIndex: Maybe; @@ -666,11 +709,13 @@ export type Mutation = { cancelOrder: Maybe; /** Process Order Authorization */ processOrderAuthorization: Maybe; + sendProductViewEvent: Scalars['Boolean']['output']; /** * Submits an uploaded file for bulk import into a VTEX cart via the Order Entry Service. * Returns an operationId to poll for the operation status. */ startOrderEntryOperation: Maybe; + startSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter: Maybe; /** @@ -696,6 +741,13 @@ export type MutationProcessOrderAuthorizationArgs = { }; +export type MutationSendProductViewEventArgs = { + product: Scalars['String']['input']; + source: InputMaybe; + userId: Scalars['String']['input']; +}; + + export type MutationStartOrderEntryOperationArgs = { data: IOrderEntryOperation; }; @@ -722,6 +774,22 @@ export type MutationValidateSessionArgs = { session: IStoreSession; }; +export type Offer = { + AvailableQuantity: Maybe; + Installments: Maybe>; + ListPrice: Maybe; + Price: Maybe; + PriceValidUntil: Maybe; + PriceWithoutDiscount: Maybe; + RewardValue: Maybe; + Tax: Maybe; + discountHighlights: Maybe>; + giftSkuIds: Maybe>; + spotPrice: Maybe; + taxPercentage: Maybe; + teasers: Maybe>; +}; + /** Newsletter information. */ export type PersonNewsletter = { /** Person's ID in the newsletter list. */ @@ -807,6 +875,11 @@ export type PickupStoreInfo = { isPickupStore: Maybe; }; +export type PriceRange = { + highPrice: Maybe; + lowPrice: Maybe; +}; + /** Process Order Authorization response. */ export type ProcessOrderAuthorizationResponse = { /** Indicates if authorization is pending for other authorizers. */ @@ -825,11 +898,28 @@ export type ProcessOrderAuthorizationRule = { rule: CommercialAuthorizationRule; }; +export type ProductClusters = { + id: Maybe; + name: Maybe; +}; + export type ProductCountResult = { /** Total product count. */ total: Scalars['Int']['output']; }; +export type ProductPriceRange = { + listPrice: Maybe; + sellingPrice: Maybe; +}; + +export type ProductViewSource = + | 'DESKTOP' + | 'MOBILE' + | 'MOBILE_APP' + | 'WEB_DESKTOP' + | 'WEB_MOBILE'; + export type Profile = { /** Collection of user's address */ addresses: Maybe>>; @@ -864,6 +954,12 @@ export type ProfileAddress = { street: Maybe; }; +export type Property = { + name: Maybe; + originalName: Maybe; + values: Maybe>>; +}; + export type Query = { /** Returns the account profile information for the current authenticated user (b2b or b2c user). */ accountProfile: StoreAccountProfile; @@ -873,6 +969,7 @@ export type Query = { allProducts: StoreProductConnection; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; + getCookies: Maybe; /** Returns the list of Orders that the User can view. */ listUserOrders: Maybe; /** Returns the status of an Order Entry Service operation by its ID. */ @@ -889,6 +986,7 @@ export type Query = { products: Array; /** Returns information about the profile. */ profile: Maybe; + recommendations: RecommendationResponse; /** Returns if there's a redirect for a search. */ redirect: Maybe; /** Returns the result of a product, facet, or suggestion search. */ @@ -970,6 +1068,13 @@ export type QueryProfileArgs = { }; +export type QueryRecommendationsArgs = { + campaignVrn: Scalars['String']['input']; + products: InputMaybe>; + userId: InputMaybe; +}; + + export type QueryRedirectArgs = { selectedFacets: InputMaybe>; term: InputMaybe; @@ -1005,6 +1110,66 @@ export type QueryUserOrderArgs = { orderId: Scalars['String']['input']; }; +export type RecommendationCampaign = { + id: Scalars['String']['output']; + title: Maybe; + type: Scalars['String']['output']; +}; + +export type RecommendationProduct = { + /** If this product is sponsored, ad information will be added here. */ + advertisement: Maybe; + benefits: Maybe>; + brand: Maybe; + brandId: Maybe; + cacheId: Maybe; + categories: Maybe>; + categoriesIds: Maybe>; + categoryId: Maybe; + clusterHighlights: Maybe>; + description: Maybe; + items: Array; + link: Maybe; + linkText: Maybe; + metaTagDescription: Maybe; + priceRange: Maybe; + productClusters: Maybe>; + productId: Scalars['String']['output']; + productName: Maybe; + productReference: Maybe; + properties: Maybe>; + releaseDate: Maybe; + skuSpecifications: Maybe>; + specificationGroups: Maybe>; + titleTag: Maybe; +}; + +export type RecommendationResponse = { + campaign: RecommendationCampaign; + correlationId: Scalars['String']['output']; + products: Array; +}; + +export type Reference = { + Key: Maybe; + Value: Maybe; +}; + +export type Sku = { + complementName: Maybe; + ean: Maybe; + images: Array; + itemId: Scalars['String']['output']; + measurementUnit: Maybe; + name: Maybe; + nameComplete: Maybe; + referenceId: Maybe>; + sellers: Maybe>; + unitMultiplier: Maybe; + variations: Maybe>; + videos: Maybe>; +}; + export type SkuSpecificationField = { id: Maybe; name: Scalars['String']['output']; @@ -1028,6 +1193,14 @@ export type SearchMetadata = { logicalOperator: Scalars['String']['output']; }; +export type Seller = { + addToCartLink: Maybe; + commertialOffer: Maybe; + sellerDefault: Maybe; + sellerId: Maybe; + sellerName: Maybe; +}; + /** Information of sellers. */ export type SellerInfo = { /** Identification of the seller */ @@ -1153,6 +1326,18 @@ export type SpecificationGroup = { specifications: Array; }; +export type SpecificationGroupRec = { + name: Maybe; + originalName: Maybe; + specifications: Maybe>; +}; + +export type SpecificationGroupRecProperty = { + name: Maybe; + originalName: Maybe; + values: Maybe>; +}; + /** Account profile information. */ export type StoreAccountProfile = { email: Maybe; @@ -1755,6 +1940,26 @@ export type StoreUserDetails = { username: Maybe; }; +export type Teaser = { + conditions: Maybe; + effects: Maybe; + name: Maybe; +}; + +export type TeaserCondition = { + minimumQuantity: Maybe; + parameters: Maybe>>; +}; + +export type TeaserEffects = { + parameters: Maybe>>; +}; + +export type TeaserValue = { + name: Maybe; + value: Maybe; +}; + export type UserOrder = { affiliateId: Maybe; allowCancellation: Maybe; @@ -2565,6 +2770,24 @@ export type ValidateUserData = { isValid: Scalars['Boolean']['output']; }; +export type Video = { + videoUrl: Maybe; +}; + +export type VtCookies = { + vtex_segment: Scalars['String']['output']; + vtex_session: Scalars['String']['output']; +}; + +export type FetchRecommendationsQueryQueryVariables = Exact<{ + campaignVrn: Scalars['String']['input']; + userId: InputMaybe; + products: InputMaybe | Scalars['String']['input']>; +}>; + + +export type FetchRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ cacheId: string | null, productId: string, description: string | null, productName: string | null, productReference: string | null, linkText: string | null, brand: string | null, brandId: number | null, link: string | null, categories: Array | null, categoryId: string | null, releaseDate: string | null, advertisement: { adId: string, campaignId: string, actionCost: number, adRequestId: string, adResponseId: string } | null, priceRange: { sellingPrice: { highPrice: number | null, lowPrice: number | null } | null, listPrice: { highPrice: number | null, lowPrice: number | null } | null } | null, specificationGroups: Array<{ name: string | null, originalName: string | null, specifications: Array<{ name: string | null, originalName: string | null, values: Array | null }> | null }> | null, skuSpecifications: Array<{ field: { name: string, originalName: string | null }, values: Array<{ name: string, originalName: string | null }> }> | null, productClusters: Array<{ id: string | null, name: string | null }> | null, clusterHighlights: Array<{ id: string | null, name: string | null }> | null, properties: Array<{ name: string | null, values: Array | null }> | null, items: Array<{ itemId: string, name: string | null, nameComplete: string | null, complementName: string | null, ean: string | null, measurementUnit: string | null, unitMultiplier: number | null, variations: Array<{ name: string | null, values: Array | null }> | null, referenceId: Array<{ Key: string | null, Value: string | null }> | null, images: Array<{ cacheId: string | null, imageId: string | null, imageLabel: string | null, imageTag: string | null, imageUrl: string, imageText: string | null }>, sellers: Array<{ sellerId: string | null, sellerName: string | null, sellerDefault: boolean | null, commertialOffer: { Price: number | null, ListPrice: number | null, Tax: number | null, taxPercentage: number | null, spotPrice: number | null, PriceWithoutDiscount: number | null, RewardValue: number | null, PriceValidUntil: string | null, AvailableQuantity: number | null, discountHighlights: Array<{ name: string | null }> | null, teasers: Array<{ name: string | null, conditions: { minimumQuantity: number | null, parameters: Array<{ name: string | null, value: string | null } | null> | null } | null, effects: { parameters: Array<{ name: string | null, value: string | null } | null> | null } | null }> | null, Installments: Array<{ Value: number | null, InterestRate: number | null, TotalValuePlusInterestRate: number | null, NumberOfInstallments: number | null, Name: string | null, PaymentSystemName: string | null }> | null } | null }> | null }> }>, campaign: { id: string, title: string | null, type: string } } }; + export type ProductSummary_ProductFragment = { slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }; type Filter_Facets_StoreFacetBoolean_Fragment = { __typename: 'StoreFacetBoolean', key: string, label: string, values: Array<{ label: string, value: string, selected: boolean, quantity: number | null }> }; @@ -2674,6 +2897,20 @@ export type ValidateUserQueryVariables = Exact<{ [key: string]: never; }>; export type ValidateUserQuery = { validateUser: { isValid: boolean } | null }; +export type StartSessionMutationVariables = Exact<{ [key: string]: never; }>; + + +export type StartSessionMutation = { startSession: boolean }; + +export type SendProductViewEventMutationVariables = Exact<{ + userId: Scalars['String']['input']; + product: Scalars['String']['input']; + source: InputMaybe; +}>; + + +export type SendProductViewEventMutation = { sendProductViewEvent: boolean }; + export type ValidateCartMutationMutationVariables = Exact<{ cart: IStoreCart; session: IStoreSession; @@ -2714,7 +2951,7 @@ export type OrderEntryOperationQueryQueryVariables = Exact<{ }>; -export type OrderEntryOperationQueryQuery = { orderEntryOperation: { status: string, entityId: string, message: string | null, missingItems: Array<{ itemId: string, itemName: string | null, reason: string }> | null } | null }; +export type OrderEntryOperationQueryQuery = { orderEntryOperation: { status: string, entityId: string | null, message: string | null, missingItems: Array<{ itemId: string, itemName: string | null, reason: string }> | null } | null }; export type UploadFileToOrderEntryMutationMutationVariables = Exact<{ data: IOrderEntryUpload; @@ -3361,6 +3598,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` fuzzy } `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString; +export const FetchRecommendationsQueryDocument = {"__meta__":{"operationName":"FetchRecommendationsQuery","operationHash":"767c3273d7aa61ca0cb1c5a60545d69bcfaf7e77"}} as unknown as TypedDocumentString; export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString; export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"4b33c5c07f440dc7489e55619dc2211a13786e72"}} as unknown as TypedDocumentString; export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"f03d0963fed159ac4bbe11f90ea09c635a66b68c"}} as unknown as TypedDocumentString; @@ -3372,6 +3610,8 @@ export const ServerUserDetailsQueryDocument = {"__meta__":{"operationName":"Serv export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelOrderMutation","operationHash":"e2b06da6840614d3c72768e56579b9d3b8e80802"}} as unknown as TypedDocumentString; export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString; export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString; +export const StartSessionDocument = {"__meta__":{"operationName":"StartSession","operationHash":"bf9c4a8bc27fb6ed256336880aea8939b04943cf"}} as unknown as TypedDocumentString; +export const SendProductViewEventDocument = {"__meta__":{"operationName":"SendProductViewEvent","operationHash":"2c1d370d6d66811a23d8862d38a83a89b5c2baa1"}} as unknown as TypedDocumentString; export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"32c15f8888ca34f223def7972b7f19090808435a"}} as unknown as TypedDocumentString; export const ClientPickupPointsQueryDocument = {"__meta__":{"operationName":"ClientPickupPointsQuery","operationHash":"3fa04e88c811fcb5ece7206fd5aa745bdbc143a8"}} as unknown as TypedDocumentString; export const SubscribeToNewsletterDocument = {"__meta__":{"operationName":"SubscribeToNewsletter","operationHash":"feb7005103a859e2bc8cf2360d568806fd88deba"}} as unknown as TypedDocumentString; diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc new file mode 100644 index 0000000000..29ba51fd09 --- /dev/null +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -0,0 +1,19 @@ +{ + "$extends": ["#/$defs/base-component"], + "$componentKey": "RecommendationShelf", + "$componentTitle": "RecommendationShelf", + "type": "object", + "required": ["campaignVrn"], + "properties": { + "title": { + "title": "Title", + "description": "Override the shelf title. If not set, the campaign title will be used.", + "type": "string" + }, + "campaignVrn": { + "title": "Campaign VRN", + "description": "Newtail campaign VRN that drives the recommendation (e.g. vrn:newtail:campaign:PERSONALIZED:abc123).", + "type": "string" + } + } +} diff --git a/packages/core/src/Layout.tsx b/packages/core/src/Layout.tsx index 713255e004..97f8d0e1d9 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -1,10 +1,15 @@ import { isValidElement, type PropsWithChildren } from 'react' import { usePageViewEvent } from './sdk/analytics/hooks/usePageViewEvent' +import { useStartSession } from './sdk/analytics/hooks/useStartSession' function Layout({ children }: PropsWithChildren) { const props = isValidElement(children) ? children.props : undefined usePageViewEvent(props) + // Implemented here because the personalization session must be initiated once + // per browser session on each page. Product is read from props (same pattern as + // usePageViewEvent), since usePDP() has no context above PageProvider. + useStartSession(props) return <>{children} } diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss b/packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss new file mode 100644 index 0000000000..379b1eadb7 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss @@ -0,0 +1,49 @@ +@use "@faststore/ui/src/styles/base/utilities"; +@use "sass:meta"; + +@layer components { + .recommendationShelfItem { + display: flex; + width: 100%; + height: 100%; + + > * { + width: 100%; + } + } + + .recommendationShelf { + [data-fs-product-shelf-skeleton] { + --fs-carousel-item-margin-right: var(--fs-spacing-3); + + padding-bottom: var(--fs-spacing-5); + + [data-fs-product-shelf-items] { + @include utilities.layout-content; + } + + @include utilities.media(" { + const id = useId() + const isMobile = checkIsMobile() + const itemsPerPage = isMobile ? 2 : 4 + const [userId, setUserId] = useState(undefined) + + const { data: productDetailPage } = usePDP() + + const recommendationArgs = getRecommendationArguments(campaignVrn, { + userId, + pdpProduct: productDetailPage?.product.isVariantOf.productGroupID, + }) + + const { data, isLoading, error } = useRecommendations(recommendationArgs) + + const items = data?.products || [] + const correlationId = data?.correlationId + const campaignId = data?.campaign.id + + const productIds = useMemo( + () => items.map((p) => p.productId).join(', '), + [items] + ) + + const shouldAddAFAttr = !!( + !isLoading && + correlationId && + campaignId && + productIds.length + ) + + if (!userId) { + // The pixel might take a while to load and set the userId cookie, + // so we use a retry mechanism to ensure we get the userId if available. + getWithRetry(() => { + if (!userId) { + return getUserIdFromCookie() + } + + return '' + }) + .then((value) => { + setUserId(value) + }) + .catch((error) => { + console.error('Error retrieving userId from cookie', error, campaignVrn) + setUserId(null) + }) + } + + if (error) { + console.error( + 'Error fetching recommendations', + error.cause, + error.message, + 'with args', + recommendationArgs + ) + return null + } + + if (!isLoading && items.length === 0) { + return <> + } + + return ( +
+ +

+ {title ?? data?.campaign.title} +

+ + + {items.map((item, index) => ( +
+ +
+ ))} +
+
+
+
+ ) +} diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts new file mode 100644 index 0000000000..a363398de5 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts @@ -0,0 +1,14 @@ +export type RecommendationShelfProps = { + title?: string + campaignVrn: string +} + +export type RecommendationType = + | 'CROSS_SELL' + | 'SIMILAR_ITEMS' + | 'PERSONALIZED' + | 'TOP_ITEMS' + | 'LAST_SEEN' + | 'SEARCH_BASED' + | 'VISUAL_SIMILARITY' + | 'NEXT_INTERACTION' diff --git a/packages/core/src/components/RecommendationShelf/index.ts b/packages/core/src/components/RecommendationShelf/index.ts new file mode 100644 index 0000000000..c82a76d729 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/index.ts @@ -0,0 +1 @@ +export * from './RecommendationShelf' diff --git a/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts b/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts new file mode 100644 index 0000000000..e3631174ef --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts @@ -0,0 +1,69 @@ +import type { + ProductSummary_ProductFragment, + RecommendationProduct, +} from '@generated/graphql' + +/** + * Maps a VTEX `RecommendationProduct` into the `ProductSummary_ProductFragment` + * shape consumed by the core `ProductCard` (src/components/product/ProductCard), + * so recommendation shelves render identical cards to regular shelves. + * + * The card reads `offers`/`image`/`isVariantOf` to compute title, price and + * discount, so we surface the default seller's commercial offer here. + */ +export function mapRecommendationToProductCard( + product: RecommendationProduct +): ProductSummary_ProductFragment { + const item = product.items?.[0] + const image = item?.images?.[0] + const seller = + item?.sellers?.find((currentSeller) => currentSeller.sellerDefault) ?? + item?.sellers?.[0] + const offer = seller?.commertialOffer + + const price = offer?.Price ?? 0 + const listPrice = offer?.ListPrice ?? 0 + const availableQuantity = offer?.AvailableQuantity ?? 0 + const availability = + availableQuantity > 0 + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock' + + const imageUrl = image?.imageUrl ?? '' + const imageAlt = image?.imageText ?? product.productName ?? '' + + return { + id: product.productId, + sku: item?.itemId ?? product.productId, + slug: product.linkText ?? '', + name: item?.nameComplete ?? item?.name ?? product.productName ?? '', + gtin: '', + unitMultiplier: null, + hasSpecifications: null, + brand: { name: product.brand ?? '', brandName: product.brand ?? '' }, + isVariantOf: { + name: product.productName ?? '', + productGroupID: product.productId, + skuVariants: null, + }, + image: imageUrl ? [{ url: imageUrl, alternateName: imageAlt }] : [], + offers: { + lowPrice: price, + lowPriceWithTaxes: price, + offers: [ + { + availability, + price, + listPrice, + listPriceWithTaxes: listPrice, + priceWithTaxes: price, + quantity: availableQuantity, + seller: { identifier: seller?.sellerId ?? '1' }, + }, + ], + }, + additionalProperty: [], + advertisement: null, + deliveryPromiseBadges: [], + } +} diff --git a/packages/core/src/components/RecommendationShelf/queries/request.ts b/packages/core/src/components/RecommendationShelf/queries/request.ts new file mode 100644 index 0000000000..676a0f5792 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/queries/request.ts @@ -0,0 +1,111 @@ +export type RequestOptions = Omit +export type Operation = { + __meta__?: Record +} + +export interface GraphQLResponse { + data: D + errors: any[] +} + +export interface BaseRequestOptions { + operation: Operation + variables: V + fetchOptions?: RequestInit +} + +const DEFAULT_HEADERS_BY_VERB: Record> = { + POST: { + 'Content-Type': 'application/json', + }, +} + +export const request = async ( + operation: Operation, + variables: Variables, + options?: RequestOptions +) => { + const { data, errors } = await baseRequest('/api/graphql', { + ...options, + variables, + operation, + }) + + if (errors?.length) { + throw new Error('GraphQL error', { cause: errors }) + } + + return data +} + +/* This piece of code was taken out of @faststore/graphql-utils */ +const baseRequest = async ( + endpoint: string, + { operation, variables, fetchOptions }: BaseRequestOptions +): Promise> => { + const operationName = operation['__meta__']?.['operationName'] + const operationHash = operation['__meta__']?.['operationHash'] + + // Uses method from fetchOptions. + // If no one is passed, figure out with via heuristic + const method = + fetchOptions?.method !== undefined + ? fetchOptions.method.toUpperCase() + : operationName.endsWith('Query') + ? 'GET' + : 'POST' + + const params = new URLSearchParams({ + operationName, + operationHash, + ...(method === 'GET' && { variables: JSON.stringify(variables) }), + }) + + const body = + method === 'POST' + ? JSON.stringify({ + operationName, + operationHash, + variables, + }) + : undefined + + const url = `${endpoint}?${params.toString()}` + + const response = await fetch(url, { + method, + body, + ...fetchOptions, + headers: { + ...DEFAULT_HEADERS_BY_VERB[method], + ...fetchOptions?.headers, + }, + }) + + const contentType = response.headers.get('Content-Type') + + let responseBody = null + + if (contentType && contentType.includes('application/json')) { + try { + responseBody = await response.json() + } catch (error) { + console.error('Error parsing JSON', error) + throw new Error('Error parsing JSON response') + } + } else if (contentType && contentType.includes('text/plain')) { + responseBody = await response.text() + } + + if (response.ok) { + return responseBody + } + + if (response.ok) { + return response.status !== 204 ? response.json() : (undefined as any) + } + + const error = new Error('Error while fetching', { cause: responseBody }) + + throw error +} diff --git a/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts b/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts new file mode 100644 index 0000000000..ce9ec4b183 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts @@ -0,0 +1,33 @@ +import useSWR from 'swr' + +import { type Operation, request } from './request' +import { DEFAULT_OPTIONS, getKey } from './useQuery' +import type { QueryOptions } from './useQuery' + +export const useLazyQuery = >( + operation: Operation, + variables: Variables, + options?: QueryOptions +) => { + const response = useSWR( + getKey( + operation['__meta__'] ? operation['__meta__']['operationName'] : '', + variables + ), + () => null, + DEFAULT_OPTIONS + ) + + const execute = async (queryVariables: Variables) => { + const data = await request( + operation, + queryVariables, + options + ) + + const updatedData = await response.mutate(data, false) + return updatedData + } + + return [execute, response] as [typeof execute, typeof response] +} diff --git a/packages/core/src/components/RecommendationShelf/queries/useQuery.ts b/packages/core/src/components/RecommendationShelf/queries/useQuery.ts new file mode 100644 index 0000000000..6470917fdb --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/queries/useQuery.ts @@ -0,0 +1,42 @@ +import useSWR from 'swr' +import type { SWRConfiguration } from 'swr' + +import { request } from './request' +import type { Operation, RequestOptions } from './request' + +export type QueryOptions = SWRConfiguration & + RequestOptions & { doNotRun?: boolean } + +export const getKey = ( + operationName: string, + variables: Variables +) => `${operationName}::${JSON.stringify(variables)}` + +export const DEFAULT_OPTIONS: SWRConfiguration = { + errorRetryCount: 3, + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: true, +} + +export const useQuery = >( + operation: Operation, + variables: Variables, + options?: QueryOptions +) => + useSWR( + () => + options?.doNotRun + ? null + : getKey( + operation['__meta__'] ? operation['__meta__']['operationName'] : '', + variables + ), + { + fetcher: () => request(operation, variables, options), + ...DEFAULT_OPTIONS, + ...options, + } + ) diff --git a/packages/core/src/components/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/RecommendationShelf/useRecommendations.ts new file mode 100644 index 0000000000..812a1dce28 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/useRecommendations.ts @@ -0,0 +1,174 @@ +import { gql } from '@faststore/core/api' +import type { RecommendationResponse } from '@generated/graphql' +import { useQuery } from './queries/useQuery' + +const query = gql(`query FetchRecommendationsQuery( + $campaignVrn: String! + $userId: String + $products: [String!] +) { + recommendations( + userId: $userId + campaignVrn: $campaignVrn + products: $products + ) { + products { + cacheId + productId + description + productName + productReference + linkText + brand + brandId + link + categories + categoryId + releaseDate + advertisement { + adId + campaignId + actionCost + adRequestId + adResponseId + } + priceRange { + sellingPrice { + highPrice + lowPrice + } + listPrice { + highPrice + lowPrice + } + } + specificationGroups { + name + originalName + specifications { + name + originalName + values + } + } + skuSpecifications { + field { + name + originalName + } + values { + name + originalName + } + } + productClusters { + id + name + } + clusterHighlights { + id + name + } + properties { + name + values + } + items { + itemId + name + nameComplete + complementName + ean + variations { + name + values + } + referenceId { + Key + Value + } + measurementUnit + unitMultiplier + images { + cacheId + imageId + imageLabel + imageTag + imageUrl + imageText + } + sellers { + sellerId + sellerName + sellerDefault + commertialOffer { + discountHighlights { + name + } + teasers { + name + conditions { + minimumQuantity + parameters { + name + value + } + } + effects { + parameters { + name + value + } + } + } + Price + ListPrice + Tax + taxPercentage + spotPrice + PriceWithoutDiscount + RewardValue + PriceValidUntil + AvailableQuantity + Installments { + Value + InterestRate + TotalValuePlusInterestRate + NumberOfInstallments + Name + PaymentSystemName + } + } + } + } + } + correlationId + campaign { + id + title + type + } + } +} +`) + +export type RecommendationInput = { + userId: string + campaignVrn: string + products: string[] +} + +export const useRecommendations = (args: RecommendationInput | null) => { + const { data, isLoading, error } = useQuery< + { recommendations: RecommendationResponse }, + RecommendationInput + >(query, args ?? ({} as RecommendationInput), { + doNotRun: args === null, + }) + + return { + data: data?.recommendations, + error, + isLoading, + } +} diff --git a/packages/core/src/components/RecommendationShelf/utils.ts b/packages/core/src/components/RecommendationShelf/utils.ts new file mode 100644 index 0000000000..fac05922a1 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/utils.ts @@ -0,0 +1,43 @@ +import { getCookie } from 'src/utils/getCookie' + +const MAX_MOBILE_WIDTH = 768 + +// Name of the cookie that holds the anonymous user id used by the +// recommendation/personalization pixel. +const USER_ID_COOKIE = 'vtex-rec-user-id' + +export function checkIsMobile(): boolean { + if (typeof window === 'undefined') { + return false + } + + return window.innerWidth <= MAX_MOBILE_WIDTH +} + +export function getUserIdFromCookie(): string { + return getCookie(USER_ID_COOKIE) ?? '' +} + +interface RetryOptions { + retries?: number + delayMs?: number +} + +// Repeatedly calls `fn` until it returns a truthy value or the retry budget +// is exhausted. Useful when a value (e.g. a cookie) is set asynchronously by +// an external script. +export async function getWithRetry( + fn: () => T, + { retries = 5, delayMs = 500 }: RetryOptions = {} +): Promise { + let value = fn() + let attempts = 0 + + while (!value && attempts < retries) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + value = fn() + attempts += 1 + } + + return value +} diff --git a/packages/core/src/components/RecommendationShelf/vrn.ts b/packages/core/src/components/RecommendationShelf/vrn.ts new file mode 100644 index 0000000000..e1ebac9461 --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/vrn.ts @@ -0,0 +1,71 @@ +/* eslint-disable padding-line-between-statements */ + +import type { RecommendationType } from './RecommendationShelf.types' + +type RecommendationVrnType = + | 'rec-cross-v2' + | 'rec-similar-v2' + | 'rec-persona-v2' + | 'rec-last-v2' + | 'rec-top-items-v2' + | 'rec-search-v2' + | 'rec-next-v2' + | 'rec-visual-v2' + +const vrnPattern = + /^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$/ + +export function isValidVrn(campaignVrn: string): boolean { + return vrnPattern.test(campaignVrn) +} + +function parseCampaignVrn(campaignVrn: string) { + const [_, __, accountName, campaignType, campaignId] = campaignVrn.split(':') + + return { + accountName, + campaignId, + campaignVrnType: campaignType as RecommendationVrnType, + } +} + +// Cross-Sell: rec-cross-v2 +// Similar Items: rec-similar-v2 +// Personalized: rec-persona-v2 +// Last Seen: rec-last-v2 +// Top Sellers: rec-top-items-v2 +// Next Interactions: rec-next-v2 +// Visual Similarity: rec-visual-v2 +// Search-Based: rec-search-v2 +export function getTypeFromVrn(campaignVrn: string): RecommendationType { + const { campaignVrnType } = parseCampaignVrn(campaignVrn) + + switch (campaignVrnType) { + case 'rec-cross-v2': + return 'CROSS_SELL' + + case 'rec-similar-v2': + return 'SIMILAR_ITEMS' + + case 'rec-persona-v2': + return 'PERSONALIZED' + + case 'rec-last-v2': + return 'LAST_SEEN' + + case 'rec-top-items-v2': + return 'TOP_ITEMS' + + case 'rec-search-v2': + return 'SEARCH_BASED' + + case 'rec-next-v2': + return 'NEXT_INTERACTION' + + case 'rec-visual-v2': + return 'VISUAL_SIMILARITY' + + default: + throw new Error(`Unknown campaign type: ${campaignVrnType}`) + } +} diff --git a/packages/core/src/components/cms/global/Components.ts b/packages/core/src/components/cms/global/Components.ts index 669ce5a403..1eac140e5d 100644 --- a/packages/core/src/components/cms/global/Components.ts +++ b/packages/core/src/components/cms/global/Components.ts @@ -44,6 +44,14 @@ const ShoppingAssistant = dynamic( { ssr: false } ) +const RecommendationShelf = dynamic( + () => + import( + /* webpackChunkName: "RecommendationShelf" */ 'src/components/RecommendationShelf' + ).then((mod) => mod.RecommendationShelf), + { ssr: false } +) + const COMPONENTS: Record> = { [getComponentKey(Alert, 'Alert')]: Alert, [getComponentKey(Navbar, 'Navbar')]: Navbar, @@ -54,6 +62,8 @@ const COMPONENTS: Record> = { [getComponentKey(RegionSlider, 'RegionSlider')]: RegionSlider, // out of viewport [getComponentKey(Footer, 'Footer')]: Footer, // out of viewport [getComponentKey(ShoppingAssistant, 'ShoppingAssistant')]: ShoppingAssistant, + [getComponentKey(RecommendationShelf, 'RecommendationShelf')]: + RecommendationShelf, ...PLUGINS_COMPONENTS, ...CUSTOM_COMPONENTS, } diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts new file mode 100644 index 0000000000..526651eb15 --- /dev/null +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -0,0 +1,139 @@ +import { useEffect, useRef } from 'react' + +import { gql } from '@faststore/core/api' + +import { + checkIsMobile, + getUserIdFromCookie, +} from 'src/components/RecommendationShelf/utils' +import { useLazyQuery } from 'src/components/RecommendationShelf/queries/useLazyQuery' +import { getCookie } from 'src/utils/getCookie' + +// Cookie set once the anonymous personalization session has been started, +// so we only fire `StartSession` a single time per browser session. +const VTEX_REC_USER_START_SESSION = 'vtex-rec-user-start-session' + +const startSessionMutation = gql(` + mutation StartSession { + startSession + } +`) + +const sendProductViewEventMutation = gql(` + mutation SendProductViewEvent( + $userId: String! + $product: String! + $source: ProductViewSource + ) { + sendProductViewEvent(userId: $userId, product: $product, source: $source) + } +`) + +type ProductViewSource = 'WEB_DESKTOP' | 'WEB_MOBILE' + +type StartSessionData = { startSession: boolean } +type StartSessionVariables = Record + +type SendProductViewEventData = { sendProductViewEvent: boolean } +type SendProductViewEventVariables = { + userId: string + product: string + source: ProductViewSource +} + +/** + * Global recommendation/personalization tracking side effects: + * - starts an anonymous session once per browser session; + * - reports a product view whenever a new PDP product is displayed. + * + * Mounted globally from `Layout` so it runs on every page, regardless of + * whether a recommendation section is present. + * + * `Layout` sits above every `PageProvider`, so we can't read the PDP context + * via `usePDP()` here. Instead the current page's props are forwarded from + * `Layout` (same pattern as `usePageViewEvent`); on a PDP they contain the + * product, elsewhere they don't. + * + * All work runs client-side in effects (after hydration), so it doesn't affect + * SSR/TTFB or Lighthouse render metrics. + */ +export function useStartSession(props?: any) { + const productId: string | null = + props?.data?.product?.isVariantOf?.productGroupID ?? null + const lastProductViewSentRef = useRef(null) + + const [runStartSession] = useLazyQuery< + StartSessionData, + StartSessionVariables + >(startSessionMutation, {} as StartSessionVariables, { doNotRun: true }) + + const [runSendProductViewEvent] = useLazyQuery< + SendProductViewEventData, + SendProductViewEventVariables + >(sendProductViewEventMutation, {} as SendProductViewEventVariables, { + doNotRun: true, + }) + + useEffect(() => { + const startSessionCookie = getCookie(VTEX_REC_USER_START_SESSION) + + if (startSessionCookie) { + return + } + + let cancelled = false + + // The session endpoint may not be ready on the first try, so we retry with + // exponential backoff until it returns a defined result (or we give up). + const retryUntilSuccess = async () => { + const MAX_ATTEMPTS = 10 + let delay = 300 + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + if (cancelled) { + return + } + + const result = await runStartSession({}) + + if (result !== undefined) { + return + } + + await new Promise((resolve) => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, 3000) + } + } + + void retryUntilSuccess() + + return () => { + cancelled = true + } + }, [runStartSession]) + + useEffect(() => { + if (!productId) { + lastProductViewSentRef.current = null + return + } + + const userId = getUserIdFromCookie() + if (!userId) { + return + } + + // Avoid re-sending the same product view on unrelated re-renders. + if (lastProductViewSentRef.current === productId) { + return + } + + lastProductViewSentRef.current = productId + + const source: ProductViewSource = checkIsMobile() + ? 'WEB_MOBILE' + : 'WEB_DESKTOP' + + void runSendProductViewEvent({ userId, product: productId, source }) + }, [productId, runSendProductViewEvent]) +} From 5f3999b3a045d6ca7218cbc9eadee00021a4231c Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 12:07:27 -0300 Subject: [PATCH 02/30] fix(core): add recommendation/personalization GraphQL typeDefs Codegen regenerates @generated/schema.graphql from the API plus the src/graphql customization folders, so the operations used by the RecommendationShelf (recommendations, startSession, sendProductViewEvent) must be defined as thirdParty typeDefs to be valid in CI. Adds the schema extension and the recommendation types (referencing existing Advertisement and SkuSpecification), fixing the @faststore/core#generate build failure. --- packages/core/@generated/graphql.ts | 6 - .../thirdParty/recommendations.graphql | 211 ++++++++++++++++++ 2 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/graphql/thirdParty/recommendations.graphql diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index bf37b2b348..a326c512ae 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -969,7 +969,6 @@ export type Query = { allProducts: StoreProductConnection; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; - getCookies: Maybe; /** Returns the list of Orders that the User can view. */ listUserOrders: Maybe; /** Returns the status of an Order Entry Service operation by its ID. */ @@ -2774,11 +2773,6 @@ export type Video = { videoUrl: Maybe; }; -export type VtCookies = { - vtex_segment: Scalars['String']['output']; - vtex_session: Scalars['String']['output']; -}; - export type FetchRecommendationsQueryQueryVariables = Exact<{ campaignVrn: Scalars['String']['input']; userId: InputMaybe; diff --git a/packages/core/src/graphql/thirdParty/recommendations.graphql b/packages/core/src/graphql/thirdParty/recommendations.graphql new file mode 100644 index 0000000000..ea0e403635 --- /dev/null +++ b/packages/core/src/graphql/thirdParty/recommendations.graphql @@ -0,0 +1,211 @@ +extend type Query { + recommendations( + campaignVrn: String! + userId: String + products: [String!] + ): RecommendationResponse! + @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) +} + +extend type Mutation { + startSession: Boolean! + sendProductViewEvent( + userId: String! + product: String! + source: ProductViewSource + ): Boolean! +} + +type RecommendationResponse { + products: [RecommendationProduct!]! + correlationId: String! + campaign: RecommendationCampaign! +} + +type RecommendationCampaign { + id: String! + title: String + type: String! +} + +type RecommendationProduct { + brand: String + brandId: Int + benefits: [Benefit!] + cacheId: String + categoryId: ID + categories: [String!] + categoriesIds: [String!] + clusterHighlights: [ClusterHighlight!] + description: String + items: [SKU!]! + link: String + linkText: String + metaTagDescription: String + priceRange: ProductPriceRange + releaseDate: String + productClusters: [ProductClusters!] + productId: String! + productName: String + productReference: String + properties: [Property!] + skuSpecifications: [SkuSpecification!] + specificationGroups: [SpecificationGroupRec!] + titleTag: String + """ + If this product is sponsored, ad information will be added here. + """ + advertisement: Advertisement +} + +type Benefit { + featured: Boolean + id: String + name: String + items: [BenefitItem!] + teaserType: String +} + +type BenefitItem { + benefitProduct: RecommendationProduct + benefitSKUIds: [String!] + discount: Float + minQuantity: Int +} + +type SKU { + itemId: String! + name: String + nameComplete: String + complementName: String + ean: String + referenceId: [Reference!] + measurementUnit: String + unitMultiplier: Float + images: [Image!]! + videos: [Video!] + sellers: [Seller!] + variations: [Property!] +} + +type Seller { + sellerId: String + sellerName: String + addToCartLink: String + sellerDefault: Boolean + commertialOffer: Offer +} + +type Offer { + Installments: [Installment!] + Price: Float + ListPrice: Float + spotPrice: Float + PriceWithoutDiscount: Float + AvailableQuantity: Float + Tax: Float + taxPercentage: Float + teasers: [Teaser!] + discountHighlights: [Discount!] + giftSkuIds: [String!] + RewardValue: Float + PriceValidUntil: String +} + +type Installment { + Value: Float + InterestRate: Float + TotalValuePlusInterestRate: Float + NumberOfInstallments: Int + Name: String + PaymentSystemName: String + PaymentSystemGroupName: String +} + +type Discount { + name: String +} + +type Teaser { + name: String + conditions: TeaserCondition + effects: TeaserEffects +} + +type TeaserCondition { + minimumQuantity: Int + parameters: [TeaserValue] +} + +type TeaserEffects { + parameters: [TeaserValue] +} + +type TeaserValue { + name: String + value: String +} + +type Image { + cacheId: ID + imageId: ID + imageLabel: String + imageTag: String + imageUrl: String! + imageText: String +} + +type Video { + videoUrl: String +} + +type Reference { + Key: String + Value: String +} + +type Property { + name: String + originalName: String + values: [String] +} + +type ClusterHighlight { + id: ID + name: String +} + +type ProductClusters { + id: ID + name: String +} + +type SpecificationGroupRec { + originalName: String + name: String + specifications: [SpecificationGroupRecProperty!] +} + +type SpecificationGroupRecProperty { + originalName: String + name: String + values: [String!] +} + +type PriceRange { + highPrice: Float + lowPrice: Float +} + +type ProductPriceRange { + sellingPrice: PriceRange + listPrice: PriceRange +} + +enum ProductViewSource { + WEB_DESKTOP + WEB_MOBILE + MOBILE_APP + MOBILE + DESKTOP +} From 1f921871def63327c689aca70803a657f132f454 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 12:12:29 -0300 Subject: [PATCH 03/30] test(core): include recommendation operations in schema assertions Adds recommendations, startSession and sendProductViewEvent to the expected query/mutation lists in the server schema test, reflecting the new thirdParty typeDefs. --- packages/core/test/server/index.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index b66b22cc01..fffe752781 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -80,6 +80,7 @@ const QUERIES = [ 'pickupPoints', 'orderEntryOperation', 'orderFormItems', + 'recommendations', ] const OPTIONAL_GENERATED_QUERIES = new Set(['accountName']) @@ -92,6 +93,8 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', + 'startSession', + 'sendProductViewEvent', ] describe('FastStore GraphQL Layer', () => { From 961112a8d2434e9fa961e7b6805850d90165d70f Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 13:35:24 -0300 Subject: [PATCH 04/30] fix(api): expose recommendation and personalization operations Defines the recommendations query, the startSession and sendProductViewEvent mutations and their types (RecommendationResponse, RecommendationProduct, ProductViewSource, etc.) in the VTEX platform typeDefs. These are operations shipped by @faststore/core, so they must live in @faststore/api to be part of the schema generated for any consuming store (store-level src/graphql customizations are not visible to core features). Updates the schema assertion tests accordingly. --- packages/api/src/__generated__/schema.ts | 236 ++++++++++++++++++ .../platforms/vtex/typeDefs/mutation.graphql | 12 + .../src/platforms/vtex/typeDefs/query.graphql | 9 + .../vtex/typeDefs/recommendation.graphql} | 18 -- packages/api/test/integration/schema.test.ts | 3 + packages/core/@generated/graphql.ts | 3 + 6 files changed, 263 insertions(+), 18 deletions(-) rename packages/{core/src/graphql/thirdParty/recommendations.graphql => api/src/platforms/vtex/typeDefs/recommendation.graphql} (89%) diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index d31dee5bce..7cc9a0a6c9 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -132,6 +132,23 @@ export type AvailableDeliveryWindows = { tax?: Maybe; }; +export type Benefit = { + __typename?: 'Benefit'; + featured?: Maybe; + id?: Maybe; + items?: Maybe>; + name?: Maybe; + teaserType?: Maybe; +}; + +export type BenefitItem = { + __typename?: 'BenefitItem'; + benefitProduct?: Maybe; + benefitSKUIds?: Maybe>; + discount?: Maybe; + minQuantity?: Maybe; +}; + export type BusinessHour = { __typename?: 'BusinessHour'; /** Business hour closing time. */ @@ -142,6 +159,12 @@ export type BusinessHour = { openingTime?: Maybe; }; +export type ClusterHighlight = { + __typename?: 'ClusterHighlight'; + id?: Maybe; + name?: Maybe; +}; + /** Commercial Authorization dimension status. */ export type CommercialAuthorizationDimensionStatus = { __typename?: 'CommercialAuthorizationDimensionStatus'; @@ -357,6 +380,11 @@ export type DeliveryPromiseBadge = { typeName?: Maybe; }; +export type Discount = { + __typename?: 'Discount'; + name?: Maybe; +}; + /** Input to get commercial authorizations by order ID. */ export type ICommercialAuthorizationByOrderId = { /** Order ID to get commercial authorizations for. */ @@ -615,6 +643,27 @@ export type IUserOrderCancel = { reason?: InputMaybe; }; +export type Image = { + __typename?: 'Image'; + cacheId?: Maybe; + imageId?: Maybe; + imageLabel?: Maybe; + imageTag?: Maybe; + imageText?: Maybe; + imageUrl: Scalars['String']['output']; +}; + +export type Installment = { + __typename?: 'Installment'; + InterestRate?: Maybe; + Name?: Maybe; + NumberOfInstallments?: Maybe; + PaymentSystemGroupName?: Maybe; + PaymentSystemName?: Maybe; + TotalValuePlusInterestRate?: Maybe; + Value?: Maybe; +}; + export type LogisticsInfo = { __typename?: 'LogisticsInfo'; /** LogisticsInfo itemIndex. */ @@ -685,11 +734,15 @@ export type Mutation = { cancelOrder?: Maybe; /** Process Order Authorization */ processOrderAuthorization?: Maybe; + /** Reports a product view event to the personalization engine. */ + sendProductViewEvent: Scalars['Boolean']['output']; /** * Submits an uploaded file for bulk import into a VTEX cart via the Order Entry Service. * Returns an operationId to poll for the operation status. */ startOrderEntryOperation?: Maybe; + /** Starts an anonymous personalization session for the current shopper. */ + startSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter?: Maybe; /** @@ -715,6 +768,13 @@ export type MutationProcessOrderAuthorizationArgs = { }; +export type MutationSendProductViewEventArgs = { + product: Scalars['String']['input']; + source?: InputMaybe; + userId: Scalars['String']['input']; +}; + + export type MutationStartOrderEntryOperationArgs = { data: IOrderEntryOperation; }; @@ -741,6 +801,23 @@ export type MutationValidateSessionArgs = { session: IStoreSession; }; +export type Offer = { + __typename?: 'Offer'; + AvailableQuantity?: Maybe; + Installments?: Maybe>; + ListPrice?: Maybe; + Price?: Maybe; + PriceValidUntil?: Maybe; + PriceWithoutDiscount?: Maybe; + RewardValue?: Maybe; + Tax?: Maybe; + discountHighlights?: Maybe>; + giftSkuIds?: Maybe>; + spotPrice?: Maybe; + taxPercentage?: Maybe; + teasers?: Maybe>; +}; + /** Newsletter information. */ export type PersonNewsletter = { __typename?: 'PersonNewsletter'; @@ -832,6 +909,12 @@ export type PickupStoreInfo = { isPickupStore?: Maybe; }; +export type PriceRange = { + __typename?: 'PriceRange'; + highPrice?: Maybe; + lowPrice?: Maybe; +}; + /** Process Order Authorization response. */ export type ProcessOrderAuthorizationResponse = { __typename?: 'ProcessOrderAuthorizationResponse'; @@ -852,12 +935,32 @@ export type ProcessOrderAuthorizationRule = { rule: CommercialAuthorizationRule; }; +export type ProductClusters = { + __typename?: 'ProductClusters'; + id?: Maybe; + name?: Maybe; +}; + export type ProductCountResult = { __typename?: 'ProductCountResult'; /** Total product count. */ total: Scalars['Int']['output']; }; +export type ProductPriceRange = { + __typename?: 'ProductPriceRange'; + listPrice?: Maybe; + sellingPrice?: Maybe; +}; + +export const enum ProductViewSource { + Desktop = 'DESKTOP', + Mobile = 'MOBILE', + MobileApp = 'MOBILE_APP', + WebDesktop = 'WEB_DESKTOP', + WebMobile = 'WEB_MOBILE' +}; + export type Profile = { __typename?: 'Profile'; /** Collection of user's address */ @@ -894,6 +997,13 @@ export type ProfileAddress = { street?: Maybe; }; +export type Property = { + __typename?: 'Property'; + name?: Maybe; + originalName?: Maybe; + values?: Maybe>>; +}; + export type Query = { __typename?: 'Query'; /** Returns the account profile information for the current authenticated user (b2b or b2c user). */ @@ -920,6 +1030,8 @@ export type Query = { products: Array; /** Returns information about the profile. */ profile?: Maybe; + /** Returns personalized product recommendations for a given campaign. */ + recommendations: RecommendationResponse; /** Returns if there's a redirect for a search. */ redirect?: Maybe; /** Returns the result of a product, facet, or suggestion search. */ @@ -1001,6 +1113,13 @@ export type QueryProfileArgs = { }; +export type QueryRecommendationsArgs = { + campaignVrn: Scalars['String']['input']; + products?: InputMaybe>; + userId?: InputMaybe; +}; + + export type QueryRedirectArgs = { selectedFacets?: InputMaybe>; term?: InputMaybe; @@ -1036,6 +1155,71 @@ export type QueryUserOrderArgs = { orderId: Scalars['String']['input']; }; +export type RecommendationCampaign = { + __typename?: 'RecommendationCampaign'; + id: Scalars['String']['output']; + title?: Maybe; + type: Scalars['String']['output']; +}; + +export type RecommendationProduct = { + __typename?: 'RecommendationProduct'; + /** If this product is sponsored, ad information will be added here. */ + advertisement?: Maybe; + benefits?: Maybe>; + brand?: Maybe; + brandId?: Maybe; + cacheId?: Maybe; + categories?: Maybe>; + categoriesIds?: Maybe>; + categoryId?: Maybe; + clusterHighlights?: Maybe>; + description?: Maybe; + items: Array; + link?: Maybe; + linkText?: Maybe; + metaTagDescription?: Maybe; + priceRange?: Maybe; + productClusters?: Maybe>; + productId: Scalars['String']['output']; + productName?: Maybe; + productReference?: Maybe; + properties?: Maybe>; + releaseDate?: Maybe; + skuSpecifications?: Maybe>; + specificationGroups?: Maybe>; + titleTag?: Maybe; +}; + +export type RecommendationResponse = { + __typename?: 'RecommendationResponse'; + campaign: RecommendationCampaign; + correlationId: Scalars['String']['output']; + products: Array; +}; + +export type Reference = { + __typename?: 'Reference'; + Key?: Maybe; + Value?: Maybe; +}; + +export type Sku = { + __typename?: 'SKU'; + complementName?: Maybe; + ean?: Maybe; + images: Array; + itemId: Scalars['String']['output']; + measurementUnit?: Maybe; + name?: Maybe; + nameComplete?: Maybe; + referenceId?: Maybe>; + sellers?: Maybe>; + unitMultiplier?: Maybe; + variations?: Maybe>; + videos?: Maybe>; +}; + export type SkuSpecificationField = { __typename?: 'SKUSpecificationField'; id?: Maybe; @@ -1062,6 +1246,15 @@ export type SearchMetadata = { logicalOperator: Scalars['String']['output']; }; +export type Seller = { + __typename?: 'Seller'; + addToCartLink?: Maybe; + commertialOffer?: Maybe; + sellerDefault?: Maybe; + sellerId?: Maybe; + sellerName?: Maybe; +}; + /** Information of sellers. */ export type SellerInfo = { __typename?: 'SellerInfo'; @@ -1196,6 +1389,20 @@ export type SpecificationGroup = { specifications: Array; }; +export type SpecificationGroupRec = { + __typename?: 'SpecificationGroupRec'; + name?: Maybe; + originalName?: Maybe; + specifications?: Maybe>; +}; + +export type SpecificationGroupRecProperty = { + __typename?: 'SpecificationGroupRecProperty'; + name?: Maybe; + originalName?: Maybe; + values?: Maybe>; +}; + /** Account profile information. */ export type StoreAccountProfile = { __typename?: 'StoreAccountProfile'; @@ -1851,6 +2058,30 @@ export type StoreUserDetails = { username?: Maybe; }; +export type Teaser = { + __typename?: 'Teaser'; + conditions?: Maybe; + effects?: Maybe; + name?: Maybe; +}; + +export type TeaserCondition = { + __typename?: 'TeaserCondition'; + minimumQuantity?: Maybe; + parameters?: Maybe>>; +}; + +export type TeaserEffects = { + __typename?: 'TeaserEffects'; + parameters?: Maybe>>; +}; + +export type TeaserValue = { + __typename?: 'TeaserValue'; + name?: Maybe; + value?: Maybe; +}; + export type UserOrder = { __typename?: 'UserOrder'; affiliateId?: Maybe; @@ -2741,3 +2972,8 @@ export type ValidateUserData = { /** Indicates if the user is valid. */ isValid: Scalars['Boolean']['output']; }; + +export type Video = { + __typename?: 'Video'; + videoUrl?: Maybe; +}; diff --git a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql index fbbf79d611..46a5e395cb 100644 --- a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql @@ -35,4 +35,16 @@ type Mutation { startOrderEntryOperation( data: IOrderEntryOperation! ): StoreOrderEntryOperationResult @auth + """ + Starts an anonymous personalization session for the current shopper. + """ + startSession: Boolean! + """ + Reports a product view event to the personalization engine. + """ + sendProductViewEvent( + userId: String! + product: String! + source: ProductViewSource + ): Boolean! } diff --git a/packages/api/src/platforms/vtex/typeDefs/query.graphql b/packages/api/src/platforms/vtex/typeDefs/query.graphql index b363001ed8..89cec4b9cf 100644 --- a/packages/api/src/platforms/vtex/typeDefs/query.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/query.graphql @@ -470,6 +470,15 @@ type Query { Returns the items in an orderForm by its ID. """ orderFormItems(orderFormId: String!): [StoreOrderFormCartItem!]! @auth + """ + Returns personalized product recommendations for a given campaign. + """ + recommendations( + campaignVrn: String! + userId: String + products: [String!] + ): RecommendationResponse! + @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) } type ValidateUserData { diff --git a/packages/core/src/graphql/thirdParty/recommendations.graphql b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql similarity index 89% rename from packages/core/src/graphql/thirdParty/recommendations.graphql rename to packages/api/src/platforms/vtex/typeDefs/recommendation.graphql index ea0e403635..3ccb43f879 100644 --- a/packages/core/src/graphql/thirdParty/recommendations.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql @@ -1,21 +1,3 @@ -extend type Query { - recommendations( - campaignVrn: String! - userId: String - products: [String!] - ): RecommendationResponse! - @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) -} - -extend type Mutation { - startSession: Boolean! - sendProductViewEvent( - userId: String! - product: String! - source: ProductViewSource - ): Boolean! -} - type RecommendationResponse { products: [RecommendationProduct!]! correlationId: String! diff --git a/packages/api/test/integration/schema.test.ts b/packages/api/test/integration/schema.test.ts index 2df5b01d07..290563aa81 100644 --- a/packages/api/test/integration/schema.test.ts +++ b/packages/api/test/integration/schema.test.ts @@ -80,6 +80,7 @@ const QUERIES = [ 'pickupPoints', 'orderEntryOperation', 'orderFormItems', + 'recommendations', ] const MUTATIONS = [ @@ -90,6 +91,8 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', + 'startSession', + 'sendProductViewEvent', ] let schema: GraphQLSchema diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index a326c512ae..7c6500a030 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -709,12 +709,14 @@ export type Mutation = { cancelOrder: Maybe; /** Process Order Authorization */ processOrderAuthorization: Maybe; + /** Reports a product view event to the personalization engine. */ sendProductViewEvent: Scalars['Boolean']['output']; /** * Submits an uploaded file for bulk import into a VTEX cart via the Order Entry Service. * Returns an operationId to poll for the operation status. */ startOrderEntryOperation: Maybe; + /** Starts an anonymous personalization session for the current shopper. */ startSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter: Maybe; @@ -985,6 +987,7 @@ export type Query = { products: Array; /** Returns information about the profile. */ profile: Maybe; + /** Returns personalized product recommendations for a given campaign. */ recommendations: RecommendationResponse; /** Returns if there's a redirect for a search. */ redirect: Maybe; From c0495e57a3f293cd952b82e0aa86975448064b69 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 13:59:44 -0300 Subject: [PATCH 05/30] refactor(core): clean up RecommendationShelf code smells Addresses SonarQube findings on the new recommendation code: replaces negated conditions and nested ternaries with early returns, uses optional chaining, removes a dead duplicated branch in the request helper, drops a redundant always-falsy check and uses globalThis.window for the SSR guard. No behavior change. --- .../RecommendationShelf.tsx | 8 +----- .../RecommendationShelf/queries/request.ts | 27 +++++++++---------- .../RecommendationShelf/queries/useQuery.ts | 16 ++++++----- .../components/RecommendationShelf/utils.ts | 4 +-- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 096c40077a..8b01929db2 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -84,13 +84,7 @@ export const RecommendationShelf = ({ if (!userId) { // The pixel might take a while to load and set the userId cookie, // so we use a retry mechanism to ensure we get the userId if available. - getWithRetry(() => { - if (!userId) { - return getUserIdFromCookie() - } - - return '' - }) + getWithRetry(() => getUserIdFromCookie()) .then((value) => { setUserId(value) }) diff --git a/packages/core/src/components/RecommendationShelf/queries/request.ts b/packages/core/src/components/RecommendationShelf/queries/request.ts index 676a0f5792..8ed62dcf96 100644 --- a/packages/core/src/components/RecommendationShelf/queries/request.ts +++ b/packages/core/src/components/RecommendationShelf/queries/request.ts @@ -48,12 +48,15 @@ const baseRequest = async ( // Uses method from fetchOptions. // If no one is passed, figure out with via heuristic - const method = - fetchOptions?.method !== undefined - ? fetchOptions.method.toUpperCase() - : operationName.endsWith('Query') - ? 'GET' - : 'POST' + const resolveMethod = (): string => { + if (fetchOptions?.method) { + return fetchOptions.method.toUpperCase() + } + + return operationName.endsWith('Query') ? 'GET' : 'POST' + } + + const method = resolveMethod() const params = new URLSearchParams({ operationName, @@ -86,14 +89,14 @@ const baseRequest = async ( let responseBody = null - if (contentType && contentType.includes('application/json')) { + if (contentType?.includes('application/json')) { try { responseBody = await response.json() } catch (error) { console.error('Error parsing JSON', error) throw new Error('Error parsing JSON response') } - } else if (contentType && contentType.includes('text/plain')) { + } else if (contentType?.includes('text/plain')) { responseBody = await response.text() } @@ -101,11 +104,5 @@ const baseRequest = async ( return responseBody } - if (response.ok) { - return response.status !== 204 ? response.json() : (undefined as any) - } - - const error = new Error('Error while fetching', { cause: responseBody }) - - throw error + throw new Error('Error while fetching', { cause: responseBody }) } diff --git a/packages/core/src/components/RecommendationShelf/queries/useQuery.ts b/packages/core/src/components/RecommendationShelf/queries/useQuery.ts index 6470917fdb..e2716b5273 100644 --- a/packages/core/src/components/RecommendationShelf/queries/useQuery.ts +++ b/packages/core/src/components/RecommendationShelf/queries/useQuery.ts @@ -27,13 +27,15 @@ export const useQuery = >( options?: QueryOptions ) => useSWR( - () => - options?.doNotRun - ? null - : getKey( - operation['__meta__'] ? operation['__meta__']['operationName'] : '', - variables - ), + () => { + if (options?.doNotRun) { + return null + } + + const operationName = operation['__meta__']?.['operationName'] ?? '' + + return getKey(operationName, variables) + }, { fetcher: () => request(operation, variables, options), ...DEFAULT_OPTIONS, diff --git a/packages/core/src/components/RecommendationShelf/utils.ts b/packages/core/src/components/RecommendationShelf/utils.ts index fac05922a1..1b65e43082 100644 --- a/packages/core/src/components/RecommendationShelf/utils.ts +++ b/packages/core/src/components/RecommendationShelf/utils.ts @@ -7,11 +7,11 @@ const MAX_MOBILE_WIDTH = 768 const USER_ID_COOKIE = 'vtex-rec-user-id' export function checkIsMobile(): boolean { - if (typeof window === 'undefined') { + if (typeof globalThis.window === 'undefined') { return false } - return window.innerWidth <= MAX_MOBILE_WIDTH + return globalThis.window.innerWidth <= MAX_MOBILE_WIDTH } export function getUserIdFromCookie(): string { From 1108aa38b8685b2614374c2eb299cef8eb59a338 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 13:59:48 -0300 Subject: [PATCH 06/30] test(core): cover RecommendationShelf and useStartSession Adds unit and component tests for the recommendation feature (vrn parsing, utils, product mapping, request/query helpers, useRecommendations, useStartSession and the RecommendationShelf component), bringing coverage on the new code above the 80% quality gate. --- .../RecommendationShelf.browser.test.tsx | 170 ++++++++++++++++++ .../mapRecommendationToProductCard.test.ts | 92 ++++++++++ .../RecommendationShelf/queries.test.ts | 65 +++++++ .../RecommendationShelf/request.test.ts | 130 ++++++++++++++ .../useRecommendations.test.ts | 52 ++++++ .../RecommendationShelf/utils.test.ts | 77 ++++++++ .../RecommendationShelf/vrn.test.ts | 54 ++++++ .../sdk/analytics/useStartSession.test.ts | 136 ++++++++++++++ 8 files changed, 776 insertions(+) create mode 100644 packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx create mode 100644 packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/queries.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/request.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/useRecommendations.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/utils.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/vrn.test.ts create mode 100644 packages/core/test/sdk/analytics/useStartSession.test.ts diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx new file mode 100644 index 0000000000..816b670f71 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -0,0 +1,170 @@ +import '@testing-library/jest-dom/vitest' +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const usePDP = vi.hoisted(() => vi.fn()) +vi.mock('@faststore/core', () => ({ usePDP })) + +vi.mock('@faststore/ui', () => { + const passthrough = ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children) + return { ProductShelf: passthrough, Carousel: passthrough } +}) + +vi.mock('src/components/product/ProductCard', () => ({ + default: () => React.createElement('div', { 'data-testid': 'product-card' }), +})) + +vi.mock('src/components/skeletons/ProductShelfSkeleton', () => ({ + default: ({ + children, + loading, + }: React.PropsWithChildren<{ loading?: boolean }>) => + React.createElement( + 'div', + { 'data-testid': 'skeleton', 'data-loading': String(!!loading) }, + children + ), +})) + +const useRecommendations = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/useRecommendations', () => ({ + useRecommendations, +})) + +const checkIsMobile = vi.hoisted(() => vi.fn()) +const getUserIdFromCookie = vi.hoisted(() => vi.fn()) +const getWithRetry = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/utils', () => ({ + checkIsMobile, + getUserIdFromCookie, + getWithRetry, +})) + +import { RecommendationShelf } from 'src/components/RecommendationShelf/RecommendationShelf' + +const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' +const CROSS_SELL_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1' + +const recommendationData = { + products: [{ productId: 'p-1' }, { productId: 'p-2' }], + correlationId: 'corr-1', + campaign: { id: 'camp-1', title: 'Recommended for you', type: 'TOP_ITEMS' }, +} + +beforeEach(() => { + usePDP.mockReturnValue({ data: undefined }) + checkIsMobile.mockReturnValue(false) + getUserIdFromCookie.mockReturnValue('user-1') + // Keep the cookie lookup pending so the component doesn't trigger an async + // state update; the recommendations hook is mocked independently. + getWithRetry.mockReturnValue(new Promise(() => {})) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('RecommendationShelf', () => { + it('renders a product card per recommended product', async () => { + useRecommendations.mockReturnValue({ + data: recommendationData, + isLoading: false, + error: null, + }) + + const { getAllByTestId, getByText } = render( + + ) + + await waitFor(() => { + expect(getAllByTestId('product-card')).toHaveLength(2) + }) + expect(getByText('Recommended for you')).toBeTruthy() + }) + + it('requests cross-sell recommendations using the current PDP product', async () => { + usePDP.mockReturnValue({ + data: { product: { isVariantOf: { productGroupID: 'pg-1' } } }, + }) + // Resolve the cookie lookup so the component derives a userId and builds the + // recommendation arguments from the PDP product. + getWithRetry.mockResolvedValue('user-1') + useRecommendations.mockReturnValue({ + data: recommendationData, + isLoading: false, + error: null, + }) + + render() + + await waitFor(() => { + const lastArgs = useRecommendations.mock.calls.at(-1)?.[0] + expect(lastArgs).toEqual({ + userId: 'user-1', + campaignVrn: CROSS_SELL_VRN, + products: ['pg-1'], + }) + }) + }) + + it('prefers the provided title over the campaign title', async () => { + useRecommendations.mockReturnValue({ + data: recommendationData, + isLoading: false, + error: null, + }) + + const { getByText } = render( + + ) + + await waitFor(() => { + expect(getByText('Custom title')).toBeTruthy() + }) + }) + + it('renders nothing when there is an error', () => { + useRecommendations.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('boom'), + }) + + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when there are no items and loading has finished', () => { + useRecommendations.mockReturnValue({ + data: { ...recommendationData, products: [] }, + isLoading: false, + error: null, + }) + + const { container, queryByTestId } = render( + + ) + + expect(queryByTestId('product-card')).toBeNull() + expect(container).toBeEmptyDOMElement() + }) + + it('shows the skeleton while loading', () => { + useRecommendations.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }) + + const { getByTestId } = render( + + ) + + expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true') + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts b/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts new file mode 100644 index 0000000000..a0ece7ab4f --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' + +import { mapRecommendationToProductCard } from 'src/components/RecommendationShelf/mapRecommendationToProductCard' + +const fullProduct = { + productId: 'p-1', + productName: 'Cool Shoes', + brand: 'Acme', + linkText: 'cool-shoes', + items: [ + { + itemId: 'sku-1', + name: 'Cool Shoes', + nameComplete: 'Cool Shoes - Blue', + images: [{ imageUrl: 'https://img/1.png', imageText: 'blue shoe' }], + sellers: [ + { + sellerId: '2', + sellerDefault: false, + commertialOffer: { Price: 50, ListPrice: 80, AvailableQuantity: 0 }, + }, + { + sellerId: '1', + sellerDefault: true, + commertialOffer: { Price: 100, ListPrice: 120, AvailableQuantity: 5 }, + }, + ], + }, + ], +} as any + +describe('mapRecommendationToProductCard', () => { + it('maps the default seller commercial offer and product data', () => { + const card = mapRecommendationToProductCard(fullProduct) + + expect(card.id).toBe('p-1') + expect(card.sku).toBe('sku-1') + expect(card.slug).toBe('cool-shoes') + expect(card.name).toBe('Cool Shoes - Blue') + expect(card.brand).toEqual({ name: 'Acme', brandName: 'Acme' }) + expect(card.isVariantOf.productGroupID).toBe('p-1') + expect(card.image).toEqual([ + { url: 'https://img/1.png', alternateName: 'blue shoe' }, + ]) + + const offer = card.offers.offers[0] + expect(card.offers.lowPrice).toBe(100) + expect(offer.price).toBe(100) + expect(offer.listPrice).toBe(120) + expect(offer.quantity).toBe(5) + expect(offer.availability).toBe('https://schema.org/InStock') + expect(offer.seller.identifier).toBe('1') + }) + + it('uses the first seller when none is marked as default', () => { + const product = { + productId: 'p-2', + items: [ + { + itemId: 'sku-2', + sellers: [ + { + sellerId: '9', + commertialOffer: { Price: 10, AvailableQuantity: 1 }, + }, + ], + }, + ], + } as any + + const card = mapRecommendationToProductCard(product) + + expect(card.offers.offers[0].seller.identifier).toBe('9') + expect(card.offers.offers[0].price).toBe(10) + }) + + it('falls back to safe defaults when data is missing', () => { + const card = mapRecommendationToProductCard({ productId: 'p-3' } as any) + + expect(card.id).toBe('p-3') + expect(card.sku).toBe('p-3') + expect(card.name).toBe('') + expect(card.image).toEqual([]) + expect(card.brand).toEqual({ name: '', brandName: '' }) + + const offer = card.offers.offers[0] + expect(offer.price).toBe(0) + expect(offer.quantity).toBe(0) + expect(offer.availability).toBe('https://schema.org/OutOfStock') + expect(offer.seller.identifier).toBe('1') + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/queries.test.ts b/packages/core/test/components/RecommendationShelf/queries.test.ts new file mode 100644 index 0000000000..a3137deaa1 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/queries.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const useSWRMock = vi.hoisted(() => vi.fn()) +vi.mock('swr', () => ({ default: useSWRMock })) + +const requestMock = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/queries/request', () => ({ + request: requestMock, +})) + +import { + getKey, + useQuery, +} from 'src/components/RecommendationShelf/queries/useQuery' +import { useLazyQuery } from 'src/components/RecommendationShelf/queries/useLazyQuery' + +const op = { __meta__: { operationName: 'FooQuery', operationHash: 'h1' } } + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('getKey', () => { + it('combines the operation name and serialized variables', () => { + expect(getKey('FooQuery', { a: 1 })).toBe('FooQuery::{"a":1}') + }) +}) + +describe('useQuery', () => { + it('builds a key from the operation name when run is enabled', () => { + useQuery(op, { a: 1 }, { doNotRun: false }) + + const keyFn = useSWRMock.mock.calls[0][0] + expect(keyFn()).toBe('FooQuery::{"a":1}') + }) + + it('returns a null key when doNotRun is set', () => { + useQuery(op, { a: 1 }, { doNotRun: true }) + + const keyFn = useSWRMock.mock.calls[0][0] + expect(keyFn()).toBeNull() + }) + + it('falls back to an empty operation name without __meta__', () => { + useQuery({}, { a: 1 }) + + const keyFn = useSWRMock.mock.calls[0][0] + expect(keyFn()).toBe('::{"a":1}') + }) +}) + +describe('useLazyQuery', () => { + it('executes the request lazily and updates the SWR cache', async () => { + const mutate = vi.fn(async (data: unknown) => data) + useSWRMock.mockReturnValue({ mutate }) + requestMock.mockResolvedValue({ ok: true }) + + const [execute] = useLazyQuery(op, {}) + const result = await execute({ id: '1' }) + + expect(requestMock).toHaveBeenCalledWith(op, { id: '1' }, undefined) + expect(mutate).toHaveBeenCalledWith({ ok: true }, false) + expect(result).toEqual({ ok: true }) + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/request.test.ts b/packages/core/test/components/RecommendationShelf/request.test.ts new file mode 100644 index 0000000000..ff42b34dd2 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/request.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { request } from 'src/components/RecommendationShelf/queries/request' + +type MockResponseInit = { + ok?: boolean + status?: number + contentType?: string | null + json?: unknown + text?: string + jsonThrows?: boolean +} + +function mockResponse({ + ok = true, + status = 200, + contentType = 'application/json', + json, + text, + jsonThrows = false, +}: MockResponseInit) { + return { + ok, + status, + headers: { get: () => contentType }, + json: vi.fn(async () => { + if (jsonThrows) { + throw new Error('bad json') + } + return json + }), + text: vi.fn(async () => text ?? ''), + } +} + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const queryOp = { __meta__: { operationName: 'FooQuery', operationHash: 'h1' } } +const mutationOp = { + __meta__: { operationName: 'DoThing', operationHash: 'h2' }, +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('request', () => { + it('returns data for a successful response', async () => { + fetchMock.mockResolvedValue( + mockResponse({ json: { data: { foo: 'bar' }, errors: [] } }) + ) + + const data = await request(queryOp, { a: 1 }) + + expect(data).toEqual({ foo: 'bar' }) + }) + + it('throws when the GraphQL response has errors', async () => { + fetchMock.mockResolvedValue( + mockResponse({ json: { data: null, errors: [{ message: 'nope' }] } }) + ) + + await expect(request(queryOp, {})).rejects.toThrow('GraphQL error') + }) + + it('uses GET and serializes variables in the query string for *Query operations', async () => { + fetchMock.mockResolvedValue( + mockResponse({ json: { data: {}, errors: [] } }) + ) + + await request(queryOp, { term: 'shoes' }) + + const [url, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('GET') + expect(init.body).toBeUndefined() + expect(url).toContain('operationName=FooQuery') + expect(url).toContain(encodeURIComponent(JSON.stringify({ term: 'shoes' }))) + }) + + it('uses POST with a JSON body for non-query operations', async () => { + fetchMock.mockResolvedValue( + mockResponse({ json: { data: {}, errors: [] } }) + ) + + await request(mutationOp, { id: '1' }) + + const [, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toMatchObject({ + operationName: 'DoThing', + variables: { id: '1' }, + }) + }) + + it('honors an explicit method from fetchOptions', async () => { + fetchMock.mockResolvedValue( + mockResponse({ json: { data: {}, errors: [] } }) + ) + + await request(queryOp, {}, { fetchOptions: { method: 'put' } }) + + expect(fetchMock.mock.calls[0][1].method).toBe('put') + }) + + it('throws a readable error when JSON parsing fails', async () => { + fetchMock.mockResolvedValue(mockResponse({ jsonThrows: true })) + + await expect(request(queryOp, {})).rejects.toThrow( + 'Error parsing JSON response' + ) + }) + + it('reads text/plain responses', async () => { + fetchMock.mockResolvedValue( + mockResponse({ contentType: 'text/plain', text: 'plain body' }) + ) + + // text bodies have no `errors`, so the request resolves with the raw body + await expect(request(queryOp, {})).resolves.toBe(undefined) + }) + + it('throws when the response is not ok', async () => { + fetchMock.mockResolvedValue( + mockResponse({ ok: false, status: 500, json: { data: null, errors: [] } }) + ) + + await expect(request(queryOp, {})).rejects.toThrow('Error while fetching') + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts new file mode 100644 index 0000000000..28691a0eac --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@faststore/core/api', () => ({ gql: () => ({ __meta__: {} }) })) + +const useQueryMock = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/queries/useQuery', () => ({ + useQuery: useQueryMock, +})) + +import { useRecommendations } from 'src/components/RecommendationShelf/useRecommendations' + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useRecommendations', () => { + it('does not run the query when args are null', () => { + useQueryMock.mockReturnValue({ + data: undefined, + error: null, + isLoading: false, + }) + + const { data } = useRecommendations(null) + + expect(data).toBeUndefined() + const options = useQueryMock.mock.calls[0][2] + expect(options.doNotRun).toBe(true) + }) + + it('unwraps the recommendations payload when args are provided', () => { + const recommendations = { + products: [{ productId: 'p-1' }], + correlationId: 'c-1', + campaign: { id: 'camp-1', title: 'Top', type: 'TOP_ITEMS' }, + } + + useQueryMock.mockReturnValue({ + data: { recommendations }, + error: null, + isLoading: false, + }) + + const args = { userId: 'u-1', campaignVrn: 'vrn:x', products: [] } + const { data, isLoading, error } = useRecommendations(args) + + expect(data).toEqual(recommendations) + expect(isLoading).toBe(false) + expect(error).toBeNull() + expect(useQueryMock.mock.calls[0][2].doNotRun).toBe(false) + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/utils.test.ts b/packages/core/test/components/RecommendationShelf/utils.test.ts new file mode 100644 index 0000000000..790a67f852 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/utils.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockGetCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie })) + +import { + checkIsMobile, + getUserIdFromCookie, + getWithRetry, +} from 'src/components/RecommendationShelf/utils' + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('checkIsMobile', () => { + it('returns true when the viewport is at or below the mobile breakpoint', () => { + vi.stubGlobal('window', { innerWidth: 768 }) + expect(checkIsMobile()).toBe(true) + }) + + it('returns false when the viewport is wider than the breakpoint', () => { + vi.stubGlobal('window', { innerWidth: 1024 }) + expect(checkIsMobile()).toBe(false) + }) + + it('returns false when there is no window (SSR)', () => { + vi.stubGlobal('window', undefined) + expect(checkIsMobile()).toBe(false) + }) +}) + +describe('getUserIdFromCookie', () => { + it('returns the cookie value when present', () => { + mockGetCookie.mockReturnValue('user-123') + expect(getUserIdFromCookie()).toBe('user-123') + }) + + it('falls back to an empty string when the cookie is missing', () => { + mockGetCookie.mockReturnValue(undefined) + expect(getUserIdFromCookie()).toBe('') + }) +}) + +describe('getWithRetry', () => { + it('resolves immediately when the value is already available', async () => { + const fn = vi.fn(() => 'ready') + await expect(getWithRetry(fn)).resolves.toBe('ready') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('retries until a truthy value is returned', async () => { + const fn = vi + .fn<() => string>() + .mockReturnValueOnce('') + .mockReturnValueOnce('') + .mockReturnValue('later') + + await expect(getWithRetry(fn, { retries: 5, delayMs: 1 })).resolves.toBe( + 'later' + ) + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('gives up after exhausting the retry budget', async () => { + const fn = vi.fn(() => '') + + await expect(getWithRetry(fn, { retries: 2, delayMs: 1 })).resolves.toBe('') + // initial call + 2 retries + expect(fn).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/core/test/components/RecommendationShelf/vrn.test.ts b/packages/core/test/components/RecommendationShelf/vrn.test.ts new file mode 100644 index 0000000000..ff5923a4f0 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/vrn.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { + getTypeFromVrn, + isValidVrn, +} from 'src/components/RecommendationShelf/vrn' + +const vrn = (type: string) => `vrn:recommendations:account:${type}:campaign-1` + +describe('isValidVrn', () => { + it('accepts every supported campaign type', () => { + const types = [ + 'rec-cross-v2', + 'rec-similar-v2', + 'rec-persona-v2', + 'rec-last-v2', + 'rec-top-items-v2', + 'rec-search-v2', + 'rec-next-v2', + 'rec-visual-v2', + ] + + for (const type of types) { + expect(isValidVrn(vrn(type))).toBe(true) + } + }) + + it('rejects malformed or unknown vrns', () => { + expect(isValidVrn('not-a-vrn')).toBe(false) + expect(isValidVrn('vrn:recommendations:account:rec-unknown:campaign')).toBe( + false + ) + expect(isValidVrn('vrn:recommendations:account:rec-cross-v2')).toBe(false) + }) +}) + +describe('getTypeFromVrn', () => { + it('maps each vrn type to its recommendation type', () => { + expect(getTypeFromVrn(vrn('rec-cross-v2'))).toBe('CROSS_SELL') + expect(getTypeFromVrn(vrn('rec-similar-v2'))).toBe('SIMILAR_ITEMS') + expect(getTypeFromVrn(vrn('rec-persona-v2'))).toBe('PERSONALIZED') + expect(getTypeFromVrn(vrn('rec-last-v2'))).toBe('LAST_SEEN') + expect(getTypeFromVrn(vrn('rec-top-items-v2'))).toBe('TOP_ITEMS') + expect(getTypeFromVrn(vrn('rec-search-v2'))).toBe('SEARCH_BASED') + expect(getTypeFromVrn(vrn('rec-next-v2'))).toBe('NEXT_INTERACTION') + expect(getTypeFromVrn(vrn('rec-visual-v2'))).toBe('VISUAL_SIMILARITY') + }) + + it('throws on an unknown campaign type', () => { + expect(() => getTypeFromVrn(vrn('rec-bogus'))).toThrow( + /Unknown campaign type/ + ) + }) +}) diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartSession.test.ts new file mode 100644 index 0000000000..bf61951aa5 --- /dev/null +++ b/packages/core/test/sdk/analytics/useStartSession.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment jsdom + */ + +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@faststore/core/api', () => ({ + gql: (s: string) => ({ __meta__: {}, query: s }), +})) + +const runStartSession = vi.hoisted(() => vi.fn()) +const runSendProductViewEvent = vi.hoisted(() => vi.fn()) +const useLazyQueryMock = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/queries/useLazyQuery', () => ({ + useLazyQuery: useLazyQueryMock, +})) + +const checkIsMobile = vi.hoisted(() => vi.fn()) +const getUserIdFromCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/utils', () => ({ + checkIsMobile, + getUserIdFromCookie, +})) + +const getCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/utils/getCookie', () => ({ getCookie })) + +import { useStartSession } from 'src/sdk/analytics/hooks/useStartSession' + +const pdpProps = { + data: { product: { isVariantOf: { productGroupID: 'p-1' } } }, +} + +beforeEach(() => { + useLazyQueryMock.mockImplementation((operation: { query: string }) => { + if (operation.query.includes('sendProductViewEvent')) { + return [runSendProductViewEvent, {}] + } + return [runStartSession, {}] + }) + runStartSession.mockResolvedValue(true) + runSendProductViewEvent.mockResolvedValue(true) + checkIsMobile.mockReturnValue(false) + getUserIdFromCookie.mockReturnValue('user-1') +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useStartSession', () => { + it('does not start a session when the session cookie already exists', async () => { + getCookie.mockReturnValue('already-started') + + renderHook(() => useStartSession()) + + await waitFor(() => { + expect(runStartSession).not.toHaveBeenCalled() + }) + }) + + it('starts a session when no session cookie is present', async () => { + getCookie.mockReturnValue(undefined) + + renderHook(() => useStartSession()) + + await waitFor(() => { + expect(runStartSession).toHaveBeenCalled() + }) + }) + + it('sends a product view for a PDP with a known user (desktop)', async () => { + getCookie.mockReturnValue('already-started') + + renderHook(() => useStartSession(pdpProps)) + + await waitFor(() => { + expect(runSendProductViewEvent).toHaveBeenCalledWith({ + userId: 'user-1', + product: 'p-1', + source: 'WEB_DESKTOP', + }) + }) + }) + + it('uses the mobile source on small viewports', async () => { + getCookie.mockReturnValue('already-started') + checkIsMobile.mockReturnValue(true) + + renderHook(() => useStartSession(pdpProps)) + + await waitFor(() => { + expect(runSendProductViewEvent).toHaveBeenCalledWith( + expect.objectContaining({ source: 'WEB_MOBILE' }) + ) + }) + }) + + it('does not send a product view when there is no product', async () => { + getCookie.mockReturnValue('already-started') + + renderHook(() => useStartSession({ data: {} })) + + await waitFor(() => { + expect(runStartSession).not.toHaveBeenCalled() + }) + expect(runSendProductViewEvent).not.toHaveBeenCalled() + }) + + it('does not send a product view when the user is unknown', async () => { + getCookie.mockReturnValue('already-started') + getUserIdFromCookie.mockReturnValue('') + + renderHook(() => useStartSession(pdpProps)) + + await waitFor(() => { + expect(getUserIdFromCookie).toHaveBeenCalled() + }) + expect(runSendProductViewEvent).not.toHaveBeenCalled() + }) + + it('does not re-send the same product view across re-renders', async () => { + getCookie.mockReturnValue('already-started') + + const { rerender } = renderHook(() => useStartSession(pdpProps)) + + await waitFor(() => { + expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) + }) + + rerender() + + expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) + }) +}) From 4caead7216c6effe614e9cfd421211eaf5559726 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 14:13:47 -0300 Subject: [PATCH 07/30] refactor(core): compare globalThis.window with undefined directly Resolves the remaining SonarQube finding: globalThis.window is always a valid reference, so the typeof guard is unnecessary. --- packages/core/src/components/RecommendationShelf/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/components/RecommendationShelf/utils.ts b/packages/core/src/components/RecommendationShelf/utils.ts index 1b65e43082..966b458b6e 100644 --- a/packages/core/src/components/RecommendationShelf/utils.ts +++ b/packages/core/src/components/RecommendationShelf/utils.ts @@ -7,7 +7,7 @@ const MAX_MOBILE_WIDTH = 768 const USER_ID_COOKIE = 'vtex-rec-user-id' export function checkIsMobile(): boolean { - if (typeof globalThis.window === 'undefined') { + if (globalThis.window === undefined) { return false } From 70e3b83495d65ae278f3025224b854255c7186db Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 14:30:29 -0300 Subject: [PATCH 08/30] Update packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../components/cms_component__RecommendationShelf.jsonc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc index 29ba51fd09..6eecd2c2b0 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -12,8 +12,10 @@ }, "campaignVrn": { "title": "Campaign VRN", - "description": "Newtail campaign VRN that drives the recommendation (e.g. vrn:newtail:campaign:PERSONALIZED:abc123).", - "type": "string" + "description": "Recommendation campaign VRN (e.g. vrn:recommendations:my-account:rec-persona-v2:abc123).", + "type": "string", + "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" + } } } } From ec92aefc06d173b179478e6a94a68651d8c4e7cc Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 14:58:20 -0300 Subject: [PATCH 09/30] fix(api): use private cache scope for personalized recommendations The recommendations query is personalized (userId + campaign context), so a public/shared cache scope could leak one shopper's response to others via a CDN. Switch it to scope "private". --- packages/api/src/platforms/vtex/typeDefs/query.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/platforms/vtex/typeDefs/query.graphql b/packages/api/src/platforms/vtex/typeDefs/query.graphql index 89cec4b9cf..fbfd4f38d5 100644 --- a/packages/api/src/platforms/vtex/typeDefs/query.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/query.graphql @@ -478,7 +478,7 @@ type Query { userId: String products: [String!] ): RecommendationResponse! - @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) + @cacheControl(scope: "private", sMaxAge: 120, staleWhileRevalidate: 3600) } type ValidateUserData { From e36918123b0f14d9628d7ef71238ac714c99a7fc Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 14:58:24 -0300 Subject: [PATCH 10/30] fix(core): harden RecommendationShelf per review feedback - Fix malformed JSON in the RecommendationShelf CMS schema (stray brace). - Move the userId cookie retry into a useEffect so it no longer kicks off async work and setState during render. - Stop logging recommendation args (which carry the userId) on fetch errors; log only non-identifying context. - In useStartSession, only mark a product view as sent after the mutation succeeds, so a failed request isn't permanently suppressed. - Extend tests to cover the new failure paths. --- .../cms_component__RecommendationShelf.jsonc | 1 - .../RecommendationShelf.tsx | 34 ++++++++++++++----- .../sdk/analytics/hooks/useStartSession.ts | 10 ++++-- .../RecommendationShelf.browser.test.tsx | 17 ++++++++++ .../sdk/analytics/useStartSession.test.ts | 11 ++++++ 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc index 6eecd2c2b0..ab09ea3c3d 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -16,6 +16,5 @@ "type": "string", "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" } - } } } diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 8b01929db2..af346933fb 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -1,4 +1,4 @@ -import React, { useId, useMemo, useState } from 'react' +import React, { useEffect, useId, useMemo, useState } from 'react' import { usePDP } from '@faststore/core' import { ProductShelf, Carousel } from '@faststore/ui' @@ -81,26 +81,42 @@ export const RecommendationShelf = ({ productIds.length ) - if (!userId) { + useEffect(() => { + let cancelled = false + // The pixel might take a while to load and set the userId cookie, // so we use a retry mechanism to ensure we get the userId if available. getWithRetry(() => getUserIdFromCookie()) .then((value) => { - setUserId(value) + if (!cancelled) { + setUserId(value) + } }) - .catch((error) => { - console.error('Error retrieving userId from cookie', error, campaignVrn) - setUserId(null) + .catch((retryError) => { + console.error( + 'Error retrieving userId from cookie', + retryError, + campaignVrn + ) + if (!cancelled) { + setUserId(null) + } }) - } + + return () => { + cancelled = true + } + }, [campaignVrn]) if (error) { + // Don't log `recommendationArgs`: it carries the userId. Log only + // non-identifying context. console.error( 'Error fetching recommendations', error.cause, error.message, - 'with args', - recommendationArgs + 'for campaign', + campaignVrn ) return null } diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index 526651eb15..ebdd2d81a2 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -128,12 +128,16 @@ export function useStartSession(props?: any) { return } - lastProductViewSentRef.current = productId - const source: ProductViewSource = checkIsMobile() ? 'WEB_MOBILE' : 'WEB_DESKTOP' - void runSendProductViewEvent({ userId, product: productId, source }) + // Only mark the product view as sent once the mutation succeeds, so a + // failed/timed-out request isn't permanently suppressed. + runSendProductViewEvent({ userId, product: productId, source }) + .then(() => { + lastProductViewSentRef.current = productId + }) + .catch(() => {}) }, [productId, runSendProductViewEvent]) } diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 816b670f71..88ea9a9c00 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -125,6 +125,23 @@ describe('RecommendationShelf', () => { }) }) + it('still renders when the cookie lookup fails', async () => { + getWithRetry.mockRejectedValue(new Error('no cookie')) + useRecommendations.mockReturnValue({ + data: recommendationData, + isLoading: false, + error: null, + }) + + const { getAllByTestId } = render( + + ) + + await waitFor(() => { + expect(getAllByTestId('product-card')).toHaveLength(2) + }) + }) + it('renders nothing when there is an error', () => { useRecommendations.mockReturnValue({ data: undefined, diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartSession.test.ts index bf61951aa5..69a4d534c5 100644 --- a/packages/core/test/sdk/analytics/useStartSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartSession.test.ts @@ -120,6 +120,17 @@ describe('useStartSession', () => { expect(runSendProductViewEvent).not.toHaveBeenCalled() }) + it('swallows product-view mutation failures', async () => { + getCookie.mockReturnValue('already-started') + runSendProductViewEvent.mockRejectedValue(new Error('network')) + + renderHook(() => useStartSession(pdpProps)) + + await waitFor(() => { + expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) + }) + }) + it('does not re-send the same product view across re-renders', async () => { getCookie.mockReturnValue('already-started') From fab2acc67720503330a36061215dc63f0f3db3a8 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 15:30:45 -0300 Subject: [PATCH 11/30] refactor(core): move analytics utils out of RecommendationShelf into SDK Decouples the useStartSession SDK hook from a UI component module: checkIsMobile and getUserIdFromCookie now live in src/sdk/analytics/utils. The component-level utils keeps only getWithRetry. Tests updated/split accordingly. --- .../RecommendationShelf.tsx | 3 +- .../components/RecommendationShelf/utils.ts | 20 --------- .../sdk/analytics/hooks/useStartSession.ts | 5 +-- packages/core/src/sdk/analytics/utils.ts | 19 ++++++++ .../RecommendationShelf.browser.test.tsx | 4 +- .../RecommendationShelf/utils.test.ts | 43 +----------------- .../sdk/analytics/useStartSession.test.ts | 2 +- .../core/test/sdk/analytics/utils.test.ts | 44 +++++++++++++++++++ 8 files changed, 70 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/sdk/analytics/utils.ts create mode 100644 packages/core/test/sdk/analytics/utils.test.ts diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index af346933fb..7feddfb583 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -5,6 +5,7 @@ import { ProductShelf, Carousel } from '@faststore/ui' import ProductCard from 'src/components/product/ProductCard' import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton' +import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' import { mapRecommendationToProductCard } from './mapRecommendationToProductCard' import type { RecommendationShelfProps } from './RecommendationShelf.types' @@ -13,7 +14,7 @@ import { useRecommendations, type RecommendationInput, } from './useRecommendations' -import { checkIsMobile, getUserIdFromCookie, getWithRetry } from './utils' +import { getWithRetry } from './utils' import { getTypeFromVrn } from './vrn' function getRecommendationArguments( diff --git a/packages/core/src/components/RecommendationShelf/utils.ts b/packages/core/src/components/RecommendationShelf/utils.ts index 966b458b6e..93a92db602 100644 --- a/packages/core/src/components/RecommendationShelf/utils.ts +++ b/packages/core/src/components/RecommendationShelf/utils.ts @@ -1,23 +1,3 @@ -import { getCookie } from 'src/utils/getCookie' - -const MAX_MOBILE_WIDTH = 768 - -// Name of the cookie that holds the anonymous user id used by the -// recommendation/personalization pixel. -const USER_ID_COOKIE = 'vtex-rec-user-id' - -export function checkIsMobile(): boolean { - if (globalThis.window === undefined) { - return false - } - - return globalThis.window.innerWidth <= MAX_MOBILE_WIDTH -} - -export function getUserIdFromCookie(): string { - return getCookie(USER_ID_COOKIE) ?? '' -} - interface RetryOptions { retries?: number delayMs?: number diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index ebdd2d81a2..6b95ec084a 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -2,10 +2,7 @@ import { useEffect, useRef } from 'react' import { gql } from '@faststore/core/api' -import { - checkIsMobile, - getUserIdFromCookie, -} from 'src/components/RecommendationShelf/utils' +import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' import { useLazyQuery } from 'src/components/RecommendationShelf/queries/useLazyQuery' import { getCookie } from 'src/utils/getCookie' diff --git a/packages/core/src/sdk/analytics/utils.ts b/packages/core/src/sdk/analytics/utils.ts new file mode 100644 index 0000000000..74a3ceec74 --- /dev/null +++ b/packages/core/src/sdk/analytics/utils.ts @@ -0,0 +1,19 @@ +import { getCookie } from 'src/utils/getCookie' + +const MAX_MOBILE_WIDTH = 768 + +// Name of the cookie that holds the anonymous user id used by the +// recommendation/personalization pixel. +const USER_ID_COOKIE = 'vtex-rec-user-id' + +export function checkIsMobile(): boolean { + if (globalThis.window === undefined) { + return false + } + + return globalThis.window.innerWidth <= MAX_MOBILE_WIDTH +} + +export function getUserIdFromCookie(): string { + return getCookie(USER_ID_COOKIE) ?? '' +} diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 88ea9a9c00..04db828185 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -36,11 +36,11 @@ vi.mock('src/components/RecommendationShelf/useRecommendations', () => ({ const checkIsMobile = vi.hoisted(() => vi.fn()) const getUserIdFromCookie = vi.hoisted(() => vi.fn()) const getWithRetry = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/utils', () => ({ +vi.mock('src/sdk/analytics/utils', () => ({ checkIsMobile, getUserIdFromCookie, - getWithRetry, })) +vi.mock('src/components/RecommendationShelf/utils', () => ({ getWithRetry })) import { RecommendationShelf } from 'src/components/RecommendationShelf/RecommendationShelf' diff --git a/packages/core/test/components/RecommendationShelf/utils.test.ts b/packages/core/test/components/RecommendationShelf/utils.test.ts index 790a67f852..41f4bf170e 100644 --- a/packages/core/test/components/RecommendationShelf/utils.test.ts +++ b/packages/core/test/components/RecommendationShelf/utils.test.ts @@ -1,50 +1,9 @@ -/** - * @vitest-environment jsdom - */ - import { afterEach, describe, expect, it, vi } from 'vitest' -const mockGetCookie = vi.hoisted(() => vi.fn()) -vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie })) - -import { - checkIsMobile, - getUserIdFromCookie, - getWithRetry, -} from 'src/components/RecommendationShelf/utils' +import { getWithRetry } from 'src/components/RecommendationShelf/utils' afterEach(() => { vi.clearAllMocks() - vi.unstubAllGlobals() -}) - -describe('checkIsMobile', () => { - it('returns true when the viewport is at or below the mobile breakpoint', () => { - vi.stubGlobal('window', { innerWidth: 768 }) - expect(checkIsMobile()).toBe(true) - }) - - it('returns false when the viewport is wider than the breakpoint', () => { - vi.stubGlobal('window', { innerWidth: 1024 }) - expect(checkIsMobile()).toBe(false) - }) - - it('returns false when there is no window (SSR)', () => { - vi.stubGlobal('window', undefined) - expect(checkIsMobile()).toBe(false) - }) -}) - -describe('getUserIdFromCookie', () => { - it('returns the cookie value when present', () => { - mockGetCookie.mockReturnValue('user-123') - expect(getUserIdFromCookie()).toBe('user-123') - }) - - it('falls back to an empty string when the cookie is missing', () => { - mockGetCookie.mockReturnValue(undefined) - expect(getUserIdFromCookie()).toBe('') - }) }) describe('getWithRetry', () => { diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartSession.test.ts index 69a4d534c5..9b85ef4fb3 100644 --- a/packages/core/test/sdk/analytics/useStartSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartSession.test.ts @@ -18,7 +18,7 @@ vi.mock('src/components/RecommendationShelf/queries/useLazyQuery', () => ({ const checkIsMobile = vi.hoisted(() => vi.fn()) const getUserIdFromCookie = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/utils', () => ({ +vi.mock('src/sdk/analytics/utils', () => ({ checkIsMobile, getUserIdFromCookie, })) diff --git a/packages/core/test/sdk/analytics/utils.test.ts b/packages/core/test/sdk/analytics/utils.test.ts new file mode 100644 index 0000000000..6ae8ba93de --- /dev/null +++ b/packages/core/test/sdk/analytics/utils.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockGetCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie })) + +import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('checkIsMobile', () => { + it('returns true when the viewport is at or below the mobile breakpoint', () => { + vi.stubGlobal('window', { innerWidth: 768 }) + expect(checkIsMobile()).toBe(true) + }) + + it('returns false when the viewport is wider than the breakpoint', () => { + vi.stubGlobal('window', { innerWidth: 1024 }) + expect(checkIsMobile()).toBe(false) + }) + + it('returns false when there is no window (SSR)', () => { + vi.stubGlobal('window', undefined) + expect(checkIsMobile()).toBe(false) + }) +}) + +describe('getUserIdFromCookie', () => { + it('returns the cookie value when present', () => { + mockGetCookie.mockReturnValue('user-123') + expect(getUserIdFromCookie()).toBe('user-123') + }) + + it('falls back to an empty string when the cookie is missing', () => { + mockGetCookie.mockReturnValue(undefined) + expect(getUserIdFromCookie()).toBe('') + }) +}) From 91e14d0a225c8bb0eadebcb391675dcf4b035c9a Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 23 Jun 2026 15:45:41 -0300 Subject: [PATCH 12/30] refactor(core): reuse SDK GraphQL layer in RecommendationShelf Point useRecommendations and useStartSession at src/sdk/graphql/* (persisted-query GET allowlist, cache busting, session-aware cache keys) and drop the duplicated RecommendationShelf/queries folder. Also type the useStartSession props instead of `any`. --- .../RecommendationShelf/queries/request.ts | 108 --------------- .../queries/useLazyQuery.ts | 33 ----- .../RecommendationShelf/queries/useQuery.ts | 44 ------ .../RecommendationShelf/useRecommendations.ts | 2 +- .../sdk/analytics/hooks/useStartSession.ts | 18 ++- .../RecommendationShelf/queries.test.ts | 65 --------- .../RecommendationShelf/request.test.ts | 130 ------------------ .../useRecommendations.test.ts | 2 +- .../sdk/analytics/useStartSession.test.ts | 2 +- 9 files changed, 19 insertions(+), 385 deletions(-) delete mode 100644 packages/core/src/components/RecommendationShelf/queries/request.ts delete mode 100644 packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts delete mode 100644 packages/core/src/components/RecommendationShelf/queries/useQuery.ts delete mode 100644 packages/core/test/components/RecommendationShelf/queries.test.ts delete mode 100644 packages/core/test/components/RecommendationShelf/request.test.ts diff --git a/packages/core/src/components/RecommendationShelf/queries/request.ts b/packages/core/src/components/RecommendationShelf/queries/request.ts deleted file mode 100644 index 8ed62dcf96..0000000000 --- a/packages/core/src/components/RecommendationShelf/queries/request.ts +++ /dev/null @@ -1,108 +0,0 @@ -export type RequestOptions = Omit -export type Operation = { - __meta__?: Record -} - -export interface GraphQLResponse { - data: D - errors: any[] -} - -export interface BaseRequestOptions { - operation: Operation - variables: V - fetchOptions?: RequestInit -} - -const DEFAULT_HEADERS_BY_VERB: Record> = { - POST: { - 'Content-Type': 'application/json', - }, -} - -export const request = async ( - operation: Operation, - variables: Variables, - options?: RequestOptions -) => { - const { data, errors } = await baseRequest('/api/graphql', { - ...options, - variables, - operation, - }) - - if (errors?.length) { - throw new Error('GraphQL error', { cause: errors }) - } - - return data -} - -/* This piece of code was taken out of @faststore/graphql-utils */ -const baseRequest = async ( - endpoint: string, - { operation, variables, fetchOptions }: BaseRequestOptions -): Promise> => { - const operationName = operation['__meta__']?.['operationName'] - const operationHash = operation['__meta__']?.['operationHash'] - - // Uses method from fetchOptions. - // If no one is passed, figure out with via heuristic - const resolveMethod = (): string => { - if (fetchOptions?.method) { - return fetchOptions.method.toUpperCase() - } - - return operationName.endsWith('Query') ? 'GET' : 'POST' - } - - const method = resolveMethod() - - const params = new URLSearchParams({ - operationName, - operationHash, - ...(method === 'GET' && { variables: JSON.stringify(variables) }), - }) - - const body = - method === 'POST' - ? JSON.stringify({ - operationName, - operationHash, - variables, - }) - : undefined - - const url = `${endpoint}?${params.toString()}` - - const response = await fetch(url, { - method, - body, - ...fetchOptions, - headers: { - ...DEFAULT_HEADERS_BY_VERB[method], - ...fetchOptions?.headers, - }, - }) - - const contentType = response.headers.get('Content-Type') - - let responseBody = null - - if (contentType?.includes('application/json')) { - try { - responseBody = await response.json() - } catch (error) { - console.error('Error parsing JSON', error) - throw new Error('Error parsing JSON response') - } - } else if (contentType?.includes('text/plain')) { - responseBody = await response.text() - } - - if (response.ok) { - return responseBody - } - - throw new Error('Error while fetching', { cause: responseBody }) -} diff --git a/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts b/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts deleted file mode 100644 index ce9ec4b183..0000000000 --- a/packages/core/src/components/RecommendationShelf/queries/useLazyQuery.ts +++ /dev/null @@ -1,33 +0,0 @@ -import useSWR from 'swr' - -import { type Operation, request } from './request' -import { DEFAULT_OPTIONS, getKey } from './useQuery' -import type { QueryOptions } from './useQuery' - -export const useLazyQuery = >( - operation: Operation, - variables: Variables, - options?: QueryOptions -) => { - const response = useSWR( - getKey( - operation['__meta__'] ? operation['__meta__']['operationName'] : '', - variables - ), - () => null, - DEFAULT_OPTIONS - ) - - const execute = async (queryVariables: Variables) => { - const data = await request( - operation, - queryVariables, - options - ) - - const updatedData = await response.mutate(data, false) - return updatedData - } - - return [execute, response] as [typeof execute, typeof response] -} diff --git a/packages/core/src/components/RecommendationShelf/queries/useQuery.ts b/packages/core/src/components/RecommendationShelf/queries/useQuery.ts deleted file mode 100644 index e2716b5273..0000000000 --- a/packages/core/src/components/RecommendationShelf/queries/useQuery.ts +++ /dev/null @@ -1,44 +0,0 @@ -import useSWR from 'swr' -import type { SWRConfiguration } from 'swr' - -import { request } from './request' -import type { Operation, RequestOptions } from './request' - -export type QueryOptions = SWRConfiguration & - RequestOptions & { doNotRun?: boolean } - -export const getKey = ( - operationName: string, - variables: Variables -) => `${operationName}::${JSON.stringify(variables)}` - -export const DEFAULT_OPTIONS: SWRConfiguration = { - errorRetryCount: 3, - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - shouldRetryOnError: true, -} - -export const useQuery = >( - operation: Operation, - variables: Variables, - options?: QueryOptions -) => - useSWR( - () => { - if (options?.doNotRun) { - return null - } - - const operationName = operation['__meta__']?.['operationName'] ?? '' - - return getKey(operationName, variables) - }, - { - fetcher: () => request(operation, variables, options), - ...DEFAULT_OPTIONS, - ...options, - } - ) diff --git a/packages/core/src/components/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/RecommendationShelf/useRecommendations.ts index 812a1dce28..8642eed8b9 100644 --- a/packages/core/src/components/RecommendationShelf/useRecommendations.ts +++ b/packages/core/src/components/RecommendationShelf/useRecommendations.ts @@ -1,6 +1,6 @@ import { gql } from '@faststore/core/api' import type { RecommendationResponse } from '@generated/graphql' -import { useQuery } from './queries/useQuery' +import { useQuery } from 'src/sdk/graphql/useQuery' const query = gql(`query FetchRecommendationsQuery( $campaignVrn: String! diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index 6b95ec084a..2973e3849f 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -2,8 +2,8 @@ import { useEffect, useRef } from 'react' import { gql } from '@faststore/core/api' +import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' -import { useLazyQuery } from 'src/components/RecommendationShelf/queries/useLazyQuery' import { getCookie } from 'src/utils/getCookie' // Cookie set once the anonymous personalization session has been started, @@ -38,6 +38,20 @@ type SendProductViewEventVariables = { source: ProductViewSource } +/** + * Minimal shape of the page props forwarded by `Layout`. On a PDP they carry + * the product; on other pages the fields are simply absent. + */ +type StartSessionPageProps = { + data?: { + product?: { + isVariantOf?: { + productGroupID?: string | null + } | null + } | null + } | null +} + /** * Global recommendation/personalization tracking side effects: * - starts an anonymous session once per browser session; @@ -54,7 +68,7 @@ type SendProductViewEventVariables = { * All work runs client-side in effects (after hydration), so it doesn't affect * SSR/TTFB or Lighthouse render metrics. */ -export function useStartSession(props?: any) { +export function useStartSession(props?: StartSessionPageProps) { const productId: string | null = props?.data?.product?.isVariantOf?.productGroupID ?? null const lastProductViewSentRef = useRef(null) diff --git a/packages/core/test/components/RecommendationShelf/queries.test.ts b/packages/core/test/components/RecommendationShelf/queries.test.ts deleted file mode 100644 index a3137deaa1..0000000000 --- a/packages/core/test/components/RecommendationShelf/queries.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -const useSWRMock = vi.hoisted(() => vi.fn()) -vi.mock('swr', () => ({ default: useSWRMock })) - -const requestMock = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/queries/request', () => ({ - request: requestMock, -})) - -import { - getKey, - useQuery, -} from 'src/components/RecommendationShelf/queries/useQuery' -import { useLazyQuery } from 'src/components/RecommendationShelf/queries/useLazyQuery' - -const op = { __meta__: { operationName: 'FooQuery', operationHash: 'h1' } } - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('getKey', () => { - it('combines the operation name and serialized variables', () => { - expect(getKey('FooQuery', { a: 1 })).toBe('FooQuery::{"a":1}') - }) -}) - -describe('useQuery', () => { - it('builds a key from the operation name when run is enabled', () => { - useQuery(op, { a: 1 }, { doNotRun: false }) - - const keyFn = useSWRMock.mock.calls[0][0] - expect(keyFn()).toBe('FooQuery::{"a":1}') - }) - - it('returns a null key when doNotRun is set', () => { - useQuery(op, { a: 1 }, { doNotRun: true }) - - const keyFn = useSWRMock.mock.calls[0][0] - expect(keyFn()).toBeNull() - }) - - it('falls back to an empty operation name without __meta__', () => { - useQuery({}, { a: 1 }) - - const keyFn = useSWRMock.mock.calls[0][0] - expect(keyFn()).toBe('::{"a":1}') - }) -}) - -describe('useLazyQuery', () => { - it('executes the request lazily and updates the SWR cache', async () => { - const mutate = vi.fn(async (data: unknown) => data) - useSWRMock.mockReturnValue({ mutate }) - requestMock.mockResolvedValue({ ok: true }) - - const [execute] = useLazyQuery(op, {}) - const result = await execute({ id: '1' }) - - expect(requestMock).toHaveBeenCalledWith(op, { id: '1' }, undefined) - expect(mutate).toHaveBeenCalledWith({ ok: true }, false) - expect(result).toEqual({ ok: true }) - }) -}) diff --git a/packages/core/test/components/RecommendationShelf/request.test.ts b/packages/core/test/components/RecommendationShelf/request.test.ts deleted file mode 100644 index ff42b34dd2..0000000000 --- a/packages/core/test/components/RecommendationShelf/request.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { request } from 'src/components/RecommendationShelf/queries/request' - -type MockResponseInit = { - ok?: boolean - status?: number - contentType?: string | null - json?: unknown - text?: string - jsonThrows?: boolean -} - -function mockResponse({ - ok = true, - status = 200, - contentType = 'application/json', - json, - text, - jsonThrows = false, -}: MockResponseInit) { - return { - ok, - status, - headers: { get: () => contentType }, - json: vi.fn(async () => { - if (jsonThrows) { - throw new Error('bad json') - } - return json - }), - text: vi.fn(async () => text ?? ''), - } -} - -const fetchMock = vi.fn() -vi.stubGlobal('fetch', fetchMock) - -const queryOp = { __meta__: { operationName: 'FooQuery', operationHash: 'h1' } } -const mutationOp = { - __meta__: { operationName: 'DoThing', operationHash: 'h2' }, -} - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('request', () => { - it('returns data for a successful response', async () => { - fetchMock.mockResolvedValue( - mockResponse({ json: { data: { foo: 'bar' }, errors: [] } }) - ) - - const data = await request(queryOp, { a: 1 }) - - expect(data).toEqual({ foo: 'bar' }) - }) - - it('throws when the GraphQL response has errors', async () => { - fetchMock.mockResolvedValue( - mockResponse({ json: { data: null, errors: [{ message: 'nope' }] } }) - ) - - await expect(request(queryOp, {})).rejects.toThrow('GraphQL error') - }) - - it('uses GET and serializes variables in the query string for *Query operations', async () => { - fetchMock.mockResolvedValue( - mockResponse({ json: { data: {}, errors: [] } }) - ) - - await request(queryOp, { term: 'shoes' }) - - const [url, init] = fetchMock.mock.calls[0] - expect(init.method).toBe('GET') - expect(init.body).toBeUndefined() - expect(url).toContain('operationName=FooQuery') - expect(url).toContain(encodeURIComponent(JSON.stringify({ term: 'shoes' }))) - }) - - it('uses POST with a JSON body for non-query operations', async () => { - fetchMock.mockResolvedValue( - mockResponse({ json: { data: {}, errors: [] } }) - ) - - await request(mutationOp, { id: '1' }) - - const [, init] = fetchMock.mock.calls[0] - expect(init.method).toBe('POST') - expect(JSON.parse(init.body)).toMatchObject({ - operationName: 'DoThing', - variables: { id: '1' }, - }) - }) - - it('honors an explicit method from fetchOptions', async () => { - fetchMock.mockResolvedValue( - mockResponse({ json: { data: {}, errors: [] } }) - ) - - await request(queryOp, {}, { fetchOptions: { method: 'put' } }) - - expect(fetchMock.mock.calls[0][1].method).toBe('put') - }) - - it('throws a readable error when JSON parsing fails', async () => { - fetchMock.mockResolvedValue(mockResponse({ jsonThrows: true })) - - await expect(request(queryOp, {})).rejects.toThrow( - 'Error parsing JSON response' - ) - }) - - it('reads text/plain responses', async () => { - fetchMock.mockResolvedValue( - mockResponse({ contentType: 'text/plain', text: 'plain body' }) - ) - - // text bodies have no `errors`, so the request resolves with the raw body - await expect(request(queryOp, {})).resolves.toBe(undefined) - }) - - it('throws when the response is not ok', async () => { - fetchMock.mockResolvedValue( - mockResponse({ ok: false, status: 500, json: { data: null, errors: [] } }) - ) - - await expect(request(queryOp, {})).rejects.toThrow('Error while fetching') - }) -}) diff --git a/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts index 28691a0eac..78ec0ff4c9 100644 --- a/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts +++ b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@faststore/core/api', () => ({ gql: () => ({ __meta__: {} }) })) const useQueryMock = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/queries/useQuery', () => ({ +vi.mock('src/sdk/graphql/useQuery', () => ({ useQuery: useQueryMock, })) diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartSession.test.ts index 9b85ef4fb3..9acacf94d8 100644 --- a/packages/core/test/sdk/analytics/useStartSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartSession.test.ts @@ -12,7 +12,7 @@ vi.mock('@faststore/core/api', () => ({ const runStartSession = vi.hoisted(() => vi.fn()) const runSendProductViewEvent = vi.hoisted(() => vi.fn()) const useLazyQueryMock = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/queries/useLazyQuery', () => ({ +vi.mock('src/sdk/graphql/useLazyQuery', () => ({ useLazyQuery: useLazyQueryMock, })) From 402816e0dda8529ea081d9dcfd50626d889bf4f8 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Wed, 24 Jun 2026 10:40:34 -0300 Subject: [PATCH 13/30] feat(core): make RecommendationShelf carousel and product card configurable Expose carouselConfiguration (variant, infiniteMode, controls and per-viewport itemsPerPageDesktop/itemsPerPageMobile) and productCardConfiguration (showDiscountBadge, bordered) through the CMS, and add a code-level mapProductToProductCard override (defaults to mapRecommendationToProductCard, not exposed via CMS). Regenerate cms/faststore/schema.json accordingly. --- packages/core/@generated/graphql.ts | 6 ++ .../cms_component__RecommendationShelf.jsonc | 56 +++++++++++++ packages/core/cms/faststore/schema.json | 79 +++++++++++++++++++ .../RecommendationShelf.tsx | 38 +++++---- .../RecommendationShelf.types.ts | 47 +++++++++++ 5 files changed, 211 insertions(+), 15 deletions(-) diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 7c6500a030..499db12701 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -971,6 +971,7 @@ export type Query = { allProducts: StoreProductConnection; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; + getCookies: Maybe; /** Returns the list of Orders that the User can view. */ listUserOrders: Maybe; /** Returns the status of an Order Entry Service operation by its ID. */ @@ -2776,6 +2777,11 @@ export type Video = { videoUrl: Maybe; }; +export type VtCookies = { + vtex_segment: Scalars['String']['output']; + vtex_session: Scalars['String']['output']; +}; + export type FetchRecommendationsQueryQueryVariables = Exact<{ campaignVrn: Scalars['String']['input']; userId: InputMaybe; diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc index ab09ea3c3d..c07ca10df0 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -15,6 +15,62 @@ "description": "Recommendation campaign VRN (e.g. vrn:recommendations:my-account:rec-persona-v2:abc123).", "type": "string", "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" + }, + "carouselConfiguration": { + "title": "Carousel Configuration", + "type": "object", + "properties": { + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of items to display per page on desktop viewports.", + "type": "integer", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of items to display per page on mobile viewports.", + "type": "integer", + "default": 2 + }, + "variant": { + "title": "Track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + } + } + }, + "productCardConfiguration": { + "title": "Product Card Configuration", + "type": "object", + "properties": { + "showDiscountBadge": { + "title": "Show discount badge?", + "type": "boolean", + "default": true + }, + "bordered": { + "title": "Cards should be bordered?", + "type": "boolean", + "default": true + } + } } } } diff --git a/packages/core/cms/faststore/schema.json b/packages/core/cms/faststore/schema.json index 580db7e4ef..3b84f26dc8 100644 --- a/packages/core/cms/faststore/schema.json +++ b/packages/core/cms/faststore/schema.json @@ -113,6 +113,9 @@ }, { "$ref": "#/components/ShoppingAssistant" + }, + { + "$ref": "#/components/RecommendationShelf" } ] } @@ -4425,6 +4428,82 @@ "writeOnly": false, "deprecated": false, "$abstract": false + }, + "RecommendationShelf": { + "$extends": ["#/$defs/base-component"], + "$componentKey": "RecommendationShelf", + "$componentTitle": "RecommendationShelf", + "type": "object", + "required": ["campaignVrn"], + "properties": { + "title": { + "title": "Title", + "description": "Override the shelf title. If not set, the campaign title will be used.", + "type": "string" + }, + "campaignVrn": { + "title": "Campaign VRN", + "description": "Recommendation campaign VRN (e.g. vrn:recommendations:my-account:rec-persona-v2:abc123).", + "type": "string", + "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" + }, + "carouselConfiguration": { + "title": "Carousel Configuration", + "type": "object", + "properties": { + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of items to display per page on desktop viewports.", + "type": "integer", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of items to display per page on mobile viewports.", + "type": "integer", + "default": 2 + }, + "variant": { + "title": "Track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + } + } + }, + "productCardConfiguration": { + "title": "Product Card Configuration", + "type": "object", + "properties": { + "showDiscountBadge": { + "title": "Show discount badge?", + "type": "boolean", + "default": true + }, + "bordered": { + "title": "Cards should be bordered?", + "type": "boolean", + "default": true + } + } + } + } } } } diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 7feddfb583..7d315b9b81 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -5,7 +5,6 @@ import { ProductShelf, Carousel } from '@faststore/ui' import ProductCard from 'src/components/product/ProductCard' import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton' -import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' import { mapRecommendationToProductCard } from './mapRecommendationToProductCard' import type { RecommendationShelfProps } from './RecommendationShelf.types' @@ -14,6 +13,7 @@ import { useRecommendations, type RecommendationInput, } from './useRecommendations' +import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' import { getWithRetry } from './utils' import { getTypeFromVrn } from './vrn' @@ -51,10 +51,18 @@ function getRecommendationArguments( export const RecommendationShelf = ({ title, campaignVrn, + mapProductToProductCard = mapRecommendationToProductCard, + carouselConfiguration, + productCardConfiguration, }: RecommendationShelfProps) => { const id = useId() const isMobile = checkIsMobile() - const itemsPerPage = isMobile ? 2 : 4 + const { + itemsPerPageDesktop = 4, + itemsPerPageMobile = 2, + ...carouselProps + } = carouselConfiguration ?? {} + const itemsPerPage = isMobile ? itemsPerPageMobile : itemsPerPageDesktop const [userId, setUserId] = useState(undefined) const { data: productDetailPage } = usePDP() @@ -150,27 +158,27 @@ export const RecommendationShelf = ({ itemsPerPage={itemsPerPage} variant="scroll" infiniteMode={false} + {...carouselProps} > {items.map((item, index) => (
))} diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts index a363398de5..475601c53d 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts @@ -1,6 +1,53 @@ +import type { CarouselProps } from '@faststore/ui' +import type { + ProductSummary_ProductFragment, + RecommendationProduct, +} from '@generated/graphql' + +/** + * Maps a `RecommendationProduct` into the `ProductCard` data shape. + * Implemented by `mapRecommendationToProductCard` and overridable per shelf. + */ +export type RecommendationProductCardMapper = ( + product: RecommendationProduct +) => ProductSummary_ProductFragment + export type RecommendationShelfProps = { title?: string campaignVrn: string + /** + * Maps a recommendation product into the `ProductCard` data shape. Defaults to + * `mapRecommendationToProductCard`. This is a code-level override and is not + * exposed through the CMS schema (`cms_component__RecommendationShelf.jsonc`). + */ + mapProductToProductCard?: RecommendationProductCardMapper + /** + * Forwarded to the underlying `Carousel`. `id` stays controlled by the shelf. + * `itemsPerPage` is derived from `itemsPerPageDesktop`/`itemsPerPageMobile` + * based on the viewport. + */ + carouselConfiguration?: Pick< + CarouselProps, + 'infiniteMode' | 'variant' | 'controls' + > & { + /** + * Number of items per page on desktop viewports. + * @default 4 + */ + itemsPerPageDesktop?: number + /** + * Number of items per page on mobile viewports. + * @default 2 + */ + itemsPerPageMobile?: number + } + /** + * Forwarded to each `ProductCard` rendered by the shelf. + */ + productCardConfiguration?: { + showDiscountBadge?: boolean + bordered?: boolean + } } export type RecommendationType = From 4658ec0cc1e3ead4143ca4b1c9557def5d4e54a6 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Wed, 24 Jun 2026 16:23:33 -0300 Subject: [PATCH 14/30] refactor(core): remove SendProductViewEvent mutation and related logic Eliminate the SendProductViewEvent mutation and its associated logic from the useStartSession hook. The product view event is now captured through meta tags on the PDP, streamlining the session management process. Update tests to reflect these changes and ensure proper functionality without the removed mutation. --- packages/core/@generated/gql.ts | 6 -- packages/core/@generated/graphql.ts | 10 -- packages/core/src/Layout.tsx | 5 +- packages/core/src/pages/[slug]/p.tsx | 28 ++++++ .../sdk/analytics/hooks/useStartSession.ts | 90 ++--------------- .../sdk/analytics/useStartSession.test.ts | 97 +------------------ 6 files changed, 38 insertions(+), 198 deletions(-) diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 5dbc6c3f96..1dae4a8493 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -42,7 +42,6 @@ type Documents = { "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": typeof types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": typeof types.ValidateUserDocument, "\n mutation StartSession {\n startSession\n }\n": typeof types.StartSessionDocument, - "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n": typeof types.SendProductViewEventDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": typeof types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": typeof types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": typeof types.SubscribeToNewsletterDocument, @@ -92,7 +91,6 @@ const documents: Documents = { "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": types.ValidateUserDocument, "\n mutation StartSession {\n startSession\n }\n": types.StartSessionDocument, - "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n": types.SendProductViewEventDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": types.SubscribeToNewsletterDocument, @@ -223,10 +221,6 @@ export function gql(source: "\n query ValidateUser {\n validateUser {\n * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n mutation StartSession {\n startSession\n }\n"): typeof import('./graphql').StartSessionDocument; -/** - * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function gql(source: "\n mutation SendProductViewEvent(\n $userId: String!\n $product: String!\n $source: ProductViewSource\n ) {\n sendProductViewEvent(userId: $userId, product: $product, source: $source)\n }\n"): typeof import('./graphql').SendProductViewEventDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 499db12701..e3b057c3f6 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -2905,15 +2905,6 @@ export type StartSessionMutationVariables = Exact<{ [key: string]: never; }>; export type StartSessionMutation = { startSession: boolean }; -export type SendProductViewEventMutationVariables = Exact<{ - userId: Scalars['String']['input']; - product: Scalars['String']['input']; - source: InputMaybe; -}>; - - -export type SendProductViewEventMutation = { sendProductViewEvent: boolean }; - export type ValidateCartMutationMutationVariables = Exact<{ cart: IStoreCart; session: IStoreSession; @@ -3614,7 +3605,6 @@ export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelO export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString; export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString; export const StartSessionDocument = {"__meta__":{"operationName":"StartSession","operationHash":"bf9c4a8bc27fb6ed256336880aea8939b04943cf"}} as unknown as TypedDocumentString; -export const SendProductViewEventDocument = {"__meta__":{"operationName":"SendProductViewEvent","operationHash":"2c1d370d6d66811a23d8862d38a83a89b5c2baa1"}} as unknown as TypedDocumentString; export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"32c15f8888ca34f223def7972b7f19090808435a"}} as unknown as TypedDocumentString; export const ClientPickupPointsQueryDocument = {"__meta__":{"operationName":"ClientPickupPointsQuery","operationHash":"3fa04e88c811fcb5ece7206fd5aa745bdbc143a8"}} as unknown as TypedDocumentString; export const SubscribeToNewsletterDocument = {"__meta__":{"operationName":"SubscribeToNewsletter","operationHash":"feb7005103a859e2bc8cf2360d568806fd88deba"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/Layout.tsx b/packages/core/src/Layout.tsx index 97f8d0e1d9..214b0a244c 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -7,9 +7,8 @@ function Layout({ children }: PropsWithChildren) { const props = isValidElement(children) ? children.props : undefined usePageViewEvent(props) // Implemented here because the personalization session must be initiated once - // per browser session on each page. Product is read from props (same pattern as - // usePageViewEvent), since usePDP() has no context above PageProvider. - useStartSession(props) + // per browser session on each page. + useStartSession() return <>{children} } diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index 6f55f2feba..043a8aed89 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -199,6 +199,34 @@ function Page({ property: 'product:price:currency', content: currency.code, }, + { + property: 'product:id', + content: product.id, + }, + { + property: 'product:sku', + content: product.sku, + }, + { + property: 'product:name', + content: product.name, + }, + { + property: 'product:category', + content: itemListElements[0]?.name ?? undefined, + }, + { + property: 'product:url', + content: meta.canonical, + }, + { + property: 'product:brand', + content: product.brand.name, + }, + { + property: 'product:condition', + content: 'new', + }, ]} titleTemplate={titleTemplate} /> diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index 2973e3849f..d52f0d3af5 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -1,9 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { gql } from '@faststore/core/api' import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' -import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' import { getCookie } from 'src/utils/getCookie' // Cookie set once the anonymous personalization session has been started, @@ -16,75 +15,29 @@ const startSessionMutation = gql(` } `) -const sendProductViewEventMutation = gql(` - mutation SendProductViewEvent( - $userId: String! - $product: String! - $source: ProductViewSource - ) { - sendProductViewEvent(userId: $userId, product: $product, source: $source) - } -`) - -type ProductViewSource = 'WEB_DESKTOP' | 'WEB_MOBILE' - type StartSessionData = { startSession: boolean } type StartSessionVariables = Record -type SendProductViewEventData = { sendProductViewEvent: boolean } -type SendProductViewEventVariables = { - userId: string - product: string - source: ProductViewSource -} - /** - * Minimal shape of the page props forwarded by `Layout`. On a PDP they carry - * the product; on other pages the fields are simply absent. - */ -type StartSessionPageProps = { - data?: { - product?: { - isVariantOf?: { - productGroupID?: string | null - } | null - } | null - } | null -} - -/** - * Global recommendation/personalization tracking side effects: - * - starts an anonymous session once per browser session; - * - reports a product view whenever a new PDP product is displayed. + * Starts the anonymous recommendation/personalization session once per browser + * session. * * Mounted globally from `Layout` so it runs on every page, regardless of * whether a recommendation section is present. * - * `Layout` sits above every `PageProvider`, so we can't read the PDP context - * via `usePDP()` here. Instead the current page's props are forwarded from - * `Layout` (same pattern as `usePageViewEvent`); on a PDP they contain the - * product, elsewhere they don't. + * The product view is no longer reported through a mutation here: the PDP now + * exposes the product data as `` tags, which the + * Activity Flow script reads to capture the product view event. * * All work runs client-side in effects (after hydration), so it doesn't affect * SSR/TTFB or Lighthouse render metrics. */ -export function useStartSession(props?: StartSessionPageProps) { - const productId: string | null = - props?.data?.product?.isVariantOf?.productGroupID ?? null - const lastProductViewSentRef = useRef(null) - +export function useStartSession() { const [runStartSession] = useLazyQuery< StartSessionData, StartSessionVariables >(startSessionMutation, {} as StartSessionVariables, { doNotRun: true }) - const [runSendProductViewEvent] = useLazyQuery< - SendProductViewEventData, - SendProductViewEventVariables - >(sendProductViewEventMutation, {} as SendProductViewEventVariables, { - doNotRun: true, - }) - useEffect(() => { const startSessionCookie = getCookie(VTEX_REC_USER_START_SESSION) @@ -122,33 +75,4 @@ export function useStartSession(props?: StartSessionPageProps) { cancelled = true } }, [runStartSession]) - - useEffect(() => { - if (!productId) { - lastProductViewSentRef.current = null - return - } - - const userId = getUserIdFromCookie() - if (!userId) { - return - } - - // Avoid re-sending the same product view on unrelated re-renders. - if (lastProductViewSentRef.current === productId) { - return - } - - const source: ProductViewSource = checkIsMobile() - ? 'WEB_MOBILE' - : 'WEB_DESKTOP' - - // Only mark the product view as sent once the mutation succeeds, so a - // failed/timed-out request isn't permanently suppressed. - runSendProductViewEvent({ userId, product: productId, source }) - .then(() => { - lastProductViewSentRef.current = productId - }) - .catch(() => {}) - }, [productId, runSendProductViewEvent]) } diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartSession.test.ts index 9acacf94d8..4c239c085b 100644 --- a/packages/core/test/sdk/analytics/useStartSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartSession.test.ts @@ -10,39 +10,19 @@ vi.mock('@faststore/core/api', () => ({ })) const runStartSession = vi.hoisted(() => vi.fn()) -const runSendProductViewEvent = vi.hoisted(() => vi.fn()) const useLazyQueryMock = vi.hoisted(() => vi.fn()) vi.mock('src/sdk/graphql/useLazyQuery', () => ({ useLazyQuery: useLazyQueryMock, })) -const checkIsMobile = vi.hoisted(() => vi.fn()) -const getUserIdFromCookie = vi.hoisted(() => vi.fn()) -vi.mock('src/sdk/analytics/utils', () => ({ - checkIsMobile, - getUserIdFromCookie, -})) - const getCookie = vi.hoisted(() => vi.fn()) vi.mock('src/utils/getCookie', () => ({ getCookie })) import { useStartSession } from 'src/sdk/analytics/hooks/useStartSession' -const pdpProps = { - data: { product: { isVariantOf: { productGroupID: 'p-1' } } }, -} - beforeEach(() => { - useLazyQueryMock.mockImplementation((operation: { query: string }) => { - if (operation.query.includes('sendProductViewEvent')) { - return [runSendProductViewEvent, {}] - } - return [runStartSession, {}] - }) + useLazyQueryMock.mockReturnValue([runStartSession, {}]) runStartSession.mockResolvedValue(true) - runSendProductViewEvent.mockResolvedValue(true) - checkIsMobile.mockReturnValue(false) - getUserIdFromCookie.mockReturnValue('user-1') }) afterEach(() => { @@ -69,79 +49,4 @@ describe('useStartSession', () => { expect(runStartSession).toHaveBeenCalled() }) }) - - it('sends a product view for a PDP with a known user (desktop)', async () => { - getCookie.mockReturnValue('already-started') - - renderHook(() => useStartSession(pdpProps)) - - await waitFor(() => { - expect(runSendProductViewEvent).toHaveBeenCalledWith({ - userId: 'user-1', - product: 'p-1', - source: 'WEB_DESKTOP', - }) - }) - }) - - it('uses the mobile source on small viewports', async () => { - getCookie.mockReturnValue('already-started') - checkIsMobile.mockReturnValue(true) - - renderHook(() => useStartSession(pdpProps)) - - await waitFor(() => { - expect(runSendProductViewEvent).toHaveBeenCalledWith( - expect.objectContaining({ source: 'WEB_MOBILE' }) - ) - }) - }) - - it('does not send a product view when there is no product', async () => { - getCookie.mockReturnValue('already-started') - - renderHook(() => useStartSession({ data: {} })) - - await waitFor(() => { - expect(runStartSession).not.toHaveBeenCalled() - }) - expect(runSendProductViewEvent).not.toHaveBeenCalled() - }) - - it('does not send a product view when the user is unknown', async () => { - getCookie.mockReturnValue('already-started') - getUserIdFromCookie.mockReturnValue('') - - renderHook(() => useStartSession(pdpProps)) - - await waitFor(() => { - expect(getUserIdFromCookie).toHaveBeenCalled() - }) - expect(runSendProductViewEvent).not.toHaveBeenCalled() - }) - - it('swallows product-view mutation failures', async () => { - getCookie.mockReturnValue('already-started') - runSendProductViewEvent.mockRejectedValue(new Error('network')) - - renderHook(() => useStartSession(pdpProps)) - - await waitFor(() => { - expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) - }) - }) - - it('does not re-send the same product view across re-renders', async () => { - getCookie.mockReturnValue('already-started') - - const { rerender } = renderHook(() => useStartSession(pdpProps)) - - await waitFor(() => { - expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) - }) - - rerender() - - expect(runSendProductViewEvent).toHaveBeenCalledTimes(1) - }) }) From 814da6279f9eec699acf21a490684541f8eb1cf6 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Wed, 24 Jun 2026 17:19:57 -0300 Subject: [PATCH 15/30] refactor(core): simplify RecommendationShelf per quality review - Centralize campaign-type taxonomy in a single map in vrn.ts and make getTypeFromVrn total (returns null instead of throwing during render). - Extract cookie/userId orchestration into a useRecommendationUserId hook. - Use the canonical useScreenResize hook for paging instead of a bespoke checkIsMobile helper. - Unify the two ad-hoc retry mechanisms into a single src/utils/retry util (with backoff and AbortSignal support). - Document why the recommendations query fetches the full product shape (mapProductToProductCard is overridable). --- .../RecommendationShelf.tsx | 47 +++----- .../useRecommendationUserId.ts | 49 +++++++++ .../RecommendationShelf/useRecommendations.ts | 6 ++ .../components/RecommendationShelf/utils.ts | 23 ---- .../src/components/RecommendationShelf/vrn.ts | 100 ++++++++---------- .../sdk/analytics/hooks/useStartSession.ts | 34 ++---- packages/core/src/sdk/analytics/utils.ts | 10 -- packages/core/src/utils/retry.ts | 69 ++++++++++++ .../RecommendationShelf.browser.test.tsx | 32 +++--- .../RecommendationShelf/utils.test.ts | 36 ------- .../RecommendationShelf/vrn.test.ts | 10 +- .../core/test/sdk/analytics/utils.test.ts | 19 +--- packages/core/test/utils/retry.test.ts | 59 +++++++++++ 13 files changed, 271 insertions(+), 223 deletions(-) create mode 100644 packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts delete mode 100644 packages/core/src/components/RecommendationShelf/utils.ts create mode 100644 packages/core/src/utils/retry.ts delete mode 100644 packages/core/test/components/RecommendationShelf/utils.test.ts create mode 100644 packages/core/test/utils/retry.test.ts diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 7d315b9b81..03473551a1 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useId, useMemo, useState } from 'react' +import { useId, useMemo } from 'react' import { usePDP } from '@faststore/core' import { ProductShelf, Carousel } from '@faststore/ui' import ProductCard from 'src/components/product/ProductCard' import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton' +import useScreenResize from 'src/sdk/ui/useScreenResize' import { mapRecommendationToProductCard } from './mapRecommendationToProductCard' import type { RecommendationShelfProps } from './RecommendationShelf.types' @@ -13,8 +14,7 @@ import { useRecommendations, type RecommendationInput, } from './useRecommendations' -import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' -import { getWithRetry } from './utils' +import { useRecommendationUserId } from './useRecommendationUserId' import { getTypeFromVrn } from './vrn' function getRecommendationArguments( @@ -24,7 +24,9 @@ function getRecommendationArguments( const { userId, pdpProduct } = context const type = getTypeFromVrn(campaignVrn) - if (!userId) return null + // `type` is null for malformed/unknown VRNs; bail out so we never fetch (and + // never throw) on an invalid campaign coming from the CMS. + if (!type || !userId) return null switch (type) { case 'NEXT_INTERACTION': @@ -56,14 +58,18 @@ export const RecommendationShelf = ({ productCardConfiguration, }: RecommendationShelfProps) => { const id = useId() - const isMobile = checkIsMobile() + const { isMobile, isTablet } = useScreenResize() const { itemsPerPageDesktop = 4, itemsPerPageMobile = 2, ...carouselProps } = carouselConfiguration ?? {} - const itemsPerPage = isMobile ? itemsPerPageMobile : itemsPerPageDesktop - const [userId, setUserId] = useState(undefined) + // Treat mobile and tablet viewports (<= 768px) as "mobile" for paging, which + // matches the carousel layout the shelf was designed around. + const itemsPerPage = + isMobile || isTablet ? itemsPerPageMobile : itemsPerPageDesktop + + const userId = useRecommendationUserId(campaignVrn) const { data: productDetailPage } = usePDP() @@ -90,33 +96,6 @@ export const RecommendationShelf = ({ productIds.length ) - useEffect(() => { - let cancelled = false - - // The pixel might take a while to load and set the userId cookie, - // so we use a retry mechanism to ensure we get the userId if available. - getWithRetry(() => getUserIdFromCookie()) - .then((value) => { - if (!cancelled) { - setUserId(value) - } - }) - .catch((retryError) => { - console.error( - 'Error retrieving userId from cookie', - retryError, - campaignVrn - ) - if (!cancelled) { - setUserId(null) - } - }) - - return () => { - cancelled = true - } - }, [campaignVrn]) - if (error) { // Don't log `recommendationArgs`: it carries the userId. Log only // non-identifying context. diff --git a/packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts b/packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts new file mode 100644 index 0000000000..53261369eb --- /dev/null +++ b/packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +import { getUserIdFromCookie } from 'src/sdk/analytics/utils' +import { retry } from 'src/utils/retry' + +/** + * Reads the anonymous recommendation user id from the cookie set by the + * personalization pixel. + * + * The pixel may take a while to load and set the cookie, so the lookup is + * retried until a value is available (or the budget runs out). The returned + * value is tri-state: + * + * - `undefined`: still resolving (initial state); + * - `null`: lookup finished without a usable id (e.g. the pixel never set it); + * - `string`: the resolved user id. + */ +export function useRecommendationUserId( + campaignVrn: string +): string | null | undefined { + const [userId, setUserId] = useState(undefined) + + useEffect(() => { + const controller = new AbortController() + + retry(() => getUserIdFromCookie(), { signal: controller.signal }) + .then((value) => { + if (!controller.signal.aborted) { + setUserId(value || null) + } + }) + .catch((retryError) => { + console.error( + 'Error retrieving userId from cookie', + retryError, + campaignVrn + ) + if (!controller.signal.aborted) { + setUserId(null) + } + }) + + return () => { + controller.abort() + } + }, [campaignVrn]) + + return userId +} diff --git a/packages/core/src/components/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/RecommendationShelf/useRecommendations.ts index 8642eed8b9..2cd6817502 100644 --- a/packages/core/src/components/RecommendationShelf/useRecommendations.ts +++ b/packages/core/src/components/RecommendationShelf/useRecommendations.ts @@ -2,6 +2,12 @@ import { gql } from '@faststore/core/api' import type { RecommendationResponse } from '@generated/graphql' import { useQuery } from 'src/sdk/graphql/useQuery' +// This query intentionally fetches the full `RecommendationProduct` shape rather +// than only the fields consumed by the default `mapRecommendationToProductCard`. +// The shelf exposes a `mapProductToProductCard` override, so a store can plug in +// a custom mapper that reads additional fields (specifications, clusters, +// teasers, installments, etc.). Keeping the selection complete means those +// customizations work without having to edit this query. const query = gql(`query FetchRecommendationsQuery( $campaignVrn: String! $userId: String diff --git a/packages/core/src/components/RecommendationShelf/utils.ts b/packages/core/src/components/RecommendationShelf/utils.ts deleted file mode 100644 index 93a92db602..0000000000 --- a/packages/core/src/components/RecommendationShelf/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -interface RetryOptions { - retries?: number - delayMs?: number -} - -// Repeatedly calls `fn` until it returns a truthy value or the retry budget -// is exhausted. Useful when a value (e.g. a cookie) is set asynchronously by -// an external script. -export async function getWithRetry( - fn: () => T, - { retries = 5, delayMs = 500 }: RetryOptions = {} -): Promise { - let value = fn() - let attempts = 0 - - while (!value && attempts < retries) { - await new Promise((resolve) => setTimeout(resolve, delayMs)) - value = fn() - attempts += 1 - } - - return value -} diff --git a/packages/core/src/components/RecommendationShelf/vrn.ts b/packages/core/src/components/RecommendationShelf/vrn.ts index e1ebac9461..263dfbbb46 100644 --- a/packages/core/src/components/RecommendationShelf/vrn.ts +++ b/packages/core/src/components/RecommendationShelf/vrn.ts @@ -1,71 +1,55 @@ -/* eslint-disable padding-line-between-statements */ - import type { RecommendationType } from './RecommendationShelf.types' -type RecommendationVrnType = - | 'rec-cross-v2' - | 'rec-similar-v2' - | 'rec-persona-v2' - | 'rec-last-v2' - | 'rec-top-items-v2' - | 'rec-search-v2' - | 'rec-next-v2' - | 'rec-visual-v2' - -const vrnPattern = - /^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$/ +// Single source of truth for the campaign taxonomy: maps each Intelligent Search +// campaign VRN type to its `RecommendationType`. The supported VRN types, the +// validation regex and the VRN-to-type resolution are all derived from this map +// so the list of campaign types lives in exactly one place. +// +// Reference: +// Cross-Sell rec-cross-v2 +// Similar Items rec-similar-v2 +// Personalized rec-persona-v2 +// Last Seen rec-last-v2 +// Top Sellers rec-top-items-v2 +// Search-Based rec-search-v2 +// Next Interactions rec-next-v2 +// Visual Similarity rec-visual-v2 +const VRN_TYPE_TO_RECOMMENDATION = { + 'rec-cross-v2': 'CROSS_SELL', + 'rec-similar-v2': 'SIMILAR_ITEMS', + 'rec-persona-v2': 'PERSONALIZED', + 'rec-last-v2': 'LAST_SEEN', + 'rec-top-items-v2': 'TOP_ITEMS', + 'rec-search-v2': 'SEARCH_BASED', + 'rec-next-v2': 'NEXT_INTERACTION', + 'rec-visual-v2': 'VISUAL_SIMILARITY', +} as const satisfies Record + +type RecommendationVrnType = keyof typeof VRN_TYPE_TO_RECOMMENDATION + +const vrnPattern = new RegExp( + `^vrn:recommendations:[^:]+:(${Object.keys(VRN_TYPE_TO_RECOMMENDATION).join( + '|' + )}):[^:]+$` +) export function isValidVrn(campaignVrn: string): boolean { return vrnPattern.test(campaignVrn) } function parseCampaignVrn(campaignVrn: string) { - const [_, __, accountName, campaignType, campaignId] = campaignVrn.split(':') + const [, , accountName, campaignType, campaignId] = campaignVrn.split(':') - return { - accountName, - campaignId, - campaignVrnType: campaignType as RecommendationVrnType, - } + return { accountName, campaignType, campaignId } } -// Cross-Sell: rec-cross-v2 -// Similar Items: rec-similar-v2 -// Personalized: rec-persona-v2 -// Last Seen: rec-last-v2 -// Top Sellers: rec-top-items-v2 -// Next Interactions: rec-next-v2 -// Visual Similarity: rec-visual-v2 -// Search-Based: rec-search-v2 -export function getTypeFromVrn(campaignVrn: string): RecommendationType { - const { campaignVrnType } = parseCampaignVrn(campaignVrn) - - switch (campaignVrnType) { - case 'rec-cross-v2': - return 'CROSS_SELL' - - case 'rec-similar-v2': - return 'SIMILAR_ITEMS' - - case 'rec-persona-v2': - return 'PERSONALIZED' - - case 'rec-last-v2': - return 'LAST_SEEN' - - case 'rec-top-items-v2': - return 'TOP_ITEMS' - - case 'rec-search-v2': - return 'SEARCH_BASED' - - case 'rec-next-v2': - return 'NEXT_INTERACTION' - - case 'rec-visual-v2': - return 'VISUAL_SIMILARITY' +// Resolves a campaign VRN into its `RecommendationType`. Returns `null` for +// malformed or unknown VRNs so callers can degrade gracefully instead of +// throwing during render. +export function getTypeFromVrn(campaignVrn: string): RecommendationType | null { + const { campaignType } = parseCampaignVrn(campaignVrn) - default: - throw new Error(`Unknown campaign type: ${campaignVrnType}`) - } + return ( + VRN_TYPE_TO_RECOMMENDATION[campaignType as RecommendationVrnType] ?? null + ) } diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index d52f0d3af5..1fac445a24 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -4,6 +4,7 @@ import { gql } from '@faststore/core/api' import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' import { getCookie } from 'src/utils/getCookie' +import { retry } from 'src/utils/retry' // Cookie set once the anonymous personalization session has been started, // so we only fire `StartSession` a single time per browser session. @@ -45,34 +46,21 @@ export function useStartSession() { return } - let cancelled = false + const controller = new AbortController() // The session endpoint may not be ready on the first try, so we retry with // exponential backoff until it returns a defined result (or we give up). - const retryUntilSuccess = async () => { - const MAX_ATTEMPTS = 10 - let delay = 300 - - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - if (cancelled) { - return - } - - const result = await runStartSession({}) - - if (result !== undefined) { - return - } - - await new Promise((resolve) => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, 3000) - } - } - - void retryUntilSuccess() + void retry(() => runStartSession({}), { + attempts: 10, + delayMs: 300, + backoff: true, + maxDelayMs: 3000, + until: (result) => result !== undefined, + signal: controller.signal, + }) return () => { - cancelled = true + controller.abort() } }, [runStartSession]) } diff --git a/packages/core/src/sdk/analytics/utils.ts b/packages/core/src/sdk/analytics/utils.ts index 74a3ceec74..7f7d8a9432 100644 --- a/packages/core/src/sdk/analytics/utils.ts +++ b/packages/core/src/sdk/analytics/utils.ts @@ -1,19 +1,9 @@ import { getCookie } from 'src/utils/getCookie' -const MAX_MOBILE_WIDTH = 768 - // Name of the cookie that holds the anonymous user id used by the // recommendation/personalization pixel. const USER_ID_COOKIE = 'vtex-rec-user-id' -export function checkIsMobile(): boolean { - if (globalThis.window === undefined) { - return false - } - - return globalThis.window.innerWidth <= MAX_MOBILE_WIDTH -} - export function getUserIdFromCookie(): string { return getCookie(USER_ID_COOKIE) ?? '' } diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts new file mode 100644 index 0000000000..8c0e5875a6 --- /dev/null +++ b/packages/core/src/utils/retry.ts @@ -0,0 +1,69 @@ +export interface RetryOptions { + /** + * Maximum number of retries after the initial attempt. + * @default 5 + */ + attempts?: number + /** + * Delay between attempts, in milliseconds. + * @default 500 + */ + delayMs?: number + /** + * Doubles the delay after each attempt, capped at `maxDelayMs`. + * @default false + */ + backoff?: boolean + /** + * Upper bound for the delay when `backoff` is enabled. + * @default 3000 + */ + maxDelayMs?: number + /** + * Stops retrying once this predicate returns `true`. Defaults to "the value + * is truthy". + */ + until?: (value: T) => boolean + /** + * Aborts the retry loop early (e.g. on component unmount). + */ + signal?: AbortSignal +} + +// Repeatedly calls `fn` until `until` is satisfied or the retry budget is +// exhausted. Useful when a value is produced asynchronously by an external +// script (e.g. a cookie set by a pixel, or an endpoint that is not ready on +// the first call). Returns the last value produced, even when the budget runs +// out, so callers can decide how to handle the give-up case. +export async function retry( + fn: () => T | Promise, + { + attempts = 5, + delayMs = 500, + backoff = false, + maxDelayMs = 3000, + until = (value) => Boolean(value), + signal, + }: RetryOptions = {} +): Promise { + let value = await fn() + let delay = delayMs + let remaining = attempts + + while (!until(value) && remaining > 0 && !signal?.aborted) { + await new Promise((resolve) => setTimeout(resolve, delay)) + + if (signal?.aborted) { + break + } + + value = await fn() + remaining -= 1 + + if (backoff) { + delay = Math.min(delay * 2, maxDelayMs) + } + } + + return value +} diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 04db828185..6ea43567f8 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -33,14 +33,13 @@ vi.mock('src/components/RecommendationShelf/useRecommendations', () => ({ useRecommendations, })) -const checkIsMobile = vi.hoisted(() => vi.fn()) -const getUserIdFromCookie = vi.hoisted(() => vi.fn()) -const getWithRetry = vi.hoisted(() => vi.fn()) -vi.mock('src/sdk/analytics/utils', () => ({ - checkIsMobile, - getUserIdFromCookie, +const useScreenResize = vi.hoisted(() => vi.fn()) +vi.mock('src/sdk/ui/useScreenResize', () => ({ default: useScreenResize })) + +const useRecommendationUserId = vi.hoisted(() => vi.fn()) +vi.mock('src/components/RecommendationShelf/useRecommendationUserId', () => ({ + useRecommendationUserId, })) -vi.mock('src/components/RecommendationShelf/utils', () => ({ getWithRetry })) import { RecommendationShelf } from 'src/components/RecommendationShelf/RecommendationShelf' @@ -55,11 +54,13 @@ const recommendationData = { beforeEach(() => { usePDP.mockReturnValue({ data: undefined }) - checkIsMobile.mockReturnValue(false) - getUserIdFromCookie.mockReturnValue('user-1') - // Keep the cookie lookup pending so the component doesn't trigger an async - // state update; the recommendations hook is mocked independently. - getWithRetry.mockReturnValue(new Promise(() => {})) + useScreenResize.mockReturnValue({ + isMobile: false, + isTablet: false, + isDesktop: true, + loading: false, + }) + useRecommendationUserId.mockReturnValue('user-1') }) afterEach(() => { @@ -88,9 +89,6 @@ describe('RecommendationShelf', () => { usePDP.mockReturnValue({ data: { product: { isVariantOf: { productGroupID: 'pg-1' } } }, }) - // Resolve the cookie lookup so the component derives a userId and builds the - // recommendation arguments from the PDP product. - getWithRetry.mockResolvedValue('user-1') useRecommendations.mockReturnValue({ data: recommendationData, isLoading: false, @@ -125,8 +123,8 @@ describe('RecommendationShelf', () => { }) }) - it('still renders when the cookie lookup fails', async () => { - getWithRetry.mockRejectedValue(new Error('no cookie')) + it('still renders when there is no resolved userId', async () => { + useRecommendationUserId.mockReturnValue(null) useRecommendations.mockReturnValue({ data: recommendationData, isLoading: false, diff --git a/packages/core/test/components/RecommendationShelf/utils.test.ts b/packages/core/test/components/RecommendationShelf/utils.test.ts deleted file mode 100644 index 41f4bf170e..0000000000 --- a/packages/core/test/components/RecommendationShelf/utils.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { getWithRetry } from 'src/components/RecommendationShelf/utils' - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('getWithRetry', () => { - it('resolves immediately when the value is already available', async () => { - const fn = vi.fn(() => 'ready') - await expect(getWithRetry(fn)).resolves.toBe('ready') - expect(fn).toHaveBeenCalledTimes(1) - }) - - it('retries until a truthy value is returned', async () => { - const fn = vi - .fn<() => string>() - .mockReturnValueOnce('') - .mockReturnValueOnce('') - .mockReturnValue('later') - - await expect(getWithRetry(fn, { retries: 5, delayMs: 1 })).resolves.toBe( - 'later' - ) - expect(fn).toHaveBeenCalledTimes(3) - }) - - it('gives up after exhausting the retry budget', async () => { - const fn = vi.fn(() => '') - - await expect(getWithRetry(fn, { retries: 2, delayMs: 1 })).resolves.toBe('') - // initial call + 2 retries - expect(fn).toHaveBeenCalledTimes(3) - }) -}) diff --git a/packages/core/test/components/RecommendationShelf/vrn.test.ts b/packages/core/test/components/RecommendationShelf/vrn.test.ts index ff5923a4f0..5c4486bd2d 100644 --- a/packages/core/test/components/RecommendationShelf/vrn.test.ts +++ b/packages/core/test/components/RecommendationShelf/vrn.test.ts @@ -46,9 +46,11 @@ describe('getTypeFromVrn', () => { expect(getTypeFromVrn(vrn('rec-visual-v2'))).toBe('VISUAL_SIMILARITY') }) - it('throws on an unknown campaign type', () => { - expect(() => getTypeFromVrn(vrn('rec-bogus'))).toThrow( - /Unknown campaign type/ - ) + it('returns null on an unknown campaign type', () => { + expect(getTypeFromVrn(vrn('rec-bogus'))).toBeNull() + }) + + it('returns null on a malformed vrn', () => { + expect(getTypeFromVrn('not-a-vrn')).toBeNull() }) }) diff --git a/packages/core/test/sdk/analytics/utils.test.ts b/packages/core/test/sdk/analytics/utils.test.ts index 6ae8ba93de..4fe5869b21 100644 --- a/packages/core/test/sdk/analytics/utils.test.ts +++ b/packages/core/test/sdk/analytics/utils.test.ts @@ -7,30 +7,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const mockGetCookie = vi.hoisted(() => vi.fn()) vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie })) -import { checkIsMobile, getUserIdFromCookie } from 'src/sdk/analytics/utils' +import { getUserIdFromCookie } from 'src/sdk/analytics/utils' afterEach(() => { vi.clearAllMocks() vi.unstubAllGlobals() }) -describe('checkIsMobile', () => { - it('returns true when the viewport is at or below the mobile breakpoint', () => { - vi.stubGlobal('window', { innerWidth: 768 }) - expect(checkIsMobile()).toBe(true) - }) - - it('returns false when the viewport is wider than the breakpoint', () => { - vi.stubGlobal('window', { innerWidth: 1024 }) - expect(checkIsMobile()).toBe(false) - }) - - it('returns false when there is no window (SSR)', () => { - vi.stubGlobal('window', undefined) - expect(checkIsMobile()).toBe(false) - }) -}) - describe('getUserIdFromCookie', () => { it('returns the cookie value when present', () => { mockGetCookie.mockReturnValue('user-123') diff --git a/packages/core/test/utils/retry.test.ts b/packages/core/test/utils/retry.test.ts new file mode 100644 index 0000000000..b498359903 --- /dev/null +++ b/packages/core/test/utils/retry.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { retry } from 'src/utils/retry' + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('retry', () => { + it('resolves immediately when the value is already available', async () => { + const fn = vi.fn(() => 'ready') + + await expect(retry(fn)).resolves.toBe('ready') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('retries until a truthy value is returned', async () => { + const fn = vi + .fn<() => string>() + .mockReturnValueOnce('') + .mockReturnValueOnce('') + .mockReturnValue('later') + + await expect(retry(fn, { attempts: 5, delayMs: 1 })).resolves.toBe('later') + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('gives up after exhausting the retry budget', async () => { + const fn = vi.fn(() => '') + + await expect(retry(fn, { attempts: 2, delayMs: 1 })).resolves.toBe('') + // initial call + 2 retries + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('honours a custom `until` predicate', async () => { + const fn = vi + .fn<() => number | undefined>() + .mockReturnValueOnce(undefined) + .mockReturnValue(0) + + await expect( + retry(fn, { attempts: 5, delayMs: 1, until: (v) => v !== undefined }) + ).resolves.toBe(0) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('stops retrying once the signal is aborted', async () => { + const controller = new AbortController() + const fn = vi.fn(() => '') + controller.abort() + + await expect( + retry(fn, { attempts: 5, delayMs: 1, signal: controller.signal }) + ).resolves.toBe('') + // Only the initial call runs; the loop never starts because it is aborted. + expect(fn).toHaveBeenCalledTimes(1) + }) +}) From 4c934e8a97e5be3b445a643356ac1448a665539d Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Fri, 26 Jun 2026 14:53:44 -0300 Subject: [PATCH 16/30] refactor(api): remove SendProductViewEvent mutation and related types --- packages/api/src/__generated__/schema.ts | 17 -------------- .../platforms/vtex/typeDefs/mutation.graphql | 8 ------- .../vtex/typeDefs/recommendation.graphql | 8 ------- packages/api/test/integration/schema.test.ts | 1 - packages/core/@generated/graphql.ts | 22 ------------------- packages/core/test/server/index.test.ts | 1 - 6 files changed, 57 deletions(-) diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 7cc9a0a6c9..33142ff9f5 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -734,8 +734,6 @@ export type Mutation = { cancelOrder?: Maybe; /** Process Order Authorization */ processOrderAuthorization?: Maybe; - /** Reports a product view event to the personalization engine. */ - sendProductViewEvent: Scalars['Boolean']['output']; /** * Submits an uploaded file for bulk import into a VTEX cart via the Order Entry Service. * Returns an operationId to poll for the operation status. @@ -768,13 +766,6 @@ export type MutationProcessOrderAuthorizationArgs = { }; -export type MutationSendProductViewEventArgs = { - product: Scalars['String']['input']; - source?: InputMaybe; - userId: Scalars['String']['input']; -}; - - export type MutationStartOrderEntryOperationArgs = { data: IOrderEntryOperation; }; @@ -953,14 +944,6 @@ export type ProductPriceRange = { sellingPrice?: Maybe; }; -export const enum ProductViewSource { - Desktop = 'DESKTOP', - Mobile = 'MOBILE', - MobileApp = 'MOBILE_APP', - WebDesktop = 'WEB_DESKTOP', - WebMobile = 'WEB_MOBILE' -}; - export type Profile = { __typename?: 'Profile'; /** Collection of user's address */ diff --git a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql index 46a5e395cb..82f72ae6b9 100644 --- a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql @@ -39,12 +39,4 @@ type Mutation { Starts an anonymous personalization session for the current shopper. """ startSession: Boolean! - """ - Reports a product view event to the personalization engine. - """ - sendProductViewEvent( - userId: String! - product: String! - source: ProductViewSource - ): Boolean! } diff --git a/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql index 3ccb43f879..c43f9fd32f 100644 --- a/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql @@ -183,11 +183,3 @@ type ProductPriceRange { sellingPrice: PriceRange listPrice: PriceRange } - -enum ProductViewSource { - WEB_DESKTOP - WEB_MOBILE - MOBILE_APP - MOBILE - DESKTOP -} diff --git a/packages/api/test/integration/schema.test.ts b/packages/api/test/integration/schema.test.ts index 290563aa81..a186c80882 100644 --- a/packages/api/test/integration/schema.test.ts +++ b/packages/api/test/integration/schema.test.ts @@ -92,7 +92,6 @@ const MUTATIONS = [ 'uploadFileToOrderEntry', 'startOrderEntryOperation', 'startSession', - 'sendProductViewEvent', ] let schema: GraphQLSchema diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index e3b057c3f6..c503d65227 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -709,8 +709,6 @@ export type Mutation = { cancelOrder: Maybe; /** Process Order Authorization */ processOrderAuthorization: Maybe; - /** Reports a product view event to the personalization engine. */ - sendProductViewEvent: Scalars['Boolean']['output']; /** * Submits an uploaded file for bulk import into a VTEX cart via the Order Entry Service. * Returns an operationId to poll for the operation status. @@ -743,13 +741,6 @@ export type MutationProcessOrderAuthorizationArgs = { }; -export type MutationSendProductViewEventArgs = { - product: Scalars['String']['input']; - source: InputMaybe; - userId: Scalars['String']['input']; -}; - - export type MutationStartOrderEntryOperationArgs = { data: IOrderEntryOperation; }; @@ -915,13 +906,6 @@ export type ProductPriceRange = { sellingPrice: Maybe; }; -export type ProductViewSource = - | 'DESKTOP' - | 'MOBILE' - | 'MOBILE_APP' - | 'WEB_DESKTOP' - | 'WEB_MOBILE'; - export type Profile = { /** Collection of user's address */ addresses: Maybe>>; @@ -971,7 +955,6 @@ export type Query = { allProducts: StoreProductConnection; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; - getCookies: Maybe; /** Returns the list of Orders that the User can view. */ listUserOrders: Maybe; /** Returns the status of an Order Entry Service operation by its ID. */ @@ -2777,11 +2760,6 @@ export type Video = { videoUrl: Maybe; }; -export type VtCookies = { - vtex_segment: Scalars['String']['output']; - vtex_session: Scalars['String']['output']; -}; - export type FetchRecommendationsQueryQueryVariables = Exact<{ campaignVrn: Scalars['String']['input']; userId: InputMaybe; diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index fffe752781..471e991470 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -94,7 +94,6 @@ const MUTATIONS = [ 'uploadFileToOrderEntry', 'startOrderEntryOperation', 'startSession', - 'sendProductViewEvent', ] describe('FastStore GraphQL Layer', () => { From f3ef796c1f741e07b938b13a97d29ce61dc2abc6 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Fri, 26 Jun 2026 15:05:56 -0300 Subject: [PATCH 17/30] refactor(api, core): rename startSession to startRecommendationSession and update related logic - Updated GraphQL schema and mutation definitions to reflect the renaming of `startSession` to `startRecommendationSession`. - Adjusted integration tests and hooks to utilize the new mutation name. - Introduced a new hook, `useStartRecommendationSession`, to manage the session initiation process. - Updated the `Layout` component to call the new hook, ensuring the recommendation session starts correctly per browser session. --- packages/api/src/__generated__/schema.ts | 2 +- .../platforms/vtex/typeDefs/mutation.graphql | 2 +- packages/api/test/integration/schema.test.ts | 2 +- packages/core/@generated/gql.ts | 6 +- packages/core/@generated/graphql.ts | 8 +- packages/core/src/Layout.tsx | 4 +- .../hooks/useStartRecommendationSession.ts | 74 +++++++++++++++++++ .../sdk/analytics/hooks/useStartSession.ts | 30 +++++--- ... => useStartRecommendationSession.test.ts} | 18 ++--- packages/core/test/server/index.test.ts | 2 +- 10 files changed, 115 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts rename packages/core/test/sdk/analytics/{useStartSession.test.ts => useStartRecommendationSession.test.ts} (60%) diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 33142ff9f5..2f86810676 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -740,7 +740,7 @@ export type Mutation = { */ startOrderEntryOperation?: Maybe; /** Starts an anonymous personalization session for the current shopper. */ - startSession: Scalars['Boolean']['output']; + startRecommendationSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter?: Maybe; /** diff --git a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql index 82f72ae6b9..a692e43e72 100644 --- a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql @@ -38,5 +38,5 @@ type Mutation { """ Starts an anonymous personalization session for the current shopper. """ - startSession: Boolean! + startRecommendationSession: Boolean! } diff --git a/packages/api/test/integration/schema.test.ts b/packages/api/test/integration/schema.test.ts index a186c80882..fcae1a1dad 100644 --- a/packages/api/test/integration/schema.test.ts +++ b/packages/api/test/integration/schema.test.ts @@ -91,7 +91,7 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', - 'startSession', + 'startRecommendationSession', ] let schema: GraphQLSchema diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 1dae4a8493..651eb09214 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -41,7 +41,7 @@ type Documents = { "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": typeof types.CancelOrderMutationDocument, "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": typeof types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": typeof types.ValidateUserDocument, - "\n mutation StartSession {\n startSession\n }\n": typeof types.StartSessionDocument, + "\n mutation StartRecommendationSession {\n startRecommendationSession\n }\n": typeof types.StartRecommendationSessionDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": typeof types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": typeof types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": typeof types.SubscribeToNewsletterDocument, @@ -90,7 +90,7 @@ const documents: Documents = { "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": types.CancelOrderMutationDocument, "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": types.ValidateUserDocument, - "\n mutation StartSession {\n startSession\n }\n": types.StartSessionDocument, + "\n mutation StartRecommendationSession {\n startRecommendationSession\n }\n": types.StartRecommendationSessionDocument, "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": types.SubscribeToNewsletterDocument, @@ -220,7 +220,7 @@ export function gql(source: "\n query ValidateUser {\n validateUser {\n /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n mutation StartSession {\n startSession\n }\n"): typeof import('./graphql').StartSessionDocument; +export function gql(source: "\n mutation StartRecommendationSession {\n startRecommendationSession\n }\n"): typeof import('./graphql').StartRecommendationSessionDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index c503d65227..a435d2ab75 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -715,7 +715,7 @@ export type Mutation = { */ startOrderEntryOperation: Maybe; /** Starts an anonymous personalization session for the current shopper. */ - startSession: Scalars['Boolean']['output']; + startRecommendationSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter: Maybe; /** @@ -2878,10 +2878,10 @@ export type ValidateUserQueryVariables = Exact<{ [key: string]: never; }>; export type ValidateUserQuery = { validateUser: { isValid: boolean } | null }; -export type StartSessionMutationVariables = Exact<{ [key: string]: never; }>; +export type StartRecommendationSessionMutationVariables = Exact<{ [key: string]: never; }>; -export type StartSessionMutation = { startSession: boolean }; +export type StartRecommendationSessionMutation = { startRecommendationSession: boolean }; export type ValidateCartMutationMutationVariables = Exact<{ cart: IStoreCart; @@ -3582,7 +3582,7 @@ export const ServerUserDetailsQueryDocument = {"__meta__":{"operationName":"Serv export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelOrderMutation","operationHash":"e2b06da6840614d3c72768e56579b9d3b8e80802"}} as unknown as TypedDocumentString; export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString; export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString; -export const StartSessionDocument = {"__meta__":{"operationName":"StartSession","operationHash":"bf9c4a8bc27fb6ed256336880aea8939b04943cf"}} as unknown as TypedDocumentString; +export const StartRecommendationSessionDocument = {"__meta__":{"operationName":"StartRecommendationSession","operationHash":"1def6438c0cd87b85002411ac7326c221f192583"}} as unknown as TypedDocumentString; export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"32c15f8888ca34f223def7972b7f19090808435a"}} as unknown as TypedDocumentString; export const ClientPickupPointsQueryDocument = {"__meta__":{"operationName":"ClientPickupPointsQuery","operationHash":"3fa04e88c811fcb5ece7206fd5aa745bdbc143a8"}} as unknown as TypedDocumentString; export const SubscribeToNewsletterDocument = {"__meta__":{"operationName":"SubscribeToNewsletter","operationHash":"feb7005103a859e2bc8cf2360d568806fd88deba"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/Layout.tsx b/packages/core/src/Layout.tsx index 214b0a244c..3f8b9c9e60 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -1,14 +1,14 @@ import { isValidElement, type PropsWithChildren } from 'react' import { usePageViewEvent } from './sdk/analytics/hooks/usePageViewEvent' -import { useStartSession } from './sdk/analytics/hooks/useStartSession' +import { useStartRecommendationSession } from './sdk/analytics/hooks/useStartRecommendationSession' function Layout({ children }: PropsWithChildren) { const props = isValidElement(children) ? children.props : undefined usePageViewEvent(props) // Implemented here because the personalization session must be initiated once // per browser session on each page. - useStartSession() + useStartRecommendationSession() return <>{children} } diff --git a/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts b/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts new file mode 100644 index 0000000000..f786e4e977 --- /dev/null +++ b/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react' + +import { gql } from '@faststore/core/api' + +import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' +import { getCookie } from 'src/utils/getCookie' +import { retry } from 'src/utils/retry' + +// Cookie set once the anonymous personalization session has been started, +// so we only fire `StartRecommendationSession` a single time per browser session. +const VTEX_REC_USER_START_SESSION = 'vtex-rec-user-start-session' + +const startRecommendationSessionMutation = gql(` + mutation StartRecommendationSession { + startRecommendationSession + } +`) + +type StartRecommendationSessionData = { startRecommendationSession: boolean } +type StartRecommendationSessionVariables = Record + +/** + * Starts the anonymous recommendation/personalization session once per browser + * session. + * + * Mounted globally from `Layout` so it runs on every page, regardless of + * whether a recommendation section is present. + * + * The product view is no longer reported through a mutation here: the PDP now + * exposes the product data as `` tags, which the + * Activity Flow script reads to capture the product view event. + * + * All work runs client-side in effects (after hydration), so it doesn't affect + * SSR/TTFB or Lighthouse render metrics. + */ +export function useStartRecommendationSession() { + const [runStartRecommendationSession] = useLazyQuery< + StartRecommendationSessionData, + StartRecommendationSessionVariables + >( + startRecommendationSessionMutation, + {} as StartRecommendationSessionVariables, + { + doNotRun: true, + } + ) + + useEffect(() => { + const startRecommendationSessionCookie = getCookie( + VTEX_REC_USER_START_SESSION + ) + + if (startRecommendationSessionCookie) { + return + } + + const controller = new AbortController() + + // The session endpoint may not be ready on the first try, so we retry with + // exponential backoff until it returns a defined result (or we give up). + void retry(() => runStartRecommendationSession({}), { + attempts: 10, + delayMs: 300, + backoff: true, + maxDelayMs: 3000, + until: (result) => result !== undefined, + signal: controller.signal, + }) + + return () => { + controller.abort() + } + }, [runStartRecommendationSession]) +} diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts index 1fac445a24..34952f16e4 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartSession.ts @@ -7,17 +7,17 @@ import { getCookie } from 'src/utils/getCookie' import { retry } from 'src/utils/retry' // Cookie set once the anonymous personalization session has been started, -// so we only fire `StartSession` a single time per browser session. +// so we only fire `StartRecommendationSession` a single time per browser session. const VTEX_REC_USER_START_SESSION = 'vtex-rec-user-start-session' -const startSessionMutation = gql(` - mutation StartSession { - startSession +const startRecommendationSessionMutation = gql(` + mutation StartRecommendationSession { + startRecommendationSession } `) -type StartSessionData = { startSession: boolean } -type StartSessionVariables = Record +type StartRecommendationSessionData = { startRecommendationSession: boolean } +type StartRecommendationSessionVariables = Record /** * Starts the anonymous recommendation/personalization session once per browser @@ -35,14 +35,22 @@ type StartSessionVariables = Record */ export function useStartSession() { const [runStartSession] = useLazyQuery< - StartSessionData, - StartSessionVariables - >(startSessionMutation, {} as StartSessionVariables, { doNotRun: true }) + StartRecommendationSessionData, + StartRecommendationSessionVariables + >( + startRecommendationSessionMutation, + {} as StartRecommendationSessionVariables, + { + doNotRun: true, + } + ) useEffect(() => { - const startSessionCookie = getCookie(VTEX_REC_USER_START_SESSION) + const startRecommendationSessionCookie = getCookie( + VTEX_REC_USER_START_SESSION + ) - if (startSessionCookie) { + if (startRecommendationSessionCookie) { return } diff --git a/packages/core/test/sdk/analytics/useStartSession.test.ts b/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts similarity index 60% rename from packages/core/test/sdk/analytics/useStartSession.test.ts rename to packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts index 4c239c085b..540ab5041b 100644 --- a/packages/core/test/sdk/analytics/useStartSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts @@ -9,7 +9,7 @@ vi.mock('@faststore/core/api', () => ({ gql: (s: string) => ({ __meta__: {}, query: s }), })) -const runStartSession = vi.hoisted(() => vi.fn()) +const runStartRecommendationSession = vi.hoisted(() => vi.fn()) const useLazyQueryMock = vi.hoisted(() => vi.fn()) vi.mock('src/sdk/graphql/useLazyQuery', () => ({ useLazyQuery: useLazyQueryMock, @@ -18,35 +18,35 @@ vi.mock('src/sdk/graphql/useLazyQuery', () => ({ const getCookie = vi.hoisted(() => vi.fn()) vi.mock('src/utils/getCookie', () => ({ getCookie })) -import { useStartSession } from 'src/sdk/analytics/hooks/useStartSession' +import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession' beforeEach(() => { - useLazyQueryMock.mockReturnValue([runStartSession, {}]) - runStartSession.mockResolvedValue(true) + useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}]) + runStartRecommendationSession.mockResolvedValue(true) }) afterEach(() => { vi.clearAllMocks() }) -describe('useStartSession', () => { +describe('useStartRecommendationSession', () => { it('does not start a session when the session cookie already exists', async () => { getCookie.mockReturnValue('already-started') - renderHook(() => useStartSession()) + renderHook(() => useStartRecommendationSession()) await waitFor(() => { - expect(runStartSession).not.toHaveBeenCalled() + expect(runStartRecommendationSession).not.toHaveBeenCalled() }) }) it('starts a session when no session cookie is present', async () => { getCookie.mockReturnValue(undefined) - renderHook(() => useStartSession()) + renderHook(() => useStartRecommendationSession()) await waitFor(() => { - expect(runStartSession).toHaveBeenCalled() + expect(runStartRecommendationSession).toHaveBeenCalled() }) }) }) diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 471e991470..f87cb7856a 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -93,7 +93,7 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', - 'startSession', + 'startRecommendationSession', ] describe('FastStore GraphQL Layer', () => { From 1ea6afacbc768c1395b3b46e735bac6a0cfed73e Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Fri, 26 Jun 2026 19:24:37 -0300 Subject: [PATCH 18/30] feat(api, core): return StoreProduct from recommendations for consistent interface Resolve recommendations in @faststore/api (recommendations query and startRecommendationSession mutation) and map the BFF payload straight to the normalized StoreProduct shape via pickBestSku + enhanceSku, so recommendation shelves expose the same product interface as the rest of the storefront. Update RecommendationShelf and useRecommendations to consume StoreProduct directly, with a configurable product card component and mapper, and drop the now-unused RecommendationProduct mapper. --- packages/api/src/__generated__/schema.ts | 196 +----------------- .../api/src/platforms/vtex/clients/index.ts | 3 + .../vtex/clients/recommendation/index.ts | 81 ++++++++ .../types/RecommendationResult.ts | 27 +++ .../src/platforms/vtex/resolvers/mutation.ts | 2 + .../api/src/platforms/vtex/resolvers/query.ts | 2 + .../vtex/resolvers/recommendations.ts | 49 +++++ .../resolvers/startRecommendationSession.ts | 19 ++ .../vtex/typeDefs/recommendation.graphql | 176 +--------------- packages/core/@generated/gql.ts | 6 +- packages/core/@generated/graphql.ts | 178 +--------------- .../RecommendationShelf.tsx | 78 ++++--- .../RecommendationShelf.types.ts | 43 ++-- .../mapRecommendationToProductCard.ts | 69 ------ .../RecommendationShelf/useRecommendations.ts | 147 +------------ .../RecommendationShelf.browser.test.tsx | 5 +- .../mapRecommendationToProductCard.test.ts | 92 -------- 17 files changed, 283 insertions(+), 890 deletions(-) create mode 100644 packages/api/src/platforms/vtex/clients/recommendation/index.ts create mode 100644 packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts create mode 100644 packages/api/src/platforms/vtex/resolvers/recommendations.ts create mode 100644 packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts delete mode 100644 packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts delete mode 100644 packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 2f86810676..b59342e61e 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -132,23 +132,6 @@ export type AvailableDeliveryWindows = { tax?: Maybe; }; -export type Benefit = { - __typename?: 'Benefit'; - featured?: Maybe; - id?: Maybe; - items?: Maybe>; - name?: Maybe; - teaserType?: Maybe; -}; - -export type BenefitItem = { - __typename?: 'BenefitItem'; - benefitProduct?: Maybe; - benefitSKUIds?: Maybe>; - discount?: Maybe; - minQuantity?: Maybe; -}; - export type BusinessHour = { __typename?: 'BusinessHour'; /** Business hour closing time. */ @@ -159,12 +142,6 @@ export type BusinessHour = { openingTime?: Maybe; }; -export type ClusterHighlight = { - __typename?: 'ClusterHighlight'; - id?: Maybe; - name?: Maybe; -}; - /** Commercial Authorization dimension status. */ export type CommercialAuthorizationDimensionStatus = { __typename?: 'CommercialAuthorizationDimensionStatus'; @@ -380,11 +357,6 @@ export type DeliveryPromiseBadge = { typeName?: Maybe; }; -export type Discount = { - __typename?: 'Discount'; - name?: Maybe; -}; - /** Input to get commercial authorizations by order ID. */ export type ICommercialAuthorizationByOrderId = { /** Order ID to get commercial authorizations for. */ @@ -643,27 +615,6 @@ export type IUserOrderCancel = { reason?: InputMaybe; }; -export type Image = { - __typename?: 'Image'; - cacheId?: Maybe; - imageId?: Maybe; - imageLabel?: Maybe; - imageTag?: Maybe; - imageText?: Maybe; - imageUrl: Scalars['String']['output']; -}; - -export type Installment = { - __typename?: 'Installment'; - InterestRate?: Maybe; - Name?: Maybe; - NumberOfInstallments?: Maybe; - PaymentSystemGroupName?: Maybe; - PaymentSystemName?: Maybe; - TotalValuePlusInterestRate?: Maybe; - Value?: Maybe; -}; - export type LogisticsInfo = { __typename?: 'LogisticsInfo'; /** LogisticsInfo itemIndex. */ @@ -792,23 +743,6 @@ export type MutationValidateSessionArgs = { session: IStoreSession; }; -export type Offer = { - __typename?: 'Offer'; - AvailableQuantity?: Maybe; - Installments?: Maybe>; - ListPrice?: Maybe; - Price?: Maybe; - PriceValidUntil?: Maybe; - PriceWithoutDiscount?: Maybe; - RewardValue?: Maybe; - Tax?: Maybe; - discountHighlights?: Maybe>; - giftSkuIds?: Maybe>; - spotPrice?: Maybe; - taxPercentage?: Maybe; - teasers?: Maybe>; -}; - /** Newsletter information. */ export type PersonNewsletter = { __typename?: 'PersonNewsletter'; @@ -900,12 +834,6 @@ export type PickupStoreInfo = { isPickupStore?: Maybe; }; -export type PriceRange = { - __typename?: 'PriceRange'; - highPrice?: Maybe; - lowPrice?: Maybe; -}; - /** Process Order Authorization response. */ export type ProcessOrderAuthorizationResponse = { __typename?: 'ProcessOrderAuthorizationResponse'; @@ -926,24 +854,12 @@ export type ProcessOrderAuthorizationRule = { rule: CommercialAuthorizationRule; }; -export type ProductClusters = { - __typename?: 'ProductClusters'; - id?: Maybe; - name?: Maybe; -}; - export type ProductCountResult = { __typename?: 'ProductCountResult'; /** Total product count. */ total: Scalars['Int']['output']; }; -export type ProductPriceRange = { - __typename?: 'ProductPriceRange'; - listPrice?: Maybe; - sellingPrice?: Maybe; -}; - export type Profile = { __typename?: 'Profile'; /** Collection of user's address */ @@ -980,13 +896,6 @@ export type ProfileAddress = { street?: Maybe; }; -export type Property = { - __typename?: 'Property'; - name?: Maybe; - originalName?: Maybe; - values?: Maybe>>; -}; - export type Query = { __typename?: 'Query'; /** Returns the account profile information for the current authenticated user (b2b or b2c user). */ @@ -1145,62 +1054,11 @@ export type RecommendationCampaign = { type: Scalars['String']['output']; }; -export type RecommendationProduct = { - __typename?: 'RecommendationProduct'; - /** If this product is sponsored, ad information will be added here. */ - advertisement?: Maybe; - benefits?: Maybe>; - brand?: Maybe; - brandId?: Maybe; - cacheId?: Maybe; - categories?: Maybe>; - categoriesIds?: Maybe>; - categoryId?: Maybe; - clusterHighlights?: Maybe>; - description?: Maybe; - items: Array; - link?: Maybe; - linkText?: Maybe; - metaTagDescription?: Maybe; - priceRange?: Maybe; - productClusters?: Maybe>; - productId: Scalars['String']['output']; - productName?: Maybe; - productReference?: Maybe; - properties?: Maybe>; - releaseDate?: Maybe; - skuSpecifications?: Maybe>; - specificationGroups?: Maybe>; - titleTag?: Maybe; -}; - export type RecommendationResponse = { __typename?: 'RecommendationResponse'; campaign: RecommendationCampaign; correlationId: Scalars['String']['output']; - products: Array; -}; - -export type Reference = { - __typename?: 'Reference'; - Key?: Maybe; - Value?: Maybe; -}; - -export type Sku = { - __typename?: 'SKU'; - complementName?: Maybe; - ean?: Maybe; - images: Array; - itemId: Scalars['String']['output']; - measurementUnit?: Maybe; - name?: Maybe; - nameComplete?: Maybe; - referenceId?: Maybe>; - sellers?: Maybe>; - unitMultiplier?: Maybe; - variations?: Maybe>; - videos?: Maybe>; + products: Array; }; export type SkuSpecificationField = { @@ -1229,15 +1087,6 @@ export type SearchMetadata = { logicalOperator: Scalars['String']['output']; }; -export type Seller = { - __typename?: 'Seller'; - addToCartLink?: Maybe; - commertialOffer?: Maybe; - sellerDefault?: Maybe; - sellerId?: Maybe; - sellerName?: Maybe; -}; - /** Information of sellers. */ export type SellerInfo = { __typename?: 'SellerInfo'; @@ -1372,20 +1221,6 @@ export type SpecificationGroup = { specifications: Array; }; -export type SpecificationGroupRec = { - __typename?: 'SpecificationGroupRec'; - name?: Maybe; - originalName?: Maybe; - specifications?: Maybe>; -}; - -export type SpecificationGroupRecProperty = { - __typename?: 'SpecificationGroupRecProperty'; - name?: Maybe; - originalName?: Maybe; - values?: Maybe>; -}; - /** Account profile information. */ export type StoreAccountProfile = { __typename?: 'StoreAccountProfile'; @@ -2041,30 +1876,6 @@ export type StoreUserDetails = { username?: Maybe; }; -export type Teaser = { - __typename?: 'Teaser'; - conditions?: Maybe; - effects?: Maybe; - name?: Maybe; -}; - -export type TeaserCondition = { - __typename?: 'TeaserCondition'; - minimumQuantity?: Maybe; - parameters?: Maybe>>; -}; - -export type TeaserEffects = { - __typename?: 'TeaserEffects'; - parameters?: Maybe>>; -}; - -export type TeaserValue = { - __typename?: 'TeaserValue'; - name?: Maybe; - value?: Maybe; -}; - export type UserOrder = { __typename?: 'UserOrder'; affiliateId?: Maybe; @@ -2955,8 +2766,3 @@ export type ValidateUserData = { /** Indicates if the user is valid. */ isValid: Scalars['Boolean']['output']; }; - -export type Video = { - __typename?: 'Video'; - videoUrl?: Maybe; -}; diff --git a/packages/api/src/platforms/vtex/clients/index.ts b/packages/api/src/platforms/vtex/clients/index.ts index 910989d913..f99c0f22e3 100644 --- a/packages/api/src/platforms/vtex/clients/index.ts +++ b/packages/api/src/platforms/vtex/clients/index.ts @@ -1,5 +1,6 @@ import type { GraphqlContext } from '..' import { VtexCommerce } from './commerce' +import { Recommendation } from './recommendation' import { IntelligentSearch } from './search' export type Clients = ReturnType @@ -7,9 +8,11 @@ export type Clients = ReturnType export const getClients = (options: Options, ctx: GraphqlContext) => { const search = IntelligentSearch(options, ctx) const commerce = VtexCommerce(options, ctx) + const recommendation = Recommendation(options, ctx) return { search, commerce, + recommendation, } } diff --git a/packages/api/src/platforms/vtex/clients/recommendation/index.ts b/packages/api/src/platforms/vtex/clients/recommendation/index.ts new file mode 100644 index 0000000000..a23a3e0276 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/recommendation/index.ts @@ -0,0 +1,81 @@ +import type { GraphqlContext } from '../..' +import { getStoreCookie, getWithCookie } from '../../utils/cookies' +import { fetchAPI } from '../fetch' +import type { + RecommendationResult, + StartSessionResult, +} from './types/RecommendationResult' + +// Identifies the storefront origin to the Recommendations BFF, as required by +// the API (`x-vtex-rec-origin` header). +const REC_ORIGIN_SUFFIX = 'storefront/vtex.recommendation-shelf@2.x' + +export interface RecommendationArgs { + campaignVrn: string + userId?: string + products?: string[] + salesChannel?: string + locale?: string +} + +export const Recommendation = ( + { account, environment }: Options, + ctx: GraphqlContext +) => { + const base = `https://${account}.${environment}.com.br/api/recommend-bff/v2` + const withCookie = getWithCookie(ctx) + const storeCookies = getStoreCookie(ctx) + + const headers: HeadersInit = withCookie({ + accept: 'application/json', + 'content-type': 'application/json', + 'x-vtex-rec-origin': `${account}/${REC_ORIGIN_SUFFIX}`, + }) + + const recommendations = ({ + campaignVrn, + userId, + products = [], + salesChannel, + locale, + }: RecommendationArgs): Promise => { + const params = new URLSearchParams({ an: account, campaignVrn }) + + if (userId) { + params.append('userId', userId) + } + + if (products.length > 0) { + params.append('products', products.join(',')) + } + + if (salesChannel) { + params.append('salesChannel', salesChannel) + } + + if (locale) { + params.append('locale', locale) + } + + return fetchAPI(`${base}/recommendations?${params.toString()}`, { headers }) + } + + // Starts/updates the anonymous personalization session. The BFF resolves the + // orderForm from the forwarded `checkout.vtex.com` cookie and replies with the + // `vtex-rec-user-id`/`vtex-rec-user-start-session` Set-Cookie headers, which we + // forward to the browser through `ctx.storage.cookies`. + const startSession = (): Promise => { + const params = new URLSearchParams({ an: account }) + + return fetchAPI( + `${base}/users/start-session?${params.toString()}`, + { method: 'POST', headers }, + { storeCookies } + ) + } + + return { + recommendations, + startSession, + } +} diff --git a/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts b/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts new file mode 100644 index 0000000000..746ed3cc2a --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts @@ -0,0 +1,27 @@ +import type { Product } from '../../search/types/ProductSearchResult' + +/** + * Raw response of the VTEX Recommendations BFF + * (`GET /api/recommend-bff/v2/recommendations`). + * + * The BFF already returns the products fully hydrated in the same Intelligent + * Search shape (`Product`) used by the `search` query, so the resolver can map + * them straight to the normalized `StoreProduct` shape via `pickBestSku` + + * `enhanceSku` — no extra round-trip to search is needed. + */ +export interface RecommendationResult { + products: Product[] + correlationId: string + campaign: RecommendationBffCampaign +} + +export interface RecommendationBffCampaign { + id: string + title?: string + type: string +} + +/** Response of `POST /api/recommend-bff/v2/users/start-session`. */ +export interface StartSessionResult { + recommendationsUserId: string +} diff --git a/packages/api/src/platforms/vtex/resolvers/mutation.ts b/packages/api/src/platforms/vtex/resolvers/mutation.ts index 0d111c84bf..c82317f615 100644 --- a/packages/api/src/platforms/vtex/resolvers/mutation.ts +++ b/packages/api/src/platforms/vtex/resolvers/mutation.ts @@ -1,6 +1,7 @@ import { cancelOrder } from './cancelOrder' import { processOrderAuthorization } from './processOrderAuthorization' import { startOrderEntryOperation } from './startOrderEntryOperation' +import { startRecommendationSession } from './startRecommendationSession' import { subscribeToNewsletter } from './subscribeToNewsletter' import { uploadFileToOrderEntry } from './uploadFileToOrderEntry' import { validateCart } from './validateCart' @@ -14,4 +15,5 @@ export const Mutation = { processOrderAuthorization, uploadFileToOrderEntry, startOrderEntryOperation, + startRecommendationSession, } diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 7cc7847d8c..be29d16a71 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -18,6 +18,7 @@ import type { } from '../../../__generated__/schema' import { getOrderEntryOperation } from './getOrderEntryOperation' import { getOrderFormItems } from './getOrderFormItems' +import { recommendations } from './recommendations' import { BadRequestError, ForbiddenError, @@ -675,4 +676,5 @@ export const Query = { }, orderEntryOperation: getOrderEntryOperation, orderFormItems: getOrderFormItems, + recommendations, } diff --git a/packages/api/src/platforms/vtex/resolvers/recommendations.ts b/packages/api/src/platforms/vtex/resolvers/recommendations.ts new file mode 100644 index 0000000000..f325c7aa9a --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/recommendations.ts @@ -0,0 +1,49 @@ +import type { QueryRecommendationsArgs } from '../../../__generated__/schema' +import type { GraphqlContext } from '../index' +import { enhanceSku, type EnhancedSku } from '../utils/enhanceSku' +import { pickBestSku } from '../utils/sku' + +/** + * Resolves personalized recommendations for a campaign. + * + * The VTEX Recommendations BFF already returns the products fully hydrated in + * the same Intelligent Search shape used by the `search` query. We map them + * straight to the normalized `StoreProduct` shape (`pickBestSku` + `enhanceSku`) + * so recommendation shelves render identical cards to regular shelves, while + * preserving the recommendation order returned by the BFF. + */ +export const recommendations = async ( + _: unknown, + { campaignVrn, userId, products }: QueryRecommendationsArgs, + ctx: GraphqlContext +) => { + const { + clients: { recommendation }, + } = ctx + + const { salesChannel } = ctx.storage.channel + + const response = await recommendation.recommendations({ + campaignVrn, + userId: userId ?? undefined, + products: products ?? [], + salesChannel: salesChannel ?? undefined, + locale: ctx.storage.locale, + }) + + const { campaign, correlationId } = response + + const orderedProducts = (response.products ?? []) + .map((product) => { + const sku = pickBestSku(product.items) + + return sku ? enhanceSku(sku, product) : null + }) + .filter((sku): sku is EnhancedSku => Boolean(sku)) + + return { + products: orderedProducts, + correlationId, + campaign, + } +} diff --git a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts new file mode 100644 index 0000000000..c8fb355208 --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts @@ -0,0 +1,19 @@ +import type { GraphqlContext } from '../index' + +/** + * Starts (or updates) the anonymous personalization session for the current + * shopper via the Recommendations BFF. + * + * The BFF replies with the `vtex-rec-user-id`/`vtex-rec-user-start-session` + * Set-Cookie headers, which the client forwards to the browser through + * `ctx.storage.cookies`. Returns `true` once the session has been started. + */ +export const startRecommendationSession = async ( + _: unknown, + __: unknown, + ctx: GraphqlContext +) => { + await ctx.clients.recommendation.startSession() + + return true +} diff --git a/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql index c43f9fd32f..4a00c1f2f4 100644 --- a/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql @@ -1,5 +1,5 @@ type RecommendationResponse { - products: [RecommendationProduct!]! + products: [StoreProduct!]! correlationId: String! campaign: RecommendationCampaign! } @@ -9,177 +9,3 @@ type RecommendationCampaign { title: String type: String! } - -type RecommendationProduct { - brand: String - brandId: Int - benefits: [Benefit!] - cacheId: String - categoryId: ID - categories: [String!] - categoriesIds: [String!] - clusterHighlights: [ClusterHighlight!] - description: String - items: [SKU!]! - link: String - linkText: String - metaTagDescription: String - priceRange: ProductPriceRange - releaseDate: String - productClusters: [ProductClusters!] - productId: String! - productName: String - productReference: String - properties: [Property!] - skuSpecifications: [SkuSpecification!] - specificationGroups: [SpecificationGroupRec!] - titleTag: String - """ - If this product is sponsored, ad information will be added here. - """ - advertisement: Advertisement -} - -type Benefit { - featured: Boolean - id: String - name: String - items: [BenefitItem!] - teaserType: String -} - -type BenefitItem { - benefitProduct: RecommendationProduct - benefitSKUIds: [String!] - discount: Float - minQuantity: Int -} - -type SKU { - itemId: String! - name: String - nameComplete: String - complementName: String - ean: String - referenceId: [Reference!] - measurementUnit: String - unitMultiplier: Float - images: [Image!]! - videos: [Video!] - sellers: [Seller!] - variations: [Property!] -} - -type Seller { - sellerId: String - sellerName: String - addToCartLink: String - sellerDefault: Boolean - commertialOffer: Offer -} - -type Offer { - Installments: [Installment!] - Price: Float - ListPrice: Float - spotPrice: Float - PriceWithoutDiscount: Float - AvailableQuantity: Float - Tax: Float - taxPercentage: Float - teasers: [Teaser!] - discountHighlights: [Discount!] - giftSkuIds: [String!] - RewardValue: Float - PriceValidUntil: String -} - -type Installment { - Value: Float - InterestRate: Float - TotalValuePlusInterestRate: Float - NumberOfInstallments: Int - Name: String - PaymentSystemName: String - PaymentSystemGroupName: String -} - -type Discount { - name: String -} - -type Teaser { - name: String - conditions: TeaserCondition - effects: TeaserEffects -} - -type TeaserCondition { - minimumQuantity: Int - parameters: [TeaserValue] -} - -type TeaserEffects { - parameters: [TeaserValue] -} - -type TeaserValue { - name: String - value: String -} - -type Image { - cacheId: ID - imageId: ID - imageLabel: String - imageTag: String - imageUrl: String! - imageText: String -} - -type Video { - videoUrl: String -} - -type Reference { - Key: String - Value: String -} - -type Property { - name: String - originalName: String - values: [String] -} - -type ClusterHighlight { - id: ID - name: String -} - -type ProductClusters { - id: ID - name: String -} - -type SpecificationGroupRec { - originalName: String - name: String - specifications: [SpecificationGroupRecProperty!] -} - -type SpecificationGroupRecProperty { - originalName: String - name: String - values: [String!] -} - -type PriceRange { - highPrice: Float - lowPrice: Float -} - -type ProductPriceRange { - sellingPrice: PriceRange - listPrice: PriceRange -} diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 651eb09214..834fb9fa1a 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -15,7 +15,7 @@ import * as types from './graphql'; * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.FetchRecommendationsQueryDocument, + "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": typeof types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": typeof types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": typeof types.ProductDetailsFragment_ProductFragmentDoc, @@ -64,7 +64,7 @@ type Documents = { "\n query ServerManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n": typeof types.ServerManyProductsQueryDocument, }; const documents: Documents = { - "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.FetchRecommendationsQueryDocument, + "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": types.ProductDetailsFragment_ProductFragmentDoc, @@ -116,7 +116,7 @@ const documents: Documents = { /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n cacheId\n productId\n description\n productName\n productReference\n linkText\n brand\n brandId\n link\n categories\n categoryId\n releaseDate\n advertisement {\n adId\n campaignId\n actionCost\n adRequestId\n adResponseId\n }\n priceRange {\n sellingPrice {\n highPrice\n lowPrice\n }\n listPrice {\n highPrice\n lowPrice\n }\n }\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n skuSpecifications {\n field {\n name\n originalName\n }\n values {\n name\n originalName\n }\n }\n productClusters {\n id\n name\n }\n clusterHighlights {\n id\n name\n }\n properties {\n name\n values\n }\n items {\n itemId\n name\n nameComplete\n complementName\n ean\n variations {\n name\n values\n }\n referenceId {\n Key\n Value\n }\n measurementUnit\n unitMultiplier\n images {\n cacheId\n imageId\n imageLabel\n imageTag\n imageUrl\n imageText\n }\n sellers {\n sellerId\n sellerName\n sellerDefault\n commertialOffer {\n discountHighlights {\n name\n }\n teasers {\n name\n conditions {\n minimumQuantity\n parameters {\n name\n value\n }\n }\n effects {\n parameters {\n name\n value\n }\n }\n }\n Price\n ListPrice\n Tax\n taxPercentage\n spotPrice\n PriceWithoutDiscount\n RewardValue\n PriceValidUntil\n AvailableQuantity\n Installments {\n Value\n InterestRate\n TotalValuePlusInterestRate\n NumberOfInstallments\n Name\n PaymentSystemName\n }\n }\n }\n }\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').FetchRecommendationsQueryDocument; +export function gql(source: "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').FetchRecommendationsQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index a435d2ab75..fa931be544 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -132,21 +132,6 @@ export type AvailableDeliveryWindows = { tax: Maybe; }; -export type Benefit = { - featured: Maybe; - id: Maybe; - items: Maybe>; - name: Maybe; - teaserType: Maybe; -}; - -export type BenefitItem = { - benefitProduct: Maybe; - benefitSKUIds: Maybe>; - discount: Maybe; - minQuantity: Maybe; -}; - export type BusinessHour = { /** Business hour closing time. */ closingTime: Maybe; @@ -156,11 +141,6 @@ export type BusinessHour = { openingTime: Maybe; }; -export type ClusterHighlight = { - id: Maybe; - name: Maybe; -}; - /** Commercial Authorization dimension status. */ export type CommercialAuthorizationDimensionStatus = { /** Creation date. */ @@ -363,10 +343,6 @@ export type DeliveryPromiseBadge = { typeName: Maybe; }; -export type Discount = { - name: Maybe; -}; - /** Input to get commercial authorizations by order ID. */ export type ICommercialAuthorizationByOrderId = { /** Order ID to get commercial authorizations for. */ @@ -625,25 +601,6 @@ export type IUserOrderCancel = { reason: InputMaybe; }; -export type Image = { - cacheId: Maybe; - imageId: Maybe; - imageLabel: Maybe; - imageTag: Maybe; - imageText: Maybe; - imageUrl: Scalars['String']['output']; -}; - -export type Installment = { - InterestRate: Maybe; - Name: Maybe; - NumberOfInstallments: Maybe; - PaymentSystemGroupName: Maybe; - PaymentSystemName: Maybe; - TotalValuePlusInterestRate: Maybe; - Value: Maybe; -}; - export type LogisticsInfo = { /** LogisticsInfo itemIndex. */ itemIndex: Maybe; @@ -767,22 +724,6 @@ export type MutationValidateSessionArgs = { session: IStoreSession; }; -export type Offer = { - AvailableQuantity: Maybe; - Installments: Maybe>; - ListPrice: Maybe; - Price: Maybe; - PriceValidUntil: Maybe; - PriceWithoutDiscount: Maybe; - RewardValue: Maybe; - Tax: Maybe; - discountHighlights: Maybe>; - giftSkuIds: Maybe>; - spotPrice: Maybe; - taxPercentage: Maybe; - teasers: Maybe>; -}; - /** Newsletter information. */ export type PersonNewsletter = { /** Person's ID in the newsletter list. */ @@ -868,11 +809,6 @@ export type PickupStoreInfo = { isPickupStore: Maybe; }; -export type PriceRange = { - highPrice: Maybe; - lowPrice: Maybe; -}; - /** Process Order Authorization response. */ export type ProcessOrderAuthorizationResponse = { /** Indicates if authorization is pending for other authorizers. */ @@ -891,21 +827,11 @@ export type ProcessOrderAuthorizationRule = { rule: CommercialAuthorizationRule; }; -export type ProductClusters = { - id: Maybe; - name: Maybe; -}; - export type ProductCountResult = { /** Total product count. */ total: Scalars['Int']['output']; }; -export type ProductPriceRange = { - listPrice: Maybe; - sellingPrice: Maybe; -}; - export type Profile = { /** Collection of user's address */ addresses: Maybe>>; @@ -940,12 +866,6 @@ export type ProfileAddress = { street: Maybe; }; -export type Property = { - name: Maybe; - originalName: Maybe; - values: Maybe>>; -}; - export type Query = { /** Returns the account profile information for the current authenticated user (b2b or b2c user). */ accountProfile: StoreAccountProfile; @@ -1102,58 +1022,10 @@ export type RecommendationCampaign = { type: Scalars['String']['output']; }; -export type RecommendationProduct = { - /** If this product is sponsored, ad information will be added here. */ - advertisement: Maybe; - benefits: Maybe>; - brand: Maybe; - brandId: Maybe; - cacheId: Maybe; - categories: Maybe>; - categoriesIds: Maybe>; - categoryId: Maybe; - clusterHighlights: Maybe>; - description: Maybe; - items: Array; - link: Maybe; - linkText: Maybe; - metaTagDescription: Maybe; - priceRange: Maybe; - productClusters: Maybe>; - productId: Scalars['String']['output']; - productName: Maybe; - productReference: Maybe; - properties: Maybe>; - releaseDate: Maybe; - skuSpecifications: Maybe>; - specificationGroups: Maybe>; - titleTag: Maybe; -}; - export type RecommendationResponse = { campaign: RecommendationCampaign; correlationId: Scalars['String']['output']; - products: Array; -}; - -export type Reference = { - Key: Maybe; - Value: Maybe; -}; - -export type Sku = { - complementName: Maybe; - ean: Maybe; - images: Array; - itemId: Scalars['String']['output']; - measurementUnit: Maybe; - name: Maybe; - nameComplete: Maybe; - referenceId: Maybe>; - sellers: Maybe>; - unitMultiplier: Maybe; - variations: Maybe>; - videos: Maybe>; + products: Array; }; export type SkuSpecificationField = { @@ -1179,14 +1051,6 @@ export type SearchMetadata = { logicalOperator: Scalars['String']['output']; }; -export type Seller = { - addToCartLink: Maybe; - commertialOffer: Maybe; - sellerDefault: Maybe; - sellerId: Maybe; - sellerName: Maybe; -}; - /** Information of sellers. */ export type SellerInfo = { /** Identification of the seller */ @@ -1312,18 +1176,6 @@ export type SpecificationGroup = { specifications: Array; }; -export type SpecificationGroupRec = { - name: Maybe; - originalName: Maybe; - specifications: Maybe>; -}; - -export type SpecificationGroupRecProperty = { - name: Maybe; - originalName: Maybe; - values: Maybe>; -}; - /** Account profile information. */ export type StoreAccountProfile = { email: Maybe; @@ -1926,26 +1778,6 @@ export type StoreUserDetails = { username: Maybe; }; -export type Teaser = { - conditions: Maybe; - effects: Maybe; - name: Maybe; -}; - -export type TeaserCondition = { - minimumQuantity: Maybe; - parameters: Maybe>>; -}; - -export type TeaserEffects = { - parameters: Maybe>>; -}; - -export type TeaserValue = { - name: Maybe; - value: Maybe; -}; - export type UserOrder = { affiliateId: Maybe; allowCancellation: Maybe; @@ -2756,10 +2588,6 @@ export type ValidateUserData = { isValid: Scalars['Boolean']['output']; }; -export type Video = { - videoUrl: Maybe; -}; - export type FetchRecommendationsQueryQueryVariables = Exact<{ campaignVrn: Scalars['String']['input']; userId: InputMaybe; @@ -2767,7 +2595,7 @@ export type FetchRecommendationsQueryQueryVariables = Exact<{ }>; -export type FetchRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ cacheId: string | null, productId: string, description: string | null, productName: string | null, productReference: string | null, linkText: string | null, brand: string | null, brandId: number | null, link: string | null, categories: Array | null, categoryId: string | null, releaseDate: string | null, advertisement: { adId: string, campaignId: string, actionCost: number, adRequestId: string, adResponseId: string } | null, priceRange: { sellingPrice: { highPrice: number | null, lowPrice: number | null } | null, listPrice: { highPrice: number | null, lowPrice: number | null } | null } | null, specificationGroups: Array<{ name: string | null, originalName: string | null, specifications: Array<{ name: string | null, originalName: string | null, values: Array | null }> | null }> | null, skuSpecifications: Array<{ field: { name: string, originalName: string | null }, values: Array<{ name: string, originalName: string | null }> }> | null, productClusters: Array<{ id: string | null, name: string | null }> | null, clusterHighlights: Array<{ id: string | null, name: string | null }> | null, properties: Array<{ name: string | null, values: Array | null }> | null, items: Array<{ itemId: string, name: string | null, nameComplete: string | null, complementName: string | null, ean: string | null, measurementUnit: string | null, unitMultiplier: number | null, variations: Array<{ name: string | null, values: Array | null }> | null, referenceId: Array<{ Key: string | null, Value: string | null }> | null, images: Array<{ cacheId: string | null, imageId: string | null, imageLabel: string | null, imageTag: string | null, imageUrl: string, imageText: string | null }>, sellers: Array<{ sellerId: string | null, sellerName: string | null, sellerDefault: boolean | null, commertialOffer: { Price: number | null, ListPrice: number | null, Tax: number | null, taxPercentage: number | null, spotPrice: number | null, PriceWithoutDiscount: number | null, RewardValue: number | null, PriceValidUntil: string | null, AvailableQuantity: number | null, discountHighlights: Array<{ name: string | null }> | null, teasers: Array<{ name: string | null, conditions: { minimumQuantity: number | null, parameters: Array<{ name: string | null, value: string | null } | null> | null } | null, effects: { parameters: Array<{ name: string | null, value: string | null } | null> | null } | null }> | null, Installments: Array<{ Value: number | null, InterestRate: number | null, TotalValuePlusInterestRate: number | null, NumberOfInstallments: number | null, Name: string | null, PaymentSystemName: string | null }> | null } | null }> | null }> }>, campaign: { id: string, title: string | null, type: string } } }; +export type FetchRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }>, campaign: { id: string, title: string | null, type: string } } }; export type ProductSummary_ProductFragment = { slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }; @@ -3570,7 +3398,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` fuzzy } `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString; -export const FetchRecommendationsQueryDocument = {"__meta__":{"operationName":"FetchRecommendationsQuery","operationHash":"767c3273d7aa61ca0cb1c5a60545d69bcfaf7e77"}} as unknown as TypedDocumentString; +export const FetchRecommendationsQueryDocument = {"__meta__":{"operationName":"FetchRecommendationsQuery","operationHash":"64a95fe632da7ee9c9d6ddf136eb36e938268292"}} as unknown as TypedDocumentString; export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString; export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"4b33c5c07f440dc7489e55619dc2211a13786e72"}} as unknown as TypedDocumentString; export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"f03d0963fed159ac4bbe11f90ea09c635a66b68c"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 03473551a1..d88525efc9 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -1,14 +1,19 @@ -import { useId, useMemo } from 'react' +import { type ComponentType, useId, useMemo } from 'react' import { usePDP } from '@faststore/core' import { ProductShelf, Carousel } from '@faststore/ui' +import type { ProductSummary_ProductFragment } from '@generated/graphql' -import ProductCard from 'src/components/product/ProductCard' +import DefaultProductCard, { + type ProductCardProps, +} from 'src/components/product/ProductCard' import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton' import useScreenResize from 'src/sdk/ui/useScreenResize' -import { mapRecommendationToProductCard } from './mapRecommendationToProductCard' -import type { RecommendationShelfProps } from './RecommendationShelf.types' +import type { + RecommendationProductCardMapper, + RecommendationShelfProps, +} from './RecommendationShelf.types' import styles from './RecommendationShelf.module.scss' import { useRecommendations, @@ -50,13 +55,26 @@ function getRecommendationArguments( } } -export const RecommendationShelf = ({ +export function RecommendationShelf< + TCardProps extends object = ProductCardProps, +>({ title, campaignVrn, - mapProductToProductCard = mapRecommendationToProductCard, + ProductCard, + mapProductToProductCard, carouselConfiguration, productCardConfiguration, -}: RecommendationShelfProps) => { +}: RecommendationShelfProps) { + const CardComponent = (ProductCard ?? + DefaultProductCard) as ComponentType + + const mapToCardProps = (mapProductToProductCard ?? + ((product: ProductSummary_ProductFragment, index: number) => ({ + product, + index, + ...productCardConfiguration, + }))) as RecommendationProductCardMapper + const id = useId() const { isMobile, isTablet } = useScreenResize() const { @@ -85,7 +103,7 @@ export const RecommendationShelf = ({ const campaignId = data?.campaign.id const productIds = useMemo( - () => items.map((p) => p.productId).join(', '), + () => items.map((p) => p.isVariantOf.productGroupID).join(', '), [items] ) @@ -139,28 +157,28 @@ export const RecommendationShelf = ({ infiniteMode={false} {...carouselProps} > - {items.map((item, index) => ( -
- -
- ))} + {items.map((item, index) => { + const productId = item.isVariantOf.productGroupID + + return ( +
+ +
+ ) + })} diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts index 475601c53d..983a94d6a9 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts @@ -1,26 +1,41 @@ +import type { ComponentType } from 'react' + import type { CarouselProps } from '@faststore/ui' -import type { - ProductSummary_ProductFragment, - RecommendationProduct, -} from '@generated/graphql' +import type { ProductSummary_ProductFragment } from '@generated/graphql' + +import type { ProductCardProps } from 'src/components/product/ProductCard' /** - * Maps a `RecommendationProduct` into the `ProductCard` data shape. - * Implemented by `mapRecommendationToProductCard` and overridable per shelf. + * Maps a recommendation product (a normalized `StoreProduct`, identical to the + * search response consumed by the rest of the shelves) into the props passed to + * the card component. Defaults to identity (`(product, index) => ({ product, + * index })`). Override it — typically together with `ProductCard` — to render + * custom cards that read additional/personalized fields. */ -export type RecommendationProductCardMapper = ( - product: RecommendationProduct -) => ProductSummary_ProductFragment +export type RecommendationProductCardMapper = ( + product: ProductSummary_ProductFragment, + index: number +) => TCardProps -export type RecommendationShelfProps = { +export type RecommendationShelfProps< + TCardProps extends object = ProductCardProps, +> = { title?: string campaignVrn: string /** - * Maps a recommendation product into the `ProductCard` data shape. Defaults to - * `mapRecommendationToProductCard`. This is a code-level override and is not - * exposed through the CMS schema (`cms_component__RecommendationShelf.jsonc`). + * Custom card component rendered for each recommended product. Defaults to the + * core `ProductCard`. This is a code-level override and is not exposed through + * the CMS schema (`cms_component__RecommendationShelf.jsonc`). + */ + ProductCard?: ComponentType + /** + * Maps a recommendation product into the props of the card component. Defaults + * to passing the product through as `{ product, index, ...productCardConfiguration }`. + * When provided, this mapper is fully responsible for the card props (the + * default `productCardConfiguration` merge no longer applies). This is a + * code-level override and is not exposed through the CMS schema. */ - mapProductToProductCard?: RecommendationProductCardMapper + mapProductToProductCard?: RecommendationProductCardMapper /** * Forwarded to the underlying `Carousel`. `id` stays controlled by the shelf. * `itemsPerPage` is derived from `itemsPerPageDesktop`/`itemsPerPageMobile` diff --git a/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts b/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts deleted file mode 100644 index e3631174ef..0000000000 --- a/packages/core/src/components/RecommendationShelf/mapRecommendationToProductCard.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { - ProductSummary_ProductFragment, - RecommendationProduct, -} from '@generated/graphql' - -/** - * Maps a VTEX `RecommendationProduct` into the `ProductSummary_ProductFragment` - * shape consumed by the core `ProductCard` (src/components/product/ProductCard), - * so recommendation shelves render identical cards to regular shelves. - * - * The card reads `offers`/`image`/`isVariantOf` to compute title, price and - * discount, so we surface the default seller's commercial offer here. - */ -export function mapRecommendationToProductCard( - product: RecommendationProduct -): ProductSummary_ProductFragment { - const item = product.items?.[0] - const image = item?.images?.[0] - const seller = - item?.sellers?.find((currentSeller) => currentSeller.sellerDefault) ?? - item?.sellers?.[0] - const offer = seller?.commertialOffer - - const price = offer?.Price ?? 0 - const listPrice = offer?.ListPrice ?? 0 - const availableQuantity = offer?.AvailableQuantity ?? 0 - const availability = - availableQuantity > 0 - ? 'https://schema.org/InStock' - : 'https://schema.org/OutOfStock' - - const imageUrl = image?.imageUrl ?? '' - const imageAlt = image?.imageText ?? product.productName ?? '' - - return { - id: product.productId, - sku: item?.itemId ?? product.productId, - slug: product.linkText ?? '', - name: item?.nameComplete ?? item?.name ?? product.productName ?? '', - gtin: '', - unitMultiplier: null, - hasSpecifications: null, - brand: { name: product.brand ?? '', brandName: product.brand ?? '' }, - isVariantOf: { - name: product.productName ?? '', - productGroupID: product.productId, - skuVariants: null, - }, - image: imageUrl ? [{ url: imageUrl, alternateName: imageAlt }] : [], - offers: { - lowPrice: price, - lowPriceWithTaxes: price, - offers: [ - { - availability, - price, - listPrice, - listPriceWithTaxes: listPrice, - priceWithTaxes: price, - quantity: availableQuantity, - seller: { identifier: seller?.sellerId ?? '1' }, - }, - ], - }, - additionalProperty: [], - advertisement: null, - deliveryPromiseBadges: [], - } -} diff --git a/packages/core/src/components/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/RecommendationShelf/useRecommendations.ts index 2cd6817502..379c083a39 100644 --- a/packages/core/src/components/RecommendationShelf/useRecommendations.ts +++ b/packages/core/src/components/RecommendationShelf/useRecommendations.ts @@ -1,13 +1,11 @@ import { gql } from '@faststore/core/api' -import type { RecommendationResponse } from '@generated/graphql' +import type { FetchRecommendationsQueryQuery } from '@generated/graphql' import { useQuery } from 'src/sdk/graphql/useQuery' -// This query intentionally fetches the full `RecommendationProduct` shape rather -// than only the fields consumed by the default `mapRecommendationToProductCard`. -// The shelf exposes a `mapProductToProductCard` override, so a store can plug in -// a custom mapper that reads additional fields (specifications, clusters, -// teasers, installments, etc.). Keeping the selection complete means those -// customizations work without having to edit this query. +// Recommendations return the same normalized `StoreProduct` shape as the search +// response, so the shelf renders identical product cards to regular shelves. +// We select `...ProductSummary_product` (the fragment consumed by `ProductCard`) +// to keep this interface consistent with the rest of the components. const query = gql(`query FetchRecommendationsQuery( $campaignVrn: String! $userId: String @@ -19,134 +17,7 @@ const query = gql(`query FetchRecommendationsQuery( products: $products ) { products { - cacheId - productId - description - productName - productReference - linkText - brand - brandId - link - categories - categoryId - releaseDate - advertisement { - adId - campaignId - actionCost - adRequestId - adResponseId - } - priceRange { - sellingPrice { - highPrice - lowPrice - } - listPrice { - highPrice - lowPrice - } - } - specificationGroups { - name - originalName - specifications { - name - originalName - values - } - } - skuSpecifications { - field { - name - originalName - } - values { - name - originalName - } - } - productClusters { - id - name - } - clusterHighlights { - id - name - } - properties { - name - values - } - items { - itemId - name - nameComplete - complementName - ean - variations { - name - values - } - referenceId { - Key - Value - } - measurementUnit - unitMultiplier - images { - cacheId - imageId - imageLabel - imageTag - imageUrl - imageText - } - sellers { - sellerId - sellerName - sellerDefault - commertialOffer { - discountHighlights { - name - } - teasers { - name - conditions { - minimumQuantity - parameters { - name - value - } - } - effects { - parameters { - name - value - } - } - } - Price - ListPrice - Tax - taxPercentage - spotPrice - PriceWithoutDiscount - RewardValue - PriceValidUntil - AvailableQuantity - Installments { - Value - InterestRate - TotalValuePlusInterestRate - NumberOfInstallments - Name - PaymentSystemName - } - } - } - } + ...ProductSummary_product } correlationId campaign { @@ -164,9 +35,13 @@ export type RecommendationInput = { products: string[] } +export type RecommendationResponse = + FetchRecommendationsQueryQuery['recommendations'] +export type RecommendationProduct = RecommendationResponse['products'][number] + export const useRecommendations = (args: RecommendationInput | null) => { const { data, isLoading, error } = useQuery< - { recommendations: RecommendationResponse }, + FetchRecommendationsQueryQuery, RecommendationInput >(query, args ?? ({} as RecommendationInput), { doNotRun: args === null, diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 6ea43567f8..64167e8b3c 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -47,7 +47,10 @@ const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' const CROSS_SELL_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1' const recommendationData = { - products: [{ productId: 'p-1' }, { productId: 'p-2' }], + products: [ + { id: 'p-1', isVariantOf: { productGroupID: 'p-1' } }, + { id: 'p-2', isVariantOf: { productGroupID: 'p-2' } }, + ], correlationId: 'corr-1', campaign: { id: 'camp-1', title: 'Recommended for you', type: 'TOP_ITEMS' }, } diff --git a/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts b/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts deleted file mode 100644 index a0ece7ab4f..0000000000 --- a/packages/core/test/components/RecommendationShelf/mapRecommendationToProductCard.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { mapRecommendationToProductCard } from 'src/components/RecommendationShelf/mapRecommendationToProductCard' - -const fullProduct = { - productId: 'p-1', - productName: 'Cool Shoes', - brand: 'Acme', - linkText: 'cool-shoes', - items: [ - { - itemId: 'sku-1', - name: 'Cool Shoes', - nameComplete: 'Cool Shoes - Blue', - images: [{ imageUrl: 'https://img/1.png', imageText: 'blue shoe' }], - sellers: [ - { - sellerId: '2', - sellerDefault: false, - commertialOffer: { Price: 50, ListPrice: 80, AvailableQuantity: 0 }, - }, - { - sellerId: '1', - sellerDefault: true, - commertialOffer: { Price: 100, ListPrice: 120, AvailableQuantity: 5 }, - }, - ], - }, - ], -} as any - -describe('mapRecommendationToProductCard', () => { - it('maps the default seller commercial offer and product data', () => { - const card = mapRecommendationToProductCard(fullProduct) - - expect(card.id).toBe('p-1') - expect(card.sku).toBe('sku-1') - expect(card.slug).toBe('cool-shoes') - expect(card.name).toBe('Cool Shoes - Blue') - expect(card.brand).toEqual({ name: 'Acme', brandName: 'Acme' }) - expect(card.isVariantOf.productGroupID).toBe('p-1') - expect(card.image).toEqual([ - { url: 'https://img/1.png', alternateName: 'blue shoe' }, - ]) - - const offer = card.offers.offers[0] - expect(card.offers.lowPrice).toBe(100) - expect(offer.price).toBe(100) - expect(offer.listPrice).toBe(120) - expect(offer.quantity).toBe(5) - expect(offer.availability).toBe('https://schema.org/InStock') - expect(offer.seller.identifier).toBe('1') - }) - - it('uses the first seller when none is marked as default', () => { - const product = { - productId: 'p-2', - items: [ - { - itemId: 'sku-2', - sellers: [ - { - sellerId: '9', - commertialOffer: { Price: 10, AvailableQuantity: 1 }, - }, - ], - }, - ], - } as any - - const card = mapRecommendationToProductCard(product) - - expect(card.offers.offers[0].seller.identifier).toBe('9') - expect(card.offers.offers[0].price).toBe(10) - }) - - it('falls back to safe defaults when data is missing', () => { - const card = mapRecommendationToProductCard({ productId: 'p-3' } as any) - - expect(card.id).toBe('p-3') - expect(card.sku).toBe('p-3') - expect(card.name).toBe('') - expect(card.image).toEqual([]) - expect(card.brand).toEqual({ name: '', brandName: '' }) - - const offer = card.offers.offers[0] - expect(offer.price).toBe(0) - expect(offer.quantity).toBe(0) - expect(offer.availability).toBe('https://schema.org/OutOfStock') - expect(offer.seller.identifier).toBe('1') - }) -}) From 3a529565116ea85add445d6b8b3e057182996b4c Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Fri, 26 Jun 2026 19:31:20 -0300 Subject: [PATCH 19/30] refactor(api): rename StartSessionResult to StartRecommendationSessionResult and update related logic - Renamed `startSession` to `startRecommendationSession` in the recommendation client and updated the corresponding types. - Adjusted the resolver to call the new session initiation method. - Removed the now-unnecessary `useStartSession` hook to streamline the session management process. --- .../vtex/clients/recommendation/index.ts | 8 +- .../types/RecommendationResult.ts | 2 +- .../resolvers/startRecommendationSession.ts | 2 +- .../sdk/analytics/hooks/useStartSession.ts | 74 ------------------- 4 files changed, 7 insertions(+), 79 deletions(-) delete mode 100644 packages/core/src/sdk/analytics/hooks/useStartSession.ts diff --git a/packages/api/src/platforms/vtex/clients/recommendation/index.ts b/packages/api/src/platforms/vtex/clients/recommendation/index.ts index a23a3e0276..c0c3b66ac7 100644 --- a/packages/api/src/platforms/vtex/clients/recommendation/index.ts +++ b/packages/api/src/platforms/vtex/clients/recommendation/index.ts @@ -3,7 +3,7 @@ import { getStoreCookie, getWithCookie } from '../../utils/cookies' import { fetchAPI } from '../fetch' import type { RecommendationResult, - StartSessionResult, + StartRecommendationSessionResult, } from './types/RecommendationResult' // Identifies the storefront origin to the Recommendations BFF, as required by @@ -64,7 +64,9 @@ export const Recommendation = ( // orderForm from the forwarded `checkout.vtex.com` cookie and replies with the // `vtex-rec-user-id`/`vtex-rec-user-start-session` Set-Cookie headers, which we // forward to the browser through `ctx.storage.cookies`. - const startSession = (): Promise => { + const startRecommendationSession = (): Promise< + StartRecommendationSessionResult | undefined + > => { const params = new URLSearchParams({ an: account }) return fetchAPI( @@ -76,6 +78,6 @@ export const Recommendation = ( return { recommendations, - startSession, + startRecommendationSession, } } diff --git a/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts b/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts index 746ed3cc2a..a2eff379c0 100644 --- a/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts +++ b/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts @@ -22,6 +22,6 @@ export interface RecommendationBffCampaign { } /** Response of `POST /api/recommend-bff/v2/users/start-session`. */ -export interface StartSessionResult { +export interface StartRecommendationSessionResult { recommendationsUserId: string } diff --git a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts index c8fb355208..9981af5cdd 100644 --- a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts +++ b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts @@ -13,7 +13,7 @@ export const startRecommendationSession = async ( __: unknown, ctx: GraphqlContext ) => { - await ctx.clients.recommendation.startSession() + await ctx.clients.recommendation.startRecommendationSession() return true } diff --git a/packages/core/src/sdk/analytics/hooks/useStartSession.ts b/packages/core/src/sdk/analytics/hooks/useStartSession.ts deleted file mode 100644 index 34952f16e4..0000000000 --- a/packages/core/src/sdk/analytics/hooks/useStartSession.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect } from 'react' - -import { gql } from '@faststore/core/api' - -import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' -import { getCookie } from 'src/utils/getCookie' -import { retry } from 'src/utils/retry' - -// Cookie set once the anonymous personalization session has been started, -// so we only fire `StartRecommendationSession` a single time per browser session. -const VTEX_REC_USER_START_SESSION = 'vtex-rec-user-start-session' - -const startRecommendationSessionMutation = gql(` - mutation StartRecommendationSession { - startRecommendationSession - } -`) - -type StartRecommendationSessionData = { startRecommendationSession: boolean } -type StartRecommendationSessionVariables = Record - -/** - * Starts the anonymous recommendation/personalization session once per browser - * session. - * - * Mounted globally from `Layout` so it runs on every page, regardless of - * whether a recommendation section is present. - * - * The product view is no longer reported through a mutation here: the PDP now - * exposes the product data as `` tags, which the - * Activity Flow script reads to capture the product view event. - * - * All work runs client-side in effects (after hydration), so it doesn't affect - * SSR/TTFB or Lighthouse render metrics. - */ -export function useStartSession() { - const [runStartSession] = useLazyQuery< - StartRecommendationSessionData, - StartRecommendationSessionVariables - >( - startRecommendationSessionMutation, - {} as StartRecommendationSessionVariables, - { - doNotRun: true, - } - ) - - useEffect(() => { - const startRecommendationSessionCookie = getCookie( - VTEX_REC_USER_START_SESSION - ) - - if (startRecommendationSessionCookie) { - return - } - - const controller = new AbortController() - - // The session endpoint may not be ready on the first try, so we retry with - // exponential backoff until it returns a defined result (or we give up). - void retry(() => runStartSession({}), { - attempts: 10, - delayMs: 300, - backoff: true, - maxDelayMs: 3000, - until: (result) => result !== undefined, - signal: controller.signal, - }) - - return () => { - controller.abort() - } - }, [runStartSession]) -} From 7b4e2d274d8acb07b74f6a29c34e806eff311b1f Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Mon, 29 Jun 2026 13:01:10 -0300 Subject: [PATCH 20/30] feat(core): enhance RecommendationShelf with configurable items context and carousel settings - Introduced `itemsContext` prop to allow selection between 'PDP' and 'CART' for product context in recommendations. - Updated `RecommendationShelf` to utilize the new `itemsContext` for fetching recommendations based on the selected context. - Refactored carousel configuration to directly accept `itemsPerPageDesktop`, `itemsPerPageMobile`, `variant`, `infiniteMode`, and `controls` as props, improving customization options. - Adjusted related types and documentation to reflect these changes. --- .../cms_component__RecommendationShelf.jsonc | 80 ++++++++++--------- packages/core/cms/faststore/schema.json | 80 ++++++++++--------- .../RecommendationShelf.tsx | 51 ++++++++---- .../RecommendationShelf.types.ts | 65 ++++++++++----- 4 files changed, 166 insertions(+), 110 deletions(-) diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc index c07ca10df0..760e6f6434 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -16,45 +16,47 @@ "type": "string", "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" }, - "carouselConfiguration": { - "title": "Carousel Configuration", - "type": "object", - "properties": { - "itemsPerPageDesktop": { - "title": "Items per page (desktop)", - "description": "Number of items to display per page on desktop viewports.", - "type": "integer", - "default": 4 - }, - "itemsPerPageMobile": { - "title": "Items per page (mobile)", - "description": "Number of items to display per page on mobile viewports.", - "type": "integer", - "default": 2 - }, - "variant": { - "title": "Track variant", - "description": "How the carousel navigates between items.", - "type": "string", - "default": "scroll", - "enum": ["slide", "scroll"], - "enumNames": ["Slide", "Scroll"] - }, - "infiniteMode": { - "title": "Infinite navigation?", - "description": "Only applies to the 'slide' variant.", - "type": "boolean", - "default": false - }, - "controls": { - "title": "Navigation controls", - "description": "Which navigation elements should be visible.", - "type": "string", - "default": "complete", - "enum": ["complete", "navigationArrows", "paginationBullets"], - "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] - } - } + "itemsContext": { + "title": "Items context", + "description": "Source of the products used as context for the recommendation request. 'Product page' uses the product currently being viewed; 'Cart' uses the items currently in the cart (useful for cross-sell shelves on the cart page). Only affects context-based campaigns such as cross-sell, similar items, visual similarity and next interaction.", + "type": "string", + "default": "PDP", + "enum": ["PDP", "CART"], + "enumNames": ["Product page", "Cart"] + }, + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of products shown per page on desktop viewports.", + "type": "number", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of products shown per page on mobile and tablet viewports.", + "type": "number", + "default": 2 + }, + "variant": { + "title": "Carousel track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] }, "productCardConfiguration": { "title": "Product Card Configuration", diff --git a/packages/core/cms/faststore/schema.json b/packages/core/cms/faststore/schema.json index 3b84f26dc8..5c224e0cd0 100644 --- a/packages/core/cms/faststore/schema.json +++ b/packages/core/cms/faststore/schema.json @@ -4447,45 +4447,47 @@ "type": "string", "pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$" }, - "carouselConfiguration": { - "title": "Carousel Configuration", - "type": "object", - "properties": { - "itemsPerPageDesktop": { - "title": "Items per page (desktop)", - "description": "Number of items to display per page on desktop viewports.", - "type": "integer", - "default": 4 - }, - "itemsPerPageMobile": { - "title": "Items per page (mobile)", - "description": "Number of items to display per page on mobile viewports.", - "type": "integer", - "default": 2 - }, - "variant": { - "title": "Track variant", - "description": "How the carousel navigates between items.", - "type": "string", - "default": "scroll", - "enum": ["slide", "scroll"], - "enumNames": ["Slide", "Scroll"] - }, - "infiniteMode": { - "title": "Infinite navigation?", - "description": "Only applies to the 'slide' variant.", - "type": "boolean", - "default": false - }, - "controls": { - "title": "Navigation controls", - "description": "Which navigation elements should be visible.", - "type": "string", - "default": "complete", - "enum": ["complete", "navigationArrows", "paginationBullets"], - "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] - } - } + "itemsContext": { + "title": "Items context", + "description": "Source of the products used as context for the recommendation request. 'Product page' uses the product currently being viewed; 'Cart' uses the items currently in the cart (useful for cross-sell shelves on the cart page). Only affects context-based campaigns such as cross-sell, similar items, visual similarity and next interaction.", + "type": "string", + "default": "PDP", + "enum": ["PDP", "CART"], + "enumNames": ["Product page", "Cart"] + }, + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of products shown per page on desktop viewports.", + "type": "number", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of products shown per page on mobile and tablet viewports.", + "type": "number", + "default": 2 + }, + "variant": { + "title": "Carousel track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] }, "productCardConfiguration": { "title": "Product Card Configuration", diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index d88525efc9..0a4f7a249a 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -8,6 +8,7 @@ import DefaultProductCard, { type ProductCardProps, } from 'src/components/product/ProductCard' import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton' +import { useCart } from 'src/sdk/cart' import useScreenResize from 'src/sdk/ui/useScreenResize' import type { @@ -24,9 +25,9 @@ import { getTypeFromVrn } from './vrn' function getRecommendationArguments( campaignVrn: string, - context: { userId?: string | null; pdpProduct?: string } + context: { userId?: string | null; contextProducts: string[] } ): RecommendationInput | null { - const { userId, pdpProduct } = context + const { userId, contextProducts } = context const type = getTypeFromVrn(campaignVrn) // `type` is null for malformed/unknown VRNs; bail out so we never fetch (and @@ -38,13 +39,16 @@ function getRecommendationArguments( case 'VISUAL_SIMILARITY': case 'CROSS_SELL': case 'SIMILAR_ITEMS': - if (!pdpProduct) { + // These campaigns need product context. Without any (e.g. `CART` context + // on an empty cart, or `PDP` context outside a product page), skip the + // fetch instead of requesting recommendations we can't anchor. + if (contextProducts.length === 0) { return null } return { userId, campaignVrn, - products: [pdpProduct], + products: contextProducts, } default: return { @@ -60,9 +64,14 @@ export function RecommendationShelf< >({ title, campaignVrn, + itemsContext = 'PDP', ProductCard, mapProductToProductCard, - carouselConfiguration, + itemsPerPageDesktop = 4, + itemsPerPageMobile = 2, + variant = 'scroll', + infiniteMode = false, + controls, productCardConfiguration, }: RecommendationShelfProps) { const CardComponent = (ProductCard ?? @@ -77,11 +86,6 @@ export function RecommendationShelf< const id = useId() const { isMobile, isTablet } = useScreenResize() - const { - itemsPerPageDesktop = 4, - itemsPerPageMobile = 2, - ...carouselProps - } = carouselConfiguration ?? {} // Treat mobile and tablet viewports (<= 768px) as "mobile" for paging, which // matches the carousel layout the shelf was designed around. const itemsPerPage = @@ -90,10 +94,29 @@ export function RecommendationShelf< const userId = useRecommendationUserId(campaignVrn) const { data: productDetailPage } = usePDP() + const { items: cartItems } = useCart() + + // Resolve the products used as context for the request from the configured + // source: the current PDP product, or the (deduplicated) cart items. + const contextProducts = useMemo(() => { + if (itemsContext === 'CART') { + return Array.from( + new Set( + cartItems + .map((item) => item.itemOffered.isVariantOf.productGroupID) + .filter(Boolean) + ) + ) + } + + const pdpProduct = productDetailPage?.product.isVariantOf.productGroupID + + return pdpProduct ? [pdpProduct] : [] + }, [itemsContext, cartItems, productDetailPage]) const recommendationArgs = getRecommendationArguments(campaignVrn, { userId, - pdpProduct: productDetailPage?.product.isVariantOf.productGroupID, + contextProducts, }) const { data, isLoading, error } = useRecommendations(recommendationArgs) @@ -153,9 +176,9 @@ export function RecommendationShelf< {items.map((item, index) => { const productId = item.isVariantOf.productGroupID diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts index 983a94d6a9..113668aa7b 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts @@ -17,11 +17,31 @@ export type RecommendationProductCardMapper = ( index: number ) => TCardProps +/** + * Source of the products used as context for the recommendation request: + * - `'PDP'`: the current product detail page product. + * - `'CART'`: the products currently in the cart (useful for cross-sell on the + * cart page). + */ +export type ItemContext = 'PDP' | 'CART' + export type RecommendationShelfProps< TCardProps extends object = ProductCardProps, > = { title?: string campaignVrn: string + /** + * Where to read the products used as context for the recommendation request: + * - `'PDP'`: the current product detail page product. + * - `'CART'`: the products currently in the cart (useful for cross-sell on the + * cart page). + * + * Only affects campaigns that require product context (cross-sell, similar + * items, visual similarity, next interaction). Context-agnostic campaigns + * (top items, personalized, last seen, search-based) ignore it. + * @default 'PDP' + */ + itemsContext?: ItemContext /** * Custom card component rendered for each recommended product. Defaults to the * core `ProductCard`. This is a code-level override and is not exposed through @@ -37,25 +57,34 @@ export type RecommendationShelfProps< */ mapProductToProductCard?: RecommendationProductCardMapper /** - * Forwarded to the underlying `Carousel`. `id` stays controlled by the shelf. - * `itemsPerPage` is derived from `itemsPerPageDesktop`/`itemsPerPageMobile` - * based on the viewport. + * Number of items per page on desktop viewports. Forwarded to the underlying + * `Carousel` as `itemsPerPage` when the viewport is desktop. + * @default 4 */ - carouselConfiguration?: Pick< - CarouselProps, - 'infiniteMode' | 'variant' | 'controls' - > & { - /** - * Number of items per page on desktop viewports. - * @default 4 - */ - itemsPerPageDesktop?: number - /** - * Number of items per page on mobile viewports. - * @default 2 - */ - itemsPerPageMobile?: number - } + itemsPerPageDesktop?: number + /** + * Number of items per page on mobile and tablet viewports. Forwarded to the + * underlying `Carousel` as `itemsPerPage` when the viewport is mobile/tablet. + * @default 2 + */ + itemsPerPageMobile?: number + /** + * How the carousel navigates between items. Forwarded to the underlying + * `Carousel`. + * @default 'scroll' + */ + variant?: CarouselProps['variant'] + /** + * Enables infinite navigation (only applies to the `slide` variant). + * Forwarded to the underlying `Carousel`. + * @default false + */ + infiniteMode?: CarouselProps['infiniteMode'] + /** + * Which navigation elements are visible. Forwarded to the underlying + * `Carousel`. + */ + controls?: CarouselProps['controls'] /** * Forwarded to each `ProductCard` rendered by the shelf. */ From 6ea9e5f8040267d02538300f6f5d04452c2a6f2b Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Wed, 1 Jul 2026 16:40:48 -0300 Subject: [PATCH 21/30] Update packages/api/src/platforms/vtex/clients/recommendation/index.ts Co-authored-by: Felipe Sales --- packages/api/src/platforms/vtex/clients/recommendation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/platforms/vtex/clients/recommendation/index.ts b/packages/api/src/platforms/vtex/clients/recommendation/index.ts index c0c3b66ac7..6bbb441265 100644 --- a/packages/api/src/platforms/vtex/clients/recommendation/index.ts +++ b/packages/api/src/platforms/vtex/clients/recommendation/index.ts @@ -8,7 +8,7 @@ import type { // Identifies the storefront origin to the Recommendations BFF, as required by // the API (`x-vtex-rec-origin` header). -const REC_ORIGIN_SUFFIX = 'storefront/vtex.recommendation-shelf@2.x' +const REC_ORIGIN_SUFFIX = 'storefront/faststore.recommendation-shelf@v4' export interface RecommendationArgs { campaignVrn: string From 060d0a0a151faa8494d1ae781f18baad5505b149 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Thu, 2 Jul 2026 18:34:31 -0300 Subject: [PATCH 22/30] feat(api, core): implement server-side validation for recommendations and enable opt-in feature - Added server-side validation for `campaignVrn`, `userId`, and `products` in the recommendations resolver to ensure clean input. - Introduced an opt-in flag `enableRecommendations` in the discovery configuration to control the initiation of recommendation sessions and API calls. - Updated `RecommendationShelf` and related components to respect the opt-in flag, preventing unnecessary API calls when recommendations are disabled. - Enhanced tests to cover scenarios for the new validation and opt-in feature. --- .../vtex/resolvers/recommendations.ts | 35 ++++++++- .../resolvers/startRecommendationSession.ts | 12 +++- .../cms_component__RecommendationShelf.jsonc | 72 ++++++++++--------- packages/core/cms/faststore/schema.json | 72 ++++++++++--------- packages/core/discovery.config.default.js | 3 + packages/core/src/Layout.tsx | 4 +- .../RecommendationShelf.tsx | 28 +++++--- .../RecommendationShelf.types.ts | 58 ++++++++------- packages/core/src/pages/[slug]/p.tsx | 27 +++++-- .../hooks/useStartRecommendationSession.ts | 38 +++++++--- .../RecommendationShelf.browser.test.tsx | 49 +++++++++++-- .../useStartRecommendationSession.test.ts | 17 +++++ .../core/test/sdk/analytics/utils.test.ts | 4 -- 13 files changed, 293 insertions(+), 126 deletions(-) diff --git a/packages/api/src/platforms/vtex/resolvers/recommendations.ts b/packages/api/src/platforms/vtex/resolvers/recommendations.ts index f325c7aa9a..64713ccd4d 100644 --- a/packages/api/src/platforms/vtex/resolvers/recommendations.ts +++ b/packages/api/src/platforms/vtex/resolvers/recommendations.ts @@ -1,8 +1,15 @@ +import { BadRequestError } from '../../errors' import type { QueryRecommendationsArgs } from '../../../__generated__/schema' import type { GraphqlContext } from '../index' import { enhanceSku, type EnhancedSku } from '../utils/enhanceSku' import { pickBestSku } from '../utils/sku' +// Structural check for a recommendations campaign VRN +// (`vrn:recommendations:::`). Kept generic +// on purpose: the authoritative campaign taxonomy lives in `@faststore/core`, +// and `@faststore/api` must not depend on it, so we only validate the shape. +const RECOMMENDATION_VRN_PATTERN = /^vrn:recommendations:[^:]+:[^:]+:[^:]+$/ + /** * Resolves personalized recommendations for a campaign. * @@ -23,9 +30,33 @@ export const recommendations = async ( const { salesChannel } = ctx.storage.channel + // Validate inputs server-side so only clean, well-formed payloads reach the + // Recommendations BFF (see @faststore/api server-side validation principle). + const normalizedCampaignVrn = campaignVrn?.trim() + if ( + !normalizedCampaignVrn || + !RECOMMENDATION_VRN_PATTERN.test(normalizedCampaignVrn) + ) { + throw new BadRequestError(`Invalid campaignVrn: "${campaignVrn}"`) + } + + const normalizedUserId = userId?.trim() + if (userId != null && !normalizedUserId) { + throw new BadRequestError('Invalid userId: must be a non-empty string') + } + + if ( + products != null && + (!Array.isArray(products) || products.some((product) => !product?.trim())) + ) { + throw new BadRequestError( + 'Invalid products: must be an array of non-empty strings' + ) + } + const response = await recommendation.recommendations({ - campaignVrn, - userId: userId ?? undefined, + campaignVrn: normalizedCampaignVrn, + userId: normalizedUserId || undefined, products: products ?? [], salesChannel: salesChannel ?? undefined, locale: ctx.storage.locale, diff --git a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts index 9981af5cdd..b9ff2249fe 100644 --- a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts +++ b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts @@ -13,7 +13,17 @@ export const startRecommendationSession = async ( __: unknown, ctx: GraphqlContext ) => { - await ctx.clients.recommendation.startRecommendationSession() + const result = await ctx.clients.recommendation.startRecommendationSession() + + // The BFF may not be ready on the first call and can resolve without a + // session payload. Only report success once a session actually exists (its + // `recommendationsUserId`); otherwise surface an error so the caller retries + // instead of treating an empty response as a started session. + if (!result?.recommendationsUserId) { + throw new Error( + 'Failed to start recommendation session: no session data returned' + ) + } return true } diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc index 760e6f6434..c2e24b0635 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc @@ -24,39 +24,45 @@ "enum": ["PDP", "CART"], "enumNames": ["Product page", "Cart"] }, - "itemsPerPageDesktop": { - "title": "Items per page (desktop)", - "description": "Number of products shown per page on desktop viewports.", - "type": "number", - "default": 4 - }, - "itemsPerPageMobile": { - "title": "Items per page (mobile)", - "description": "Number of products shown per page on mobile and tablet viewports.", - "type": "number", - "default": 2 - }, - "variant": { - "title": "Carousel track variant", - "description": "How the carousel navigates between items.", - "type": "string", - "default": "scroll", - "enum": ["slide", "scroll"], - "enumNames": ["Slide", "Scroll"] - }, - "infiniteMode": { - "title": "Infinite navigation?", - "description": "Only applies to the 'slide' variant.", - "type": "boolean", - "default": false - }, - "controls": { - "title": "Navigation controls", - "description": "Which navigation elements should be visible.", - "type": "string", - "default": "complete", - "enum": ["complete", "navigationArrows", "paginationBullets"], - "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + "carouselConfiguration": { + "title": "Carousel Configuration", + "type": "object", + "properties": { + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of products shown per page on desktop viewports.", + "type": "number", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of products shown per page on mobile and tablet viewports.", + "type": "number", + "default": 2 + }, + "variant": { + "title": "Carousel track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + } + } }, "productCardConfiguration": { "title": "Product Card Configuration", diff --git a/packages/core/cms/faststore/schema.json b/packages/core/cms/faststore/schema.json index 5c224e0cd0..f84527e558 100644 --- a/packages/core/cms/faststore/schema.json +++ b/packages/core/cms/faststore/schema.json @@ -4455,39 +4455,45 @@ "enum": ["PDP", "CART"], "enumNames": ["Product page", "Cart"] }, - "itemsPerPageDesktop": { - "title": "Items per page (desktop)", - "description": "Number of products shown per page on desktop viewports.", - "type": "number", - "default": 4 - }, - "itemsPerPageMobile": { - "title": "Items per page (mobile)", - "description": "Number of products shown per page on mobile and tablet viewports.", - "type": "number", - "default": 2 - }, - "variant": { - "title": "Carousel track variant", - "description": "How the carousel navigates between items.", - "type": "string", - "default": "scroll", - "enum": ["slide", "scroll"], - "enumNames": ["Slide", "Scroll"] - }, - "infiniteMode": { - "title": "Infinite navigation?", - "description": "Only applies to the 'slide' variant.", - "type": "boolean", - "default": false - }, - "controls": { - "title": "Navigation controls", - "description": "Which navigation elements should be visible.", - "type": "string", - "default": "complete", - "enum": ["complete", "navigationArrows", "paginationBullets"], - "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + "carouselConfiguration": { + "title": "Carousel Configuration", + "type": "object", + "properties": { + "itemsPerPageDesktop": { + "title": "Items per page (desktop)", + "description": "Number of products shown per page on desktop viewports.", + "type": "number", + "default": 4 + }, + "itemsPerPageMobile": { + "title": "Items per page (mobile)", + "description": "Number of products shown per page on mobile and tablet viewports.", + "type": "number", + "default": 2 + }, + "variant": { + "title": "Carousel track variant", + "description": "How the carousel navigates between items.", + "type": "string", + "default": "scroll", + "enum": ["slide", "scroll"], + "enumNames": ["Slide", "Scroll"] + }, + "infiniteMode": { + "title": "Infinite navigation?", + "description": "Only applies to the 'slide' variant.", + "type": "boolean", + "default": false + }, + "controls": { + "title": "Navigation controls", + "description": "Which navigation elements should be visible.", + "type": "string", + "default": "complete", + "enum": ["complete", "navigationArrows", "paginationBullets"], + "enumNames": ["Arrows and bullets", "Arrows only", "Bullets only"] + } + } }, "productCardConfiguration": { "title": "Product Card Configuration", diff --git a/packages/core/discovery.config.default.js b/packages/core/discovery.config.default.js index db951b6610..a55b349067 100644 --- a/packages/core/discovery.config.default.js +++ b/packages/core/discovery.config.default.js @@ -201,6 +201,9 @@ module.exports = { }, refreshToken: false, scrollRestoration: false, + // Opt-in flag for VTEX Recommendations. When disabled (default), the store + // never starts a personalization session nor calls the Recommendations API. + enableRecommendations: false, /** Package names to transpile (e.g. ['@vtex/components']). Use a non-empty list to enable Next.js transpilation. */ transpilePackages: [], }, diff --git a/packages/core/src/Layout.tsx b/packages/core/src/Layout.tsx index 3f8b9c9e60..174ce28bc6 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -7,7 +7,9 @@ function Layout({ children }: PropsWithChildren) { const props = isValidElement(children) ? children.props : undefined usePageViewEvent(props) // Implemented here because the personalization session must be initiated once - // per browser session on each page. + // per browser session on each page. Gated behind the + // `experimental.enableRecommendations` flag: stores that don't opt into + // Recommendations never start a session nor call the Recommendations API. useStartRecommendationSession() return <>{children} diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx index 0a4f7a249a..40fe55918c 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx @@ -4,6 +4,8 @@ import { usePDP } from '@faststore/core' import { ProductShelf, Carousel } from '@faststore/ui' import type { ProductSummary_ProductFragment } from '@generated/graphql' +import storeConfig from 'discovery.config' + import DefaultProductCard, { type ProductCardProps, } from 'src/components/product/ProductCard' @@ -67,13 +69,17 @@ export function RecommendationShelf< itemsContext = 'PDP', ProductCard, mapProductToProductCard, - itemsPerPageDesktop = 4, - itemsPerPageMobile = 2, - variant = 'scroll', - infiniteMode = false, - controls, + carouselConfiguration, productCardConfiguration, }: RecommendationShelfProps) { + const { + itemsPerPageDesktop = 4, + itemsPerPageMobile = 2, + variant = 'scroll', + infiniteMode = false, + controls, + } = carouselConfiguration ?? {} + const CardComponent = (ProductCard ?? DefaultProductCard) as ComponentType @@ -114,10 +120,14 @@ export function RecommendationShelf< return pdpProduct ? [pdpProduct] : [] }, [itemsContext, cartItems, productDetailPage]) - const recommendationArgs = getRecommendationArguments(campaignVrn, { - userId, - contextProducts, - }) + // Gate the whole feature behind the opt-in flag: when Recommendations is + // disabled the shelf never requests recommendations (nor renders). + const recommendationArgs = storeConfig.experimental.enableRecommendations + ? getRecommendationArguments(campaignVrn, { + userId, + contextProducts, + }) + : null const { data, isLoading, error } = useRecommendations(recommendationArgs) diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts index 113668aa7b..d64ac26cf7 100644 --- a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts +++ b/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts @@ -57,34 +57,40 @@ export type RecommendationShelfProps< */ mapProductToProductCard?: RecommendationProductCardMapper /** - * Number of items per page on desktop viewports. Forwarded to the underlying - * `Carousel` as `itemsPerPage` when the viewport is desktop. - * @default 4 - */ - itemsPerPageDesktop?: number - /** - * Number of items per page on mobile and tablet viewports. Forwarded to the - * underlying `Carousel` as `itemsPerPage` when the viewport is mobile/tablet. - * @default 2 - */ - itemsPerPageMobile?: number - /** - * How the carousel navigates between items. Forwarded to the underlying - * `Carousel`. - * @default 'scroll' - */ - variant?: CarouselProps['variant'] - /** - * Enables infinite navigation (only applies to the `slide` variant). - * Forwarded to the underlying `Carousel`. - * @default false - */ - infiniteMode?: CarouselProps['infiniteMode'] - /** - * Which navigation elements are visible. Forwarded to the underlying + * Carousel behaviour and paging configuration. Forwarded to the underlying * `Carousel`. */ - controls?: CarouselProps['controls'] + carouselConfiguration?: { + /** + * Number of items per page on desktop viewports. Forwarded to the + * underlying `Carousel` as `itemsPerPage` when the viewport is desktop. + * @default 4 + */ + itemsPerPageDesktop?: number + /** + * Number of items per page on mobile and tablet viewports. Forwarded to the + * underlying `Carousel` as `itemsPerPage` when the viewport is mobile/tablet. + * @default 2 + */ + itemsPerPageMobile?: number + /** + * How the carousel navigates between items. Forwarded to the underlying + * `Carousel`. + * @default 'scroll' + */ + variant?: CarouselProps['variant'] + /** + * Enables infinite navigation (only applies to the `slide` variant). + * Forwarded to the underlying `Carousel`. + * @default false + */ + infiniteMode?: CarouselProps['infiniteMode'] + /** + * Which navigation elements are visible. Forwarded to the underlying + * `Carousel`. + */ + controls?: CarouselProps['controls'] + } /** * Forwarded to each `ProductCard` rendered by the shelf. */ diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index 043a8aed89..8fbccd4bdb 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -69,6 +69,14 @@ const COMPONENTS: Record> = { ...CUSTOM_COMPONENTS, } +// Maps schema.org `itemCondition` URLs to the values accepted by the +// Open Graph `product:condition` meta tag (`new` | `refurbished` | `used`). +const OG_PRODUCT_CONDITION_BY_SCHEMA: Record = { + 'https://schema.org/NewCondition': 'new', + 'https://schema.org/RefurbishedCondition': 'refurbished', + 'https://schema.org/UsedCondition': 'used', +} + type Props = PDPContentType & { data: ServerProductQueryQuery globalSections: GlobalSectionsData @@ -123,6 +131,11 @@ function Page({ .toString() } + const productCondition = + OG_PRODUCT_CONDITION_BY_SCHEMA[ + product.offers.offers[0]?.itemCondition ?? '' + ] + let itemListElements = product.breadcrumbList.itemListElement ?? [] if (itemListElements.length !== 0) { itemListElements = itemListElements.map( @@ -201,7 +214,7 @@ function Page({ }, { property: 'product:id', - content: product.id, + content: product.isVariantOf?.productGroupID ?? undefined, }, { property: 'product:sku', @@ -223,10 +236,14 @@ function Page({ property: 'product:brand', content: product.brand.name, }, - { - property: 'product:condition', - content: 'new', - }, + ...(productCondition + ? [ + { + property: 'product:condition', + content: productCondition, + }, + ] + : []), ]} titleTemplate={titleTemplate} /> diff --git a/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts b/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts index f786e4e977..41d1daa730 100644 --- a/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts +++ b/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { gql } from '@faststore/core/api' +import storeConfig from 'discovery.config' import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery' import { getCookie } from 'src/utils/getCookie' import { retry } from 'src/utils/retry' @@ -32,6 +33,9 @@ type StartRecommendationSessionVariables = Record * * All work runs client-side in effects (after hydration), so it doesn't affect * SSR/TTFB or Lighthouse render metrics. + * + * Gated behind the `experimental.enableRecommendations` flag: stores that don't + * opt into Recommendations never start a session nor hit the Recommendations API. */ export function useStartRecommendationSession() { const [runStartRecommendationSession] = useLazyQuery< @@ -46,6 +50,10 @@ export function useStartRecommendationSession() { ) useEffect(() => { + if (!storeConfig.experimental.enableRecommendations) { + return + } + const startRecommendationSessionCookie = getCookie( VTEX_REC_USER_START_SESSION ) @@ -58,14 +66,28 @@ export function useStartRecommendationSession() { // The session endpoint may not be ready on the first try, so we retry with // exponential backoff until it returns a defined result (or we give up). - void retry(() => runStartRecommendationSession({}), { - attempts: 10, - delayMs: 300, - backoff: true, - maxDelayMs: 3000, - until: (result) => result !== undefined, - signal: controller.signal, - }) + // Transient request/GraphQL rejections are treated as retryable: swallow + // the error and return `undefined` so the backoff loop keeps going instead + // of aborting on the first failure. + void retry( + async () => { + try { + return await runStartRecommendationSession({}) + } catch { + return undefined + } + }, + { + attempts: 10, + delayMs: 300, + backoff: true, + maxDelayMs: 3000, + until: (result) => result !== undefined, + signal: controller.signal, + } + // Guard the discarded promise so a give-up (or any unexpected rejection) + // never surfaces as an unhandled promise rejection. + ).catch(() => {}) return () => { controller.abort() diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 64167e8b3c..4ad5728846 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -41,6 +41,27 @@ vi.mock('src/components/RecommendationShelf/useRecommendationUserId', () => ({ useRecommendationUserId, })) +// Keep the real store config (the cart SDK reads `storeConfig.cart` at module +// init) and only override the opt-in flag, which we toggle per test. +const recommendationsFlag = vi.hoisted(() => ({ enabled: true })) +vi.mock('discovery.config', async (original) => { + const actual = (await original()) as { default?: Record } + const base = (actual.default ?? actual) as Record + const experimental = (base.experimental ?? {}) as Record + + return { + default: { + ...base, + experimental: { + ...experimental, + get enableRecommendations() { + return recommendationsFlag.enabled + }, + }, + }, + } +}) + import { RecommendationShelf } from 'src/components/RecommendationShelf/RecommendationShelf' const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' @@ -64,6 +85,7 @@ beforeEach(() => { loading: false, }) useRecommendationUserId.mockReturnValue('user-1') + recommendationsFlag.enabled = true }) afterEach(() => { @@ -126,21 +148,24 @@ describe('RecommendationShelf', () => { }) }) - it('still renders when there is no resolved userId', async () => { + it('skips the fetch and renders empty when there is no resolved userId', async () => { useRecommendationUserId.mockReturnValue(null) useRecommendations.mockReturnValue({ - data: recommendationData, + data: undefined, isLoading: false, error: null, }) - const { getAllByTestId } = render( + const { container, queryByTestId } = render( ) await waitFor(() => { - expect(getAllByTestId('product-card')).toHaveLength(2) + const lastArgs = useRecommendations.mock.calls.at(-1)?.[0] + expect(lastArgs).toBeNull() }) + expect(queryByTestId('product-card')).toBeNull() + expect(container).toBeEmptyDOMElement() }) it('renders nothing when there is an error', () => { @@ -185,4 +210,20 @@ describe('RecommendationShelf', () => { expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true') }) + + it('skips the fetch when recommendations are disabled', async () => { + recommendationsFlag.enabled = false + useRecommendations.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }) + + render() + + await waitFor(() => { + const lastArgs = useRecommendations.mock.calls.at(-1)?.[0] + expect(lastArgs).toBeNull() + }) + }) }) diff --git a/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts b/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts index 540ab5041b..0ed9ed370c 100644 --- a/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts +++ b/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts @@ -18,11 +18,17 @@ vi.mock('src/sdk/graphql/useLazyQuery', () => ({ const getCookie = vi.hoisted(() => vi.fn()) vi.mock('src/utils/getCookie', () => ({ getCookie })) +const storeConfigMock = vi.hoisted(() => ({ + experimental: { enableRecommendations: true }, +})) +vi.mock('discovery.config', () => ({ default: storeConfigMock })) + import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession' beforeEach(() => { useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}]) runStartRecommendationSession.mockResolvedValue(true) + storeConfigMock.experimental.enableRecommendations = true }) afterEach(() => { @@ -49,4 +55,15 @@ describe('useStartRecommendationSession', () => { expect(runStartRecommendationSession).toHaveBeenCalled() }) }) + + it('does not start a session when recommendations are disabled', async () => { + storeConfigMock.experimental.enableRecommendations = false + getCookie.mockReturnValue(undefined) + + renderHook(() => useStartRecommendationSession()) + + await waitFor(() => { + expect(runStartRecommendationSession).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/core/test/sdk/analytics/utils.test.ts b/packages/core/test/sdk/analytics/utils.test.ts index 4fe5869b21..8128cca09b 100644 --- a/packages/core/test/sdk/analytics/utils.test.ts +++ b/packages/core/test/sdk/analytics/utils.test.ts @@ -1,7 +1,3 @@ -/** - * @vitest-environment jsdom - */ - import { afterEach, describe, expect, it, vi } from 'vitest' const mockGetCookie = vi.hoisted(() => vi.fn()) From 3214947496a3ca28282af1b8a07a8f0567be01e2 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Thu, 2 Jul 2026 19:05:42 -0300 Subject: [PATCH 23/30] feat(core): add RecommendationShelf component for personalized product recommendations - Introduced the `RecommendationShelf` component to display a carousel of personalized product recommendations based on a VTEX Recommendations campaign. - Implemented configuration options for carousel behavior, including items per page for desktop and mobile, navigation controls, and infinite scrolling. - Added types and hooks to manage recommendation fetching and user identification. - Created associated styles and tests to ensure functionality and maintainability. - Updated existing components and tests to integrate the new `RecommendationShelf` structure. --- ... cms_component__recommendationshelf.jsonc} | 4 +++- packages/core/cms/faststore/schema.json | 4 +++- .../src/components/cms/global/Components.ts | 2 +- .../RecommendationShelf.module.scss | 0 .../RecommendationShelf.tsx | 0 .../RecommendationShelf.types.ts | 0 .../RecommendationShelf/index.ts | 0 .../useRecommendationUserId.ts | 0 .../RecommendationShelf/useRecommendations.ts | 0 .../{ => sections}/RecommendationShelf/vrn.ts | 0 .../RecommendationShelf.browser.test.tsx | 20 ++++++++++++------- .../useRecommendations.test.ts | 2 +- .../RecommendationShelf/vrn.test.ts | 2 +- 13 files changed, 22 insertions(+), 12 deletions(-) rename packages/core/cms/faststore/components/{cms_component__RecommendationShelf.jsonc => cms_component__recommendationshelf.jsonc} (93%) rename packages/core/src/components/{ => sections}/RecommendationShelf/RecommendationShelf.module.scss (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/RecommendationShelf.tsx (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/RecommendationShelf.types.ts (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/index.ts (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/useRecommendationUserId.ts (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/useRecommendations.ts (100%) rename packages/core/src/components/{ => sections}/RecommendationShelf/vrn.ts (100%) diff --git a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc b/packages/core/cms/faststore/components/cms_component__recommendationshelf.jsonc similarity index 93% rename from packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc rename to packages/core/cms/faststore/components/cms_component__recommendationshelf.jsonc index c2e24b0635..29940bc0a8 100644 --- a/packages/core/cms/faststore/components/cms_component__RecommendationShelf.jsonc +++ b/packages/core/cms/faststore/components/cms_component__recommendationshelf.jsonc @@ -1,7 +1,9 @@ { "$extends": ["#/$defs/base-component"], "$componentKey": "RecommendationShelf", - "$componentTitle": "RecommendationShelf", + "$componentTitle": "Recommendation Shelf", + "title": "Recommendation Shelf", + "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.", "type": "object", "required": ["campaignVrn"], "properties": { diff --git a/packages/core/cms/faststore/schema.json b/packages/core/cms/faststore/schema.json index f84527e558..df6baf4a5e 100644 --- a/packages/core/cms/faststore/schema.json +++ b/packages/core/cms/faststore/schema.json @@ -4432,7 +4432,9 @@ "RecommendationShelf": { "$extends": ["#/$defs/base-component"], "$componentKey": "RecommendationShelf", - "$componentTitle": "RecommendationShelf", + "$componentTitle": "Recommendation Shelf", + "title": "Recommendation Shelf", + "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.", "type": "object", "required": ["campaignVrn"], "properties": { diff --git a/packages/core/src/components/cms/global/Components.ts b/packages/core/src/components/cms/global/Components.ts index 1eac140e5d..cedf68846f 100644 --- a/packages/core/src/components/cms/global/Components.ts +++ b/packages/core/src/components/cms/global/Components.ts @@ -47,7 +47,7 @@ const ShoppingAssistant = dynamic( const RecommendationShelf = dynamic( () => import( - /* webpackChunkName: "RecommendationShelf" */ 'src/components/RecommendationShelf' + /* webpackChunkName: "RecommendationShelf" */ 'src/components/sections/RecommendationShelf' ).then((mod) => mod.RecommendationShelf), { ssr: false } ) diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.module.scss similarity index 100% rename from packages/core/src/components/RecommendationShelf/RecommendationShelf.module.scss rename to packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.module.scss diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx similarity index 100% rename from packages/core/src/components/RecommendationShelf/RecommendationShelf.tsx rename to packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx diff --git a/packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts similarity index 100% rename from packages/core/src/components/RecommendationShelf/RecommendationShelf.types.ts rename to packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts diff --git a/packages/core/src/components/RecommendationShelf/index.ts b/packages/core/src/components/sections/RecommendationShelf/index.ts similarity index 100% rename from packages/core/src/components/RecommendationShelf/index.ts rename to packages/core/src/components/sections/RecommendationShelf/index.ts diff --git a/packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts b/packages/core/src/components/sections/RecommendationShelf/useRecommendationUserId.ts similarity index 100% rename from packages/core/src/components/RecommendationShelf/useRecommendationUserId.ts rename to packages/core/src/components/sections/RecommendationShelf/useRecommendationUserId.ts diff --git a/packages/core/src/components/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts similarity index 100% rename from packages/core/src/components/RecommendationShelf/useRecommendations.ts rename to packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts diff --git a/packages/core/src/components/RecommendationShelf/vrn.ts b/packages/core/src/components/sections/RecommendationShelf/vrn.ts similarity index 100% rename from packages/core/src/components/RecommendationShelf/vrn.ts rename to packages/core/src/components/sections/RecommendationShelf/vrn.ts diff --git a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx index 4ad5728846..350f7edcb0 100644 --- a/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -29,17 +29,23 @@ vi.mock('src/components/skeletons/ProductShelfSkeleton', () => ({ })) const useRecommendations = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/useRecommendations', () => ({ - useRecommendations, -})) +vi.mock( + 'src/components/sections/RecommendationShelf/useRecommendations', + () => ({ + useRecommendations, + }) +) const useScreenResize = vi.hoisted(() => vi.fn()) vi.mock('src/sdk/ui/useScreenResize', () => ({ default: useScreenResize })) const useRecommendationUserId = vi.hoisted(() => vi.fn()) -vi.mock('src/components/RecommendationShelf/useRecommendationUserId', () => ({ - useRecommendationUserId, -})) +vi.mock( + 'src/components/sections/RecommendationShelf/useRecommendationUserId', + () => ({ + useRecommendationUserId, + }) +) // Keep the real store config (the cart SDK reads `storeConfig.cart` at module // init) and only override the opt-in flag, which we toggle per test. @@ -62,7 +68,7 @@ vi.mock('discovery.config', async (original) => { } }) -import { RecommendationShelf } from 'src/components/RecommendationShelf/RecommendationShelf' +import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf' const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' const CROSS_SELL_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1' diff --git a/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts index 78ec0ff4c9..68db7d1cc4 100644 --- a/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts +++ b/packages/core/test/components/RecommendationShelf/useRecommendations.test.ts @@ -7,7 +7,7 @@ vi.mock('src/sdk/graphql/useQuery', () => ({ useQuery: useQueryMock, })) -import { useRecommendations } from 'src/components/RecommendationShelf/useRecommendations' +import { useRecommendations } from 'src/components/sections/RecommendationShelf/useRecommendations' afterEach(() => { vi.clearAllMocks() diff --git a/packages/core/test/components/RecommendationShelf/vrn.test.ts b/packages/core/test/components/RecommendationShelf/vrn.test.ts index 5c4486bd2d..affebdd096 100644 --- a/packages/core/test/components/RecommendationShelf/vrn.test.ts +++ b/packages/core/test/components/RecommendationShelf/vrn.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { getTypeFromVrn, isValidVrn, -} from 'src/components/RecommendationShelf/vrn' +} from 'src/components/sections/RecommendationShelf/vrn' const vrn = (type: string) => `vrn:recommendations:account:${type}:campaign-1` From acb4bf465b99cedb4be5542a6b2ece67cb5e547f Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Fri, 3 Jul 2026 16:47:20 -0300 Subject: [PATCH 24/30] refactor(RecommendationShelf): update prop type and improve empty state handling - Changed the prop type for `RecommendationShelf` from `RecommendationShelfProps` to `Readonly>` for better type safety. - Modified the empty state return from an empty fragment to `null` for clearer intent and improved rendering performance. --- .../sections/RecommendationShelf/RecommendationShelf.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx index 40fe55918c..a2d72057d1 100644 --- a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx @@ -71,7 +71,7 @@ export function RecommendationShelf< mapProductToProductCard, carouselConfiguration, productCardConfiguration, -}: RecommendationShelfProps) { +}: Readonly>) { const { itemsPerPageDesktop = 4, itemsPerPageMobile = 2, @@ -161,7 +161,7 @@ export function RecommendationShelf< } if (!isLoading && items.length === 0) { - return <> + return null } return ( From daa57c59c09929c898b56172f9a7c168125b3d07 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Mon, 6 Jul 2026 15:46:48 -0300 Subject: [PATCH 25/30] refactor(api, core): address recommendation shelf review feedback - Fold the recommendation client into the VtexCommerce client as a `commerce.recommendation` namespace (same account host) and drop the standalone client. - Rename the client query to `ClientRecommendationsQuery` to follow the client/server prefix convention and regenerate the GraphQL types. - Align the shelf styles with the section pattern: `section.module.scss`, `.section` root class and a `data-fs-recommendation-shelf-item` attribute. --- .../platforms/vtex/clients/commerce/index.ts | 74 +++++++++++++++++ .../types/RecommendationResult.ts | 0 .../api/src/platforms/vtex/clients/index.ts | 3 - .../vtex/clients/recommendation/index.ts | 83 ------------------- .../vtex/resolvers/recommendations.ts | 4 +- .../resolvers/startRecommendationSession.ts | 3 +- packages/core/@generated/gql.ts | 12 +-- packages/core/@generated/graphql.ts | 20 ++--- .../RecommendationShelf.tsx | 6 +- ...nShelf.module.scss => section.module.scss} | 16 ++-- .../RecommendationShelf/useRecommendations.ts | 8 +- 11 files changed, 109 insertions(+), 120 deletions(-) rename packages/api/src/platforms/vtex/clients/{recommendation => commerce}/types/RecommendationResult.ts (100%) delete mode 100644 packages/api/src/platforms/vtex/clients/recommendation/index.ts rename packages/core/src/components/sections/RecommendationShelf/{RecommendationShelf.module.scss => section.module.scss} (92%) diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index fbb17d5c82..0beafcf664 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -45,11 +45,27 @@ import type { SimulationArgs, SimulationOptions, } from './types/Simulation' +import type { + RecommendationResult, + StartRecommendationSessionResult, +} from './types/RecommendationResult' import type { ScopesByUnit, UnitResponse } from './types/Unit' import type { VtexIdResponse } from './types/VtexId' type ValueOf = T extends Record ? K : never +// Identifies the storefront origin to the Recommendations BFF, as required by +// the API (`x-vtex-rec-origin` header). +const REC_ORIGIN_SUFFIX = 'storefront/faststore.recommendation-shelf@v4' + +export interface RecommendationArgs { + campaignVrn: string + userId?: string + products?: string[] + salesChannel?: string + locale?: string +} + const BASE_INIT = { method: 'POST', headers: { @@ -77,6 +93,15 @@ export const VtexCommerce = ( const forwardedHost = host.replace(selectedPrefix, '') + // Recommendations BFF (`/api/recommend-bff/v2`) lives under the same account + // host as the other commerce APIs, so it's exposed here as a namespace. + const recommendationBase = `${base}/api/recommend-bff/v2` + const recommendationHeaders: HeadersInit = withCookie({ + accept: 'application/json', + 'content-type': 'application/json', + 'x-vtex-rec-origin': `${account}/${REC_ORIGIN_SUFFIX}`, + }) + return { catalog: { salesChannel: (sc: string): Promise => @@ -985,5 +1010,54 @@ export const VtexCommerce = ( ) }, }, + + // Anonymous personalization / recommendations served by the VTEX + // Recommendations BFF. The BFF replies with `vtex-rec-*` Set-Cookie headers + // (forwarded to the browser via `ctx.storage.cookies`) and returns products + // already hydrated in the Intelligent Search shape. + recommendation: { + recommendations: ({ + campaignVrn, + userId, + products = [], + salesChannel, + locale, + }: RecommendationArgs): Promise => { + const params = new URLSearchParams({ an: account, campaignVrn }) + + if (userId) { + params.append('userId', userId) + } + + if (products.length > 0) { + params.append('products', products.join(',')) + } + + if (salesChannel) { + params.append('salesChannel', salesChannel) + } + + if (locale) { + params.append('locale', locale) + } + + return fetchAPI( + `${recommendationBase}/recommendations?${params.toString()}`, + { headers: recommendationHeaders } + ) + }, + + startRecommendationSession: (): Promise< + StartRecommendationSessionResult | undefined + > => { + const params = new URLSearchParams({ an: account }) + + return fetchAPI( + `${recommendationBase}/users/start-session?${params.toString()}`, + { method: 'POST', headers: recommendationHeaders }, + { storeCookies } + ) + }, + }, } } diff --git a/packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts b/packages/api/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts similarity index 100% rename from packages/api/src/platforms/vtex/clients/recommendation/types/RecommendationResult.ts rename to packages/api/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts diff --git a/packages/api/src/platforms/vtex/clients/index.ts b/packages/api/src/platforms/vtex/clients/index.ts index f99c0f22e3..910989d913 100644 --- a/packages/api/src/platforms/vtex/clients/index.ts +++ b/packages/api/src/platforms/vtex/clients/index.ts @@ -1,6 +1,5 @@ import type { GraphqlContext } from '..' import { VtexCommerce } from './commerce' -import { Recommendation } from './recommendation' import { IntelligentSearch } from './search' export type Clients = ReturnType @@ -8,11 +7,9 @@ export type Clients = ReturnType export const getClients = (options: Options, ctx: GraphqlContext) => { const search = IntelligentSearch(options, ctx) const commerce = VtexCommerce(options, ctx) - const recommendation = Recommendation(options, ctx) return { search, commerce, - recommendation, } } diff --git a/packages/api/src/platforms/vtex/clients/recommendation/index.ts b/packages/api/src/platforms/vtex/clients/recommendation/index.ts deleted file mode 100644 index 6bbb441265..0000000000 --- a/packages/api/src/platforms/vtex/clients/recommendation/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { GraphqlContext } from '../..' -import { getStoreCookie, getWithCookie } from '../../utils/cookies' -import { fetchAPI } from '../fetch' -import type { - RecommendationResult, - StartRecommendationSessionResult, -} from './types/RecommendationResult' - -// Identifies the storefront origin to the Recommendations BFF, as required by -// the API (`x-vtex-rec-origin` header). -const REC_ORIGIN_SUFFIX = 'storefront/faststore.recommendation-shelf@v4' - -export interface RecommendationArgs { - campaignVrn: string - userId?: string - products?: string[] - salesChannel?: string - locale?: string -} - -export const Recommendation = ( - { account, environment }: Options, - ctx: GraphqlContext -) => { - const base = `https://${account}.${environment}.com.br/api/recommend-bff/v2` - const withCookie = getWithCookie(ctx) - const storeCookies = getStoreCookie(ctx) - - const headers: HeadersInit = withCookie({ - accept: 'application/json', - 'content-type': 'application/json', - 'x-vtex-rec-origin': `${account}/${REC_ORIGIN_SUFFIX}`, - }) - - const recommendations = ({ - campaignVrn, - userId, - products = [], - salesChannel, - locale, - }: RecommendationArgs): Promise => { - const params = new URLSearchParams({ an: account, campaignVrn }) - - if (userId) { - params.append('userId', userId) - } - - if (products.length > 0) { - params.append('products', products.join(',')) - } - - if (salesChannel) { - params.append('salesChannel', salesChannel) - } - - if (locale) { - params.append('locale', locale) - } - - return fetchAPI(`${base}/recommendations?${params.toString()}`, { headers }) - } - - // Starts/updates the anonymous personalization session. The BFF resolves the - // orderForm from the forwarded `checkout.vtex.com` cookie and replies with the - // `vtex-rec-user-id`/`vtex-rec-user-start-session` Set-Cookie headers, which we - // forward to the browser through `ctx.storage.cookies`. - const startRecommendationSession = (): Promise< - StartRecommendationSessionResult | undefined - > => { - const params = new URLSearchParams({ an: account }) - - return fetchAPI( - `${base}/users/start-session?${params.toString()}`, - { method: 'POST', headers }, - { storeCookies } - ) - } - - return { - recommendations, - startRecommendationSession, - } -} diff --git a/packages/api/src/platforms/vtex/resolvers/recommendations.ts b/packages/api/src/platforms/vtex/resolvers/recommendations.ts index 64713ccd4d..0c4877b75c 100644 --- a/packages/api/src/platforms/vtex/resolvers/recommendations.ts +++ b/packages/api/src/platforms/vtex/resolvers/recommendations.ts @@ -25,7 +25,7 @@ export const recommendations = async ( ctx: GraphqlContext ) => { const { - clients: { recommendation }, + clients: { commerce }, } = ctx const { salesChannel } = ctx.storage.channel @@ -54,7 +54,7 @@ export const recommendations = async ( ) } - const response = await recommendation.recommendations({ + const response = await commerce.recommendation.recommendations({ campaignVrn: normalizedCampaignVrn, userId: normalizedUserId || undefined, products: products ?? [], diff --git a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts index b9ff2249fe..9adeef803c 100644 --- a/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts +++ b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts @@ -13,7 +13,8 @@ export const startRecommendationSession = async ( __: unknown, ctx: GraphqlContext ) => { - const result = await ctx.clients.recommendation.startRecommendationSession() + const result = + await ctx.clients.commerce.recommendation.startRecommendationSession() // The BFF may not be ready on the first call and can resolve without a // session payload. Only report success once a session actually exists (its diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 834fb9fa1a..6007048875 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -15,10 +15,10 @@ import * as types from './graphql'; * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": typeof types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": typeof types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": typeof types.ProductDetailsFragment_ProductFragmentDoc, + "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.ClientRecommendationsQueryDocument, "\n fragment ProductComparisonFragment_product on StoreProduct {\n id: productID\n sku\n slug\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n quantity\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n hasSpecifications\n\n skuSpecifications {\n field {\n id\n name\n originalName\n }\n values {\n name\n id\n fieldId\n originalName\n }\n }\n\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n }\n": typeof types.ProductComparisonFragment_ProductFragmentDoc, "\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n": typeof types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc, "\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n": typeof types.ClientManyProductsFragmentDoc, @@ -64,10 +64,10 @@ type Documents = { "\n query ServerManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n": typeof types.ServerManyProductsQueryDocument, }; const documents: Documents = { - "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.FetchRecommendationsQueryDocument, "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": types.ProductDetailsFragment_ProductFragmentDoc, + "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.ClientRecommendationsQueryDocument, "\n fragment ProductComparisonFragment_product on StoreProduct {\n id: productID\n sku\n slug\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n quantity\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n hasSpecifications\n\n skuSpecifications {\n field {\n id\n name\n originalName\n }\n values {\n name\n id\n fieldId\n originalName\n }\n }\n\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n }\n": types.ProductComparisonFragment_ProductFragmentDoc, "\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n": types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc, "\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n": types.ClientManyProductsFragmentDoc, @@ -113,10 +113,6 @@ const documents: Documents = { "\n query ServerManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n": types.ServerManyProductsQueryDocument, }; -/** - * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function gql(source: "query FetchRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').FetchRecommendationsQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -129,6 +125,10 @@ export function gql(source: "\n fragment Filter_facets on StoreFacet {\n ... * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n"): typeof import('./graphql').ProductDetailsFragment_ProductFragmentDoc; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').ClientRecommendationsQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index fa931be544..3326a4a044 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -2588,15 +2588,6 @@ export type ValidateUserData = { isValid: Scalars['Boolean']['output']; }; -export type FetchRecommendationsQueryQueryVariables = Exact<{ - campaignVrn: Scalars['String']['input']; - userId: InputMaybe; - products: InputMaybe | Scalars['String']['input']>; -}>; - - -export type FetchRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }>, campaign: { id: string, title: string | null, type: string } } }; - export type ProductSummary_ProductFragment = { slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }; type Filter_Facets_StoreFacetBoolean_Fragment = { __typename: 'StoreFacetBoolean', key: string, label: string, values: Array<{ label: string, value: string, selected: boolean, quantity: number | null }> }; @@ -2610,6 +2601,15 @@ export type Filter_FacetsFragment = export type ProductDetailsFragment_ProductFragment = { sku: string, name: string, gtin: string, description: string, unitMultiplier: number | null, id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, brand: { name: string }, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, priceWithTaxes: number, listPrice: number, listPriceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }> }; +export type ClientRecommendationsQueryQueryVariables = Exact<{ + campaignVrn: Scalars['String']['input']; + userId: InputMaybe; + products: InputMaybe | Scalars['String']['input']>; +}>; + + +export type ClientRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }>, campaign: { id: string, title: string | null, type: string } } }; + export type ProductComparisonFragment_ProductFragment = { sku: string, slug: string, name: string, gtin: string, description: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, brand: { name: string }, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, priceWithTaxes: number, listPrice: number, quantity: number, listPriceWithTaxes: number, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, skuSpecifications: Array<{ field: { id: string | null, name: string, originalName: string | null }, values: Array<{ name: string, id: string | null, fieldId: string | null, originalName: string | null }> }>, specificationGroups: Array<{ name: string, originalName: string, specifications: Array<{ name: string, originalName: string, values: Array }> }> }; export type ProductSkuMatrixSidebarFragment_ProductFragment = { id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ sku: string, name: string, image: Array<{ url: string, alternateName: string }>, offers: { highPrice: number, lowPrice: number, lowPriceWithTaxes: number, offerCount: number, priceCurrency: string, offers: Array<{ listPrice: number, listPriceWithTaxes: number, sellingPrice: number, priceCurrency: string, price: number, priceWithTaxes: number, priceValidUntil: string, itemCondition: string, availability: string, quantity: number }> }, additionalProperty: Array<{ propertyID: string, value: any, name: string, valueReference: any }> }> | null } | null } }; @@ -3398,7 +3398,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` fuzzy } `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString; -export const FetchRecommendationsQueryDocument = {"__meta__":{"operationName":"FetchRecommendationsQuery","operationHash":"64a95fe632da7ee9c9d6ddf136eb36e938268292"}} as unknown as TypedDocumentString; +export const ClientRecommendationsQueryDocument = {"__meta__":{"operationName":"ClientRecommendationsQuery","operationHash":"db61ca57203036d24d5e1e2c9e40d6d0906f0056"}} as unknown as TypedDocumentString; export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString; export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"4b33c5c07f440dc7489e55619dc2211a13786e72"}} as unknown as TypedDocumentString; export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"f03d0963fed159ac4bbe11f90ea09c635a66b68c"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx index a2d72057d1..7e67dfc807 100644 --- a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx @@ -17,7 +17,7 @@ import type { RecommendationProductCardMapper, RecommendationShelfProps, } from './RecommendationShelf.types' -import styles from './RecommendationShelf.module.scss' +import styles from './section.module.scss' import { useRecommendations, type RecommendationInput, @@ -166,7 +166,7 @@ export function RecommendationShelf< return (
* { + .section { + [data-fs-recommendation-shelf-item] { + display: flex; width: 100%; + height: 100%; + + > * { + width: 100%; + } } - } - .recommendationShelf { [data-fs-product-shelf-skeleton] { --fs-carousel-item-margin-right: var(--fs-spacing-3); diff --git a/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts index 379c083a39..009643ed45 100644 --- a/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts +++ b/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts @@ -1,12 +1,12 @@ import { gql } from '@faststore/core/api' -import type { FetchRecommendationsQueryQuery } from '@generated/graphql' +import type { ClientRecommendationsQueryQuery } from '@generated/graphql' import { useQuery } from 'src/sdk/graphql/useQuery' // Recommendations return the same normalized `StoreProduct` shape as the search // response, so the shelf renders identical product cards to regular shelves. // We select `...ProductSummary_product` (the fragment consumed by `ProductCard`) // to keep this interface consistent with the rest of the components. -const query = gql(`query FetchRecommendationsQuery( +const query = gql(`query ClientRecommendationsQuery( $campaignVrn: String! $userId: String $products: [String!] @@ -36,12 +36,12 @@ export type RecommendationInput = { } export type RecommendationResponse = - FetchRecommendationsQueryQuery['recommendations'] + ClientRecommendationsQueryQuery['recommendations'] export type RecommendationProduct = RecommendationResponse['products'][number] export const useRecommendations = (args: RecommendationInput | null) => { const { data, isLoading, error } = useQuery< - FetchRecommendationsQueryQuery, + ClientRecommendationsQueryQuery, RecommendationInput >(query, args ?? ({} as RecommendationInput), { doNotRun: args === null, From 9de413ff6cb75eda46a29381e03c666d4dd0e44c Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Mon, 6 Jul 2026 18:09:57 -0300 Subject: [PATCH 26/30] test(api, core): cover recommendation resolvers/client and fix Sonar S6759 - Add unit tests for the `recommendations` and `startRecommendationSession` resolvers, the `commerce.recommendation` client namespace and the `useRecommendationUserId` hook to raise new-code coverage above the gate. - Mark `RecommendationShelfProps` members as `readonly` (instead of the `Readonly<>` wrapper, which Sonar S6759 doesn't detect on a generic component) to satisfy the read-only props rule. --- .../platforms/vtex/clients/commerce.test.ts | 60 ++++++++ .../vtex/resolvers/recommendations.test.ts | 128 ++++++++++++++++++ .../startRecommendationSession.test.ts | 47 +++++++ .../RecommendationShelf.tsx | 2 +- .../RecommendationShelf.types.ts | 28 ++-- .../useRecommendationUserId.test.ts | 69 ++++++++++ 6 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 packages/api/test/unit/platforms/vtex/resolvers/recommendations.test.ts create mode 100644 packages/api/test/unit/platforms/vtex/resolvers/startRecommendationSession.test.ts create mode 100644 packages/core/test/components/RecommendationShelf/useRecommendationUserId.test.ts diff --git a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts index 39ea35cfda..6cb2cb4f9c 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -234,4 +234,64 @@ describe('VTEX Commerce', () => { }) }) }) + + describe('Recommendation', () => { + describe('recommendations', () => { + it('builds the query string with all optional params', async () => { + const mockResponse = { products: [], correlationId: '', campaign: {} } + fetchAPIMocked.mockResolvedValueOnce(mockResponse) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.recommendation.recommendations({ + campaignVrn: 'vrn:recommendations:acc:rec-cross-v2:c-1', + userId: 'user-1', + products: ['pg-1', 'pg-2'], + salesChannel: '1', + locale: 'en-US', + }) + + expect(fetchAPIMocked).toHaveBeenCalledTimes(1) + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toContain('/api/recommend-bff/v2/recommendations') + expect(url).toContain('campaignVrn=vrn') + expect(url).toContain('userId=user-1') + expect(url).toContain('products=pg-1%2Cpg-2') + expect(url).toContain('salesChannel=1') + expect(url).toContain('locale=en-US') + expect(result).toEqual(mockResponse) + }) + + it('omits optional params when not provided', async () => { + fetchAPIMocked.mockResolvedValueOnce({}) + + const { commerce } = clients.getClients(apiOptions, context) + await commerce.recommendation.recommendations({ + campaignVrn: 'vrn:recommendations:acc:rec-top-items-v2:c-1', + }) + + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).not.toContain('userId=') + expect(url).not.toContain('products=') + expect(url).not.toContain('salesChannel=') + expect(url).not.toContain('locale=') + }) + }) + + describe('startRecommendationSession', () => { + it('posts to the start-session endpoint', async () => { + const mockResponse = { recommendationsUserId: 'user-1' } + fetchAPIMocked.mockResolvedValueOnce(mockResponse) + + const { commerce } = clients.getClients(apiOptions, context) + const result = + await commerce.recommendation.startRecommendationSession() + + expect(fetchAPIMocked).toHaveBeenCalledTimes(1) + const [url, init] = fetchAPIMocked.mock.calls[0] + expect(url).toContain('/api/recommend-bff/v2/users/start-session') + expect(init.method).toBe('POST') + expect(result).toEqual(mockResponse) + }) + }) + }) }) diff --git a/packages/api/test/unit/platforms/vtex/resolvers/recommendations.test.ts b/packages/api/test/unit/platforms/vtex/resolvers/recommendations.test.ts new file mode 100644 index 0000000000..05153b205c --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/resolvers/recommendations.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' + +import { BadRequestError } from '../../../../../src/platforms/errors' + +vi.mock('../../../../../src/platforms/vtex/utils/sku', () => ({ + pickBestSku: vi.fn((items: unknown[]) => items?.[0] ?? null), +})) + +vi.mock('../../../../../src/platforms/vtex/utils/enhanceSku', () => ({ + enhanceSku: vi.fn((sku: unknown, product: unknown) => ({ sku, product })), +})) + +import { recommendations } from '../../../../../src/platforms/vtex/resolvers/recommendations' + +const VALID_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' + +const makeContext = (recResult: unknown = {}) => { + const recommendationsFn = vi.fn().mockResolvedValue(recResult) + + return { + ctx: { + clients: { + commerce: { + recommendation: { + recommendations: recommendationsFn, + }, + }, + }, + storage: { + channel: { salesChannel: '1' }, + locale: 'en-US', + }, + } as any, + recommendationsFn, + } +} + +describe('recommendations resolver', () => { + it('throws BadRequestError when campaignVrn is missing', async () => { + const { ctx } = makeContext() + + await expect( + recommendations(null, { campaignVrn: '' } as any, ctx) + ).rejects.toThrow(BadRequestError) + }) + + it('throws BadRequestError when campaignVrn is malformed', async () => { + const { ctx } = makeContext() + + await expect( + recommendations(null, { campaignVrn: 'not-a-vrn' } as any, ctx) + ).rejects.toThrow(BadRequestError) + }) + + it('throws BadRequestError when userId is present but blank', async () => { + const { ctx } = makeContext() + + await expect( + recommendations( + null, + { campaignVrn: VALID_VRN, userId: ' ' } as any, + ctx + ) + ).rejects.toThrow(BadRequestError) + }) + + it('throws BadRequestError when products contains an empty string', async () => { + const { ctx } = makeContext() + + await expect( + recommendations( + null, + { campaignVrn: VALID_VRN, products: ['ok', ' '] } as any, + ctx + ) + ).rejects.toThrow(BadRequestError) + }) + + it('forwards normalized args and maps the BFF response', async () => { + const recResult = { + products: [{ items: [{ id: 'sku-1' }] }], + correlationId: 'corr-1', + campaign: { id: 'camp-1', title: 'Top items', type: 'TOP_ITEMS' }, + } + const { ctx, recommendationsFn } = makeContext(recResult) + + const result = await recommendations( + null, + { + campaignVrn: ` ${VALID_VRN} `, + userId: ' user-1 ', + products: ['pg-1'], + } as any, + ctx + ) + + expect(recommendationsFn).toHaveBeenCalledWith({ + campaignVrn: VALID_VRN, + userId: 'user-1', + products: ['pg-1'], + salesChannel: '1', + locale: 'en-US', + }) + expect(result.correlationId).toBe('corr-1') + expect(result.campaign).toEqual(recResult.campaign) + expect(result.products).toHaveLength(1) + }) + + it('defaults products to an empty array and drops items without a sku', async () => { + const recResult = { + products: [{ items: [] }], + correlationId: 'corr-2', + campaign: { id: 'camp-2', type: 'PERSONALIZED' }, + } + const { ctx, recommendationsFn } = makeContext(recResult) + + const result = await recommendations( + null, + { campaignVrn: VALID_VRN } as any, + ctx + ) + + expect(recommendationsFn).toHaveBeenCalledWith( + expect.objectContaining({ products: [], userId: undefined }) + ) + expect(result.products).toHaveLength(0) + }) +}) diff --git a/packages/api/test/unit/platforms/vtex/resolvers/startRecommendationSession.test.ts b/packages/api/test/unit/platforms/vtex/resolvers/startRecommendationSession.test.ts new file mode 100644 index 0000000000..1b6bd8e7de --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/resolvers/startRecommendationSession.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' + +import { startRecommendationSession } from '../../../../../src/platforms/vtex/resolvers/startRecommendationSession' + +const makeContext = (sessionResult: unknown) => { + const startFn = vi.fn().mockResolvedValue(sessionResult) + + return { + ctx: { + clients: { + commerce: { + recommendation: { + startRecommendationSession: startFn, + }, + }, + }, + } as any, + startFn, + } +} + +describe('startRecommendationSession resolver', () => { + it('returns true once a session has been started', async () => { + const { ctx, startFn } = makeContext({ recommendationsUserId: 'user-1' }) + + await expect(startRecommendationSession(null, null, ctx)).resolves.toBe( + true + ) + expect(startFn).toHaveBeenCalledTimes(1) + }) + + it('throws when the BFF returns no session payload', async () => { + const { ctx } = makeContext(undefined) + + await expect(startRecommendationSession(null, null, ctx)).rejects.toThrow( + /Failed to start recommendation session/ + ) + }) + + it('throws when the session payload has no recommendationsUserId', async () => { + const { ctx } = makeContext({}) + + await expect(startRecommendationSession(null, null, ctx)).rejects.toThrow( + /Failed to start recommendation session/ + ) + }) +}) diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx index 7e67dfc807..1fef11c45e 100644 --- a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx @@ -71,7 +71,7 @@ export function RecommendationShelf< mapProductToProductCard, carouselConfiguration, productCardConfiguration, -}: Readonly>) { +}: RecommendationShelfProps) { const { itemsPerPageDesktop = 4, itemsPerPageMobile = 2, diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts index d64ac26cf7..9095f7f55c 100644 --- a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts @@ -28,8 +28,8 @@ export type ItemContext = 'PDP' | 'CART' export type RecommendationShelfProps< TCardProps extends object = ProductCardProps, > = { - title?: string - campaignVrn: string + readonly title?: string + readonly campaignVrn: string /** * Where to read the products used as context for the recommendation request: * - `'PDP'`: the current product detail page product. @@ -41,13 +41,13 @@ export type RecommendationShelfProps< * (top items, personalized, last seen, search-based) ignore it. * @default 'PDP' */ - itemsContext?: ItemContext + readonly itemsContext?: ItemContext /** * Custom card component rendered for each recommended product. Defaults to the * core `ProductCard`. This is a code-level override and is not exposed through * the CMS schema (`cms_component__RecommendationShelf.jsonc`). */ - ProductCard?: ComponentType + readonly ProductCard?: ComponentType /** * Maps a recommendation product into the props of the card component. Defaults * to passing the product through as `{ product, index, ...productCardConfiguration }`. @@ -55,48 +55,48 @@ export type RecommendationShelfProps< * default `productCardConfiguration` merge no longer applies). This is a * code-level override and is not exposed through the CMS schema. */ - mapProductToProductCard?: RecommendationProductCardMapper + readonly mapProductToProductCard?: RecommendationProductCardMapper /** * Carousel behaviour and paging configuration. Forwarded to the underlying * `Carousel`. */ - carouselConfiguration?: { + readonly carouselConfiguration?: { /** * Number of items per page on desktop viewports. Forwarded to the * underlying `Carousel` as `itemsPerPage` when the viewport is desktop. * @default 4 */ - itemsPerPageDesktop?: number + readonly itemsPerPageDesktop?: number /** * Number of items per page on mobile and tablet viewports. Forwarded to the * underlying `Carousel` as `itemsPerPage` when the viewport is mobile/tablet. * @default 2 */ - itemsPerPageMobile?: number + readonly itemsPerPageMobile?: number /** * How the carousel navigates between items. Forwarded to the underlying * `Carousel`. * @default 'scroll' */ - variant?: CarouselProps['variant'] + readonly variant?: CarouselProps['variant'] /** * Enables infinite navigation (only applies to the `slide` variant). * Forwarded to the underlying `Carousel`. * @default false */ - infiniteMode?: CarouselProps['infiniteMode'] + readonly infiniteMode?: CarouselProps['infiniteMode'] /** * Which navigation elements are visible. Forwarded to the underlying * `Carousel`. */ - controls?: CarouselProps['controls'] + readonly controls?: CarouselProps['controls'] } /** * Forwarded to each `ProductCard` rendered by the shelf. */ - productCardConfiguration?: { - showDiscountBadge?: boolean - bordered?: boolean + readonly productCardConfiguration?: { + readonly showDiscountBadge?: boolean + readonly bordered?: boolean } } diff --git a/packages/core/test/components/RecommendationShelf/useRecommendationUserId.test.ts b/packages/core/test/components/RecommendationShelf/useRecommendationUserId.test.ts new file mode 100644 index 0000000000..7f22144b4e --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/useRecommendationUserId.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ + +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const getUserIdFromCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/sdk/analytics/utils', () => ({ getUserIdFromCookie })) + +// Run the retried function once and resolve/reject with its result so the tests +// don't wait on the real backoff schedule. +vi.mock('src/utils/retry', () => ({ + retry: (fn: () => unknown) => Promise.resolve().then(fn), +})) + +import { useRecommendationUserId } from 'src/components/sections/RecommendationShelf/useRecommendationUserId' + +const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1' + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useRecommendationUserId', () => { + it('starts as undefined while resolving', () => { + getUserIdFromCookie.mockReturnValue('user-1') + + const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN)) + + expect(result.current).toBeUndefined() + }) + + it('resolves the user id from the cookie', async () => { + getUserIdFromCookie.mockReturnValue('user-1') + + const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN)) + + await waitFor(() => { + expect(result.current).toBe('user-1') + }) + }) + + it('resolves to null when the cookie has no usable id', async () => { + getUserIdFromCookie.mockReturnValue('') + + const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN)) + + await waitFor(() => { + expect(result.current).toBeNull() + }) + }) + + it('resolves to null when the lookup throws', async () => { + getUserIdFromCookie.mockImplementation(() => { + throw new Error('boom') + }) + + const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN)) + + await waitFor(() => { + expect(result.current).toBeNull() + }) + }) +}) From a43bf23de10e43ea614b3188f93267ea52df71e0 Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Mon, 6 Jul 2026 18:20:21 -0300 Subject: [PATCH 27/30] refactor(core): use Boolean directly as the default retry predicate Replaces the `(value) => Boolean(value)` wrapper flagged by Sonar with a direct `Boolean` reference. --- packages/core/src/utils/retry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 8c0e5875a6..a3ec6c11a9 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -42,7 +42,7 @@ export async function retry( delayMs = 500, backoff = false, maxDelayMs = 3000, - until = (value) => Boolean(value), + until = Boolean, signal, }: RetryOptions = {} ): Promise { From 1852ee1c70602898b482439f247e018d21956a3e Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Mon, 6 Jul 2026 18:41:52 -0300 Subject: [PATCH 28/30] fix(api): close Recommendation describe block after merge with dev --- .../api/test/unit/platforms/vtex/clients/commerce.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts index f4a219d459..df69c5d16c 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -291,6 +291,10 @@ describe('VTEX Commerce', () => { expect(url).toContain('/api/recommend-bff/v2/users/start-session') expect(init.method).toBe('POST') expect(result).toEqual(mockResponse) + }) + }) + }) + describe('Quotes', () => { describe('listUserQuotes', () => { it('calls the quoting endpoint with pagination, status, date range and trimmed label params', async () => { From 0e36d269600ad9d219e8899ad58d6f81b18f70cb Mon Sep 17 00:00:00 2001 From: Thiago Pereira Date: Tue, 7 Jul 2026 10:55:42 -0300 Subject: [PATCH 29/30] fix(api): add storeCookies option to fetchAPI for recommendations - Updated the VtexCommerce client to include the `storeCookies` option in the fetchAPI call for recommendations. - Enhanced unit tests to verify the presence of the `storeCookies` property in the API call. - Refactored the RecommendationShelf component to ensure safe access to product group ID. --- .../api/src/platforms/vtex/clients/commerce/index.ts | 3 ++- .../test/unit/platforms/vtex/clients/commerce.test.ts | 3 +++ .../RecommendationShelf/RecommendationShelf.tsx | 11 +++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index c989d5de83..d58e4abce8 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -1143,7 +1143,8 @@ export const VtexCommerce = ( return fetchAPI( `${recommendationBase}/recommendations?${params.toString()}`, - { headers: recommendationHeaders } + { headers: recommendationHeaders }, + { storeCookies } ) }, diff --git a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts index df69c5d16c..68d82d788a 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -258,6 +258,9 @@ describe('VTEX Commerce', () => { expect(url).toContain('products=pg-1%2Cpg-2') expect(url).toContain('salesChannel=1') expect(url).toContain('locale=en-US') + // Forwards the cookie-storage hook so the BFF's `vtex-rec-*` Set-Cookie + // headers are persisted through `ctx.storage.cookies`. + expect(fetchAPIMocked.mock.calls[0][2]).toHaveProperty('storeCookies') expect(result).toEqual(mockResponse) }) diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx index 1fef11c45e..456e4aaa38 100644 --- a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx @@ -115,7 +115,7 @@ export function RecommendationShelf< ) } - const pdpProduct = productDetailPage?.product.isVariantOf.productGroupID + const pdpProduct = productDetailPage?.product?.isVariantOf?.productGroupID return pdpProduct ? [pdpProduct] : [] }, [itemsContext, cartItems, productDetailPage]) @@ -179,9 +179,12 @@ export function RecommendationShelf< : {})} > -

- {title ?? data?.campaign.title} -

+ {(title || data?.campaign.title) && ( +

+ {title || data?.campaign.title} +

+ )} + Date: Tue, 14 Jul 2026 18:07:51 -0300 Subject: [PATCH 30/30] test(VTEX Commerce): add session API test for shopper contract fields Added a new test case to verify the functionality of requesting shopper contract fields from the sessions API in the VTEX Commerce test suite. This enhances test coverage for session-related API interactions. --- packages/api/test/unit/platforms/vtex/clients/commerce.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts index 8547fce1aa..b13c29b573 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -295,6 +295,9 @@ describe('VTEX Commerce', () => { expect(init.method).toBe('POST') expect(result).toEqual(mockResponse) }) + }) + }) + describe('Session', () => { it('requests shopper contract fields from the sessions API', async () => { fetchAPIMocked.mockResolvedValueOnce({ namespaces: {} })