@faststore/core 4.4.1-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.
Files changed (89) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +137 -51
  3. package/@generated/cached-operations.json +4 -0
  4. package/@generated/gql.ts +30 -0
  5. package/@generated/graphql.ts +141 -0
  6. package/@generated/persisted-documents.json +5 -0
  7. package/@generated/schema.graphql +100 -0
  8. package/CHANGELOG.md +26 -0
  9. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
  10. package/cms/faststore/schema.json +89 -0
  11. package/discovery.config.default.js +3 -0
  12. package/discovery.config.js +10 -1
  13. package/discovery.config.local.example.js +62 -0
  14. package/package.json +6 -6
  15. package/public/icons.svg +4 -0
  16. package/src/Layout.tsx +6 -0
  17. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
  18. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  22. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  23. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  24. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  25. package/src/components/account/Layout/Layout.tsx +14 -5
  26. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
  27. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
  28. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
  29. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
  30. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
  31. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
  32. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
  33. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
  34. package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
  35. package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
  36. package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
  37. package/src/components/cms/global/Components.ts +10 -0
  38. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  39. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  40. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  41. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  42. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  43. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  44. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  45. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  46. package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
  47. package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
  48. package/src/pages/[slug]/p.tsx +45 -0
  49. package/src/pages/pvt/account/quotes/index.tsx +264 -0
  50. package/src/sdk/account/changeContractToken.ts +119 -0
  51. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  52. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  53. package/src/sdk/account/refreshToken.ts +21 -8
  54. package/src/sdk/account/useAvailableContracts.ts +49 -0
  55. package/src/sdk/account/useSwitchContract.ts +60 -0
  56. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  57. package/src/sdk/analytics/utils.ts +9 -0
  58. package/src/utils/quoteStatus.ts +27 -0
  59. package/src/utils/retry.ts +69 -0
  60. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  61. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  62. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  63. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  64. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  65. package/test/components/account/Layout.test.tsx +62 -0
  66. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  67. package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
  68. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  69. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  70. package/test/components/account/doLogout.browser.test.ts +102 -0
  71. package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
  72. package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
  73. package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
  74. package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
  75. package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
  76. package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
  77. package/test/components/quotes/quoteFilters.test.ts +185 -0
  78. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  79. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  80. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  81. package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
  82. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  83. package/test/sdk/account/refreshToken.test.ts +87 -0
  84. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  85. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  86. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  87. package/test/sdk/analytics/utils.test.ts +23 -0
  88. package/test/server/index.test.ts +5 -0
  89. 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
+ }
@@ -0,0 +1,5 @@
1
+ function After() {
2
+ return <></>
3
+ }
4
+
5
+ export default After
@@ -0,0 +1,5 @@
1
+ function Before() {
2
+ return <></>
3
+ }
4
+
5
+ export default Before
@@ -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
  />