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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +93 -91
  3. package/CHANGELOG.md +21 -0
  4. package/cms/faststore/components/cms_component__cartsidebar.jsonc +74 -0
  5. package/cms/faststore/schema.json +78 -0
  6. package/cms/faststore/sections.json +78 -0
  7. package/package.json +6 -6
  8. package/src/components/cart/CartRecommendationShelf/CartRecommendationProductCard.tsx +88 -0
  9. package/src/components/cart/CartRecommendationShelf/CartRecommendationShelf.tsx +110 -0
  10. package/src/components/cart/CartRecommendationShelf/index.ts +4 -0
  11. package/src/components/cart/CartRecommendationShelf/section.module.scss +124 -0
  12. package/src/components/cart/CartSidebar/CartSidebar.tsx +47 -1
  13. package/src/components/product/ProductCard/ProductCard.tsx +5 -3
  14. package/src/components/sections/RecommendationShelf/README.md +74 -6
  15. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +15 -3
  16. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -2
  17. package/src/components/sections/RecommendationShelf/index.ts +7 -1
  18. package/src/sdk/recommendations/useRecommendationShelf.ts +2 -2
  19. package/test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx +125 -0
  20. package/test/components/CartRecommendationShelf/CartRecommendationShelf.browser.test.tsx +286 -0
  21. package/test/components/CartSidebar/CartSidebar.browser.test.tsx +209 -0
  22. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +19 -0
  23. package/test/sdk/recommendations/useRecommendationShelf.browser.test.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.7.1-dev.2",
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.7.1-dev.2",
75
- "@faststore/lighthouse": "4.7.1-dev.2",
76
- "@faststore/ui": "4.7.1-dev.2",
77
- "@faststore/diagnostics": "4.7.1-dev.2",
78
- "@faststore/sdk": "4.7.1-dev.2"
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
- }: CartSidebarProps) {
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?: () => void
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 carry no presentation: they live in `src/sdk/recommendations` and are orchestrated by `useRecommendationShelf()`, which the shelf consumes.
150
+ Steps 1 to 4 live in `src/sdk/recommendations` behind `useRecommendationShelf()`. Step 5 is owned by `RecommendationShelf`; the mini cart shelf is a thin wrapper around it that locks cart context and applies drawer-safe carousel/tax defaults — so `ProductCard` / `mapProductToProductCard` work the same way on both surfaces.
114
151
 
115
152
  Related files:
116
153
 
117
154
  - Shared data layer: `src/sdk/recommendations/`
118
- - Shelf: `src/components/sections/RecommendationShelf/`
119
- - Hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
155
+ - Page shelf (also the presentation used by the mini cart): `src/components/sections/RecommendationShelf/`
156
+ - Mini cart wrapper: `src/components/cart/CartRecommendationShelf/`
157
+ - Session hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
120
158
  - Layout: `src/Layout.tsx`
121
159
  - Flag: `discovery.config.default.js` → `experimental.enableRecommendations`
122
160
 
@@ -143,7 +181,30 @@ import { RecommendationShelf } from 'src/components/sections/RecommendationShelf
143
181
  - **`ProductCard`**: custom card component (defaults to the core `ProductCard`).
144
182
  - **`mapProductToProductCard`**: maps each recommended product (normalized `StoreProduct`) into card props. When provided, it fully owns the card props and the default `productCardConfiguration` merge no longer applies.
145
183
 
146
- Campaign types and VRN validation are defined in `src/sdk/recommendations/vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the CMS schema `pattern`.
184
+ Campaign types and VRN validation are defined in `src/sdk/recommendations/vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the `pattern` of both CMS schemas (`cms_component__recommendationshelf.jsonc` and the `recommendations` group of `cms_component__cartsidebar.jsonc`).
185
+
186
+ The mini cart shelf (`CartRecommendationShelf`) reuses `RecommendationShelf`, so the same `ProductCard` / `mapProductToProductCard` overrides apply. By default it renders `CartRecommendationProductCard`, which adds an **Add to cart** action (first offer / SKU, drawer stays open). Because the drawer is rendered by `CartSidebar` from CMS props, passing a custom card from a store means customizing `CartSidebar` (or `CartRecommendationShelf`) under `src/customizations` and forwarding the override into `recommendations`.
187
+
188
+ ## CSS customization
189
+
190
+ The shelf root (`<section>`) exposes `data-fs-*` attributes as styling hooks, so store CSS can target the shelf without depending on layout class names (`section-product-shelf`, `layout__section`, …):
191
+
192
+ | Attribute | Present on | Purpose |
193
+ | --- | --- | --- |
194
+ | `data-fs-recommendation-shelf` | Every shelf (page and mini cart) | Root hook for the recommendation shelf. |
195
+ | `data-fs-cart-recommendation-shelf` | Mini cart shelf only | Distinguishes the drawer instance from page shelves. |
196
+
197
+ Related hooks on the same tree: `data-fs-recommendation-shelf-title` (heading) and `data-fs-recommendation-shelf-item` (each card wrapper).
198
+
199
+ ```scss
200
+ [data-fs-recommendation-shelf] {
201
+ /* page shelf and mini cart shelf */
202
+ }
203
+
204
+ [data-fs-cart-recommendation-shelf] {
205
+ /* mini cart shelf only */
206
+ }
207
+ ```
147
208
 
148
209
  ## Placement guidelines
149
210
 
@@ -153,6 +214,7 @@ As a global component, the shelf can be placed on any CMS page. Suggested placem
153
214
  - **PDP:** context-based campaigns with `itemsContext: PDP`.
154
215
  - **Cart:** cross-sell with `itemsContext: CART`.
155
216
  - **PLP:** context-agnostic campaigns.
217
+ - **Cart drawer (mini cart):** cross-sell, configured on the **Cart Sidebar** component. See [Mini cart shelf](#mini-cart-shelf).
156
218
 
157
219
  Prefer enabling `experimental.enableRecommendations` so the session is ready on every page. Stores without the flag and without a Recommendation Shelf never call `startRecommendationSession`.
158
220
 
@@ -171,6 +233,12 @@ Prefer enabling `experimental.enableRecommendations` so the session is ready on
171
233
  3. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
172
234
  4. Did the campaign return products? An empty response renders nothing by design.
173
235
 
236
+ **The mini cart shelf does not render.** Same checklist, plus:
237
+
238
+ 5. Is **Should display Recommendation Shelf?** enabled on the **Cart Sidebar** component (under **Recommendations**), not on a separate Recommendation Shelf? Only that shape opts the drawer in.
239
+ 6. With the discovery flag off, the session starts when the shelf mounts (when the drawer opens). Confirm `vtex-rec-user-start-session` and then `vtex-rec-user-id` after opening the cart.
240
+ 7. Is the cart empty? The mini cart shelf does not mount until there is at least one item. Use a page Recommendation Shelf for empty-cart campaigns.
241
+
174
242
  **Skeleton never resolves.** Inspect the console and the GraphQL `recommendations` response for pending or failing requests.
175
243
 
176
244
  **No tracking events.** Confirm that the Activity Flow script is present and that PDP `product:*` meta tags and shelf `data-af-*` attributes are rendered.
@@ -16,16 +16,20 @@ import type {
16
16
  } from './RecommendationShelf.types'
17
17
  import styles from './section.module.scss'
18
18
 
19
+ const DEFAULT_SECTION_CLASS_NAME = `${styles.section} section-product-shelf layout__section section`
20
+
19
21
  export function RecommendationShelf<
20
22
  TCardProps extends object = ProductCardProps,
21
23
  >({
22
24
  title,
23
25
  campaignVrn,
24
26
  itemsContext = 'PDP',
27
+ className,
25
28
  ProductCard,
26
29
  mapProductToProductCard,
27
30
  carouselConfiguration,
28
31
  productCardConfiguration,
32
+ ...otherProps
29
33
  }: RecommendationShelfProps<TCardProps>) {
30
34
  const {
31
35
  itemsPerPageDesktop = 4,
@@ -82,7 +86,8 @@ export function RecommendationShelf<
82
86
 
83
87
  return (
84
88
  <section
85
- className={`${styles.section} section-product-shelf layout__section section`}
89
+ className={className ?? DEFAULT_SECTION_CLASS_NAME}
90
+ {...otherProps}
86
91
  {...(shouldAddAFAttr
87
92
  ? {
88
93
  'data-af-element': 'recommendation-shelf' as const,
@@ -93,10 +98,17 @@ export function RecommendationShelf<
93
98
  'data-af-products': productIds,
94
99
  }
95
100
  : {})}
101
+ data-fs-recommendation-shelf
96
102
  >
97
- <ProductShelfSkeleton loading={isLoading} itemsPerPage={itemsPerPage}>
103
+ <ProductShelfSkeleton
104
+ loading={isLoading}
105
+ itemsPerPage={Math.ceil(itemsPerPage)}
106
+ >
98
107
  {(title || campaign?.title) && (
99
- <h2 className="text__title-section layout__content">
108
+ <h2
109
+ className="text__title-section layout__content"
110
+ data-fs-recommendation-shelf-title
111
+ >
100
112
  {title || campaign?.title}
101
113
  </h2>
102
114
  )}