Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6c426a8
feat(core): add RecommendationShelf section with personalization trac…
thiagopereira-vtex Jun 23, 2026
5f3999b
fix(core): add recommendation/personalization GraphQL typeDefs
thiagopereira-vtex Jun 23, 2026
1f92187
test(core): include recommendation operations in schema assertions
thiagopereira-vtex Jun 23, 2026
961112a
fix(api): expose recommendation and personalization operations
thiagopereira-vtex Jun 23, 2026
c0495e5
refactor(core): clean up RecommendationShelf code smells
thiagopereira-vtex Jun 23, 2026
1108aa3
test(core): cover RecommendationShelf and useStartSession
thiagopereira-vtex Jun 23, 2026
4caead7
refactor(core): compare globalThis.window with undefined directly
thiagopereira-vtex Jun 23, 2026
70e3b83
Update packages/core/cms/faststore/components/cms_component__Recommen…
thiagopereira-vtex Jun 23, 2026
ec92aef
fix(api): use private cache scope for personalized recommendations
thiagopereira-vtex Jun 23, 2026
e369181
fix(core): harden RecommendationShelf per review feedback
thiagopereira-vtex Jun 23, 2026
fab2acc
refactor(core): move analytics utils out of RecommendationShelf into SDK
thiagopereira-vtex Jun 23, 2026
91e14d0
refactor(core): reuse SDK GraphQL layer in RecommendationShelf
thiagopereira-vtex Jun 23, 2026
402816e
feat(core): make RecommendationShelf carousel and product card config…
thiagopereira-vtex Jun 24, 2026
4658ec0
refactor(core): remove SendProductViewEvent mutation and related logic
thiagopereira-vtex Jun 24, 2026
814da62
refactor(core): simplify RecommendationShelf per quality review
thiagopereira-vtex Jun 24, 2026
4c934e8
refactor(api): remove SendProductViewEvent mutation and related types
thiagopereira-vtex Jun 26, 2026
f3ef796
refactor(api, core): rename startSession to startRecommendationSessio…
thiagopereira-vtex Jun 26, 2026
1ea6afa
feat(api, core): return StoreProduct from recommendations for consist…
thiagopereira-vtex Jun 26, 2026
3a52956
refactor(api): rename StartSessionResult to StartRecommendationSessio…
thiagopereira-vtex Jun 26, 2026
7b4e2d2
feat(core): enhance RecommendationShelf with configurable items conte…
thiagopereira-vtex Jun 29, 2026
6ea9e5f
Update packages/api/src/platforms/vtex/clients/recommendation/index.ts
thiagopereira-vtex Jul 1, 2026
060d0a0
feat(api, core): implement server-side validation for recommendations…
thiagopereira-vtex Jul 2, 2026
3214947
feat(core): add RecommendationShelf component for personalized produc…
thiagopereira-vtex Jul 2, 2026
acb4bf4
refactor(RecommendationShelf): update prop type and improve empty sta…
thiagopereira-vtex Jul 3, 2026
daa57c5
refactor(api, core): address recommendation shelf review feedback
thiagopereira-vtex Jul 6, 2026
9de413f
test(api, core): cover recommendation resolvers/client and fix Sonar …
thiagopereira-vtex Jul 6, 2026
a43bf23
refactor(core): use Boolean directly as the default retry predicate
thiagopereira-vtex Jul 6, 2026
d89732b
Merge branch 'dev' into feat/recommendation-shelf
thiagopereira-vtex Jul 6, 2026
1852ee1
fix(api): close Recommendation describe block after merge with dev
thiagopereira-vtex Jul 6, 2026
0e36d26
fix(api): add storeCookies option to fetchAPI for recommendations
thiagopereira-vtex Jul 7, 2026
fd3006a
Merge branch 'dev' into feat/recommendation-shelf
thiagopereira-vtex Jul 13, 2026
80773f5
Merge branch 'dev' into feat/recommendation-shelf
eduardoformiga Jul 14, 2026
be3f6b4
test(VTEX Commerce): add session API test for shopper contract fields
eduardoformiga Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions packages/api/src/platforms/vtex/clients/commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T extends Record<string, infer K> ? 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: {
Expand Down Expand Up @@ -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<string, string> = {}
): HeadersInit => {
Expand Down Expand Up @@ -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<RecommendationResult> => {
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 }
)
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

startRecommendationSession: (): Promise<
StartRecommendationSessionResult | undefined
> => {
const params = new URLSearchParams({ an: account })

return fetchAPI(
`${recommendationBase}/users/start-session?${params.toString()}`,
{ method: 'POST', headers: recommendationHeaders },
{ storeCookies }
)
},
},
}
}
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/mutation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,4 +15,5 @@ export const Mutation = {
processOrderAuthorization,
uploadFileToOrderEntry,
startOrderEntryOperation,
startRecommendationSession,
}
2 changes: 2 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
} from '../../../__generated__/schema'
import { getOrderEntryOperation } from './getOrderEntryOperation'
import { getOrderFormItems } from './getOrderFormItems'
import { recommendations } from './recommendations'
import {
BadRequestError,
ForbiddenError,
Expand Down Expand Up @@ -838,4 +839,5 @@ export const Query = {
},
orderEntryOperation: getOrderEntryOperation,
orderFormItems: getOrderFormItems,
recommendations,
}
80 changes: 80 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/recommendations.ts
Original file line number Diff line number Diff line change
@@ -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:<account>:<campaign-type>:<campaign-id>`). 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,
}
}
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/mutation.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ type Mutation {
startOrderEntryOperation(
data: IOrderEntryOperation!
): StoreOrderEntryOperationResult @auth
"""
Starts an anonymous personalization session for the current shopper.
"""
startRecommendationSession: Boolean!
}
9 changes: 9 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/query.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the implementation for this recommendations query?

campaignVrn: String!
userId: String
products: [String!]
): RecommendationResponse!
@cacheControl(scope: "private", sMaxAge: 120, staleWhileRevalidate: 3600)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A recomendação é feita pra cada usuário (userId)? Acho que, nesse caso, faz sentido ser private: ficar no browser do usuário e não "vazar" para cdn ou outra camada compartilhada. Caso contrário, seria melhor public.

}

type ValidateUserData {
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/recommendation.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type RecommendationResponse {
products: [StoreProduct!]!
correlationId: String!
campaign: RecommendationCampaign!
}

type RecommendationCampaign {
id: String!
title: String
type: String!
}
2 changes: 2 additions & 0 deletions packages/api/test/integration/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const QUERIES = [
'pickupPoints',
'orderEntryOperation',
'orderFormItems',
'recommendations',
]

const MUTATIONS = [
Expand All @@ -93,6 +94,7 @@ const MUTATIONS = [
'processOrderAuthorization',
'uploadFileToOrderEntry',
'startOrderEntryOperation',
'startRecommendationSession',
]

let schema: GraphQLSchema
Expand Down
Loading
Loading