Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 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
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
18 changes: 18 additions & 0 deletions packages/core/@generated/gql.ts

Large diffs are not rendered by default.

244 changes: 242 additions & 2 deletions packages/core/@generated/graphql.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$extends": ["#/$defs/base-component"],
"$componentKey": "RecommendationShelf",
"$componentTitle": "RecommendationShelf",
"type": "object",
"required": ["campaignVrn"],
"properties": {
"title": {
"title": "Title",
"description": "Override the shelf title. If not set, the campaign title will be used.",
"type": "string"
},
"campaignVrn": {
"title": "Campaign VRN",
"description": "Newtail campaign VRN that drives the recommendation (e.g. vrn:newtail:campaign:PERSONALIZED:abc123).",
"type": "string"
Comment thread
thiagopereira-vtex marked this conversation as resolved.
Outdated
}
}
}
5 changes: 5 additions & 0 deletions packages/core/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { isValidElement, type PropsWithChildren } from 'react'

import { usePageViewEvent } from './sdk/analytics/hooks/usePageViewEvent'
import { useStartSession } from './sdk/analytics/hooks/useStartSession'

function Layout({ children }: PropsWithChildren) {
const props = isValidElement(children) ? children.props : undefined
usePageViewEvent(props)
// Implemented here because the personalization session must be initiated once
// per browser session on each page. Product is read from props (same pattern as
// usePageViewEvent), since usePDP() has no context above PageProvider.
useStartSession(props)

return <>{children}</>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
@use "@faststore/ui/src/styles/base/utilities";
@use "sass:meta";

@layer components {
.recommendationShelfItem {
display: flex;
width: 100%;
height: 100%;

> * {
width: 100%;
}
}

.recommendationShelf {
[data-fs-product-shelf-skeleton] {
--fs-carousel-item-margin-right: var(--fs-spacing-3);

padding-bottom: var(--fs-spacing-5);

[data-fs-product-shelf-items] {
@include utilities.layout-content;
}

@include utilities.media("<tablet") {
[data-fs-product-shelf-item] {
&:not(:last-of-type) {
margin-right: var(--fs-carousel-item-margin-right);
}
}
}
}

@include meta.load-css("~@faststore/ui/src/components/atoms/Badge/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/Button/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/Icon/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/Link/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/Price/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/SROnly/styles");
@include meta.load-css("~@faststore/ui/src/components/atoms/Skeleton/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/Carousel/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/DiscountBadge/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/Rating/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCard/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCardSkeleton/styles");
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductPrice/styles");
@include meta.load-css("~@faststore/ui/src/components/organisms/ProductShelf/styles");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React, { useId, useMemo, useState } from 'react'

import { usePDP } from '@faststore/core'
import { ProductShelf, Carousel } from '@faststore/ui'

import ProductCard from 'src/components/product/ProductCard'
import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton'

import { mapRecommendationToProductCard } from './mapRecommendationToProductCard'
import type { RecommendationShelfProps } from './RecommendationShelf.types'
import styles from './RecommendationShelf.module.scss'
import {
useRecommendations,
type RecommendationInput,
} from './useRecommendations'
import { checkIsMobile, getUserIdFromCookie, getWithRetry } from './utils'
import { getTypeFromVrn } from './vrn'

function getRecommendationArguments(
campaignVrn: string,
context: { userId?: string | null; pdpProduct?: string }
): RecommendationInput | null {
const { userId, pdpProduct } = context
const type = getTypeFromVrn(campaignVrn)

if (!userId) return null

switch (type) {
case 'NEXT_INTERACTION':
case 'VISUAL_SIMILARITY':
case 'CROSS_SELL':
case 'SIMILAR_ITEMS':
if (!pdpProduct) {
return null
}
return {
userId,
campaignVrn,
products: [pdpProduct],
}
default:
return {
userId,
campaignVrn,
products: [],
}
}
}

export const RecommendationShelf = ({
title,
campaignVrn,
}: RecommendationShelfProps) => {
const id = useId()
const isMobile = checkIsMobile()
const itemsPerPage = isMobile ? 2 : 4
const [userId, setUserId] = useState<string | null | undefined>(undefined)

const { data: productDetailPage } = usePDP()

const recommendationArgs = getRecommendationArguments(campaignVrn, {
userId,
pdpProduct: productDetailPage?.product.isVariantOf.productGroupID,
})

const { data, isLoading, error } = useRecommendations(recommendationArgs)

const items = data?.products || []
const correlationId = data?.correlationId
const campaignId = data?.campaign.id

const productIds = useMemo(
() => items.map((p) => p.productId).join(', '),
[items]
)

const shouldAddAFAttr = !!(
!isLoading &&
correlationId &&
campaignId &&
productIds.length
)

if (!userId) {
// The pixel might take a while to load and set the userId cookie,
// so we use a retry mechanism to ensure we get the userId if available.
getWithRetry<string>(() => {
if (!userId) {
return getUserIdFromCookie()
}

return ''
})
.then((value) => {
setUserId(value)
})
.catch((error) => {
console.error('Error retrieving userId from cookie', error, campaignVrn)
setUserId(null)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (error) {
console.error(
'Error fetching recommendations',
error.cause,
error.message,
'with args',
recommendationArgs
)
return null
}

if (!isLoading && items.length === 0) {
return <></>
}

return (
<section
className={`${styles.recommendationShelf} section-product-shelf layout__section section`}
{...(shouldAddAFAttr
? {
'data-af-element': 'recommendation-shelf' as const,
'data-af-onimpression': true,
'data-af-onview': true,
'data-af-correlation-id': correlationId,
'data-af-campaign-id': campaignId,
'data-af-products': productIds,
}
: {})}
>
<ProductShelfSkeleton loading={isLoading} itemsPerPage={itemsPerPage}>
<h2 className="text__title-section layout__content">
{title ?? data?.campaign.title}
</h2>
<ProductShelf>
<Carousel
id={id}
itemsPerPage={itemsPerPage}
variant="scroll"
infiniteMode={false}
>
{items.map((item, index) => (
<div
key={item.productId}
className={styles.recommendationShelfItem}
>
<ProductCard

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.

What about receiving a custom ProductCard?

key={item.productId}
product={mapRecommendationToProductCard(item)}
index={index}
showDiscountBadge
{...(shouldAddAFAttr
? {
'data-af-element': 'recommendation-shelf-product',
'data-af-correlation-id': correlationId,
'data-af-campaign-id': campaignId,
'data-af-product-id': item.productId,
'data-af-onclick': !!item.productId,
'data-af-product-position': index + 1,
}
: {})}
/>
</div>
))}
</Carousel>
</ProductShelf>
</ProductShelfSkeleton>
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type RecommendationShelfProps = {
title?: string
campaignVrn: string
}

export type RecommendationType =
| 'CROSS_SELL'
| 'SIMILAR_ITEMS'
| 'PERSONALIZED'
| 'TOP_ITEMS'
| 'LAST_SEEN'
| 'SEARCH_BASED'
| 'VISUAL_SIMILARITY'
| 'NEXT_INTERACTION'
1 change: 1 addition & 0 deletions packages/core/src/components/RecommendationShelf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './RecommendationShelf'
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type {
ProductSummary_ProductFragment,
RecommendationProduct,
} from '@generated/graphql'

/**
* Maps a VTEX `RecommendationProduct` into the `ProductSummary_ProductFragment`
* shape consumed by the core `ProductCard` (src/components/product/ProductCard),
* so recommendation shelves render identical cards to regular shelves.
*
* The card reads `offers`/`image`/`isVariantOf` to compute title, price and
* discount, so we surface the default seller's commercial offer here.
*/
export function mapRecommendationToProductCard(

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.

If we have the same response type from the Search we wouldn't need to do this

product: RecommendationProduct
): ProductSummary_ProductFragment {
const item = product.items?.[0]
const image = item?.images?.[0]
const seller =
item?.sellers?.find((currentSeller) => currentSeller.sellerDefault) ??
item?.sellers?.[0]
const offer = seller?.commertialOffer

const price = offer?.Price ?? 0
const listPrice = offer?.ListPrice ?? 0
const availableQuantity = offer?.AvailableQuantity ?? 0
const availability =
availableQuantity > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'

const imageUrl = image?.imageUrl ?? ''
const imageAlt = image?.imageText ?? product.productName ?? ''

return {
id: product.productId,
sku: item?.itemId ?? product.productId,
slug: product.linkText ?? '',
name: item?.nameComplete ?? item?.name ?? product.productName ?? '',
gtin: '',
unitMultiplier: null,
hasSpecifications: null,
brand: { name: product.brand ?? '', brandName: product.brand ?? '' },
isVariantOf: {
name: product.productName ?? '',
productGroupID: product.productId,
skuVariants: null,
},
image: imageUrl ? [{ url: imageUrl, alternateName: imageAlt }] : [],
offers: {
lowPrice: price,
lowPriceWithTaxes: price,
offers: [
{
availability,
price,
listPrice,
listPriceWithTaxes: listPrice,
priceWithTaxes: price,
quantity: availableQuantity,
seller: { identifier: seller?.sellerId ?? '1' },
},
],
},
additionalProperty: [],
advertisement: null,
deliveryPromiseBadges: [],
}
}
Loading
Loading