-
Notifications
You must be signed in to change notification settings - Fork 81
feat(core): add RecommendationShelf section with personalization tracking #3403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
6c426a8
5f3999b
1f92187
961112a
c0495e5
1108aa3
4caead7
70e3b83
ec92aef
e369181
fab2acc
91e14d0
402816e
4658ec0
814da62
4c934e8
f3ef796
1ea6afa
3a52956
7b4e2d2
6ea9e5f
060d0a0
3214947
acb4bf4
daa57c5
9de413f
a43bf23
d89732b
1852ee1
0e36d26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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" | ||
| } | ||
| } | ||
| } | ||
| 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) | ||
| }) | ||
| } | ||
|
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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' |
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: [], | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.