diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index cd096af4da..b2764272b3 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -690,6 +690,8 @@ export type Mutation = { * Returns an operationId to poll for the operation status. */ startOrderEntryOperation?: Maybe; + /** Starts an anonymous personalization session for the current shopper. */ + startRecommendationSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter?: Maybe; /** @@ -930,6 +932,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. */ @@ -1028,6 +1032,13 @@ export type QueryProfileArgs = { }; +export type QueryRecommendationsArgs = { + campaignVrn: Scalars['String']['input']; + products?: InputMaybe>; + userId?: InputMaybe; +}; + + export type QueryRedirectArgs = { selectedFacets?: InputMaybe>; term?: InputMaybe; @@ -1063,6 +1074,20 @@ export type QueryUserOrderArgs = { orderId: Scalars['String']['input']; }; +export type RecommendationCampaign = { + __typename?: 'RecommendationCampaign'; + id: Scalars['String']['output']; + title?: Maybe; + type: Scalars['String']['output']; +}; + +export type RecommendationResponse = { + __typename?: 'RecommendationResponse'; + campaign: RecommendationCampaign; + correlationId: Scalars['String']['output']; + products: Array; +}; + export type SkuSpecificationField = { __typename?: 'SKUSpecificationField'; id?: Maybe; diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index f50979aa15..430212fcc6 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -45,12 +45,28 @@ 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' import type { QuoteListResult, ListUserQuotesArgs } from './types/Quote' 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: { @@ -156,6 +172,14 @@ 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}`, + }) const withBuyerAuthHeaders = ( additionalHeaders: Record = {} ): HeadersInit => { @@ -1096,5 +1120,55 @@ 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 }, + { storeCookies } + ) + }, + + 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/commerce/types/RecommendationResult.ts b/packages/api/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts new file mode 100644 index 0000000000..a2eff379c0 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/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 StartRecommendationSessionResult { + 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 86c8742034..d499a01d5f 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -20,6 +20,7 @@ import type { } from '../../../__generated__/schema' import { getOrderEntryOperation } from './getOrderEntryOperation' import { getOrderFormItems } from './getOrderFormItems' +import { recommendations } from './recommendations' import { BadRequestError, ForbiddenError, @@ -838,4 +839,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..0c4877b75c --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/recommendations.ts @@ -0,0 +1,80 @@ +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. + * + * 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: { commerce }, + } = ctx + + 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 commerce.recommendation.recommendations({ + campaignVrn: normalizedCampaignVrn, + userId: normalizedUserId || 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..9adeef803c --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/startRecommendationSession.ts @@ -0,0 +1,30 @@ +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 +) => { + 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 + // `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/api/src/platforms/vtex/typeDefs/mutation.graphql b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql index fbbf79d611..a692e43e72 100644 --- a/packages/api/src/platforms/vtex/typeDefs/mutation.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/mutation.graphql @@ -35,4 +35,8 @@ type Mutation { startOrderEntryOperation( data: IOrderEntryOperation! ): StoreOrderEntryOperationResult @auth + """ + Starts an anonymous personalization session for the current shopper. + """ + startRecommendationSession: Boolean! } diff --git a/packages/api/src/platforms/vtex/typeDefs/query.graphql b/packages/api/src/platforms/vtex/typeDefs/query.graphql index 6b483ff12f..be29ea5b5b 100644 --- a/packages/api/src/platforms/vtex/typeDefs/query.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/query.graphql @@ -529,6 +529,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: "private", sMaxAge: 120, staleWhileRevalidate: 3600) } type ValidateUserData { diff --git a/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql new file mode 100644 index 0000000000..4a00c1f2f4 --- /dev/null +++ b/packages/api/src/platforms/vtex/typeDefs/recommendation.graphql @@ -0,0 +1,11 @@ +type RecommendationResponse { + products: [StoreProduct!]! + correlationId: String! + campaign: RecommendationCampaign! +} + +type RecommendationCampaign { + id: String! + title: String + type: String! +} diff --git a/packages/api/test/integration/schema.test.ts b/packages/api/test/integration/schema.test.ts index cb52401551..4ff1e484f8 100644 --- a/packages/api/test/integration/schema.test.ts +++ b/packages/api/test/integration/schema.test.ts @@ -83,6 +83,7 @@ const QUERIES = [ 'pickupPoints', 'orderEntryOperation', 'orderFormItems', + 'recommendations', ] const MUTATIONS = [ @@ -93,6 +94,7 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', + 'startRecommendationSession', ] let schema: GraphQLSchema 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 11670ab186..b13c29b573 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -235,6 +235,69 @@ 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') + // 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) + }) + + 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) + }) + }) + }) + describe('Session', () => { it('requests shopper contract fields from the sessions API', async () => { fetchAPIMocked.mockResolvedValueOnce({ namespaces: {} }) 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/@generated/gql.ts b/packages/core/@generated/gql.ts index 281158e1a9..fd0421dacd 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -18,6 +18,7 @@ type Documents = { "\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, @@ -43,6 +44,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 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, @@ -68,6 +70,7 @@ const documents: Documents = { "\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, @@ -93,6 +96,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 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, @@ -127,6 +131,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. */ @@ -227,6 +235,10 @@ 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 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 9208243999..b72240f606 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -671,6 +671,8 @@ export type Mutation = { * Returns an operationId to poll for the operation status. */ startOrderEntryOperation: Maybe; + /** Starts an anonymous personalization session for the current shopper. */ + startRecommendationSession: Scalars['Boolean']['output']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter: Maybe; /** @@ -899,6 +901,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. */ @@ -997,6 +1001,13 @@ export type QueryProfileArgs = { }; +export type QueryRecommendationsArgs = { + campaignVrn: Scalars['String']['input']; + products: InputMaybe>; + userId: InputMaybe; +}; + + export type QueryRedirectArgs = { selectedFacets: InputMaybe>; term: InputMaybe; @@ -1032,6 +1043,18 @@ export type QueryUserOrderArgs = { orderId: Scalars['String']['input']; }; +export type RecommendationCampaign = { + id: Scalars['String']['output']; + title: Maybe; + type: Scalars['String']['output']; +}; + +export type RecommendationResponse = { + campaign: RecommendationCampaign; + correlationId: Scalars['String']['output']; + products: Array; +}; + export type SkuSpecificationField = { id: Maybe; name: Scalars['String']['output']; @@ -2651,6 +2674,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 } }; @@ -2773,6 +2805,11 @@ export type ValidateUserQueryVariables = Exact<{ [key: string]: never; }>; export type ValidateUserQuery = { validateUser: { isValid: boolean } | null }; +export type StartRecommendationSessionMutationVariables = Exact<{ [key: string]: never; }>; + + +export type StartRecommendationSessionMutation = { startRecommendationSession: boolean }; + export type ValidateCartMutationMutationVariables = Exact<{ cart: IStoreCart; session: IStoreSession; @@ -3460,6 +3497,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` fuzzy } `, {"fragmentName":"SearchEvent_metadata"}) 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; @@ -3474,6 +3512,7 @@ export const AvailableContractsQueryDocument = {"__meta__":{"operationName":"Ava 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 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/cms/faststore/components/cms_component__recommendationshelf.jsonc b/packages/core/cms/faststore/components/cms_component__recommendationshelf.jsonc new file mode 100644 index 0000000000..29940bc0a8 --- /dev/null +++ b/packages/core/cms/faststore/components/cms_component__recommendationshelf.jsonc @@ -0,0 +1,86 @@ +{ + "$extends": ["#/$defs/base-component"], + "$componentKey": "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": { + "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):[^:]+$" + }, + "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"] + }, + "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", + "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..df6baf4a5e 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,92 @@ "writeOnly": false, "deprecated": false, "$abstract": false + }, + "RecommendationShelf": { + "$extends": ["#/$defs/base-component"], + "$componentKey": "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": { + "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):[^:]+$" + }, + "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"] + }, + "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", + "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/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 713255e004..174ce28bc6 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -1,10 +1,16 @@ import { isValidElement, type PropsWithChildren } from 'react' import { usePageViewEvent } from './sdk/analytics/hooks/usePageViewEvent' +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. 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/cms/global/Components.ts b/packages/core/src/components/cms/global/Components.ts index 669ce5a403..cedf68846f 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/sections/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/components/sections/RecommendationShelf/RecommendationShelf.tsx b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx new file mode 100644 index 0000000000..456e4aaa38 --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.tsx @@ -0,0 +1,223 @@ +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 storeConfig from 'discovery.config' + +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 { + RecommendationProductCardMapper, + RecommendationShelfProps, +} from './RecommendationShelf.types' +import styles from './section.module.scss' +import { + useRecommendations, + type RecommendationInput, +} from './useRecommendations' +import { useRecommendationUserId } from './useRecommendationUserId' +import { getTypeFromVrn } from './vrn' + +function getRecommendationArguments( + campaignVrn: string, + context: { userId?: string | null; contextProducts: string[] } +): RecommendationInput | null { + const { userId, contextProducts } = context + const type = getTypeFromVrn(campaignVrn) + + // `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': + case 'VISUAL_SIMILARITY': + case 'CROSS_SELL': + case 'SIMILAR_ITEMS': + // 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: contextProducts, + } + default: + return { + userId, + campaignVrn, + products: [], + } + } +} + +export function RecommendationShelf< + TCardProps extends object = ProductCardProps, +>({ + title, + campaignVrn, + itemsContext = 'PDP', + ProductCard, + mapProductToProductCard, + carouselConfiguration, + productCardConfiguration, +}: RecommendationShelfProps) { + const { + itemsPerPageDesktop = 4, + itemsPerPageMobile = 2, + variant = 'scroll', + infiniteMode = false, + controls, + } = carouselConfiguration ?? {} + + 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() + // 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() + 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]) + + // 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) + + const items = data?.products || [] + const correlationId = data?.correlationId + const campaignId = data?.campaign.id + + const productIds = useMemo( + () => items.map((p) => p.isVariantOf.productGroupID).join(', '), + [items] + ) + + const shouldAddAFAttr = !!( + !isLoading && + correlationId && + campaignId && + productIds.length + ) + + if (error) { + // Don't log `recommendationArgs`: it carries the userId. Log only + // non-identifying context. + console.error( + 'Error fetching recommendations', + error.cause, + error.message, + 'for campaign', + campaignVrn + ) + return null + } + + if (!isLoading && items.length === 0) { + return null + } + + return ( +
+ + {(title || data?.campaign.title) && ( +

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

+ )} + + + + {items.map((item, index) => { + const productId = item.isVariantOf.productGroupID + + return ( +
+ +
+ ) + })} +
+
+
+
+ ) +} diff --git a/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts new file mode 100644 index 0000000000..9095f7f55c --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts @@ -0,0 +1,111 @@ +import type { ComponentType } from 'react' + +import type { CarouselProps } from '@faststore/ui' +import type { ProductSummary_ProductFragment } from '@generated/graphql' + +import type { ProductCardProps } from 'src/components/product/ProductCard' + +/** + * 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: ProductSummary_ProductFragment, + 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, +> = { + 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. + * - `'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' + */ + 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`). + */ + readonly 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. + */ + readonly mapProductToProductCard?: RecommendationProductCardMapper + /** + * Carousel behaviour and paging configuration. Forwarded to the underlying + * `Carousel`. + */ + readonly carouselConfiguration?: { + /** + * Number of items per page on desktop viewports. Forwarded to the + * underlying `Carousel` as `itemsPerPage` when the viewport is desktop. + * @default 4 + */ + 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 + */ + readonly itemsPerPageMobile?: number + /** + * How the carousel navigates between items. Forwarded to the underlying + * `Carousel`. + * @default 'scroll' + */ + readonly variant?: CarouselProps['variant'] + /** + * Enables infinite navigation (only applies to the `slide` variant). + * Forwarded to the underlying `Carousel`. + * @default false + */ + readonly infiniteMode?: CarouselProps['infiniteMode'] + /** + * Which navigation elements are visible. Forwarded to the underlying + * `Carousel`. + */ + readonly controls?: CarouselProps['controls'] + } + /** + * Forwarded to each `ProductCard` rendered by the shelf. + */ + readonly productCardConfiguration?: { + readonly showDiscountBadge?: boolean + readonly bordered?: boolean + } +} + +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/sections/RecommendationShelf/index.ts b/packages/core/src/components/sections/RecommendationShelf/index.ts new file mode 100644 index 0000000000..c82a76d729 --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/index.ts @@ -0,0 +1 @@ +export * from './RecommendationShelf' diff --git a/packages/core/src/components/sections/RecommendationShelf/section.module.scss b/packages/core/src/components/sections/RecommendationShelf/section.module.scss new file mode 100644 index 0000000000..916b3e6080 --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/section.module.scss @@ -0,0 +1,49 @@ +@use "@faststore/ui/src/styles/base/utilities"; +@use "sass:meta"; + +@layer components { + .section { + [data-fs-recommendation-shelf-item] { + display: flex; + width: 100%; + height: 100%; + + > * { + width: 100%; + } + } + + [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("(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/sections/RecommendationShelf/useRecommendations.ts b/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts new file mode 100644 index 0000000000..009643ed45 --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/useRecommendations.ts @@ -0,0 +1,55 @@ +import { gql } from '@faststore/core/api' +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 ClientRecommendationsQuery( + $campaignVrn: String! + $userId: String + $products: [String!] +) { + recommendations( + userId: $userId + campaignVrn: $campaignVrn + products: $products + ) { + products { + ...ProductSummary_product + } + correlationId + campaign { + id + title + type + } + } +} +`) + +export type RecommendationInput = { + userId: string + campaignVrn: string + products: string[] +} + +export type RecommendationResponse = + ClientRecommendationsQueryQuery['recommendations'] +export type RecommendationProduct = RecommendationResponse['products'][number] + +export const useRecommendations = (args: RecommendationInput | null) => { + const { data, isLoading, error } = useQuery< + ClientRecommendationsQueryQuery, + RecommendationInput + >(query, args ?? ({} as RecommendationInput), { + doNotRun: args === null, + }) + + return { + data: data?.recommendations, + error, + isLoading, + } +} diff --git a/packages/core/src/components/sections/RecommendationShelf/vrn.ts b/packages/core/src/components/sections/RecommendationShelf/vrn.ts new file mode 100644 index 0000000000..263dfbbb46 --- /dev/null +++ b/packages/core/src/components/sections/RecommendationShelf/vrn.ts @@ -0,0 +1,55 @@ +import type { RecommendationType } from './RecommendationShelf.types' + +// 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(':') + + return { accountName, campaignType, campaignId } +} + +// 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) + + return ( + VRN_TYPE_TO_RECOMMENDATION[campaignType as RecommendationVrnType] ?? null + ) +} diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index 6f55f2feba..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( @@ -199,6 +212,38 @@ function Page({ property: 'product:price:currency', content: currency.code, }, + { + property: 'product:id', + content: product.isVariantOf?.productGroupID ?? undefined, + }, + { + 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, + }, + ...(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 new file mode 100644 index 0000000000..41d1daa730 --- /dev/null +++ b/packages/core/src/sdk/analytics/hooks/useStartRecommendationSession.ts @@ -0,0 +1,96 @@ +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' + +// 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. + * + * 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< + StartRecommendationSessionData, + StartRecommendationSessionVariables + >( + startRecommendationSessionMutation, + {} as StartRecommendationSessionVariables, + { + doNotRun: true, + } + ) + + useEffect(() => { + if (!storeConfig.experimental.enableRecommendations) { + return + } + + 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). + // 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() + } + }, [runStartRecommendationSession]) +} diff --git a/packages/core/src/sdk/analytics/utils.ts b/packages/core/src/sdk/analytics/utils.ts new file mode 100644 index 0000000000..7f7d8a9432 --- /dev/null +++ b/packages/core/src/sdk/analytics/utils.ts @@ -0,0 +1,9 @@ +import { getCookie } from 'src/utils/getCookie' + +// 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 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..a3ec6c11a9 --- /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 = Boolean, + 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 new file mode 100644 index 0000000000..350f7edcb0 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx @@ -0,0 +1,235 @@ +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/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/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. +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/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' + +const recommendationData = { + 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' }, +} + +beforeEach(() => { + usePDP.mockReturnValue({ data: undefined }) + useScreenResize.mockReturnValue({ + isMobile: false, + isTablet: false, + isDesktop: true, + loading: false, + }) + useRecommendationUserId.mockReturnValue('user-1') + recommendationsFlag.enabled = true +}) + +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' } } }, + }) + 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('skips the fetch and renders empty when there is no resolved userId', async () => { + useRecommendationUserId.mockReturnValue(null) + useRecommendations.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }) + + const { container, queryByTestId } = render( + + ) + + await waitFor(() => { + 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', () => { + 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') + }) + + 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/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() + }) + }) +}) 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..68db7d1cc4 --- /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/sdk/graphql/useQuery', () => ({ + useQuery: useQueryMock, +})) + +import { useRecommendations } from 'src/components/sections/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/vrn.test.ts b/packages/core/test/components/RecommendationShelf/vrn.test.ts new file mode 100644 index 0000000000..affebdd096 --- /dev/null +++ b/packages/core/test/components/RecommendationShelf/vrn.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { + getTypeFromVrn, + isValidVrn, +} from 'src/components/sections/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('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/useStartRecommendationSession.test.ts b/packages/core/test/sdk/analytics/useStartRecommendationSession.test.ts new file mode 100644 index 0000000000..0ed9ed370c --- /dev/null +++ b/packages/core/test/sdk/analytics/useStartRecommendationSession.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' + +vi.mock('@faststore/core/api', () => ({ + gql: (s: string) => ({ __meta__: {}, query: s }), +})) + +const runStartRecommendationSession = vi.hoisted(() => vi.fn()) +const useLazyQueryMock = vi.hoisted(() => vi.fn()) +vi.mock('src/sdk/graphql/useLazyQuery', () => ({ + useLazyQuery: useLazyQueryMock, +})) + +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(() => { + vi.clearAllMocks() +}) + +describe('useStartRecommendationSession', () => { + it('does not start a session when the session cookie already exists', async () => { + getCookie.mockReturnValue('already-started') + + renderHook(() => useStartRecommendationSession()) + + await waitFor(() => { + expect(runStartRecommendationSession).not.toHaveBeenCalled() + }) + }) + + it('starts a session when no session cookie is present', async () => { + getCookie.mockReturnValue(undefined) + + renderHook(() => useStartRecommendationSession()) + + await waitFor(() => { + 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 new file mode 100644 index 0000000000..8128cca09b --- /dev/null +++ b/packages/core/test/sdk/analytics/utils.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockGetCookie = vi.hoisted(() => vi.fn()) +vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie })) + +import { getUserIdFromCookie } from 'src/sdk/analytics/utils' + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +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('') + }) +}) diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 37f6803f48..a38286e807 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -83,6 +83,7 @@ const QUERIES = [ 'pickupPoints', 'orderEntryOperation', 'orderFormItems', + 'recommendations', ] const OPTIONAL_GENERATED_QUERIES = new Set(['accountName']) @@ -95,6 +96,7 @@ const MUTATIONS = [ 'processOrderAuthorization', 'uploadFileToOrderEntry', 'startOrderEntryOperation', + 'startRecommendationSession', ] describe('FastStore GraphQL Layer', () => { 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) + }) +})