@faststore/core 4.7.1-dev.2 → 4.8.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 (23) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +93 -91
  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
@@ -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
+ })
@@ -0,0 +1,209 @@
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('next/dynamic', () => ({
7
+ default: (loader: () => Promise<unknown>) => {
8
+ const source = Function.prototype.toString.call(loader)
9
+ const chunk = /webpackChunkName:\s*"([^"]+)"/.exec(source)?.[1] ?? 'dynamic'
10
+
11
+ return function DynamicStub({
12
+ children,
13
+ ...props
14
+ }: React.PropsWithChildren<Record<string, unknown>>) {
15
+ const serializableProps = Object.fromEntries(
16
+ Object.entries(props).filter(
17
+ ([, value]) =>
18
+ value == null ||
19
+ typeof value === 'string' ||
20
+ typeof value === 'number' ||
21
+ typeof value === 'boolean'
22
+ )
23
+ )
24
+
25
+ return React.createElement(
26
+ 'div',
27
+ {
28
+ 'data-testid': chunk,
29
+ 'data-props': JSON.stringify(serializableProps),
30
+ },
31
+ children
32
+ )
33
+ }
34
+ },
35
+ }))
36
+
37
+ const useUI = vi.hoisted(() => vi.fn())
38
+ vi.mock('@faststore/ui', () => ({
39
+ Icon: ({ name }: { name?: string }) =>
40
+ React.createElement('span', { 'data-testid': 'icon', 'data-name': name }),
41
+ useFadeEffect: () => ({ fadeOut: vi.fn() }),
42
+ useUI,
43
+ }))
44
+
45
+ const useCart = vi.hoisted(() => vi.fn())
46
+ vi.mock('src/sdk/cart', () => ({ useCart }))
47
+
48
+ vi.mock('src/sdk/cart/useCheckoutButton', () => ({
49
+ useCheckoutButton: () => ({
50
+ onClick: vi.fn(),
51
+ disabled: false,
52
+ 'data-testid': 'checkout-button',
53
+ }),
54
+ }))
55
+
56
+ vi.mock('src/sdk/session', () => ({
57
+ useSession: () => ({ currency: { code: 'USD' } }),
58
+ }))
59
+
60
+ vi.mock('@faststore/sdk', () => ({
61
+ sendAnalyticsEvent: vi.fn(),
62
+ }))
63
+
64
+ import CartSidebar from 'src/components/cart/CartSidebar'
65
+
66
+ const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1'
67
+
68
+ const cartItem = {
69
+ id: 'item-1',
70
+ quantity: 1,
71
+ price: 100,
72
+ listPrice: 120,
73
+ itemOffered: {
74
+ sku: 'sku-1',
75
+ name: 'SKU 1',
76
+ gtin: 'gtin-1',
77
+ brand: { name: 'Brand' },
78
+ isVariantOf: { productGroupID: 'pg-1', name: 'Product 1' },
79
+ },
80
+ }
81
+
82
+ const sidebarProps = {
83
+ title: 'Your cart',
84
+ alert: {
85
+ icon: { icon: 'Truck', alt: 'Truck' },
86
+ text: 'Free shipping',
87
+ },
88
+ emptyCart: {
89
+ title: 'Your Cart is empty',
90
+ buttonLabel: 'Start Shopping',
91
+ },
92
+ checkoutButton: {
93
+ label: 'Checkout',
94
+ loadingLabel: 'Loading...',
95
+ icon: { icon: 'ArrowRight', alt: 'Arrow Right' },
96
+ },
97
+ quantitySelector: { useUnitMultiplier: false },
98
+ }
99
+
100
+ const enabledRecommendations = {
101
+ shouldDisplayRecommendationShelf: true,
102
+ campaignVrn: CAMPAIGN_VRN,
103
+ title: 'You may also like',
104
+ }
105
+
106
+ function filledCart() {
107
+ useCart.mockReturnValue({
108
+ items: [cartItem],
109
+ gifts: [],
110
+ totalItems: 1,
111
+ isValidating: false,
112
+ subTotal: 100,
113
+ total: 100,
114
+ subTotalWithTaxes: 110,
115
+ totalWithTaxes: 110,
116
+ })
117
+ }
118
+
119
+ function emptyCart() {
120
+ useCart.mockReturnValue({
121
+ items: [],
122
+ gifts: [],
123
+ totalItems: 0,
124
+ isValidating: false,
125
+ subTotal: 0,
126
+ total: 0,
127
+ subTotalWithTaxes: 0,
128
+ totalWithTaxes: 0,
129
+ })
130
+ }
131
+
132
+ beforeEach(() => {
133
+ useUI.mockReturnValue({ cart: true, closeCart: vi.fn() })
134
+ filledCart()
135
+ })
136
+
137
+ afterEach(() => {
138
+ vi.clearAllMocks()
139
+ })
140
+
141
+ describe('CartSidebar recommendation shelf gate', () => {
142
+ it('does not mount the shelf when the display toggle is off', () => {
143
+ const { queryByTestId } = render(
144
+ <CartSidebar
145
+ {...sidebarProps}
146
+ recommendations={{
147
+ shouldDisplayRecommendationShelf: false,
148
+ campaignVrn: CAMPAIGN_VRN,
149
+ }}
150
+ />
151
+ )
152
+
153
+ expect(queryByTestId('CartRecommendationShelf')).toBeNull()
154
+ expect(queryByTestId('OrderSummary')).toBeTruthy()
155
+ })
156
+
157
+ it('does not mount the shelf when the toggle is on but campaign VRN is missing', () => {
158
+ const { queryByTestId } = render(
159
+ <CartSidebar
160
+ {...sidebarProps}
161
+ recommendations={{ shouldDisplayRecommendationShelf: true }}
162
+ />
163
+ )
164
+
165
+ expect(queryByTestId('CartRecommendationShelf')).toBeNull()
166
+ })
167
+
168
+ it('does not mount the shelf on an empty cart, even with the toggle on', () => {
169
+ emptyCart()
170
+
171
+ const { queryByTestId } = render(
172
+ <CartSidebar {...sidebarProps} recommendations={enabledRecommendations} />
173
+ )
174
+
175
+ expect(queryByTestId('EmptyCart')).toBeTruthy()
176
+ expect(queryByTestId('CartRecommendationShelf')).toBeNull()
177
+ expect(queryByTestId('OrderSummary')).toBeNull()
178
+ })
179
+
180
+ it('renders the shelf before OrderSummary when the cart has items', () => {
181
+ const { getByTestId } = render(
182
+ <CartSidebar {...sidebarProps} recommendations={enabledRecommendations} />
183
+ )
184
+
185
+ const footerChildren = Array.from(
186
+ getByTestId('UICartSidebarFooter').children
187
+ )
188
+
189
+ expect(footerChildren[0]).toHaveAttribute(
190
+ 'data-testid',
191
+ 'CartRecommendationShelf'
192
+ )
193
+ expect(footerChildren[1]).toHaveAttribute('data-testid', 'OrderSummary')
194
+ })
195
+
196
+ it('omits shouldDisplayRecommendationShelf from the shelf props', () => {
197
+ const { getByTestId } = render(
198
+ <CartSidebar {...sidebarProps} recommendations={enabledRecommendations} />
199
+ )
200
+
201
+ const props = JSON.parse(
202
+ getByTestId('CartRecommendationShelf').getAttribute('data-props') ?? '{}'
203
+ ) as Record<string, unknown>
204
+
205
+ expect(props).not.toHaveProperty('shouldDisplayRecommendationShelf')
206
+ expect(props.campaignVrn).toBe(CAMPAIGN_VRN)
207
+ expect(props.title).toBe('You may also like')
208
+ })
209
+ })
@@ -75,6 +75,25 @@ afterEach(() => {
75
75
  })
76
76
 
77
77
  describe('RecommendationShelf', () => {
78
+ it('forwards extra props onto the root section', async () => {
79
+ useRecommendations.mockReturnValue({
80
+ data: recommendationData,
81
+ isLoading: false,
82
+ error: null,
83
+ })
84
+
85
+ const { getByTestId } = render(
86
+ <RecommendationShelf
87
+ campaignVrn={CAMPAIGN_VRN}
88
+ data-testid="recommendation-shelf"
89
+ />
90
+ )
91
+
92
+ await waitFor(() => {
93
+ expect(getByTestId('recommendation-shelf').tagName).toBe('SECTION')
94
+ })
95
+ })
96
+
78
97
  it('renders a product card per recommended product', async () => {
79
98
  useRecommendations.mockReturnValue({
80
99
  data: recommendationData,
@@ -151,7 +151,7 @@ describe('useRecommendationShelf', () => {
151
151
  })
152
152
  })
153
153
 
154
- it('follows the live cart context when not frozen', () => {
154
+ it('follows the live cart context', () => {
155
155
  useCart.mockReturnValue({ items: [cartItem('pg-1')] })
156
156
 
157
157
  const { rerender } = renderHook(() =>