@faststore/core 4.7.1-dev.1 → 4.8.0-dev.0

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 (23) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +104 -104
  3. package/CHANGELOG.md +21 -0
  4. package/cms/faststore/components/cms_component__cartsidebar.jsonc +74 -0
  5. package/cms/faststore/schema.json +78 -0
  6. package/cms/faststore/sections.json +78 -0
  7. package/package.json +6 -6
  8. package/src/components/cart/CartRecommendationShelf/CartRecommendationProductCard.tsx +88 -0
  9. package/src/components/cart/CartRecommendationShelf/CartRecommendationShelf.tsx +110 -0
  10. package/src/components/cart/CartRecommendationShelf/index.ts +4 -0
  11. package/src/components/cart/CartRecommendationShelf/section.module.scss +124 -0
  12. package/src/components/cart/CartSidebar/CartSidebar.tsx +47 -1
  13. package/src/components/product/ProductCard/ProductCard.tsx +5 -3
  14. package/src/components/sections/RecommendationShelf/README.md +74 -6
  15. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +15 -3
  16. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -2
  17. package/src/components/sections/RecommendationShelf/index.ts +7 -1
  18. package/src/sdk/recommendations/useRecommendationShelf.ts +2 -2
  19. package/test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx +125 -0
  20. package/test/components/CartRecommendationShelf/CartRecommendationShelf.browser.test.tsx +286 -0
  21. package/test/components/CartSidebar/CartSidebar.browser.test.tsx +209 -0
  22. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +19 -0
  23. package/test/sdk/recommendations/useRecommendationShelf.browser.test.tsx +1 -1
@@ -6,14 +6,23 @@ CMS section that renders a carousel of personalized product recommendations from
6
6
  - **Registration:** global CMS component (`$componentKey: "RecommendationShelf"`), available on any page via CMS.
7
7
  - **Session opt-in:** personalization session starts via the `experimental.enableRecommendations` discovery feature flag (Layout, every page). When the flag is off, any mounted Recommendation Shelf starts the session as a fallback if `userId` is missing.
8
8
 
9
+ Recommendations are rendered by two surfaces that share one data layer (`src/sdk/recommendations`):
10
+
11
+ | Surface | Component | Configured in | Layout |
12
+ | --- | --- | --- | --- |
13
+ | Page shelf | `RecommendationShelf` | Its own CMS component | Full-width carousel |
14
+ | Mini cart shelf | `CartRecommendationShelf` | The **Recommendations** group of the **Cart Sidebar** component | Compact carousel inside the cart drawer |
15
+
9
16
  ## Contents
10
17
 
11
18
  - [Prerequisites](#prerequisites)
12
19
  - [Setup](#setup)
13
20
  - [CMS configuration](#cms-configuration)
14
21
  - [Campaign types (VRN) and context](#campaign-types-vrn-and-context)
22
+ - [Mini cart shelf](#mini-cart-shelf)
15
23
  - [Runtime behavior](#runtime-behavior)
16
24
  - [Code-level overrides](#code-level-overrides)
25
+ - [CSS customization](#css-customization)
17
26
  - [Placement guidelines](#placement-guidelines)
18
27
  - [Privacy and cache](#privacy-and-cache)
19
28
  - [Troubleshooting](#troubleshooting)
@@ -99,24 +108,53 @@ The campaign type is derived from the `<campaign-type>` segment of the VRN:
99
108
 
100
109
  Context-based campaigns (cross-sell, similar items, visual similarity, next interaction) skip the request when no context products are available. Context-agnostic campaigns (top sellers, personalized, last seen, search-based) ignore `itemsContext`.
101
110
 
111
+ ## Mini cart shelf
112
+
113
+ A compact shelf can be rendered **inside the cart drawer**, between the cart items and the order summary. It is the highest-intent cross-sell surface in the store: the shopper has just added an item and the drawer is open.
114
+
115
+ The CMS cannot nest one section inside another, so this shelf is not a separate CMS component — it is configured as a **Recommendations** group on the **Cart Sidebar** component.
116
+
117
+ Schema: `cms/faststore/components/cms_component__cartsidebar.jsonc`, under `recommendations`.
118
+
119
+ | CMS property | Key | Type | Required | Default | Description |
120
+ | --- | --- | --- | --- | --- | --- |
121
+ | Should display Recommendation Shelf? | `shouldDisplayRecommendationShelf` | boolean | No | `false` | Opt-in. Off means the drawer does not mount the shelf, so there is no request and nothing rendered. |
122
+ | Campaign VRN | `campaignVrn` | string | No | — | Same VRN taxonomy and validation as the page shelf. Needed at runtime when the display toggle is on; the drawer does not fetch without it. |
123
+ | Title | `title` | string | No | — | Overrides the shelf title. Falls back to the campaign title. |
124
+ | › Items per page | `carouselConfiguration.itemsPerPage` | number | No | `1` | The drawer is around 340px wide on every breakpoint, so keep this low. Fractional values such as `1.5` hint that the carousel scrolls. |
125
+ | › Carousel track variant | `carouselConfiguration.variant` | `slide` \| `scroll` | No | `scroll` | Carousel navigation mode. |
126
+ | › Navigation controls | `carouselConfiguration.controls` | `complete` \| `navigationArrows` \| `paginationBullets` | No | `navigationArrows` | Visible navigation controls. |
127
+ | › Show discount badge? | `productCardConfiguration.showDiscountBadge` | boolean | No | `true` | Shows the discount badge. |
128
+ | › Cards should be bordered? | `productCardConfiguration.bordered` | boolean | No | `false` | Renders bordered cards. |
129
+
130
+ Differences from the page shelf:
131
+
132
+ - **The context is always the cart.** There is no `itemsContext` property.
133
+ - **The context follows the live cart.** Adding a product updates the campaign context while the drawer stays open.
134
+ - **Taxes are inherited.** The shelf reuses the drawer's own **Taxes Configuration** so recommended products and cart items price alike.
135
+ - **It does not render on an empty cart.** The drawer shows the empty-cart state instead of mounting the shelf. Context-agnostic campaigns that would work without product context are still suppressed on this surface.
136
+ - **It only fetches when the drawer opens.** The drawer is gated by `SECTIONS_OUT_OF_VIEWPORT`, and the shelf ships in its own chunk, so stores without a mini cart shelf download neither the component nor its styles.
137
+ - **CSS hook.** The root also has `data-fs-cart-recommendation-shelf`, so store CSS can style the drawer shelf without hitting page shelves. See [CSS customization](#css-customization).
138
+
102
139
  ## Runtime behavior
103
140
 
104
141
  All recommendation work runs client-side after hydration:
105
142
 
106
- 1. **Session start (feature flag + shelf fallback)** — `Layout` calls `useStartRecommendationSession()` once. The hook starts the session when `experimental.enableRecommendations` is `true` (no CMS lookup). When the flag is `false`, `useRecommendationShelf()` (used by every RecommendationShelf) calls the same hook as a fallback while `userId` is missing (`undefined` or `null`), in parallel with cookie retry. Cookie (`vtex-rec-user-start-session`) + in-memory lock ensure at most one attempt per browser session. If `userId` is already available, the shelf does not start the session.
143
+ 1. **Session start (feature flag + shelf fallback)** — `Layout` calls `useStartRecommendationSession()` once. The hook starts the session when `experimental.enableRecommendations` is `true` (no CMS lookup). When the flag is `false`, `useRecommendationShelf()` (used by every RecommendationShelf, including the mini cart) calls the same hook as a fallback while `userId` is missing (`undefined` or `null`), in parallel with cookie retry. Cookie (`vtex-rec-user-start-session`) + in-memory lock ensure at most one attempt per browser session. If `userId` is already available, the shelf does not start the session.
107
144
  2. **User id resolution** — `useRecommendationUserId()` reads the `vtex-rec-user-id` cookie, retrying until a value is available or the retry budget is exhausted.
108
145
  3. **Request arguments** — `getRecommendationArguments()` builds `{ userId, campaignVrn, products }` or returns `null` when the VRN is invalid, the user id is missing, or a context-based campaign has no context products.
109
146
  4. **Fetch** — `useRecommendations()` runs `ClientRecommendationsQuery` and returns `products`, `correlationId`, and `campaign`.
110
147
  5. **Render** — shows `ProductShelfSkeleton` while loading; returns `null` on error or empty results; otherwise renders the carousel and optional heading.
111
- 6. **Tracking** — when correlation and campaign identifiers are present, the shelf emits `data-af-*` attributes for Activity Flow. PDP product views use `product:*` meta tags from `pages/[slug]/p.tsx`.
148
+ 6. **Tracking** — when correlation and campaign identifiers are present, the shelf emits `data-af-*` attributes for Activity Flow (`recommendation-shelf` on the root, `recommendation-shelf-product` on items). PDP product views use `product:*` meta tags from `pages/[slug]/p.tsx`.
112
149
 
113
- Steps 1 to 4 carry no presentation: they live in `src/sdk/recommendations` and are orchestrated by `useRecommendationShelf()`, which the shelf consumes.
150
+ Steps 1 to 4 live in `src/sdk/recommendations` behind `useRecommendationShelf()`. Step 5 is owned by `RecommendationShelf`; the mini cart shelf is a thin wrapper around it that locks cart context and applies drawer-safe carousel/tax defaults — so `ProductCard` / `mapProductToProductCard` work the same way on both surfaces.
114
151
 
115
152
  Related files:
116
153
 
117
154
  - Shared data layer: `src/sdk/recommendations/`
118
- - Shelf: `src/components/sections/RecommendationShelf/`
119
- - Hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
155
+ - Page shelf (also the presentation used by the mini cart): `src/components/sections/RecommendationShelf/`
156
+ - Mini cart wrapper: `src/components/cart/CartRecommendationShelf/`
157
+ - Session hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
120
158
  - Layout: `src/Layout.tsx`
121
159
  - Flag: `discovery.config.default.js` → `experimental.enableRecommendations`
122
160
 
@@ -143,7 +181,30 @@ import { RecommendationShelf } from 'src/components/sections/RecommendationShelf
143
181
  - **`ProductCard`**: custom card component (defaults to the core `ProductCard`).
144
182
  - **`mapProductToProductCard`**: maps each recommended product (normalized `StoreProduct`) into card props. When provided, it fully owns the card props and the default `productCardConfiguration` merge no longer applies.
145
183
 
146
- Campaign types and VRN validation are defined in `src/sdk/recommendations/vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the CMS schema `pattern`.
184
+ Campaign types and VRN validation are defined in `src/sdk/recommendations/vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the `pattern` of both CMS schemas (`cms_component__recommendationshelf.jsonc` and the `recommendations` group of `cms_component__cartsidebar.jsonc`).
185
+
186
+ The mini cart shelf (`CartRecommendationShelf`) reuses `RecommendationShelf`, so the same `ProductCard` / `mapProductToProductCard` overrides apply. By default it renders `CartRecommendationProductCard`, which adds an **Add to cart** action (first offer / SKU, drawer stays open). Because the drawer is rendered by `CartSidebar` from CMS props, passing a custom card from a store means customizing `CartSidebar` (or `CartRecommendationShelf`) under `src/customizations` and forwarding the override into `recommendations`.
187
+
188
+ ## CSS customization
189
+
190
+ The shelf root (`<section>`) exposes `data-fs-*` attributes as styling hooks, so store CSS can target the shelf without depending on layout class names (`section-product-shelf`, `layout__section`, …):
191
+
192
+ | Attribute | Present on | Purpose |
193
+ | --- | --- | --- |
194
+ | `data-fs-recommendation-shelf` | Every shelf (page and mini cart) | Root hook for the recommendation shelf. |
195
+ | `data-fs-cart-recommendation-shelf` | Mini cart shelf only | Distinguishes the drawer instance from page shelves. |
196
+
197
+ Related hooks on the same tree: `data-fs-recommendation-shelf-title` (heading) and `data-fs-recommendation-shelf-item` (each card wrapper).
198
+
199
+ ```scss
200
+ [data-fs-recommendation-shelf] {
201
+ /* page shelf and mini cart shelf */
202
+ }
203
+
204
+ [data-fs-cart-recommendation-shelf] {
205
+ /* mini cart shelf only */
206
+ }
207
+ ```
147
208
 
148
209
  ## Placement guidelines
149
210
 
@@ -153,6 +214,7 @@ As a global component, the shelf can be placed on any CMS page. Suggested placem
153
214
  - **PDP:** context-based campaigns with `itemsContext: PDP`.
154
215
  - **Cart:** cross-sell with `itemsContext: CART`.
155
216
  - **PLP:** context-agnostic campaigns.
217
+ - **Cart drawer (mini cart):** cross-sell, configured on the **Cart Sidebar** component. See [Mini cart shelf](#mini-cart-shelf).
156
218
 
157
219
  Prefer enabling `experimental.enableRecommendations` so the session is ready on every page. Stores without the flag and without a Recommendation Shelf never call `startRecommendationSession`.
158
220
 
@@ -171,6 +233,12 @@ Prefer enabling `experimental.enableRecommendations` so the session is ready on
171
233
  3. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
172
234
  4. Did the campaign return products? An empty response renders nothing by design.
173
235
 
236
+ **The mini cart shelf does not render.** Same checklist, plus:
237
+
238
+ 5. Is **Should display Recommendation Shelf?** enabled on the **Cart Sidebar** component (under **Recommendations**), not on a separate Recommendation Shelf? Only that shape opts the drawer in.
239
+ 6. With the discovery flag off, the session starts when the shelf mounts (when the drawer opens). Confirm `vtex-rec-user-start-session` and then `vtex-rec-user-id` after opening the cart.
240
+ 7. Is the cart empty? The mini cart shelf does not mount until there is at least one item. Use a page Recommendation Shelf for empty-cart campaigns.
241
+
174
242
  **Skeleton never resolves.** Inspect the console and the GraphQL `recommendations` response for pending or failing requests.
175
243
 
176
244
  **No tracking events.** Confirm that the Activity Flow script is present and that PDP `product:*` meta tags and shelf `data-af-*` attributes are rendered.
@@ -16,16 +16,20 @@ import type {
16
16
  } from './RecommendationShelf.types'
17
17
  import styles from './section.module.scss'
18
18
 
19
+ const DEFAULT_SECTION_CLASS_NAME = `${styles.section} section-product-shelf layout__section section`
20
+
19
21
  export function RecommendationShelf<
20
22
  TCardProps extends object = ProductCardProps,
21
23
  >({
22
24
  title,
23
25
  campaignVrn,
24
26
  itemsContext = 'PDP',
27
+ className,
25
28
  ProductCard,
26
29
  mapProductToProductCard,
27
30
  carouselConfiguration,
28
31
  productCardConfiguration,
32
+ ...otherProps
29
33
  }: RecommendationShelfProps<TCardProps>) {
30
34
  const {
31
35
  itemsPerPageDesktop = 4,
@@ -82,7 +86,8 @@ export function RecommendationShelf<
82
86
 
83
87
  return (
84
88
  <section
85
- className={`${styles.section} section-product-shelf layout__section section`}
89
+ className={className ?? DEFAULT_SECTION_CLASS_NAME}
90
+ {...otherProps}
86
91
  {...(shouldAddAFAttr
87
92
  ? {
88
93
  'data-af-element': 'recommendation-shelf' as const,
@@ -93,10 +98,17 @@ export function RecommendationShelf<
93
98
  'data-af-products': productIds,
94
99
  }
95
100
  : {})}
101
+ data-fs-recommendation-shelf
96
102
  >
97
- <ProductShelfSkeleton loading={isLoading} itemsPerPage={itemsPerPage}>
103
+ <ProductShelfSkeleton
104
+ loading={isLoading}
105
+ itemsPerPage={Math.ceil(itemsPerPage)}
106
+ >
98
107
  {(title || campaign?.title) && (
99
- <h2 className="text__title-section layout__content">
108
+ <h2
109
+ className="text__title-section layout__content"
110
+ data-fs-recommendation-shelf-title
111
+ >
100
112
  {title || campaign?.title}
101
113
  </h2>
102
114
  )}
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from 'react'
1
+ import type { ComponentPropsWithoutRef, ComponentType } from 'react'
2
2
 
3
3
  import type { CarouselProps } from '@faststore/ui'
4
4
  import type { ProductSummary_ProductFragment } from '@generated/graphql'
@@ -22,7 +22,7 @@ export type RecommendationProductCardMapper<TCardProps = ProductCardProps> = (
22
22
 
23
23
  export type RecommendationShelfProps<
24
24
  TCardProps extends object = ProductCardProps,
25
- > = {
25
+ > = Omit<ComponentPropsWithoutRef<'section'>, 'children' | 'title'> & {
26
26
  readonly title?: string
27
27
  readonly campaignVrn: string
28
28
  /**
@@ -37,6 +37,12 @@ export type RecommendationShelfProps<
37
37
  * @default 'PDP'
38
38
  */
39
39
  readonly itemsContext?: ItemContext
40
+ /**
41
+ * Replaces the default page-section class list. Surfaces like the cart drawer
42
+ * pass their own module class so the shelf does not pick up full-page layout
43
+ * utilities (`section-product-shelf`, `layout__section`, …).
44
+ */
45
+ readonly className?: string
40
46
  /**
41
47
  * Custom card component rendered for each recommended product. Defaults to the
42
48
  * core `ProductCard`. This is a code-level override and is not exposed through
@@ -1 +1,7 @@
1
- export * from './RecommendationShelf'
1
+ export { RecommendationShelf } from './RecommendationShelf'
2
+ export type {
3
+ ItemContext,
4
+ RecommendationProductCardMapper,
5
+ RecommendationShelfProps,
6
+ RecommendationType,
7
+ } from './RecommendationShelf.types'
@@ -72,8 +72,8 @@ const EMPTY_ITEMS: RecommendationResponse['products'] = []
72
72
  * Headless data layer behind every recommendation shelf: resolves the campaign
73
73
  * type, the anonymous user id and the product context, then fetches the
74
74
  * campaign. It renders nothing — presentation lives in the shelf components
75
- * that consume it, so a new surface can reuse the campaign rules instead of
76
- * reimplementing them.
75
+ * that consume it, so a page-wide carousel and the compact cart drawer shelf
76
+ * share exactly one implementation of the campaign rules.
77
77
  *
78
78
  * When `userId` is missing (`undefined` or `null`), starts the personalization
79
79
  * session as a fallback in parallel with cookie retry (Layout already starts
@@ -0,0 +1,125 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render } from '@testing-library/react'
3
+ import React from 'react'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ const buyOnClick = vi.hoisted(() => vi.fn())
7
+ const useBuyButtonMock = vi.hoisted(() => vi.fn())
8
+
9
+ vi.mock('src/sdk/cart/useBuyButton', () => ({
10
+ useBuyButton: (...args: unknown[]) => {
11
+ useBuyButtonMock(...args)
12
+ return {
13
+ onClick: buyOnClick,
14
+ 'data-testid': 'buy-button',
15
+ }
16
+ },
17
+ }))
18
+
19
+ const productCardProps = vi.hoisted(() => vi.fn())
20
+ vi.mock('src/components/product/ProductCard', () => ({
21
+ default: (props: Record<string, unknown>) => {
22
+ productCardProps(props)
23
+ return React.createElement(
24
+ 'button',
25
+ {
26
+ type: 'button',
27
+ 'data-testid': 'product-card',
28
+ onClick: props.onButtonClick as React.MouseEventHandler,
29
+ },
30
+ String(props.buttonLabel ?? '')
31
+ )
32
+ },
33
+ }))
34
+
35
+ import CartRecommendationProductCard from 'src/components/cart/CartRecommendationShelf/CartRecommendationProductCard'
36
+
37
+ const product = {
38
+ id: 'p-1',
39
+ sku: 'sku-1',
40
+ gtin: 'gtin-1',
41
+ brand: { name: 'Brand' },
42
+ unitMultiplier: 1,
43
+ additionalProperty: [],
44
+ isVariantOf: {
45
+ productGroupID: 'pg-1',
46
+ name: 'Camera',
47
+ },
48
+ image: [{ url: 'https://example.com/cam.jpg', alternateName: 'Camera' }],
49
+ offers: {
50
+ lowPrice: 100,
51
+ lowPriceWithTaxes: 110,
52
+ offers: [
53
+ {
54
+ availability: 'https://schema.org/InStock',
55
+ listPrice: 120,
56
+ listPriceWithTaxes: 130,
57
+ price: 100,
58
+ priceWithTaxes: 110,
59
+ seller: { identifier: '1' },
60
+ priceToken: 'token',
61
+ quantity: 1,
62
+ },
63
+ ],
64
+ },
65
+ }
66
+
67
+ beforeEach(() => {
68
+ buyOnClick.mockClear()
69
+ useBuyButtonMock.mockClear()
70
+ productCardProps.mockClear()
71
+ })
72
+
73
+ afterEach(() => {
74
+ vi.clearAllMocks()
75
+ })
76
+
77
+ describe('CartRecommendationProductCard', () => {
78
+ it('wires useBuyButton with the recommended SKU and keeps the drawer closed', () => {
79
+ render(
80
+ <CartRecommendationProductCard product={product as never} index={0} />
81
+ )
82
+
83
+ expect(useBuyButtonMock).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ id: 'p-1',
86
+ quantity: 1,
87
+ price: 100,
88
+ itemOffered: expect.objectContaining({
89
+ sku: 'sku-1',
90
+ name: 'Camera',
91
+ }),
92
+ }),
93
+ false
94
+ )
95
+ })
96
+
97
+ it('renders an add-to-cart action and forwards clicks to useBuyButton', () => {
98
+ const { getByTestId, getByText } = render(
99
+ <CartRecommendationProductCard product={product as never} index={0} />
100
+ )
101
+
102
+ expect(productCardProps).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ buttonLabel: 'Add to cart',
105
+ onButtonClick: expect.any(Function),
106
+ })
107
+ )
108
+ expect(getByText('Add to cart')).toBeTruthy()
109
+
110
+ fireEvent.click(getByTestId('product-card'))
111
+ expect(buyOnClick).toHaveBeenCalledTimes(1)
112
+ })
113
+
114
+ it('allows overriding the button label', () => {
115
+ const { getByText } = render(
116
+ <CartRecommendationProductCard
117
+ product={product as never}
118
+ index={0}
119
+ buttonLabel="Add"
120
+ />
121
+ )
122
+
123
+ expect(getByText('Add')).toBeTruthy()
124
+ })
125
+ })
@@ -0,0 +1,286 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render } from '@testing-library/react'
3
+ import React from 'react'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ vi.mock('@faststore/ui', () => {
7
+ const passthrough = ({ children }: React.PropsWithChildren) =>
8
+ React.createElement(React.Fragment, null, children)
9
+ return { ProductShelf: passthrough, Carousel: passthrough }
10
+ })
11
+
12
+ const productCardProps = vi.hoisted(() => vi.fn())
13
+ vi.mock('src/components/product/ProductCard', () => ({
14
+ default: (props: Record<string, unknown>) => {
15
+ productCardProps(props)
16
+ return React.createElement('div', { 'data-testid': 'product-card' })
17
+ },
18
+ }))
19
+
20
+ vi.mock('src/components/skeletons/ProductShelfSkeleton', () => ({
21
+ default: ({
22
+ children,
23
+ loading,
24
+ itemsPerPage,
25
+ }: React.PropsWithChildren<{ loading?: boolean; itemsPerPage?: number }>) =>
26
+ React.createElement(
27
+ 'div',
28
+ {
29
+ 'data-testid': 'skeleton',
30
+ 'data-loading': String(!!loading),
31
+ 'data-items-per-page': String(itemsPerPage),
32
+ },
33
+ children
34
+ ),
35
+ }))
36
+
37
+ const useRecommendationShelf = vi.hoisted(() => vi.fn())
38
+ vi.mock('src/sdk/recommendations', () => ({ useRecommendationShelf }))
39
+
40
+ vi.mock('src/sdk/cart/useBuyButton', () => ({
41
+ useBuyButton: () => ({
42
+ onClick: vi.fn(),
43
+ 'data-testid': 'buy-button',
44
+ }),
45
+ }))
46
+
47
+ vi.mock('src/sdk/ui/useScreenResize', () => ({
48
+ default: () => ({
49
+ isMobile: false,
50
+ isTablet: false,
51
+ isDesktop: true,
52
+ loading: false,
53
+ }),
54
+ }))
55
+
56
+ import CartRecommendationShelf from 'src/components/cart/CartRecommendationShelf'
57
+
58
+ const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1'
59
+
60
+ const recommendationProduct = (id: string, productGroupID: string) => ({
61
+ id,
62
+ sku: `sku-${id}`,
63
+ gtin: `gtin-${id}`,
64
+ brand: { name: 'Brand' },
65
+ unitMultiplier: 1,
66
+ additionalProperty: [],
67
+ isVariantOf: { productGroupID, name: `Product ${id}` },
68
+ image: [{ url: `https://example.com/${id}.jpg`, alternateName: id }],
69
+ offers: {
70
+ lowPrice: 100,
71
+ lowPriceWithTaxes: 110,
72
+ offers: [
73
+ {
74
+ availability: 'https://schema.org/InStock',
75
+ listPrice: 120,
76
+ listPriceWithTaxes: 130,
77
+ price: 100,
78
+ priceWithTaxes: 110,
79
+ seller: { identifier: '1' },
80
+ priceToken: 'token',
81
+ quantity: 1,
82
+ },
83
+ ],
84
+ },
85
+ })
86
+
87
+ const result = (overrides: Record<string, unknown> = {}) => ({
88
+ items: [
89
+ recommendationProduct('p-1', 'pg-1'),
90
+ recommendationProduct('p-2', 'pg-2'),
91
+ ],
92
+ isLoading: false,
93
+ error: null,
94
+ campaign: { id: 'camp-1', title: 'You may also like', type: 'CROSS_SELL' },
95
+ correlationId: 'corr-1',
96
+ ...overrides,
97
+ })
98
+
99
+ beforeEach(() => {
100
+ useRecommendationShelf.mockReturnValue(result())
101
+ })
102
+
103
+ afterEach(() => {
104
+ vi.clearAllMocks()
105
+ })
106
+
107
+ describe('CartRecommendationShelf', () => {
108
+ it('delegates to RecommendationShelf with cart context', () => {
109
+ render(<CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />)
110
+
111
+ expect(useRecommendationShelf).toHaveBeenCalledWith({
112
+ campaignVrn: CAMPAIGN_VRN,
113
+ itemsContext: 'CART',
114
+ })
115
+ })
116
+
117
+ it('renders a card per recommended product under the campaign title', () => {
118
+ const { getAllByTestId, getByText } = render(
119
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
120
+ )
121
+
122
+ expect(getAllByTestId('product-card')).toHaveLength(2)
123
+ expect(getByText('You may also like')).toBeTruthy()
124
+ })
125
+
126
+ it('prefers the configured title over the campaign title', () => {
127
+ const { getByText, queryByText } = render(
128
+ <CartRecommendationShelf
129
+ campaignVrn={CAMPAIGN_VRN}
130
+ title="Complete your purchase"
131
+ />
132
+ )
133
+
134
+ expect(getByText('Complete your purchase')).toBeTruthy()
135
+ expect(queryByText('You may also like')).toBeNull()
136
+ })
137
+
138
+ it('renders nothing when the campaign returns no products', () => {
139
+ useRecommendationShelf.mockReturnValue(result({ items: [] }))
140
+
141
+ const { container } = render(
142
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
143
+ )
144
+
145
+ expect(container).toBeEmptyDOMElement()
146
+ })
147
+
148
+ it('renders nothing when the request fails, leaving the cart untouched', () => {
149
+ useRecommendationShelf.mockReturnValue(
150
+ result({ items: [], error: new Error('boom') })
151
+ )
152
+
153
+ const { container } = render(
154
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
155
+ )
156
+
157
+ expect(container).toBeEmptyDOMElement()
158
+ })
159
+
160
+ it('shows the skeleton while loading', () => {
161
+ useRecommendationShelf.mockReturnValue(
162
+ result({ items: [], isLoading: true })
163
+ )
164
+
165
+ const { getByTestId } = render(
166
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
167
+ )
168
+
169
+ expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true')
170
+ })
171
+
172
+ it('rounds a fractional items per page up for the skeleton', () => {
173
+ useRecommendationShelf.mockReturnValue(
174
+ result({ items: [], isLoading: true })
175
+ )
176
+
177
+ const { getByTestId } = render(
178
+ <CartRecommendationShelf
179
+ campaignVrn={CAMPAIGN_VRN}
180
+ carouselConfiguration={{ itemsPerPage: 1.5 }}
181
+ />
182
+ )
183
+
184
+ expect(getByTestId('skeleton').getAttribute('data-items-per-page')).toBe(
185
+ '2'
186
+ )
187
+ })
188
+
189
+ it('tags the shelf root so the cart drawer can target it', () => {
190
+ const { container } = render(
191
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
192
+ )
193
+
194
+ const shelf = container.querySelector('[data-fs-recommendation-shelf]')
195
+ expect(shelf).toBeTruthy()
196
+ expect(shelf?.hasAttribute('data-fs-cart-recommendation-shelf')).toBe(true)
197
+ })
198
+
199
+ it('tags impressions with Activity Flow attributes', () => {
200
+ const { container } = render(
201
+ <CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />
202
+ )
203
+
204
+ const shelf = container.querySelector(
205
+ '[data-af-element="recommendation-shelf"]'
206
+ )
207
+ expect(shelf).toBeTruthy()
208
+ expect(shelf?.getAttribute('data-af-campaign-id')).toBe('camp-1')
209
+ expect(shelf?.getAttribute('data-af-products')).toBe('pg-1, pg-2')
210
+
211
+ const firstItem = container.querySelector(
212
+ '[data-fs-recommendation-shelf-item]'
213
+ )
214
+ expect(firstItem?.getAttribute('data-af-element')).toBe(
215
+ 'recommendation-shelf-product'
216
+ )
217
+ expect(firstItem?.getAttribute('data-af-product-position')).toBe('1')
218
+ })
219
+
220
+ it('forwards the drawer taxes configuration to every card', () => {
221
+ const taxesConfiguration = {
222
+ usePriceWithTaxes: true,
223
+ taxesLabel: 'Tax included',
224
+ }
225
+
226
+ render(
227
+ <CartRecommendationShelf
228
+ campaignVrn={CAMPAIGN_VRN}
229
+ taxesConfiguration={taxesConfiguration}
230
+ />
231
+ )
232
+
233
+ expect(productCardProps).toHaveBeenCalledWith(
234
+ expect.objectContaining({ taxesConfiguration })
235
+ )
236
+ })
237
+
238
+ it('renders unbordered cards by default and honours the override', () => {
239
+ render(<CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />)
240
+
241
+ expect(productCardProps).toHaveBeenCalledWith(
242
+ expect.objectContaining({ bordered: false })
243
+ )
244
+
245
+ productCardProps.mockClear()
246
+
247
+ render(
248
+ <CartRecommendationShelf
249
+ campaignVrn={CAMPAIGN_VRN}
250
+ productCardConfiguration={{ bordered: true }}
251
+ />
252
+ )
253
+
254
+ expect(productCardProps).toHaveBeenCalledWith(
255
+ expect.objectContaining({ bordered: true })
256
+ )
257
+ })
258
+
259
+ it('uses CartRecommendationProductCard by default so cards expose add-to-cart', () => {
260
+ render(<CartRecommendationShelf campaignVrn={CAMPAIGN_VRN} />)
261
+
262
+ expect(productCardProps).toHaveBeenCalledWith(
263
+ expect.objectContaining({
264
+ buttonLabel: 'Add to cart',
265
+ onButtonClick: expect.any(Function),
266
+ })
267
+ )
268
+ })
269
+
270
+ it('forwards a custom ProductCard to RecommendationShelf', () => {
271
+ const CustomCard = (props: Record<string, unknown>) => {
272
+ productCardProps(props)
273
+ return React.createElement('div', { 'data-testid': 'custom-card' })
274
+ }
275
+
276
+ const { getAllByTestId, queryByTestId } = render(
277
+ <CartRecommendationShelf
278
+ campaignVrn={CAMPAIGN_VRN}
279
+ ProductCard={CustomCard}
280
+ />
281
+ )
282
+
283
+ expect(getAllByTestId('custom-card')).toHaveLength(2)
284
+ expect(queryByTestId('product-card')).toBeNull()
285
+ })
286
+ })