@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +93 -91
- package/CHANGELOG.md +21 -0
- package/cms/faststore/components/cms_component__cartsidebar.jsonc +74 -0
- package/cms/faststore/schema.json +78 -0
- package/cms/faststore/sections.json +78 -0
- package/package.json +6 -6
- package/src/components/cart/CartRecommendationShelf/CartRecommendationProductCard.tsx +88 -0
- package/src/components/cart/CartRecommendationShelf/CartRecommendationShelf.tsx +110 -0
- package/src/components/cart/CartRecommendationShelf/index.ts +4 -0
- package/src/components/cart/CartRecommendationShelf/section.module.scss +124 -0
- package/src/components/cart/CartSidebar/CartSidebar.tsx +47 -1
- package/src/components/product/ProductCard/ProductCard.tsx +5 -3
- package/src/components/sections/RecommendationShelf/README.md +74 -6
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +15 -3
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -2
- package/src/components/sections/RecommendationShelf/index.ts +7 -1
- package/src/sdk/recommendations/useRecommendationShelf.ts +2 -2
- package/test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx +125 -0
- package/test/components/CartRecommendationShelf/CartRecommendationShelf.browser.test.tsx +286 -0
- package/test/components/CartSidebar/CartSidebar.browser.test.tsx +209 -0
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +19 -0
- 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
|
|
@@ -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
|
|
76
|
-
*
|
|
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
|
package/test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx
ADDED
|
@@ -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
|
|
154
|
+
it('follows the live cart context', () => {
|
|
155
155
|
useCart.mockReturnValue({ items: [cartItem('pg-1')] })
|
|
156
156
|
|
|
157
157
|
const { rerender } = renderHook(() =>
|