@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0-dev.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
"style-loader": "^3.3.1",
|
|
72
72
|
"swr": "^2.2.5",
|
|
73
73
|
"use-sync-external-store": "^1.6.0",
|
|
74
|
-
"@faststore/api": "4.
|
|
75
|
-
"@faststore/
|
|
76
|
-
"@faststore/
|
|
77
|
-
"@faststore/
|
|
78
|
-
"@faststore/
|
|
74
|
+
"@faststore/api": "4.8.0-dev.1",
|
|
75
|
+
"@faststore/diagnostics": "4.8.0-dev.1",
|
|
76
|
+
"@faststore/lighthouse": "4.8.0-dev.1",
|
|
77
|
+
"@faststore/sdk": "4.8.0-dev.1",
|
|
78
|
+
"@faststore/ui": "4.8.0-dev.1"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@cypress/code-coverage": "^3.12.1",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { MouseEventHandler } from 'react'
|
|
2
|
+
|
|
3
|
+
import ProductCard, {
|
|
4
|
+
type ProductCardProps,
|
|
5
|
+
} from 'src/components/product/ProductCard'
|
|
6
|
+
import { useBuyButton } from 'src/sdk/cart/useBuyButton'
|
|
7
|
+
|
|
8
|
+
export interface CartRecommendationProductCardProps extends ProductCardProps {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Product card used by the cart drawer recommendation shelf. Same surface as
|
|
12
|
+
* the page shelf card, plus an add-to-cart action that appends the recommended
|
|
13
|
+
* SKU without reopening the drawer (`shouldOpenCart: false`).
|
|
14
|
+
*
|
|
15
|
+
* Adds the first offer returned by the recommendations API (the card already
|
|
16
|
+
* represents a specific `StoreProduct` / SKU). Multi-SKU matrix selection is
|
|
17
|
+
* out of scope here — shoppers who need another variant can open the PDP.
|
|
18
|
+
*/
|
|
19
|
+
function CartRecommendationProductCard({
|
|
20
|
+
product,
|
|
21
|
+
buttonLabel = 'Add to cart',
|
|
22
|
+
onButtonClick,
|
|
23
|
+
...otherProps
|
|
24
|
+
}: Readonly<CartRecommendationProductCardProps>) {
|
|
25
|
+
const {
|
|
26
|
+
id,
|
|
27
|
+
sku,
|
|
28
|
+
gtin,
|
|
29
|
+
brand,
|
|
30
|
+
isVariantOf,
|
|
31
|
+
isVariantOf: { name },
|
|
32
|
+
unitMultiplier,
|
|
33
|
+
image: [img],
|
|
34
|
+
offers: {
|
|
35
|
+
offers: [
|
|
36
|
+
{
|
|
37
|
+
listPrice,
|
|
38
|
+
price,
|
|
39
|
+
listPriceWithTaxes,
|
|
40
|
+
seller,
|
|
41
|
+
priceWithTaxes,
|
|
42
|
+
priceToken,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
additionalProperty,
|
|
47
|
+
} = product
|
|
48
|
+
|
|
49
|
+
const buyProps = useBuyButton(
|
|
50
|
+
{
|
|
51
|
+
id,
|
|
52
|
+
price,
|
|
53
|
+
priceWithTaxes,
|
|
54
|
+
listPrice,
|
|
55
|
+
listPriceWithTaxes,
|
|
56
|
+
seller,
|
|
57
|
+
quantity: 1,
|
|
58
|
+
priceToken,
|
|
59
|
+
itemOffered: {
|
|
60
|
+
sku,
|
|
61
|
+
name,
|
|
62
|
+
gtin,
|
|
63
|
+
image: [img],
|
|
64
|
+
brand,
|
|
65
|
+
isVariantOf,
|
|
66
|
+
additionalProperty,
|
|
67
|
+
unitMultiplier,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
false
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {
|
|
74
|
+
buyProps.onClick(event)
|
|
75
|
+
onButtonClick?.(event)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ProductCard
|
|
80
|
+
{...otherProps}
|
|
81
|
+
product={product}
|
|
82
|
+
buttonLabel={buttonLabel}
|
|
83
|
+
onButtonClick={handleClick}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default CartRecommendationProductCard
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CarouselProps } from '@faststore/ui'
|
|
2
|
+
import type { ProductSummary_ProductFragment } from '@generated/graphql'
|
|
3
|
+
|
|
4
|
+
import type { ProductCardProps } from 'src/components/product/ProductCard'
|
|
5
|
+
import {
|
|
6
|
+
RecommendationShelf,
|
|
7
|
+
type RecommendationProductCardMapper,
|
|
8
|
+
type RecommendationShelfProps,
|
|
9
|
+
} from 'src/components/sections/RecommendationShelf'
|
|
10
|
+
|
|
11
|
+
import CartRecommendationProductCard from './CartRecommendationProductCard'
|
|
12
|
+
import styles from './section.module.scss'
|
|
13
|
+
|
|
14
|
+
export type CartRecommendationShelfProps<
|
|
15
|
+
TCardProps extends object = ProductCardProps,
|
|
16
|
+
> = Omit<
|
|
17
|
+
RecommendationShelfProps<TCardProps>,
|
|
18
|
+
'itemsContext' | 'className' | 'carouselConfiguration'
|
|
19
|
+
> & {
|
|
20
|
+
/**
|
|
21
|
+
* Drawer-safe carousel configuration. Mapped onto
|
|
22
|
+
* `RecommendationShelf`'s desktop/mobile paging props (the drawer is the
|
|
23
|
+
* same width on every breakpoint, so both viewports share this count).
|
|
24
|
+
*/
|
|
25
|
+
readonly carouselConfiguration?: {
|
|
26
|
+
/**
|
|
27
|
+
* Products visible at once. Fractional values (e.g. `1.5`) hint that the
|
|
28
|
+
* carousel scrolls.
|
|
29
|
+
* @default 1
|
|
30
|
+
*/
|
|
31
|
+
readonly itemsPerPage?: number
|
|
32
|
+
/** @default 'scroll' */
|
|
33
|
+
readonly variant?: CarouselProps['variant']
|
|
34
|
+
/** @default false */
|
|
35
|
+
readonly infiniteMode?: CarouselProps['infiniteMode']
|
|
36
|
+
/** @default 'navigationArrows' */
|
|
37
|
+
readonly controls?: CarouselProps['controls']
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Forwarded from the cart drawer so recommended products display prices under
|
|
41
|
+
* the same tax rules as the cart items next to them. Applied through the
|
|
42
|
+
* default `mapProductToProductCard`; a custom mapper is responsible for
|
|
43
|
+
* forwarding it itself.
|
|
44
|
+
*/
|
|
45
|
+
readonly taxesConfiguration?: ProductCardProps['taxesConfiguration']
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compact recommendation shelf for the cart drawer.
|
|
50
|
+
*
|
|
51
|
+
* Presentation is owned by `RecommendationShelf` so code-level overrides
|
|
52
|
+
* (`ProductCard`, `mapProductToProductCard`, …) work the same way as on the
|
|
53
|
+
* page shelf. This wrapper only locks cart-specific behaviour: cart context,
|
|
54
|
+
* drawer-safe carousel defaults, tax/image sizing for the narrow panel, and the
|
|
55
|
+
* default `CartRecommendationProductCard` (add-to-cart without reopening the
|
|
56
|
+
* drawer).
|
|
57
|
+
*/
|
|
58
|
+
function CartRecommendationShelf<TCardProps extends object = ProductCardProps>({
|
|
59
|
+
title,
|
|
60
|
+
campaignVrn,
|
|
61
|
+
carouselConfiguration,
|
|
62
|
+
productCardConfiguration,
|
|
63
|
+
taxesConfiguration,
|
|
64
|
+
ProductCard,
|
|
65
|
+
mapProductToProductCard,
|
|
66
|
+
}: CartRecommendationShelfProps<TCardProps>) {
|
|
67
|
+
const {
|
|
68
|
+
itemsPerPage = 1,
|
|
69
|
+
variant = 'scroll',
|
|
70
|
+
infiniteMode = false,
|
|
71
|
+
controls = 'navigationArrows',
|
|
72
|
+
} = carouselConfiguration ?? {}
|
|
73
|
+
|
|
74
|
+
const mapToCardProps: RecommendationProductCardMapper<TCardProps> =
|
|
75
|
+
mapProductToProductCard ??
|
|
76
|
+
((product: ProductSummary_ProductFragment, index: number) =>
|
|
77
|
+
({
|
|
78
|
+
product,
|
|
79
|
+
index,
|
|
80
|
+
bordered: productCardConfiguration?.bordered ?? false,
|
|
81
|
+
showDiscountBadge: productCardConfiguration?.showDiscountBadge ?? true,
|
|
82
|
+
taxesConfiguration,
|
|
83
|
+
imgProps: { sizes: '(max-width: 768px) 45vw, 15vw' },
|
|
84
|
+
}) as TCardProps)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<RecommendationShelf<TCardProps>
|
|
88
|
+
title={title}
|
|
89
|
+
campaignVrn={campaignVrn}
|
|
90
|
+
itemsContext="CART"
|
|
91
|
+
className={styles.section}
|
|
92
|
+
ProductCard={
|
|
93
|
+
(ProductCard ??
|
|
94
|
+
CartRecommendationProductCard) as RecommendationShelfProps<TCardProps>['ProductCard']
|
|
95
|
+
}
|
|
96
|
+
mapProductToProductCard={mapToCardProps}
|
|
97
|
+
productCardConfiguration={productCardConfiguration}
|
|
98
|
+
carouselConfiguration={{
|
|
99
|
+
itemsPerPageDesktop: itemsPerPage,
|
|
100
|
+
itemsPerPageMobile: itemsPerPage,
|
|
101
|
+
variant,
|
|
102
|
+
infiniteMode,
|
|
103
|
+
controls,
|
|
104
|
+
}}
|
|
105
|
+
data-fs-cart-recommendation-shelf
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default CartRecommendationShelf
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default } from './CartRecommendationShelf'
|
|
2
|
+
export type { CartRecommendationShelfProps } from './CartRecommendationShelf'
|
|
3
|
+
export { default as CartRecommendationProductCard } from './CartRecommendationProductCard'
|
|
4
|
+
export type { CartRecommendationProductCardProps } from './CartRecommendationProductCard'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
@use "sass:meta";
|
|
2
|
+
|
|
3
|
+
@layer components {
|
|
4
|
+
// Rendered as a direct child of `[data-fs-cart-sidebar]`, which is a flex
|
|
5
|
+
// column whose footer is sticky. The shelf therefore takes its natural height
|
|
6
|
+
// and never pushes the checkout button out of reach.
|
|
7
|
+
// Passed to `RecommendationShelf` as `className`, so page-section utilities
|
|
8
|
+
// (`section-product-shelf`, `layout__section`, …) are not applied.
|
|
9
|
+
.section {
|
|
10
|
+
--fs-product-shelf-items-padding-top: 0;
|
|
11
|
+
--fs-product-shelf-items-padding-bottom: var(--fs-spacing-1);
|
|
12
|
+
--fs-carousel-item-margin-right: var(--fs-spacing-2);
|
|
13
|
+
|
|
14
|
+
padding: var(--fs-spacing-2) 0;
|
|
15
|
+
border-top: var(--fs-border-width) solid var(--fs-border-color-light);
|
|
16
|
+
|
|
17
|
+
[data-fs-recommendation-shelf-title] {
|
|
18
|
+
padding: 0 var(--fs-spacing-3) var(--fs-spacing-2);
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: var(--fs-text-size-2);
|
|
21
|
+
font-weight: var(--fs-text-weight-semibold);
|
|
22
|
+
color: var(--fs-color-text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
[data-fs-carousel],
|
|
26
|
+
[data-fs-product-shelf-skeleton] {
|
|
27
|
+
padding: 0 var(--fs-spacing-3);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
[data-fs-recommendation-shelf-item] {
|
|
31
|
+
display: flex;
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: 100%;
|
|
34
|
+
|
|
35
|
+
> * {
|
|
36
|
+
width: 100%;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The drawer is ~340px wide on every breakpoint, so two cards land under
|
|
41
|
+
// the card's default 10rem min-width and would overflow the track. Let the
|
|
42
|
+
// card follow the carousel item width and scale its type down with it.
|
|
43
|
+
[data-fs-product-card] {
|
|
44
|
+
--fs-product-card-min-width: 0;
|
|
45
|
+
--fs-product-card-gap: var(--fs-spacing-1);
|
|
46
|
+
--fs-product-card-padding: var(--fs-spacing-1);
|
|
47
|
+
--fs-product-card-title-size: var(--fs-text-size-1);
|
|
48
|
+
--fs-product-card-price-size: var(--fs-text-size-1);
|
|
49
|
+
--fs-product-card-title-max-lines: 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Full-width add-to-cart so the action stays tappable on ~half-drawer cards.
|
|
53
|
+
[data-fs-product-card-actions] {
|
|
54
|
+
width: 100%;
|
|
55
|
+
|
|
56
|
+
[data-fs-button] {
|
|
57
|
+
--fs-button-height: var(--fs-spacing-6);
|
|
58
|
+
--fs-button-text-size: var(--fs-text-size-1);
|
|
59
|
+
--fs-button-padding: var(--fs-spacing-0) var(--fs-spacing-1);
|
|
60
|
+
--fs-button-gap: var(--fs-spacing-1);
|
|
61
|
+
|
|
62
|
+
width: 100%;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Button, Icon, Badge, Link, Price and ProductPrice already load in
|
|
67
|
+
// CartSidebar; this chunk only adds styles the drawer does not have.
|
|
68
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/SROnly/styles");
|
|
69
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Skeleton/styles");
|
|
70
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/Carousel/styles");
|
|
71
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/DiscountBadge/styles");
|
|
72
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/Rating/styles");
|
|
73
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCard/styles");
|
|
74
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/ProductCardSkeleton/styles");
|
|
75
|
+
@include meta.load-css("~@faststore/ui/src/components/organisms/ProductShelf/styles");
|
|
76
|
+
|
|
77
|
+
// Keep the product fully visible: `cover` plus a height cap was clipping
|
|
78
|
+
// photos on both the wide drawer and the narrow one. A square box with
|
|
79
|
+
// `contain` stays compact without cropping.
|
|
80
|
+
[data-fs-product-card] [data-fs-product-card-image],
|
|
81
|
+
[data-fs-product-card-skeleton-image] {
|
|
82
|
+
width: min(100%, 7.5rem);
|
|
83
|
+
aspect-ratio: 1;
|
|
84
|
+
margin-inline: auto;
|
|
85
|
+
background-color: var(--fs-color-neutral-1);
|
|
86
|
+
|
|
87
|
+
img {
|
|
88
|
+
object-fit: contain;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[data-fs-product-card-skeleton] {
|
|
93
|
+
--fs-product-card-skeleton-sectioned-min-width: 0;
|
|
94
|
+
--fs-product-card-skeleton-padding: var(--fs-spacing-1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The drawer (and `[data-fs-product-shelf-items]`) clips overflow, so
|
|
98
|
+
// arrows must stay inside the padded carousel — not offset outside it.
|
|
99
|
+
[data-fs-product-shelf-items] {
|
|
100
|
+
overflow: visible;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[data-fs-carousel] {
|
|
104
|
+
overflow: visible;
|
|
105
|
+
|
|
106
|
+
[data-fs-carousel-track] {
|
|
107
|
+
padding: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
[data-fs-carousel-controls] {
|
|
111
|
+
width: 100%;
|
|
112
|
+
pointer-events: auto;
|
|
113
|
+
opacity: 1;
|
|
114
|
+
|
|
115
|
+
[data-fs-button] {
|
|
116
|
+
--fs-button-height: var(--fs-spacing-6);
|
|
117
|
+
|
|
118
|
+
width: var(--fs-button-height);
|
|
119
|
+
min-width: var(--fs-button-height);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import type { CurrencyCode, ViewCartEvent } from '@faststore/sdk'
|
|
9
9
|
import { Icon, useFadeEffect, useUI } from '@faststore/ui'
|
|
10
10
|
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
|
11
|
+
import type { CartRecommendationShelfProps } from '../CartRecommendationShelf'
|
|
11
12
|
import { useCart } from 'src/sdk/cart'
|
|
12
13
|
import { useCheckoutButton } from 'src/sdk/cart/useCheckoutButton'
|
|
13
14
|
import { useSession } from 'src/sdk/session'
|
|
@@ -58,6 +59,15 @@ const OrderSummary = dynamic(
|
|
|
58
59
|
() => import(/* webpackChunkName: "OrderSummary" */ '../OrderSummary'),
|
|
59
60
|
{ ssr: false }
|
|
60
61
|
)
|
|
62
|
+
// Kept in its own chunk so stores without a mini cart shelf never download the
|
|
63
|
+
// shelf or the product card/carousel styles it pulls in.
|
|
64
|
+
const CartRecommendationShelf = dynamic(
|
|
65
|
+
() =>
|
|
66
|
+
import(
|
|
67
|
+
/* webpackChunkName: "CartRecommendationShelf" */ '../CartRecommendationShelf'
|
|
68
|
+
),
|
|
69
|
+
{ ssr: false }
|
|
70
|
+
)
|
|
61
71
|
|
|
62
72
|
import styles from './section.module.scss'
|
|
63
73
|
|
|
@@ -127,6 +137,20 @@ export interface CartSidebarProps {
|
|
|
127
137
|
usePriceWithTaxes?: boolean
|
|
128
138
|
taxesLabel?: string
|
|
129
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Optional recommendation shelf rendered inside the drawer. Off by default
|
|
142
|
+
* (`shouldDisplayRecommendationShelf: false`) so the drawer stays as it was:
|
|
143
|
+
* no personalization session, no request, nothing rendered.
|
|
144
|
+
*
|
|
145
|
+
* Taxes are not configured here — the shelf reuses the drawer's own
|
|
146
|
+
* `taxesConfiguration` so recommended products and cart items price alike.
|
|
147
|
+
* `ProductCard` / `mapProductToProductCard` are code-level overrides (same
|
|
148
|
+
* contract as the page `RecommendationShelf`) and are omitted from the CMS
|
|
149
|
+
* schema; store customizations can still pass them when wrapping this section.
|
|
150
|
+
*/
|
|
151
|
+
recommendations?: Omit<CartRecommendationShelfProps, 'taxesConfiguration'> & {
|
|
152
|
+
shouldDisplayRecommendationShelf?: boolean
|
|
153
|
+
}
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
function CartSidebar({
|
|
@@ -143,7 +167,8 @@ function CartSidebar({
|
|
|
143
167
|
},
|
|
144
168
|
quantitySelector,
|
|
145
169
|
taxesConfiguration,
|
|
146
|
-
|
|
170
|
+
recommendations,
|
|
171
|
+
}: Readonly<CartSidebarProps>) {
|
|
147
172
|
const btnProps = useCheckoutButton()
|
|
148
173
|
const {
|
|
149
174
|
items,
|
|
@@ -161,6 +186,26 @@ function CartSidebar({
|
|
|
161
186
|
|
|
162
187
|
const isEmpty = useMemo(() => items.length === 0, [items])
|
|
163
188
|
|
|
189
|
+
// The mini cart shelf does not mount until the cart has at least one item,
|
|
190
|
+
// so empty carts never fetch — including context-agnostic campaigns. The
|
|
191
|
+
// element is only inserted in the non-empty footer below.
|
|
192
|
+
let recommendationShelf: ReactNode = null
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
recommendations?.shouldDisplayRecommendationShelf &&
|
|
196
|
+
recommendations.campaignVrn
|
|
197
|
+
) {
|
|
198
|
+
const { shouldDisplayRecommendationShelf: _, ...recommendationShelfProps } =
|
|
199
|
+
recommendations
|
|
200
|
+
|
|
201
|
+
recommendationShelf = (
|
|
202
|
+
<CartRecommendationShelf
|
|
203
|
+
{...recommendationShelfProps}
|
|
204
|
+
taxesConfiguration={taxesConfiguration}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
164
209
|
useEffect(() => {
|
|
165
210
|
if (!displayCart) {
|
|
166
211
|
return
|
|
@@ -214,6 +259,7 @@ function CartSidebar({
|
|
|
214
259
|
</UICartSidebarList>
|
|
215
260
|
|
|
216
261
|
<UICartSidebarFooter>
|
|
262
|
+
{recommendationShelf}
|
|
217
263
|
<OrderSummary
|
|
218
264
|
subTotal={
|
|
219
265
|
taxesConfiguration?.usePriceWithTaxes
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ProductCardImage as UIProductCardImage,
|
|
5
5
|
ProductComparisonTrigger as UIProductComparisonTrigger,
|
|
6
6
|
} from '@faststore/ui'
|
|
7
|
-
import { memo, useMemo } from 'react'
|
|
7
|
+
import { memo, useMemo, type MouseEventHandler } from 'react'
|
|
8
8
|
|
|
9
9
|
import { gql } from '@generated'
|
|
10
10
|
import type { ProductSummary_ProductFragment } from '@generated/graphql'
|
|
@@ -42,9 +42,10 @@ export interface ProductCardProps {
|
|
|
42
42
|
*/
|
|
43
43
|
ratingValue?: number
|
|
44
44
|
/**
|
|
45
|
-
* Callback function when button is clicked.
|
|
45
|
+
* Callback function when the card action button is clicked. When set, the
|
|
46
|
+
* button is rendered (unless the product is out of stock).
|
|
46
47
|
*/
|
|
47
|
-
onButtonClick?:
|
|
48
|
+
onButtonClick?: MouseEventHandler<HTMLButtonElement>
|
|
48
49
|
/**
|
|
49
50
|
* Specifies the button's label.
|
|
50
51
|
*/
|
|
@@ -178,6 +179,7 @@ function ProductCard({
|
|
|
178
179
|
}}
|
|
179
180
|
ratingValue={ratingValue}
|
|
180
181
|
outOfStock={outOfStock}
|
|
182
|
+
buttonLabel={buttonLabel}
|
|
181
183
|
onButtonClick={onButtonClick}
|
|
182
184
|
linkProps={linkProps}
|
|
183
185
|
showDiscountBadge={hasDiscount && showDiscountBadge}
|
|
@@ -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
|
)}
|