@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/retail-react-app",
3
- "version": "10.3.0-nightly-20261008080708",
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-nightly-20261008080708",
50
- "@salesforce/pwa-kit-dev": "3.21.0-nightly-20261008080708",
51
- "@salesforce/pwa-kit-react-sdk": "3.21.0-nightly-20261008080708",
52
- "@salesforce/pwa-kit-runtime": "3.21.0-nightly-20261008080708",
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": "110 kB"
111
+ "maxSize": "109 kB"
112
112
  },
113
113
  {
114
114
  "path": "build/vendor.js",
115
- "maxSize": "412 kB"
115
+ "maxSize": "403 kB"
116
116
  }
117
117
  ],
118
- "gitHead": "cc484a6a87a04e2936705ba2763dad2487c294fd"
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
- }