@salesforce/retail-react-app 10.3.0-nightly-20261008080708 → 10.3.0-preview.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/CHANGELOG.md +4 -5
- package/app/components/_app/index.jsx +3 -145
- package/app/components/_app/index.test.js +2 -100
- package/app/components/inline-agent-widget/index.jsx +3 -17
- package/app/components/shopper-agent/index.jsx +6 -13
- package/app/components/shopper-agent/index.test.js +0 -59
- package/app/hooks/use-inline-agent-widget.js +1 -49
- package/app/mocks/page-designer.js +0 -26
- package/app/page-designer/layouts/index.js +0 -1
- package/app/page-designer/registry.js +0 -8
- package/app/pages/product-detail/index.jsx +1 -10
- package/app/routes.jsx +0 -6
- package/app/ssr.js +0 -23
- package/app/static/inline-agent-widget.umd.js +28 -26
- package/package.json +8 -8
- package/app/page-designer/content/announcement-banner/index.jsx +0 -148
- package/app/page-designer/content/announcement-banner/index.test.js +0 -57
- package/app/page-designer/content/index.js +0 -8
- package/app/page-designer/layouts/header/index.jsx +0 -33
- package/app/page-designer/layouts/header/index.test.js +0 -38
- package/app/page-designer/preview-page.js +0 -28
- package/app/page-designer/preview-page.test.js +0 -26
- package/app/pages/component-preview/index.jsx +0 -73
- package/app/pages/component-preview/index.test.js +0 -117
- package/app/utils/inline-agent-widget-utils.js +0 -74
- package/app/utils/inline-agent-widget-utils.test.js +0 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/retail-react-app",
|
|
3
|
-
"version": "10.3.0-
|
|
3
|
+
"version": "10.3.0-preview.1",
|
|
4
4
|
"license": "See license in LICENSE",
|
|
5
5
|
"author": "cc-pwa-kit@salesforce.com",
|
|
6
6
|
"ccExtensibility": {
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"@loadable/component": "^5.15.3",
|
|
47
47
|
"@peculiar/webcrypto": "^1.4.2",
|
|
48
48
|
"@salesforce/cc-datacloud-typescript": "1.1.2",
|
|
49
|
-
"@salesforce/commerce-sdk-react": "5.5.0-
|
|
50
|
-
"@salesforce/pwa-kit-dev": "3.21.0-
|
|
51
|
-
"@salesforce/pwa-kit-react-sdk": "3.21.0-
|
|
52
|
-
"@salesforce/pwa-kit-runtime": "3.21.0-
|
|
49
|
+
"@salesforce/commerce-sdk-react": "5.5.0-preview.0",
|
|
50
|
+
"@salesforce/pwa-kit-dev": "3.21.0-preview.1",
|
|
51
|
+
"@salesforce/pwa-kit-react-sdk": "3.21.0-preview.1",
|
|
52
|
+
"@salesforce/pwa-kit-runtime": "3.21.0-preview.1",
|
|
53
53
|
"@salesforce/storefront-next-runtime": "1.3.1",
|
|
54
54
|
"@tanstack/react-query": "^4.28.0",
|
|
55
55
|
"@tanstack/react-query-devtools": "^4.29.1",
|
|
@@ -108,12 +108,12 @@
|
|
|
108
108
|
"bundlesize": [
|
|
109
109
|
{
|
|
110
110
|
"path": "build/main.js",
|
|
111
|
-
"maxSize": "
|
|
111
|
+
"maxSize": "109 kB"
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
"path": "build/vendor.js",
|
|
115
|
-
"maxSize": "
|
|
115
|
+
"maxSize": "403 kB"
|
|
116
116
|
}
|
|
117
117
|
],
|
|
118
|
-
"gitHead": "
|
|
118
|
+
"gitHead": "a860b13c16696b0e8253ee1016d8500a5ce8a2f0"
|
|
119
119
|
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import React from 'react'
|
|
8
|
-
import PropTypes from 'prop-types'
|
|
9
|
-
import {
|
|
10
|
-
Box,
|
|
11
|
-
Skeleton,
|
|
12
|
-
Text,
|
|
13
|
-
Link as ChakraLink
|
|
14
|
-
} from '@salesforce/retail-react-app/app/components/shared/ui'
|
|
15
|
-
import Link from '@salesforce/retail-react-app/app/components/link'
|
|
16
|
-
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'
|
|
17
|
-
|
|
18
|
-
const HEIGHT_STYLE = {
|
|
19
|
-
sm: {py: 1.5, fontSize: 'xs'},
|
|
20
|
-
md: {py: 3, fontSize: 'sm'},
|
|
21
|
-
lg: {py: 5, fontSize: 'md'}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const ALIGNMENT_JUSTIFY = {
|
|
25
|
-
left: 'flex-start',
|
|
26
|
-
center: 'center',
|
|
27
|
-
right: 'flex-end'
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const COLOR_SCHEME_STYLE = {
|
|
31
|
-
primary: {bg: 'blue.600', color: 'white'},
|
|
32
|
-
secondary: {bg: 'gray.100', color: 'gray.800'},
|
|
33
|
-
destructive: {bg: 'red.600', color: 'white'}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const normalize = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback)
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Announcement Banner component.
|
|
40
|
-
*
|
|
41
|
-
* A banner for announcements, promotions, and alerts. Rendered as an authorable
|
|
42
|
-
* content block; typically placed in the embedded header's `announcement` region.
|
|
43
|
-
*
|
|
44
|
-
* @param {object} props
|
|
45
|
-
* @param {string} props.message - The announcement text (required; renders nothing when empty).
|
|
46
|
-
* @param {string} [props.linkUrl] - Optional link target. Rendered only with linkText.
|
|
47
|
-
* @param {string} [props.linkText] - Optional link label. Rendered only with linkUrl.
|
|
48
|
-
* @param {string} [props.colorScheme] - primary | secondary | destructive (default primary).
|
|
49
|
-
* @param {string} [props.height] - sm | md | lg (default md).
|
|
50
|
-
* @param {string} [props.alignment] - left | center | right (default center).
|
|
51
|
-
* @returns {React.ReactElement|null} - AnnouncementBanner component.
|
|
52
|
-
*/
|
|
53
|
-
export const AnnouncementBanner = ({
|
|
54
|
-
message,
|
|
55
|
-
linkUrl,
|
|
56
|
-
linkText,
|
|
57
|
-
colorScheme,
|
|
58
|
-
height,
|
|
59
|
-
alignment
|
|
60
|
-
}) => {
|
|
61
|
-
if (!message) return null
|
|
62
|
-
|
|
63
|
-
const heightStyle = HEIGHT_STYLE[normalize(height, ['sm', 'md', 'lg'], 'md')]
|
|
64
|
-
const resolvedAlignment = normalize(alignment, ['left', 'center', 'right'], 'center')
|
|
65
|
-
const colorStyle =
|
|
66
|
-
COLOR_SCHEME_STYLE[
|
|
67
|
-
normalize(colorScheme, ['primary', 'secondary', 'destructive'], 'primary')
|
|
68
|
-
]
|
|
69
|
-
|
|
70
|
-
const isAbsolute = isAbsoluteURL(linkUrl)
|
|
71
|
-
const LinkWrapper = isAbsolute ? ChakraLink : Link
|
|
72
|
-
const linkProps = isAbsolute ? {href: linkUrl} : {to: linkUrl}
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<Box
|
|
76
|
-
role="status"
|
|
77
|
-
className={'announcement-banner'}
|
|
78
|
-
data-testid={'announcement-banner'}
|
|
79
|
-
display="flex"
|
|
80
|
-
alignItems="center"
|
|
81
|
-
gap={2}
|
|
82
|
-
px={{base: 4, md: 10}}
|
|
83
|
-
letterSpacing="wide"
|
|
84
|
-
justifyContent={ALIGNMENT_JUSTIFY[resolvedAlignment]}
|
|
85
|
-
{...heightStyle}
|
|
86
|
-
{...colorStyle}
|
|
87
|
-
>
|
|
88
|
-
<Text textAlign={resolvedAlignment} margin={0}>
|
|
89
|
-
{message}
|
|
90
|
-
{linkUrl && linkText && (
|
|
91
|
-
<>
|
|
92
|
-
{' '}
|
|
93
|
-
<LinkWrapper
|
|
94
|
-
{...linkProps}
|
|
95
|
-
textDecoration="underline"
|
|
96
|
-
fontWeight="medium"
|
|
97
|
-
whiteSpace="nowrap"
|
|
98
|
-
color="inherit"
|
|
99
|
-
>
|
|
100
|
-
{linkText}
|
|
101
|
-
</LinkWrapper>
|
|
102
|
-
</>
|
|
103
|
-
)}
|
|
104
|
-
</Text>
|
|
105
|
-
</Box>
|
|
106
|
-
)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
AnnouncementBanner.propTypes = {
|
|
110
|
-
message: PropTypes.string,
|
|
111
|
-
linkUrl: PropTypes.string,
|
|
112
|
-
linkText: PropTypes.string,
|
|
113
|
-
colorScheme: PropTypes.string,
|
|
114
|
-
height: PropTypes.string,
|
|
115
|
-
alignment: PropTypes.string
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
AnnouncementBanner.displayName = 'AnnouncementBanner'
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Suspense fallback for the Announcement Banner. Mirrors the default md/center/primary
|
|
122
|
-
* height so switching from fallback to real content does not shift layout.
|
|
123
|
-
*
|
|
124
|
-
* @returns {React.ReactElement} - Skeleton placeholder.
|
|
125
|
-
*/
|
|
126
|
-
export function AnnouncementBannerFallback() {
|
|
127
|
-
return (
|
|
128
|
-
<Box
|
|
129
|
-
aria-hidden="true"
|
|
130
|
-
display="flex"
|
|
131
|
-
alignItems="center"
|
|
132
|
-
justifyContent="center"
|
|
133
|
-
px={{base: 4, md: 10}}
|
|
134
|
-
py={3}
|
|
135
|
-
bg="blue.600"
|
|
136
|
-
>
|
|
137
|
-
<Skeleton height="16px" width="192px" />
|
|
138
|
-
</Box>
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
AnnouncementBannerFallback.displayName = 'AnnouncementBannerFallback'
|
|
143
|
-
|
|
144
|
-
export default AnnouncementBanner
|
|
145
|
-
|
|
146
|
-
// The V2 registry reads a module's named `fallback` export to render during the
|
|
147
|
-
// client Suspense boundary while the component chunk loads.
|
|
148
|
-
export {AnnouncementBannerFallback as fallback}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import React from 'react'
|
|
8
|
-
import {
|
|
9
|
-
AnnouncementBanner,
|
|
10
|
-
AnnouncementBannerFallback
|
|
11
|
-
} from '@salesforce/retail-react-app/app/page-designer/content/announcement-banner/index'
|
|
12
|
-
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
|
|
13
|
-
|
|
14
|
-
test('renders the message', () => {
|
|
15
|
-
const {getByRole, getByText} = renderWithProviders(
|
|
16
|
-
<AnnouncementBanner message="Free shipping this weekend" />
|
|
17
|
-
)
|
|
18
|
-
expect(getByRole('status')).toBeInTheDocument()
|
|
19
|
-
expect(getByText('Free shipping this weekend')).toBeInTheDocument()
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('renders nothing when message is empty', () => {
|
|
23
|
-
const {queryByRole} = renderWithProviders(<AnnouncementBanner message="" />)
|
|
24
|
-
expect(queryByRole('status')).not.toBeInTheDocument()
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test('renders a link only when both linkUrl and linkText are set', () => {
|
|
28
|
-
const {getByRole} = renderWithProviders(
|
|
29
|
-
<AnnouncementBanner message="Sale" linkUrl="/sale" linkText="Shop now" />
|
|
30
|
-
)
|
|
31
|
-
expect(getByRole('link', {name: 'Shop now'})).toBeInTheDocument()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
test('does not render a link when only linkUrl is set', () => {
|
|
35
|
-
const {queryByRole} = renderWithProviders(<AnnouncementBanner message="Sale" linkUrl="/sale" />)
|
|
36
|
-
expect(queryByRole('link')).not.toBeInTheDocument()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('applies destructive color scheme background', () => {
|
|
40
|
-
const {getByRole} = renderWithProviders(
|
|
41
|
-
<AnnouncementBanner message="Alert" colorScheme="destructive" />
|
|
42
|
-
)
|
|
43
|
-
// Chakra maps the `bg` prop to a background CSS declaration; assert the token resolved.
|
|
44
|
-
expect(getByRole('status')).toHaveStyle({background: 'var(--chakra-colors-red-600)'})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('falls back to md/center defaults for invalid enum values', () => {
|
|
48
|
-
const {getByRole} = renderWithProviders(
|
|
49
|
-
<AnnouncementBanner message="Hi" height="bogus" alignment="bogus" colorScheme="bogus" />
|
|
50
|
-
)
|
|
51
|
-
expect(getByRole('status')).toHaveStyle({justifyContent: 'center'})
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('fallback renders an aria-hidden skeleton', () => {
|
|
55
|
-
const {container} = renderWithProviders(<AnnouncementBannerFallback />)
|
|
56
|
-
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
|
|
57
|
-
})
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export * from './announcement-banner'
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import React from 'react'
|
|
8
|
-
import PropTypes from 'prop-types'
|
|
9
|
-
import {Region} from '@salesforce/commerce-sdk-react/page-designer'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Embedded Header component.
|
|
13
|
-
*
|
|
14
|
-
* A singleton Page Designer component (instance id `header`) whose sole purpose is to
|
|
15
|
-
* expose an `announcement` region above the storefront header. It holds no chrome of its
|
|
16
|
-
* own — the storefront's own `Header`/`AboveHeader` remain the visual header. Authors place
|
|
17
|
-
* content blocks (e.g. Announcement Banner) into the `announcement` region.
|
|
18
|
-
*
|
|
19
|
-
* @param {object} props
|
|
20
|
-
* @param {object} props.component - The Page Designer component data (injected by the V2 pipeline).
|
|
21
|
-
* @returns {React.ReactElement|null} - The rendered announcement region, or null when absent.
|
|
22
|
-
*/
|
|
23
|
-
export const Header = ({component}) => {
|
|
24
|
-
return <Region component={component} regionId="announcement" />
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
Header.propTypes = {
|
|
28
|
-
component: PropTypes.object.isRequired
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
Header.displayName = 'PageDesignerHeader'
|
|
32
|
-
|
|
33
|
-
export default Header
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import React from 'react'
|
|
8
|
-
import {render} from '@testing-library/react'
|
|
9
|
-
import Header from '@salesforce/retail-react-app/app/page-designer/layouts/header/index'
|
|
10
|
-
import {registry} from '@salesforce/commerce-sdk-react/page-designer'
|
|
11
|
-
|
|
12
|
-
const makeComponent = (regions) => ({
|
|
13
|
-
id: 'header',
|
|
14
|
-
typeId: 'commerce_layouts.header',
|
|
15
|
-
regions
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
beforeAll(() => {
|
|
19
|
-
// The embedded region renders authored components through the V2 registry.
|
|
20
|
-
registry.registerComponent('test.banner', (props) => <div>{props.message}</div>)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('renders the announcement region components', () => {
|
|
24
|
-
const component = makeComponent([
|
|
25
|
-
{
|
|
26
|
-
id: 'announcement',
|
|
27
|
-
components: [{id: 'b1', typeId: 'test.banner', data: {message: 'Hello from banner'}}]
|
|
28
|
-
}
|
|
29
|
-
])
|
|
30
|
-
const {getByText} = render(<Header component={component} />)
|
|
31
|
-
expect(getByText('Hello from banner')).toBeInTheDocument()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
test('renders nothing when there is no announcement region', () => {
|
|
35
|
-
const component = makeComponent([{id: 'other', components: []}])
|
|
36
|
-
const {container} = render(<Header component={component} />)
|
|
37
|
-
expect(container).toBeEmptyDOMElement()
|
|
38
|
-
})
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The id of the synthetic region that hosts the single previewed component.
|
|
10
|
-
* Must stay stable; the mini-PD canvas targets this region.
|
|
11
|
-
*/
|
|
12
|
-
export const PREVIEW_REGION_ID = 'preview'
|
|
13
|
-
|
|
14
|
-
/** The id of the synthetic page wrapping the previewed component. */
|
|
15
|
-
export const PREVIEW_PAGE_ID = '__pwakit_preview__'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Wrap a single fetched Page Designer component in a synthetic one-region page
|
|
19
|
-
* so the existing page-mode <Page>/<Region> pipeline can render it standalone,
|
|
20
|
-
* with no changes to the render pipeline.
|
|
21
|
-
*
|
|
22
|
-
* @param {object} component - A Shopper Experience Component (from useComponent).
|
|
23
|
-
* @returns {object} A minimal Page-shaped object hosting the component.
|
|
24
|
-
*/
|
|
25
|
-
export const injectIntoPreviewRegion = (component) => ({
|
|
26
|
-
id: PREVIEW_PAGE_ID,
|
|
27
|
-
regions: [{id: PREVIEW_REGION_ID, components: [component]}]
|
|
28
|
-
})
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import {
|
|
8
|
-
PREVIEW_REGION_ID,
|
|
9
|
-
PREVIEW_PAGE_ID,
|
|
10
|
-
injectIntoPreviewRegion
|
|
11
|
-
} from '@salesforce/retail-react-app/app/page-designer/preview-page'
|
|
12
|
-
|
|
13
|
-
describe('injectIntoPreviewRegion', () => {
|
|
14
|
-
test('wraps a component in a synthetic single-region page', () => {
|
|
15
|
-
const component = {id: 'comp1', typeId: 'commerce_assets.imageTile', data: {}}
|
|
16
|
-
const page = injectIntoPreviewRegion(component)
|
|
17
|
-
expect(page).toEqual({
|
|
18
|
-
id: PREVIEW_PAGE_ID,
|
|
19
|
-
regions: [{id: PREVIEW_REGION_ID, components: [component]}]
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('PREVIEW_REGION_ID is "preview"', () => {
|
|
24
|
-
expect(PREVIEW_REGION_ID).toBe('preview')
|
|
25
|
-
})
|
|
26
|
-
})
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import React from 'react'
|
|
9
|
-
import {useLocation} from 'react-router-dom'
|
|
10
|
-
import {useComponent} from '@salesforce/commerce-sdk-react'
|
|
11
|
-
import {Page} from '@salesforce/commerce-sdk-react/page-designer'
|
|
12
|
-
import {Box} from '@salesforce/retail-react-app/app/components/shared/ui'
|
|
13
|
-
import Seo from '@salesforce/retail-react-app/app/components/seo'
|
|
14
|
-
import {injectIntoPreviewRegion} from '@salesforce/retail-react-app/app/page-designer/preview-page'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Mini-PD component-preview route.
|
|
18
|
-
*
|
|
19
|
-
* Renders a single existing Page Designer component standalone (chrome-free) for
|
|
20
|
-
* the mini-PD "Edit in focused visual canvas" iframe. It is an internal authoring
|
|
21
|
-
* surface, not a public storefront page:
|
|
22
|
-
* - It renders only when the request is in Page Designer EDIT or PREVIEW mode
|
|
23
|
-
* (`?mode=EDIT|PREVIEW`); otherwise it renders nothing.
|
|
24
|
-
* - It emits `robots: noindex`.
|
|
25
|
-
*
|
|
26
|
-
* It fetches the component by `componentId` (from the URL), wraps it in a synthetic
|
|
27
|
-
* single-region page, and renders it via the existing <Page> pipeline — no changes
|
|
28
|
-
* to the render pipeline are required.
|
|
29
|
-
*/
|
|
30
|
-
const ComponentPreview = () => {
|
|
31
|
-
const {search} = useLocation()
|
|
32
|
-
const searchParams = new URLSearchParams(search)
|
|
33
|
-
const componentId = searchParams.get('componentId')
|
|
34
|
-
// Read mode straight from the URL search — SSR-safe (no window dependency).
|
|
35
|
-
// The runtime's isDesignModeActive()/isPreviewModeActive() no-arg forms fall
|
|
36
|
-
// back to window.location and are unsafe during SSR, so we don't use them.
|
|
37
|
-
const mode = searchParams.get('mode')
|
|
38
|
-
const inDesignOrPreview = mode === 'EDIT' || mode === 'PREVIEW'
|
|
39
|
-
|
|
40
|
-
const {
|
|
41
|
-
data: component,
|
|
42
|
-
isLoading,
|
|
43
|
-
error
|
|
44
|
-
} = useComponent(
|
|
45
|
-
{parameters: {componentId}},
|
|
46
|
-
{enabled: Boolean(inDesignOrPreview && componentId)}
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
if (!inDesignOrPreview || !componentId) {
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<Box data-testid="component-preview-page" layerStyle="page">
|
|
55
|
-
<Seo title="Component Preview" noIndex />
|
|
56
|
-
{isLoading && (
|
|
57
|
-
<Box textAlign="center" py={8}>
|
|
58
|
-
Loading...
|
|
59
|
-
</Box>
|
|
60
|
-
)}
|
|
61
|
-
{error && (
|
|
62
|
-
<Box textAlign="center" py={8} color="red.500">
|
|
63
|
-
Error loading component.
|
|
64
|
-
</Box>
|
|
65
|
-
)}
|
|
66
|
-
{component && !error && <Page page={injectIntoPreviewRegion(component)} />}
|
|
67
|
-
</Box>
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
ComponentPreview.getTemplateName = () => 'component-preview'
|
|
72
|
-
|
|
73
|
-
export default ComponentPreview
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import React from 'react'
|
|
8
|
-
import {screen, waitFor} from '@testing-library/react'
|
|
9
|
-
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
|
|
10
|
-
import ComponentPreview from '@salesforce/retail-react-app/app/pages/component-preview/index'
|
|
11
|
-
import {useComponent} from '@salesforce/commerce-sdk-react'
|
|
12
|
-
import {Page, registry} from '@salesforce/commerce-sdk-react/page-designer'
|
|
13
|
-
import {PREVIEW_PAGE_ID} from '@salesforce/retail-react-app/app/page-designer/preview-page'
|
|
14
|
-
import {initializeRegistry} from '@salesforce/retail-react-app/app/page-designer/registry'
|
|
15
|
-
|
|
16
|
-
jest.mock('@salesforce/commerce-sdk-react', () => ({
|
|
17
|
-
...jest.requireActual('@salesforce/commerce-sdk-react'),
|
|
18
|
-
useComponent: jest.fn()
|
|
19
|
-
}))
|
|
20
|
-
|
|
21
|
-
// Spy the real registry-backed Page so we can assert it is called without a
|
|
22
|
-
// `components` map, while still rendering through the V2 Page/Region pipeline.
|
|
23
|
-
jest.mock('@salesforce/commerce-sdk-react/page-designer', () => {
|
|
24
|
-
const actual = jest.requireActual('@salesforce/commerce-sdk-react/page-designer')
|
|
25
|
-
return {
|
|
26
|
-
...actual,
|
|
27
|
-
// eslint-disable-next-line react/prop-types
|
|
28
|
-
Page: jest.fn((props) => actual.Page(props))
|
|
29
|
-
}
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// Mode + componentId are derived directly from the URL search (via useLocation().search
|
|
33
|
-
// and URLSearchParams), NOT the no-arg window fallback. The test harness uses
|
|
34
|
-
// BrowserRouter, which reads window.location — so set the jsdom URL per test with
|
|
35
|
-
// history.pushState (initialEntries does NOT apply to BrowserRouter).
|
|
36
|
-
const setUrl = (path) => window.history.pushState({}, '', path)
|
|
37
|
-
|
|
38
|
-
const PREVIEW_TYPE_ID = 'commerce_assets.imageTile'
|
|
39
|
-
|
|
40
|
-
const restoreProductionRegistry = () => {
|
|
41
|
-
registry.clear()
|
|
42
|
-
initializeRegistry()
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe('ComponentPreview route', () => {
|
|
46
|
-
beforeEach(() => {
|
|
47
|
-
jest.clearAllMocks()
|
|
48
|
-
useComponent.mockReturnValue({data: undefined, isLoading: false, error: null})
|
|
49
|
-
restoreProductionRegistry()
|
|
50
|
-
})
|
|
51
|
-
afterEach(() => {
|
|
52
|
-
setUrl('/')
|
|
53
|
-
restoreProductionRegistry()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('renders nothing when not in EDIT/PREVIEW mode', () => {
|
|
57
|
-
setUrl('/preview/component?componentId=comp1')
|
|
58
|
-
renderWithProviders(<ComponentPreview />)
|
|
59
|
-
expect(screen.queryByTestId('component-preview-page')).not.toBeInTheDocument()
|
|
60
|
-
expect(Page).not.toHaveBeenCalled()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('renders nothing in EDIT mode when componentId is missing', () => {
|
|
64
|
-
setUrl('/preview/component?mode=EDIT')
|
|
65
|
-
renderWithProviders(<ComponentPreview />)
|
|
66
|
-
expect(screen.queryByTestId('component-preview-page')).not.toBeInTheDocument()
|
|
67
|
-
expect(Page).not.toHaveBeenCalled()
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('renders the component through registry-backed <Page> without a components map', async () => {
|
|
71
|
-
setUrl('/preview/component?mode=EDIT&componentId=comp1')
|
|
72
|
-
useComponent.mockReturnValue({
|
|
73
|
-
data: {
|
|
74
|
-
id: 'comp1',
|
|
75
|
-
typeId: PREVIEW_TYPE_ID,
|
|
76
|
-
data: {
|
|
77
|
-
image: {
|
|
78
|
-
url: 'https://example.com/tile.png',
|
|
79
|
-
alt: 'Preview tile'
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
isLoading: false,
|
|
84
|
-
error: null
|
|
85
|
-
})
|
|
86
|
-
renderWithProviders(<ComponentPreview />)
|
|
87
|
-
|
|
88
|
-
// Production `initializeRegistry()` registers a lazy importer, so Region/Component
|
|
89
|
-
// suspends until imageTile resolves. Await the eventual tile, not an eager mock.
|
|
90
|
-
await waitFor(() => {
|
|
91
|
-
expect(screen.getByTestId('image-tile-image')).toBeInTheDocument()
|
|
92
|
-
})
|
|
93
|
-
expect(screen.getByTestId('image-tile-image')).toHaveAttribute(
|
|
94
|
-
'src',
|
|
95
|
-
'https://example.com/tile.png'
|
|
96
|
-
)
|
|
97
|
-
expect(Page).toHaveBeenCalled()
|
|
98
|
-
const pageProps = Page.mock.calls[0][0]
|
|
99
|
-
expect(pageProps.page).toEqual(
|
|
100
|
-
expect.objectContaining({
|
|
101
|
-
id: PREVIEW_PAGE_ID,
|
|
102
|
-
regions: [
|
|
103
|
-
expect.objectContaining({
|
|
104
|
-
components: [
|
|
105
|
-
expect.objectContaining({id: 'comp1', typeId: PREVIEW_TYPE_ID})
|
|
106
|
-
]
|
|
107
|
-
})
|
|
108
|
-
]
|
|
109
|
-
})
|
|
110
|
-
)
|
|
111
|
-
expect(pageProps.components).toBeUndefined()
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('exposes getTemplateName', () => {
|
|
115
|
-
expect(ComponentPreview.getTemplateName()).toBe('component-preview')
|
|
116
|
-
})
|
|
117
|
-
})
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2024, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// Kept in one place so the PDP and the widget agree. Matches
|
|
9
|
-
// MAX_PDP_QUESTIONS in the widget (src/provider/pdp-questions.ts).
|
|
10
|
-
export const MAX_PDP_QUESTIONS = 5
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Parse the SCAPI `c_pdpQuestions` custom attribute into a sanitized list of
|
|
14
|
-
* opener questions. The SCAPI response ships the value as a JSON-encoded
|
|
15
|
-
* string (an array of strings), so we must JSON.parse it and defend against
|
|
16
|
-
* malformed / non-array / non-string content — a bad publish upstream must
|
|
17
|
-
* never break the PDP.
|
|
18
|
-
*
|
|
19
|
-
* Expected input (what the backend should publish into `pdpQuestions` /
|
|
20
|
-
* SCAPI `c_pdpQuestions`): a JSON-encoded string array, e.g.
|
|
21
|
-
*
|
|
22
|
-
* '["What does SmartTrack do?","Does it track sleep automatically?"]'
|
|
23
|
-
*
|
|
24
|
-
* Where the questions come from: Commerce Content Studio (an operator-run
|
|
25
|
-
* tool outside the storefront) generates PDP FAQ questions per product with
|
|
26
|
-
* an LLM, keeps them behind a human review gate, and publishes approved
|
|
27
|
-
* content back to Business Manager via OCAPI's `sfcc-site-archive-import`
|
|
28
|
-
* job in MERGE mode. The import writes the JSON string array into the
|
|
29
|
-
* `pdpQuestions` product custom attribute, which SCAPI exposes as
|
|
30
|
-
* `c_pdpQuestions` on the Shopper Products response. See the Commerce
|
|
31
|
-
* Content Studio User Guide (step 7 — Write-back) for the publishing side.
|
|
32
|
-
*
|
|
33
|
-
* Returns [] for any unusable input. Trims, drops empties, dedupes, and caps
|
|
34
|
-
* at MAX_PDP_QUESTIONS to keep the UI from getting overwhelmed. SSR-safe.
|
|
35
|
-
*
|
|
36
|
-
* Malformed input is logged via `console.warn` so a merchandiser who
|
|
37
|
-
* published a bad value can see what tripped the parser in the browser
|
|
38
|
-
* console; the PDP still renders without a pill shelf.
|
|
39
|
-
*/
|
|
40
|
-
export const parsePdpQuestions = (raw, maxCount = MAX_PDP_QUESTIONS) => {
|
|
41
|
-
if (!raw || typeof raw !== 'string') return []
|
|
42
|
-
let parsed
|
|
43
|
-
try {
|
|
44
|
-
parsed = JSON.parse(raw)
|
|
45
|
-
} catch (err) {
|
|
46
|
-
console.warn(
|
|
47
|
-
'[inline-agent-widget] Ignoring malformed c_pdpQuestions: not valid JSON. ' +
|
|
48
|
-
'Expected a JSON-encoded string array, e.g. \'["Q1?","Q2?"]\'. Got:',
|
|
49
|
-
raw,
|
|
50
|
-
err
|
|
51
|
-
)
|
|
52
|
-
return []
|
|
53
|
-
}
|
|
54
|
-
if (!Array.isArray(parsed)) {
|
|
55
|
-
console.warn(
|
|
56
|
-
'[inline-agent-widget] Ignoring malformed c_pdpQuestions: parsed value is not an array. ' +
|
|
57
|
-
'Expected a JSON-encoded string array, e.g. \'["Q1?","Q2?"]\'. Got:',
|
|
58
|
-
parsed
|
|
59
|
-
)
|
|
60
|
-
return []
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const seen = new Set()
|
|
64
|
-
const out = []
|
|
65
|
-
for (const q of parsed) {
|
|
66
|
-
if (typeof q !== 'string') continue
|
|
67
|
-
const trimmed = q.trim()
|
|
68
|
-
if (!trimmed || seen.has(trimmed)) continue
|
|
69
|
-
seen.add(trimmed)
|
|
70
|
-
out.push(trimmed)
|
|
71
|
-
if (out.length >= maxCount) break
|
|
72
|
-
}
|
|
73
|
-
return out
|
|
74
|
-
}
|