@faststore/core 4.5.0-dev.0 → 4.5.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +132 -60
- package/@generated/cached-operations.json +2 -0
- package/@generated/gql.ts +18 -0
- package/@generated/graphql.ts +68 -0
- package/@generated/persisted-documents.json +3 -0
- package/@generated/schema.graphql +41 -0
- package/CHANGELOG.md +20 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
- package/cms/faststore/schema.json +89 -0
- package/discovery.config.default.js +3 -0
- package/discovery.config.js +10 -1
- package/discovery.config.local.example.js +62 -0
- package/package.json +6 -6
- package/public/icons.svg +4 -0
- package/src/Layout.tsx +6 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +51 -26
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
- package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
- package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
- package/src/components/cms/global/Components.ts +10 -0
- package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
- package/src/components/sections/RecommendationShelf/index.ts +1 -0
- package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
- package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
- package/src/pages/[slug]/p.tsx +45 -0
- package/src/sdk/account/changeContractToken.ts +119 -0
- package/src/sdk/account/clearPersistedSessionState.ts +26 -0
- package/src/sdk/account/refreshToken.ts +21 -8
- package/src/sdk/account/useAvailableContracts.ts +49 -0
- package/src/sdk/account/useSwitchContract.ts +60 -0
- package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
- package/src/sdk/analytics/utils.ts +9 -0
- package/src/utils/retry.ts +69 -0
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
- package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
- package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
- package/test/components/RecommendationShelf/vrn.test.ts +56 -0
- package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
- package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
- package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
- package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
- package/test/components/account/doLogout.browser.test.ts +102 -0
- package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
- package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
- package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
- package/test/sdk/account/refreshToken.browser.test.ts +34 -0
- package/test/sdk/account/refreshToken.test.ts +87 -0
- package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
- package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
- package/test/sdk/analytics/utils.test.ts +23 -0
- package/test/server/index.test.ts +3 -0
- package/test/utils/retry.test.ts +59 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { type ComponentType, useId, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { usePDP } from '@faststore/core'
|
|
4
|
+
import { ProductShelf, Carousel } from '@faststore/ui'
|
|
5
|
+
import type { ProductSummary_ProductFragment } from '@generated/graphql'
|
|
6
|
+
|
|
7
|
+
import storeConfig from 'discovery.config'
|
|
8
|
+
|
|
9
|
+
import DefaultProductCard, {
|
|
10
|
+
type ProductCardProps,
|
|
11
|
+
} from 'src/components/product/ProductCard'
|
|
12
|
+
import ProductShelfSkeleton from 'src/components/skeletons/ProductShelfSkeleton'
|
|
13
|
+
import { useCart } from 'src/sdk/cart'
|
|
14
|
+
import useScreenResize from 'src/sdk/ui/useScreenResize'
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
RecommendationProductCardMapper,
|
|
18
|
+
RecommendationShelfProps,
|
|
19
|
+
} from './RecommendationShelf.types'
|
|
20
|
+
import styles from './section.module.scss'
|
|
21
|
+
import {
|
|
22
|
+
useRecommendations,
|
|
23
|
+
type RecommendationInput,
|
|
24
|
+
} from './useRecommendations'
|
|
25
|
+
import { useRecommendationUserId } from './useRecommendationUserId'
|
|
26
|
+
import { getTypeFromVrn } from './vrn'
|
|
27
|
+
|
|
28
|
+
function getRecommendationArguments(
|
|
29
|
+
campaignVrn: string,
|
|
30
|
+
context: { userId?: string | null; contextProducts: string[] }
|
|
31
|
+
): RecommendationInput | null {
|
|
32
|
+
const { userId, contextProducts } = context
|
|
33
|
+
const type = getTypeFromVrn(campaignVrn)
|
|
34
|
+
|
|
35
|
+
// `type` is null for malformed/unknown VRNs; bail out so we never fetch (and
|
|
36
|
+
// never throw) on an invalid campaign coming from the CMS.
|
|
37
|
+
if (!type || !userId) return null
|
|
38
|
+
|
|
39
|
+
switch (type) {
|
|
40
|
+
case 'NEXT_INTERACTION':
|
|
41
|
+
case 'VISUAL_SIMILARITY':
|
|
42
|
+
case 'CROSS_SELL':
|
|
43
|
+
case 'SIMILAR_ITEMS':
|
|
44
|
+
// These campaigns need product context. Without any (e.g. `CART` context
|
|
45
|
+
// on an empty cart, or `PDP` context outside a product page), skip the
|
|
46
|
+
// fetch instead of requesting recommendations we can't anchor.
|
|
47
|
+
if (contextProducts.length === 0) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
userId,
|
|
52
|
+
campaignVrn,
|
|
53
|
+
products: contextProducts,
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return {
|
|
57
|
+
userId,
|
|
58
|
+
campaignVrn,
|
|
59
|
+
products: [],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function RecommendationShelf<
|
|
65
|
+
TCardProps extends object = ProductCardProps,
|
|
66
|
+
>({
|
|
67
|
+
title,
|
|
68
|
+
campaignVrn,
|
|
69
|
+
itemsContext = 'PDP',
|
|
70
|
+
ProductCard,
|
|
71
|
+
mapProductToProductCard,
|
|
72
|
+
carouselConfiguration,
|
|
73
|
+
productCardConfiguration,
|
|
74
|
+
}: RecommendationShelfProps<TCardProps>) {
|
|
75
|
+
const {
|
|
76
|
+
itemsPerPageDesktop = 4,
|
|
77
|
+
itemsPerPageMobile = 2,
|
|
78
|
+
variant = 'scroll',
|
|
79
|
+
infiniteMode = false,
|
|
80
|
+
controls,
|
|
81
|
+
} = carouselConfiguration ?? {}
|
|
82
|
+
|
|
83
|
+
const CardComponent = (ProductCard ??
|
|
84
|
+
DefaultProductCard) as ComponentType<TCardProps>
|
|
85
|
+
|
|
86
|
+
const mapToCardProps = (mapProductToProductCard ??
|
|
87
|
+
((product: ProductSummary_ProductFragment, index: number) => ({
|
|
88
|
+
product,
|
|
89
|
+
index,
|
|
90
|
+
...productCardConfiguration,
|
|
91
|
+
}))) as RecommendationProductCardMapper<TCardProps>
|
|
92
|
+
|
|
93
|
+
const id = useId()
|
|
94
|
+
const { isMobile, isTablet } = useScreenResize()
|
|
95
|
+
// Treat mobile and tablet viewports (<= 768px) as "mobile" for paging, which
|
|
96
|
+
// matches the carousel layout the shelf was designed around.
|
|
97
|
+
const itemsPerPage =
|
|
98
|
+
isMobile || isTablet ? itemsPerPageMobile : itemsPerPageDesktop
|
|
99
|
+
|
|
100
|
+
const userId = useRecommendationUserId(campaignVrn)
|
|
101
|
+
|
|
102
|
+
const { data: productDetailPage } = usePDP()
|
|
103
|
+
const { items: cartItems } = useCart()
|
|
104
|
+
|
|
105
|
+
// Resolve the products used as context for the request from the configured
|
|
106
|
+
// source: the current PDP product, or the (deduplicated) cart items.
|
|
107
|
+
const contextProducts = useMemo(() => {
|
|
108
|
+
if (itemsContext === 'CART') {
|
|
109
|
+
return Array.from(
|
|
110
|
+
new Set(
|
|
111
|
+
cartItems
|
|
112
|
+
.map((item) => item.itemOffered.isVariantOf.productGroupID)
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pdpProduct = productDetailPage?.product?.isVariantOf?.productGroupID
|
|
119
|
+
|
|
120
|
+
return pdpProduct ? [pdpProduct] : []
|
|
121
|
+
}, [itemsContext, cartItems, productDetailPage])
|
|
122
|
+
|
|
123
|
+
// Gate the whole feature behind the opt-in flag: when Recommendations is
|
|
124
|
+
// disabled the shelf never requests recommendations (nor renders).
|
|
125
|
+
const recommendationArgs = storeConfig.experimental.enableRecommendations
|
|
126
|
+
? getRecommendationArguments(campaignVrn, {
|
|
127
|
+
userId,
|
|
128
|
+
contextProducts,
|
|
129
|
+
})
|
|
130
|
+
: null
|
|
131
|
+
|
|
132
|
+
const { data, isLoading, error } = useRecommendations(recommendationArgs)
|
|
133
|
+
|
|
134
|
+
const items = data?.products || []
|
|
135
|
+
const correlationId = data?.correlationId
|
|
136
|
+
const campaignId = data?.campaign.id
|
|
137
|
+
|
|
138
|
+
const productIds = useMemo(
|
|
139
|
+
() => items.map((p) => p.isVariantOf.productGroupID).join(', '),
|
|
140
|
+
[items]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const shouldAddAFAttr = !!(
|
|
144
|
+
!isLoading &&
|
|
145
|
+
correlationId &&
|
|
146
|
+
campaignId &&
|
|
147
|
+
productIds.length
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if (error) {
|
|
151
|
+
// Don't log `recommendationArgs`: it carries the userId. Log only
|
|
152
|
+
// non-identifying context.
|
|
153
|
+
console.error(
|
|
154
|
+
'Error fetching recommendations',
|
|
155
|
+
error.cause,
|
|
156
|
+
error.message,
|
|
157
|
+
'for campaign',
|
|
158
|
+
campaignVrn
|
|
159
|
+
)
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!isLoading && items.length === 0) {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<section
|
|
169
|
+
className={`${styles.section} section-product-shelf layout__section section`}
|
|
170
|
+
{...(shouldAddAFAttr
|
|
171
|
+
? {
|
|
172
|
+
'data-af-element': 'recommendation-shelf' as const,
|
|
173
|
+
'data-af-onimpression': true,
|
|
174
|
+
'data-af-onview': true,
|
|
175
|
+
'data-af-correlation-id': correlationId,
|
|
176
|
+
'data-af-campaign-id': campaignId,
|
|
177
|
+
'data-af-products': productIds,
|
|
178
|
+
}
|
|
179
|
+
: {})}
|
|
180
|
+
>
|
|
181
|
+
<ProductShelfSkeleton loading={isLoading} itemsPerPage={itemsPerPage}>
|
|
182
|
+
{(title || data?.campaign.title) && (
|
|
183
|
+
<h2 className="text__title-section layout__content">
|
|
184
|
+
{title || data?.campaign.title}
|
|
185
|
+
</h2>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<ProductShelf>
|
|
189
|
+
<Carousel
|
|
190
|
+
id={id}
|
|
191
|
+
itemsPerPage={itemsPerPage}
|
|
192
|
+
variant={variant}
|
|
193
|
+
infiniteMode={infiniteMode}
|
|
194
|
+
controls={controls}
|
|
195
|
+
>
|
|
196
|
+
{items.map((item, index) => {
|
|
197
|
+
const productId = item.isVariantOf.productGroupID
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
key={item.id}
|
|
202
|
+
data-fs-recommendation-shelf-item
|
|
203
|
+
{...(shouldAddAFAttr
|
|
204
|
+
? {
|
|
205
|
+
'data-af-element': 'recommendation-shelf-product',
|
|
206
|
+
'data-af-correlation-id': correlationId,
|
|
207
|
+
'data-af-campaign-id': campaignId,
|
|
208
|
+
'data-af-product-id': productId,
|
|
209
|
+
'data-af-onclick': !!productId,
|
|
210
|
+
'data-af-product-position': index + 1,
|
|
211
|
+
}
|
|
212
|
+
: {})}
|
|
213
|
+
>
|
|
214
|
+
<CardComponent {...mapToCardProps(item, index)} />
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
})}
|
|
218
|
+
</Carousel>
|
|
219
|
+
</ProductShelf>
|
|
220
|
+
</ProductShelfSkeleton>
|
|
221
|
+
</section>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CarouselProps } from '@faststore/ui'
|
|
4
|
+
import type { ProductSummary_ProductFragment } from '@generated/graphql'
|
|
5
|
+
|
|
6
|
+
import type { ProductCardProps } from 'src/components/product/ProductCard'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps a recommendation product (a normalized `StoreProduct`, identical to the
|
|
10
|
+
* search response consumed by the rest of the shelves) into the props passed to
|
|
11
|
+
* the card component. Defaults to identity (`(product, index) => ({ product,
|
|
12
|
+
* index })`). Override it — typically together with `ProductCard` — to render
|
|
13
|
+
* custom cards that read additional/personalized fields.
|
|
14
|
+
*/
|
|
15
|
+
export type RecommendationProductCardMapper<TCardProps = ProductCardProps> = (
|
|
16
|
+
product: ProductSummary_ProductFragment,
|
|
17
|
+
index: number
|
|
18
|
+
) => TCardProps
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Source of the products used as context for the recommendation request:
|
|
22
|
+
* - `'PDP'`: the current product detail page product.
|
|
23
|
+
* - `'CART'`: the products currently in the cart (useful for cross-sell on the
|
|
24
|
+
* cart page).
|
|
25
|
+
*/
|
|
26
|
+
export type ItemContext = 'PDP' | 'CART'
|
|
27
|
+
|
|
28
|
+
export type RecommendationShelfProps<
|
|
29
|
+
TCardProps extends object = ProductCardProps,
|
|
30
|
+
> = {
|
|
31
|
+
readonly title?: string
|
|
32
|
+
readonly campaignVrn: string
|
|
33
|
+
/**
|
|
34
|
+
* Where to read the products used as context for the recommendation request:
|
|
35
|
+
* - `'PDP'`: the current product detail page product.
|
|
36
|
+
* - `'CART'`: the products currently in the cart (useful for cross-sell on the
|
|
37
|
+
* cart page).
|
|
38
|
+
*
|
|
39
|
+
* Only affects campaigns that require product context (cross-sell, similar
|
|
40
|
+
* items, visual similarity, next interaction). Context-agnostic campaigns
|
|
41
|
+
* (top items, personalized, last seen, search-based) ignore it.
|
|
42
|
+
* @default 'PDP'
|
|
43
|
+
*/
|
|
44
|
+
readonly itemsContext?: ItemContext
|
|
45
|
+
/**
|
|
46
|
+
* Custom card component rendered for each recommended product. Defaults to the
|
|
47
|
+
* core `ProductCard`. This is a code-level override and is not exposed through
|
|
48
|
+
* the CMS schema (`cms_component__RecommendationShelf.jsonc`).
|
|
49
|
+
*/
|
|
50
|
+
readonly ProductCard?: ComponentType<TCardProps>
|
|
51
|
+
/**
|
|
52
|
+
* Maps a recommendation product into the props of the card component. Defaults
|
|
53
|
+
* to passing the product through as `{ product, index, ...productCardConfiguration }`.
|
|
54
|
+
* When provided, this mapper is fully responsible for the card props (the
|
|
55
|
+
* default `productCardConfiguration` merge no longer applies). This is a
|
|
56
|
+
* code-level override and is not exposed through the CMS schema.
|
|
57
|
+
*/
|
|
58
|
+
readonly mapProductToProductCard?: RecommendationProductCardMapper<TCardProps>
|
|
59
|
+
/**
|
|
60
|
+
* Carousel behaviour and paging configuration. Forwarded to the underlying
|
|
61
|
+
* `Carousel`.
|
|
62
|
+
*/
|
|
63
|
+
readonly carouselConfiguration?: {
|
|
64
|
+
/**
|
|
65
|
+
* Number of items per page on desktop viewports. Forwarded to the
|
|
66
|
+
* underlying `Carousel` as `itemsPerPage` when the viewport is desktop.
|
|
67
|
+
* @default 4
|
|
68
|
+
*/
|
|
69
|
+
readonly itemsPerPageDesktop?: number
|
|
70
|
+
/**
|
|
71
|
+
* Number of items per page on mobile and tablet viewports. Forwarded to the
|
|
72
|
+
* underlying `Carousel` as `itemsPerPage` when the viewport is mobile/tablet.
|
|
73
|
+
* @default 2
|
|
74
|
+
*/
|
|
75
|
+
readonly itemsPerPageMobile?: number
|
|
76
|
+
/**
|
|
77
|
+
* How the carousel navigates between items. Forwarded to the underlying
|
|
78
|
+
* `Carousel`.
|
|
79
|
+
* @default 'scroll'
|
|
80
|
+
*/
|
|
81
|
+
readonly variant?: CarouselProps['variant']
|
|
82
|
+
/**
|
|
83
|
+
* Enables infinite navigation (only applies to the `slide` variant).
|
|
84
|
+
* Forwarded to the underlying `Carousel`.
|
|
85
|
+
* @default false
|
|
86
|
+
*/
|
|
87
|
+
readonly infiniteMode?: CarouselProps['infiniteMode']
|
|
88
|
+
/**
|
|
89
|
+
* Which navigation elements are visible. Forwarded to the underlying
|
|
90
|
+
* `Carousel`.
|
|
91
|
+
*/
|
|
92
|
+
readonly controls?: CarouselProps['controls']
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Forwarded to each `ProductCard` rendered by the shelf.
|
|
96
|
+
*/
|
|
97
|
+
readonly productCardConfiguration?: {
|
|
98
|
+
readonly showDiscountBadge?: boolean
|
|
99
|
+
readonly bordered?: boolean
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type RecommendationType =
|
|
104
|
+
| 'CROSS_SELL'
|
|
105
|
+
| 'SIMILAR_ITEMS'
|
|
106
|
+
| 'PERSONALIZED'
|
|
107
|
+
| 'TOP_ITEMS'
|
|
108
|
+
| 'LAST_SEEN'
|
|
109
|
+
| 'SEARCH_BASED'
|
|
110
|
+
| 'VISUAL_SIMILARITY'
|
|
111
|
+
| 'NEXT_INTERACTION'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './RecommendationShelf'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
@use "@faststore/ui/src/styles/base/utilities";
|
|
2
|
+
@use "sass:meta";
|
|
3
|
+
|
|
4
|
+
@layer components {
|
|
5
|
+
.section {
|
|
6
|
+
[data-fs-recommendation-shelf-item] {
|
|
7
|
+
display: flex;
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
|
|
11
|
+
> * {
|
|
12
|
+
width: 100%;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
[data-fs-product-shelf-skeleton] {
|
|
17
|
+
--fs-carousel-item-margin-right: var(--fs-spacing-3);
|
|
18
|
+
|
|
19
|
+
padding-bottom: var(--fs-spacing-5);
|
|
20
|
+
|
|
21
|
+
[data-fs-product-shelf-items] {
|
|
22
|
+
@include utilities.layout-content;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@include utilities.media("<tablet") {
|
|
26
|
+
[data-fs-product-shelf-item] {
|
|
27
|
+
&:not(:last-of-type) {
|
|
28
|
+
margin-right: var(--fs-carousel-item-margin-right);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Badge/styles");
|
|
35
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Button/styles");
|
|
36
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Icon/styles");
|
|
37
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Link/styles");
|
|
38
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Price/styles");
|
|
39
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/SROnly/styles");
|
|
40
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Skeleton/styles");
|
|
41
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/Carousel/styles");
|
|
42
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/DiscountBadge/styles");
|
|
43
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/Rating/styles");
|
|
44
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCard/styles");
|
|
45
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCardSkeleton/styles");
|
|
46
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductPrice/styles");
|
|
47
|
+
@include meta.load-css("~@faststore/ui/src/components/organisms/ProductShelf/styles");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { getUserIdFromCookie } from 'src/sdk/analytics/utils'
|
|
4
|
+
import { retry } from 'src/utils/retry'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads the anonymous recommendation user id from the cookie set by the
|
|
8
|
+
* personalization pixel.
|
|
9
|
+
*
|
|
10
|
+
* The pixel may take a while to load and set the cookie, so the lookup is
|
|
11
|
+
* retried until a value is available (or the budget runs out). The returned
|
|
12
|
+
* value is tri-state:
|
|
13
|
+
*
|
|
14
|
+
* - `undefined`: still resolving (initial state);
|
|
15
|
+
* - `null`: lookup finished without a usable id (e.g. the pixel never set it);
|
|
16
|
+
* - `string`: the resolved user id.
|
|
17
|
+
*/
|
|
18
|
+
export function useRecommendationUserId(
|
|
19
|
+
campaignVrn: string
|
|
20
|
+
): string | null | undefined {
|
|
21
|
+
const [userId, setUserId] = useState<string | null | undefined>(undefined)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const controller = new AbortController()
|
|
25
|
+
|
|
26
|
+
retry(() => getUserIdFromCookie(), { signal: controller.signal })
|
|
27
|
+
.then((value) => {
|
|
28
|
+
if (!controller.signal.aborted) {
|
|
29
|
+
setUserId(value || null)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.catch((retryError) => {
|
|
33
|
+
console.error(
|
|
34
|
+
'Error retrieving userId from cookie',
|
|
35
|
+
retryError,
|
|
36
|
+
campaignVrn
|
|
37
|
+
)
|
|
38
|
+
if (!controller.signal.aborted) {
|
|
39
|
+
setUserId(null)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
controller.abort()
|
|
45
|
+
}
|
|
46
|
+
}, [campaignVrn])
|
|
47
|
+
|
|
48
|
+
return userId
|
|
49
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { gql } from '@faststore/core/api'
|
|
2
|
+
import type { ClientRecommendationsQueryQuery } from '@generated/graphql'
|
|
3
|
+
import { useQuery } from 'src/sdk/graphql/useQuery'
|
|
4
|
+
|
|
5
|
+
// Recommendations return the same normalized `StoreProduct` shape as the search
|
|
6
|
+
// response, so the shelf renders identical product cards to regular shelves.
|
|
7
|
+
// We select `...ProductSummary_product` (the fragment consumed by `ProductCard`)
|
|
8
|
+
// to keep this interface consistent with the rest of the components.
|
|
9
|
+
const query = gql(`query ClientRecommendationsQuery(
|
|
10
|
+
$campaignVrn: String!
|
|
11
|
+
$userId: String
|
|
12
|
+
$products: [String!]
|
|
13
|
+
) {
|
|
14
|
+
recommendations(
|
|
15
|
+
userId: $userId
|
|
16
|
+
campaignVrn: $campaignVrn
|
|
17
|
+
products: $products
|
|
18
|
+
) {
|
|
19
|
+
products {
|
|
20
|
+
...ProductSummary_product
|
|
21
|
+
}
|
|
22
|
+
correlationId
|
|
23
|
+
campaign {
|
|
24
|
+
id
|
|
25
|
+
title
|
|
26
|
+
type
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`)
|
|
31
|
+
|
|
32
|
+
export type RecommendationInput = {
|
|
33
|
+
userId: string
|
|
34
|
+
campaignVrn: string
|
|
35
|
+
products: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type RecommendationResponse =
|
|
39
|
+
ClientRecommendationsQueryQuery['recommendations']
|
|
40
|
+
export type RecommendationProduct = RecommendationResponse['products'][number]
|
|
41
|
+
|
|
42
|
+
export const useRecommendations = (args: RecommendationInput | null) => {
|
|
43
|
+
const { data, isLoading, error } = useQuery<
|
|
44
|
+
ClientRecommendationsQueryQuery,
|
|
45
|
+
RecommendationInput
|
|
46
|
+
>(query, args ?? ({} as RecommendationInput), {
|
|
47
|
+
doNotRun: args === null,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
data: data?.recommendations,
|
|
52
|
+
error,
|
|
53
|
+
isLoading,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RecommendationType } from './RecommendationShelf.types'
|
|
2
|
+
|
|
3
|
+
// Single source of truth for the campaign taxonomy: maps each Intelligent Search
|
|
4
|
+
// campaign VRN type to its `RecommendationType`. The supported VRN types, the
|
|
5
|
+
// validation regex and the VRN-to-type resolution are all derived from this map
|
|
6
|
+
// so the list of campaign types lives in exactly one place.
|
|
7
|
+
//
|
|
8
|
+
// Reference:
|
|
9
|
+
// Cross-Sell rec-cross-v2
|
|
10
|
+
// Similar Items rec-similar-v2
|
|
11
|
+
// Personalized rec-persona-v2
|
|
12
|
+
// Last Seen rec-last-v2
|
|
13
|
+
// Top Sellers rec-top-items-v2
|
|
14
|
+
// Search-Based rec-search-v2
|
|
15
|
+
// Next Interactions rec-next-v2
|
|
16
|
+
// Visual Similarity rec-visual-v2
|
|
17
|
+
const VRN_TYPE_TO_RECOMMENDATION = {
|
|
18
|
+
'rec-cross-v2': 'CROSS_SELL',
|
|
19
|
+
'rec-similar-v2': 'SIMILAR_ITEMS',
|
|
20
|
+
'rec-persona-v2': 'PERSONALIZED',
|
|
21
|
+
'rec-last-v2': 'LAST_SEEN',
|
|
22
|
+
'rec-top-items-v2': 'TOP_ITEMS',
|
|
23
|
+
'rec-search-v2': 'SEARCH_BASED',
|
|
24
|
+
'rec-next-v2': 'NEXT_INTERACTION',
|
|
25
|
+
'rec-visual-v2': 'VISUAL_SIMILARITY',
|
|
26
|
+
} as const satisfies Record<string, RecommendationType>
|
|
27
|
+
|
|
28
|
+
type RecommendationVrnType = keyof typeof VRN_TYPE_TO_RECOMMENDATION
|
|
29
|
+
|
|
30
|
+
const vrnPattern = new RegExp(
|
|
31
|
+
`^vrn:recommendations:[^:]+:(${Object.keys(VRN_TYPE_TO_RECOMMENDATION).join(
|
|
32
|
+
'|'
|
|
33
|
+
)}):[^:]+$`
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export function isValidVrn(campaignVrn: string): boolean {
|
|
37
|
+
return vrnPattern.test(campaignVrn)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseCampaignVrn(campaignVrn: string) {
|
|
41
|
+
const [, , accountName, campaignType, campaignId] = campaignVrn.split(':')
|
|
42
|
+
|
|
43
|
+
return { accountName, campaignType, campaignId }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolves a campaign VRN into its `RecommendationType`. Returns `null` for
|
|
47
|
+
// malformed or unknown VRNs so callers can degrade gracefully instead of
|
|
48
|
+
// throwing during render.
|
|
49
|
+
export function getTypeFromVrn(campaignVrn: string): RecommendationType | null {
|
|
50
|
+
const { campaignType } = parseCampaignVrn(campaignVrn)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
VRN_TYPE_TO_RECOMMENDATION[campaignType as RecommendationVrnType] ?? null
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/pages/[slug]/p.tsx
CHANGED
|
@@ -69,6 +69,14 @@ const COMPONENTS: Record<string, ComponentType<any>> = {
|
|
|
69
69
|
...CUSTOM_COMPONENTS,
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Maps schema.org `itemCondition` URLs to the values accepted by the
|
|
73
|
+
// Open Graph `product:condition` meta tag (`new` | `refurbished` | `used`).
|
|
74
|
+
const OG_PRODUCT_CONDITION_BY_SCHEMA: Record<string, string> = {
|
|
75
|
+
'https://schema.org/NewCondition': 'new',
|
|
76
|
+
'https://schema.org/RefurbishedCondition': 'refurbished',
|
|
77
|
+
'https://schema.org/UsedCondition': 'used',
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
type Props = PDPContentType & {
|
|
73
81
|
data: ServerProductQueryQuery
|
|
74
82
|
globalSections: GlobalSectionsData
|
|
@@ -123,6 +131,11 @@ function Page({
|
|
|
123
131
|
.toString()
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
const productCondition =
|
|
135
|
+
OG_PRODUCT_CONDITION_BY_SCHEMA[
|
|
136
|
+
product.offers.offers[0]?.itemCondition ?? ''
|
|
137
|
+
]
|
|
138
|
+
|
|
126
139
|
let itemListElements = product.breadcrumbList.itemListElement ?? []
|
|
127
140
|
if (itemListElements.length !== 0) {
|
|
128
141
|
itemListElements = itemListElements.map(
|
|
@@ -199,6 +212,38 @@ function Page({
|
|
|
199
212
|
property: 'product:price:currency',
|
|
200
213
|
content: currency.code,
|
|
201
214
|
},
|
|
215
|
+
{
|
|
216
|
+
property: 'product:id',
|
|
217
|
+
content: product.isVariantOf?.productGroupID ?? undefined,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
property: 'product:sku',
|
|
221
|
+
content: product.sku,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
property: 'product:name',
|
|
225
|
+
content: product.name,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
property: 'product:category',
|
|
229
|
+
content: itemListElements[0]?.name ?? undefined,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
property: 'product:url',
|
|
233
|
+
content: meta.canonical,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
property: 'product:brand',
|
|
237
|
+
content: product.brand.name,
|
|
238
|
+
},
|
|
239
|
+
...(productCondition
|
|
240
|
+
? [
|
|
241
|
+
{
|
|
242
|
+
property: 'product:condition',
|
|
243
|
+
content: productCondition,
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
202
247
|
]}
|
|
203
248
|
titleTemplate={titleTemplate}
|
|
204
249
|
/>
|