@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
|
@@ -2985,6 +2985,84 @@
|
|
|
2985
2985
|
"default": "Tax included"
|
|
2986
2986
|
}
|
|
2987
2987
|
}
|
|
2988
|
+
},
|
|
2989
|
+
"recommendations": {
|
|
2990
|
+
"title": "Recommendations",
|
|
2991
|
+
"description": "Optional shelf of personalized product recommendations rendered inside the cart drawer, between the cart items and the order summary.",
|
|
2992
|
+
"type": "object",
|
|
2993
|
+
"properties": {
|
|
2994
|
+
"shouldDisplayRecommendationShelf": {
|
|
2995
|
+
"title": "Should display Recommendation Shelf?",
|
|
2996
|
+
"description": "When enabled, fetches recommendations when the shopper opens the cart. Off by default so stores that do not use Recommendations never hit the Recommendations API. The personalization session starts via experimental.enableRecommendations, or as a fallback when this shelf mounts.",
|
|
2997
|
+
"type": "boolean",
|
|
2998
|
+
"default": false
|
|
2999
|
+
},
|
|
3000
|
+
"title": {
|
|
3001
|
+
"title": "Title",
|
|
3002
|
+
"description": "Override the shelf title. If not set, the campaign title will be used.",
|
|
3003
|
+
"type": "string"
|
|
3004
|
+
},
|
|
3005
|
+
"campaignVrn": {
|
|
3006
|
+
"title": "Campaign VRN",
|
|
3007
|
+
"description": "Recommendation campaign VRN (e.g. vrn:recommendations:my-account:rec-cross-v2:abc123). The mini cart shelf only mounts when the cart has items, so it never fetches or renders on an empty cart — including context-agnostic campaigns (top sellers, personalized, last seen, search-based). Use a page Recommendation Shelf for empty-cart campaigns.",
|
|
3008
|
+
"type": "string",
|
|
3009
|
+
"pattern": "^vrn:recommendations:[^:]+:(rec-cross-v2|rec-similar-v2|rec-persona-v2|rec-last-v2|rec-top-items-v2|rec-search-v2|rec-next-v2|rec-visual-v2):[^:]+$"
|
|
3010
|
+
},
|
|
3011
|
+
"carouselConfiguration": {
|
|
3012
|
+
"title": "Carousel Configuration",
|
|
3013
|
+
"type": "object",
|
|
3014
|
+
"properties": {
|
|
3015
|
+
"itemsPerPage": {
|
|
3016
|
+
"title": "Items per page",
|
|
3017
|
+
"description": "Number of products visible at once. Fractional values such as 1.5 hint that the carousel scrolls.",
|
|
3018
|
+
"type": "number",
|
|
3019
|
+
"default": 1
|
|
3020
|
+
},
|
|
3021
|
+
"variant": {
|
|
3022
|
+
"title": "Carousel track variant",
|
|
3023
|
+
"description": "How the carousel navigates between items.",
|
|
3024
|
+
"type": "string",
|
|
3025
|
+
"default": "scroll",
|
|
3026
|
+
"enum": ["slide", "scroll"],
|
|
3027
|
+
"enumNames": ["Slide", "Scroll"]
|
|
3028
|
+
},
|
|
3029
|
+
"infiniteMode": {
|
|
3030
|
+
"title": "Infinite navigation?",
|
|
3031
|
+
"description": "Only applies to the 'slide' variant.",
|
|
3032
|
+
"type": "boolean",
|
|
3033
|
+
"default": false
|
|
3034
|
+
},
|
|
3035
|
+
"controls": {
|
|
3036
|
+
"title": "Navigation controls",
|
|
3037
|
+
"description": "Which navigation elements should be visible.",
|
|
3038
|
+
"type": "string",
|
|
3039
|
+
"default": "navigationArrows",
|
|
3040
|
+
"enum": ["complete", "navigationArrows", "paginationBullets"],
|
|
3041
|
+
"enumNames": [
|
|
3042
|
+
"Arrows and bullets",
|
|
3043
|
+
"Arrows only",
|
|
3044
|
+
"Bullets only"
|
|
3045
|
+
]
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
},
|
|
3049
|
+
"productCardConfiguration": {
|
|
3050
|
+
"title": "Product Card Configuration",
|
|
3051
|
+
"type": "object",
|
|
3052
|
+
"properties": {
|
|
3053
|
+
"showDiscountBadge": {
|
|
3054
|
+
"title": "Show discount badge?",
|
|
3055
|
+
"type": "boolean",
|
|
3056
|
+
"default": true
|
|
3057
|
+
},
|
|
3058
|
+
"bordered": {
|
|
3059
|
+
"title": "Cards should be bordered?",
|
|
3060
|
+
"type": "boolean",
|
|
3061
|
+
"default": false
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
2988
3066
|
}
|
|
2989
3067
|
}
|
|
2990
3068
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0-dev.0",
|
|
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.0",
|
|
75
|
+
"@faststore/lighthouse": "4.8.0-dev.0",
|
|
76
|
+
"@faststore/sdk": "4.8.0-dev.0",
|
|
77
|
+
"@faststore/ui": "4.8.0-dev.0",
|
|
78
|
+
"@faststore/diagnostics": "4.8.0-dev.0"
|
|
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}
|