@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +104 -104
- 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
|
@@ -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
|
|
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
|
-
-
|
|
119
|
-
-
|
|
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
|
|
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={
|
|
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
|
|
103
|
+
<ProductShelfSkeleton
|
|
104
|
+
loading={isLoading}
|
|
105
|
+
itemsPerPage={Math.ceil(itemsPerPage)}
|
|
106
|
+
>
|
|
98
107
|
{(title || campaign?.title) && (
|
|
99
|
-
<h2
|
|
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
|
|
@@ -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
|
+
})
|