@salesforce/retail-react-app 10.3.0 → 10.4.0-nightly-20261012081912

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 CHANGED
@@ -1,3 +1,8 @@
1
+ ## v10.4.0-dev (Oct 09, 2026)
2
+ - [Enhancement] Update inline agent widget UMD bundle: replace the text-prefix product-context hack with the proper SCRT2 v2 session-context API. Product context is now sent as a `commerce_agent_context` StructuredValue in the SessionContextSet envelope, leaving the shopper's message text unchanged. Default `capabilitiesVersion` bumped to `"65"`. Built from [inline-agent-widget#3](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/3). [#4054](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4054)
3
+ - [Enhancement] Commerce Client shopper identity linking now retrieves the auth link key via the widget's `window.CimulateMessaging.getAuthLinkKey()` when the bundle exposes it, instead of calling SCRT2's `/iamessage/api/v2/authorization/authlink` endpoint directly with the Commerce Client JWT. Older bundles without the function fall back to the direct SCRT2 call (feature-detected, not version-checked). A `getAuthLinkKey()` rejection is surfaced as a linking failure rather than retried via the fallback, since both paths hit the same endpoint. [#4050](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4050) (W-24076703)
4
+ - [Feature] Add embedded Page Designer content-block support to the retail template, including an Announcement Banner content component, a preview-only Header layout, and a chrome-free `/preview/component` route for standalone component previews. [#4042](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4042)
5
+
1
6
  ## v10.3.0 (Oct 08, 2026)
2
7
  - [Enhancement] Rename `INLINE_AGENT_WIDGET_COMMERCE_SETTINGS` environment variable to `EMBEDDED_AGENT_COMMERCE_SETTINGS` and config key `inlineAgentWidget` to `embeddedAgent` to align with updated product terminology. [#4041](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4041)
3
8
  - [Feature] Add a configurable `cc_enableImageUpload` toggle for the Commerce Client (Embedded Messaging) shopper-agent widget, defaulting to `'false'` and forwarded to the bundle as `messagingConfig.enableImageUpload`. Set it to `'true'` via `COMMERCE_AGENT_SETTINGS` to let shoppers upload images in the chat; requires a Commerce Client bundle that supports image upload (`cc_cdnVersion` >= `1.44.0`) plus the backend file-upload prerequisites. Also adds `blob:` and `data:` to the `img-src` CSP directive in `app/ssr.js` so the widget can render the pre-send preview (a `blob:` URL) and the sent-image thumbnail (a `data:` URL); the widget renders images locally only, so no image host is added. [#4030](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4030)
@@ -13,6 +18,7 @@
13
18
  - [Enhancement] Update inline agent widget UMD bundle: auto-restart session on expiry instead of showing an error. The widget silently resets to idle and the next message starts a fresh session transparently. [#4015](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4015)
14
19
  - [Enhancement] Update inline agent widget UMD bundle: show user question bubble, add 4,000-character message limit, improve error handling with generic user-facing messages, fix 403 stale-conversation reset, and hide restored session Q&A on page reload. [#4032](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4032)
15
20
  - [Enhancement] Update inline agent widget UMD bundle: suppress agent welcome message on conversation start, and clear stale question/answer when navigating between product pages via SPA routing.
21
+ - [Feature] Add opener question pills to the inline agent widget on the PDP (@W-24333476). The Product Detail page parses SCAPI `c_pdpQuestions` (a JSON-encoded array of strings published by Commerce Content Studio) and forwards it to the widget via the new `pdp-questions` attribute. The widget renders up to 5 tappable pills above the input; tapping a pill sends it through the same message path as typed input, and pills auto-hide once the shopper engages. On SPA navigation between PDPs the pills re-appear for the new product's list. Malformed or absent `c_pdpQuestions` renders no pills.
16
22
  - [Feature] Add Guest Order Lookup (GLO): unauthenticated shoppers can look up an order by order number and email, verify ownership via a time-limited 6-digit access code (valid for 15 minutes, reusable within the window), and view a filtered order detail page (shipment tracking, items, payment summary, shipping address). Server-side endpoints (`/api/guestOrderLookup/{verify,order,oms-meta,cancel,return}`) read the SLAS access token from the HttpOnly `cc-at_*` cookie, call SCAPI with the shopper's bearer token, and filter the response to safe fields. The verify endpoint writes a `cc-goa_` HttpOnly cookie binding the order number to the verified access code; all subsequent order endpoints validate this cookie before proxying. [#3982](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3982)
17
23
  - [Feature] Forward two backend-gating signals on the Commerce Client shopper-agent widget's `routingAttributes`: `clientVersion` (sourced from `cc_cdnVersion`, so the runtime can gate rich components on the client's capability version; omitted when unset) and `isCartMgmtSupported` (string `'true'`/`'false'`, default `'false'`, read from `cc_routingAttributes`). Brings the PWA commerce-client path to parity with the SFRA cartridge.
18
24
  - [Feature] Enable shopper identity (customer context) for the Commerce Client (Cimulate) shopper-agent provider. Unlike MIAW, Commerce Client widgets do not expose `getAuthLinkKey`, so the browser extracts the Commerce Client JWT from the deployment-scoped `cim_af_ct_<orgId>_<embeddedServiceName>` key (`sessionStorage` is authoritative; `localStorage` is a compatibility fallback) and calls SCRT2's `/iamessage/api/v2/authorization/authlink` endpoint directly (`auth-link-client.js`) with an `Authorization: Bearer <jwt>` header to obtain an `auth_link_key`. The SCRT2 origin comes from `scrt2Url` in `COMMERCE_AGENT_SETTINGS` (a different host from Core's My Domain) and is strictly validated before the token is sent (HTTPS only; no credentials, port, path, query, or fragment) against the Salesforce SCRT2 domain allowlist, with the request hardened (`credentials: 'omit'`, `redirect: 'error'`, `referrerPolicy: 'no-referrer'`, `cache: 'no-store'`) and bounded by a 10s abort timeout. The `auth_link_key` is then forwarded to the existing Token Bridge to bind the conversation to the shopper's SLAS session. Linking is idempotent — deduped by `conversationId` + SLAS identity — and re-runs both on a new conversation (`onCimulateWidgetReady`) and on a SLAS identity transition (guest ↔ registered / account switch), gated on `my_domain` resolution. [#3943](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3943) [#3981](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3981)
@@ -5,7 +5,7 @@
5
5
  * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
  */
7
7
 
8
- import React, {useState, useEffect, useMemo} from 'react'
8
+ import React, {useState, useEffect, useMemo, Suspense} from 'react'
9
9
  import PropTypes from 'prop-types'
10
10
  import loadable from '@loadable/component'
11
11
  import {useHistory, useLocation} from 'react-router-dom'
@@ -16,6 +16,7 @@ import {useQuery} from '@tanstack/react-query'
16
16
  import {
17
17
  useAccessToken,
18
18
  useCategory,
19
+ useComponent,
19
20
  useShopperBasketsMutation,
20
21
  useUsid
21
22
  } from '@salesforce/commerce-sdk-react'
@@ -71,8 +72,9 @@ import {useAttribution} from '@salesforce/retail-react-app/app/hooks/use-attribu
71
72
  // HOCs
72
73
  import {withCommerceSdkReact} from '@salesforce/retail-react-app/app/components/with-commerce-sdk-react/with-commerce-sdk-react'
73
74
 
74
- import {PageDesignerProvider} from '@salesforce/commerce-sdk-react/page-designer'
75
+ import {PageDesignerProvider, Region} from '@salesforce/commerce-sdk-react/page-designer'
75
76
  import PageDesignerInit from '@salesforce/retail-react-app/app/components/page-designer-init'
77
+ import {EmbeddedSubtreeProvider} from '@salesforce/storefront-next-runtime/design/react/core'
76
78
 
77
79
  // Localization
78
80
  import {IntlProvider} from 'react-intl'
@@ -150,12 +152,16 @@ const ListMenuContentWithData = withCommerceSdkReact(
150
152
  }
151
153
  )
152
154
 
153
- const App = (props) => {
155
+ const StorefrontApp = (props) => {
154
156
  const {children} = props
155
157
  const {data: categoriesTree} = useCategory({
156
158
  parameters: {id: CAT_MENU_DEFAULT_ROOT_CATEGORY, levels: CAT_MENU_DEFAULT_NAV_SSR_DEPTH}
157
159
  })
158
160
  const categories = flatten(categoriesTree || {}, 'categories')
161
+ // Embedded content-block header: a singleton Page Designer component (instance id
162
+ // `header`) that exposes an `announcement` region rendered above the storefront header.
163
+ // `useComponent` is Page-Designer-mode aware (handles mode/pdToken internally).
164
+ const {data: embeddedHeader} = useComponent({parameters: {componentId: 'header'}})
159
165
  const {getTokenWhenReady} = useAccessToken()
160
166
  const {usid} = useUsid()
161
167
  const appOrigin = useAppOrigin()
@@ -428,6 +434,26 @@ const App = (props) => {
428
434
  <Box {...styles.headerWrapper}>
429
435
  {!isCheckout ? (
430
436
  <>
437
+ {embeddedHeader && (
438
+ // The announcement `<Region>` renders in
439
+ // *component mode*, which has no Suspense
440
+ // boundary of its own. In production the
441
+ // registry discovers components via lazy
442
+ // importers, so `<Component>` throws
443
+ // `registry.preload()`'s promise to suspend on
444
+ // first render. `renderToString` (SSR) rethrows
445
+ // an uncaught suspend, blanking the tree (a 500).
446
+ // Provide the boundary here so SSR streams the
447
+ // fallback instead of crashing.
448
+ <Suspense fallback={null}>
449
+ <EmbeddedSubtreeProvider embedded>
450
+ <Region
451
+ component={embeddedHeader}
452
+ regionId="announcement"
453
+ />
454
+ </EmbeddedSubtreeProvider>
455
+ </Suspense>
456
+ )}
431
457
  <AboveHeader />
432
458
  <Header
433
459
  onMenuClick={onOpen}
@@ -522,6 +548,122 @@ const App = (props) => {
522
548
  )
523
549
  }
524
550
 
551
+ StorefrontApp.propTypes = {
552
+ children: PropTypes.node
553
+ }
554
+
555
+ /**
556
+ * Path (without the `/:site/:locale` prefix) served chrome-free by the Page Designer
557
+ * component-preview iframe. See app/pages/component-preview and app/routes.jsx.
558
+ */
559
+ const COMPONENT_PREVIEW_PATH_RE = /\/preview\/component$/
560
+
561
+ /**
562
+ * Minimal app surface for the Page Designer component-preview iframe.
563
+ *
564
+ * It renders only the providers a single Page Designer component needs — i18n, currency,
565
+ * and the Page Designer context/init — and none of the storefront chrome (header, footer,
566
+ * menus) or shopper-state hooks (basket, customer, category, analytics, shopper agent).
567
+ * Those hooks mutate session state and fire analytics, which must not happen inside an
568
+ * authoring iframe; skipping them is the point of this split, not merely hiding the chrome.
569
+ * MRT StorefrontPreview is omitted here: the Content Block Editor iframe already owns the
570
+ * authoring postMessage bridge via PageDesignerProvider.
571
+ */
572
+ const ComponentPreviewApp = (props) => {
573
+ const {children} = props
574
+ const {usid} = useUsid()
575
+ const appOrigin = useAppOrigin()
576
+ const location = useLocation()
577
+ const {site, locale} = useMultiSite()
578
+ const {req} = useServerContext()
579
+
580
+ // Determine Page Designer mode from URL - use req for server-side detection
581
+ const pageDesignerMode = useMemo(() => {
582
+ const queryParams = location?.search || ''
583
+ if (queryParams.includes('mode=EDIT')) return 'EDIT'
584
+ else if (queryParams.includes('mode=PREVIEW')) return 'PREVIEW'
585
+ return undefined
586
+ }, [req?.url])
587
+
588
+ const targetLocale = getTargetLocale({
589
+ getUserPreferredLocales: () => [locale?.id || DEFAULT_LOCALE],
590
+ l10nConfig: site.l10n
591
+ })
592
+
593
+ const {data: messages} = useQuery({
594
+ queryKey: ['app', 'translations', 'messages', targetLocale],
595
+ queryFn: () => fetchTranslations(targetLocale, appOrigin),
596
+ enabled: isServer
597
+ })
598
+
599
+ const currency = locale.preferredCurrency || site.l10n.defaultCurrency
600
+
601
+ return (
602
+ <Box className="sf-app">
603
+ <IntlProvider
604
+ onError={(err) => {
605
+ if (!messages) {
606
+ // During the ssr prepass phase the messages object has not loaded,
607
+ // so we can suppress errors during this time.
608
+ return
609
+ }
610
+ if (err.code === 'MISSING_TRANSLATION') {
611
+ logger.warn('Missing translation', {
612
+ namespace: 'ComponentPreviewApp.IntlProvider',
613
+ additionalProperties: {errorMessage: err.message}
614
+ })
615
+ return
616
+ }
617
+ throw err
618
+ }}
619
+ locale={targetLocale}
620
+ messages={messages}
621
+ defaultLocale={DEFAULT_LOCALE}
622
+ >
623
+ <CurrencyProvider currency={currency}>
624
+ <Box
625
+ as="main"
626
+ id="app-main"
627
+ role="main"
628
+ display="flex"
629
+ flexDirection="column"
630
+ flex="1"
631
+ >
632
+ <PageDesignerProvider
633
+ clientId="pwa-kit-client"
634
+ targetOrigin="*"
635
+ usid={usid}
636
+ mode={pageDesignerMode}
637
+ >
638
+ <PageDesignerInit />
639
+ {children}
640
+ </PageDesignerProvider>
641
+ </Box>
642
+ </CurrencyProvider>
643
+ </IntlProvider>
644
+ </Box>
645
+ )
646
+ }
647
+
648
+ ComponentPreviewApp.propTypes = {
649
+ children: PropTypes.node
650
+ }
651
+
652
+ /**
653
+ * Selects the app shell by route: the Page Designer component-preview iframe gets a
654
+ * chrome-free surface, every other route gets the full storefront app. Uses `req.url`
655
+ * on the server and `location.pathname` on the client, matching the SSR-safe pattern
656
+ * already used for Page Designer mode detection.
657
+ */
658
+ const App = (props) => {
659
+ const location = useLocation()
660
+ const {req} = useServerContext()
661
+ const pathname = req?.url ? req.url.split('?')[0] : location?.pathname || ''
662
+ const isComponentPreview = COMPONENT_PREVIEW_PATH_RE.test(pathname)
663
+
664
+ return isComponentPreview ? <ComponentPreviewApp {...props} /> : <StorefrontApp {...props} />
665
+ }
666
+
525
667
  App.propTypes = {
526
668
  children: PropTypes.node
527
669
  }
@@ -7,17 +7,33 @@
7
7
 
8
8
  import React from 'react'
9
9
  import {screen, waitFor} from '@testing-library/react'
10
+ import {renderToString} from 'react-dom/server'
10
11
  import {Helmet} from 'react-helmet'
11
12
  import {rest} from 'msw'
12
13
 
13
14
  import App from '@salesforce/retail-react-app/app/components/_app/index.jsx'
14
- import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
15
+ import {renderWithProviders, TestProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
15
16
  import {DEFAULT_LOCALE} from '@salesforce/retail-react-app/app/utils/test-utils'
17
+ import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
16
18
  import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
17
19
  import messages from '@salesforce/retail-react-app/app/static/translations/compiled/en-GB.json'
18
20
  import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
19
21
  import {prependHandlersToServer} from '@salesforce/retail-react-app/jest-setup'
20
22
  import {mockCustomerBaskets} from '@salesforce/retail-react-app/app/mocks/mock-data'
23
+ import {mockEmbeddedHeader} from '@salesforce/retail-react-app/app/mocks/page-designer'
24
+ // `@salesforce/commerce-sdk-react/page-designer` is only stubbed for `PageDesignerProvider`
25
+ // below — the `registry` export it re-exports is the real V2 singleton.
26
+ import {registry} from '@salesforce/commerce-sdk-react/page-designer'
27
+ import {StorefrontPreview} from '@salesforce/commerce-sdk-react/components'
28
+
29
+ jest.mock('@salesforce/commerce-sdk-react/components', () => {
30
+ const actual = jest.requireActual('@salesforce/commerce-sdk-react/components')
31
+ return {
32
+ ...actual,
33
+ // eslint-disable-next-line react/prop-types
34
+ StorefrontPreview: jest.fn(({children}) => children)
35
+ }
36
+ })
21
37
 
22
38
  jest.mock('../../hooks/use-multi-site', () => jest.fn())
23
39
  jest.mock('../../hooks/use-update-shopper-context', () => ({
@@ -26,6 +42,14 @@ jest.mock('../../hooks/use-update-shopper-context', () => ({
26
42
  jest.mock('../../page-designer/registry', () => ({
27
43
  initializeRegistry: jest.fn()
28
44
  }))
45
+ // In EDIT/PREVIEW mode the real PageDesignerProvider lazy-loads the runtime design
46
+ // machinery (React.lazy → DesignContext), which the jsdom test environment can't mount.
47
+ // The SSR regression is about the announcement `<Region>`, not the design pipeline.
48
+ jest.mock('@salesforce/commerce-sdk-react/page-designer', () => ({
49
+ ...jest.requireActual('@salesforce/commerce-sdk-react/page-designer'),
50
+ // eslint-disable-next-line react/prop-types
51
+ PageDesignerProvider: ({children}) => <>{children}</>
52
+ }))
29
53
 
30
54
  let windowSpy
31
55
 
@@ -46,7 +70,8 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
46
70
  useDNT: () => ({selectedDnt: undefined, updateDnt: mockUpdateDnt}),
47
71
  useUsid: () => ({usid: 'test-usid', getUsidWhenReady: () => Promise.resolve('test-usid')}),
48
72
  useGlobalAnchorBlock: jest.fn(),
49
- registry: mockRegistry
73
+ registry: mockRegistry,
74
+ useComponent: jest.fn(() => ({data: undefined, isLoading: false, error: null}))
50
75
  }
51
76
  })
52
77
 
@@ -71,8 +96,11 @@ beforeEach(() => {
71
96
 
72
97
  afterEach(() => {
73
98
  windowSpy.mockRestore()
99
+ StorefrontPreview.mockClear()
74
100
  jest.restoreAllMocks()
75
101
  jest.resetModules()
102
+ // Reset the jsdom URL so a route-dependent test can't leak its location into the next.
103
+ window.history.pushState({}, '', '/')
76
104
  })
77
105
  describe('App', () => {
78
106
  const site = {
@@ -153,6 +181,76 @@ describe('App', () => {
153
181
  expect(hreflangLinks.some((link) => link.hrefLang === 'x-default')).toBe(true)
154
182
  })
155
183
 
184
+ test('renders the component-preview surface without StorefrontPreview or storefront chrome', async () => {
185
+ useMultiSite.mockImplementation(() => resultUseMultiSite)
186
+ // BrowserRouter reads window.location, so put the app on the preview route.
187
+ window.history.pushState({}, '', '/uk/en-GB/preview/component?mode=EDIT')
188
+ StorefrontPreview.mockClear()
189
+
190
+ renderWithProviders(
191
+ <App targetLocale={DEFAULT_LOCALE} defaultLocale={DEFAULT_LOCALE} messages={messages}>
192
+ <p>Preview child</p>
193
+ </App>
194
+ )
195
+
196
+ await waitFor(() => {
197
+ expect(screen.getByText('Preview child')).toBeInTheDocument()
198
+ })
199
+ // The chrome-free preview surface renders the main region but no storefront
200
+ // header navigation or footer, and does not mount MRT StorefrontPreview.
201
+ expect(screen.getByRole('main')).toBeInTheDocument()
202
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
203
+ expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument()
204
+ expect(StorefrontPreview).not.toHaveBeenCalled()
205
+ })
206
+
207
+ test('SSR-renders the embedded header when its component is only lazily registered', () => {
208
+ // The production regression: `initializeRegistry()` registers *lazy importers*, not
209
+ // eager components, so during SSR `registry.getComponent()` returns null on first
210
+ // render and `<Component>` throws `registry.preload()`'s promise to suspend. The
211
+ // announcement `<Region>` renders in *component mode*, which has no Suspense boundary
212
+ // of its own, so `_app` must provide one — otherwise `renderToString` rethrows the
213
+ // uncaught suspend and blanks the whole tree (the 500 seen on the runtime homepage).
214
+ //
215
+ // This must use `renderToString`, not `renderWithProviders`: React 18's client
216
+ // renderer tolerates a boundary-less suspend (it commits once the promise resolves),
217
+ // so a client render passes even on the broken code — only synchronous SSR reproduces
218
+ // the crash. Restore `useComponent` and the production lazy-importer registry
219
+ // afterward so later tests are not order-dependent.
220
+ const {useComponent} = jest.requireMock('@salesforce/commerce-sdk-react')
221
+ const {initializeRegistry} = jest.requireActual('../../page-designer/registry')
222
+ useComponent.mockReturnValue({
223
+ data: mockEmbeddedHeader,
224
+ isLoading: false,
225
+ error: null
226
+ })
227
+ useMultiSite.mockImplementation(() => resultUseMultiSite)
228
+ const typeId = mockEmbeddedHeader.regions[0].components[0].typeId
229
+ const AppWithProviders = withReactQuery(TestProviders)
230
+ registry.clear()
231
+ registry.registerImporter(typeId, () =>
232
+ import('@salesforce/retail-react-app/app/page-designer/content/announcement-banner')
233
+ )
234
+
235
+ try {
236
+ expect(() =>
237
+ renderToString(
238
+ <AppWithProviders locals={{}}>
239
+ <App
240
+ targetLocale={DEFAULT_LOCALE}
241
+ defaultLocale={DEFAULT_LOCALE}
242
+ messages={messages}
243
+ />
244
+ </AppWithProviders>
245
+ )
246
+ ).not.toThrow()
247
+ } finally {
248
+ useComponent.mockReturnValue({data: undefined, isLoading: false, error: null})
249
+ registry.clear()
250
+ initializeRegistry()
251
+ }
252
+ })
253
+
156
254
  test('App component updates the basket with correct currency and customer email', async () => {
157
255
  const customerEmail = 'email@test.com'
158
256
 
@@ -12,8 +12,8 @@ import useInlineAgentWidget, {
12
12
  isConfigured
13
13
  } from '@salesforce/retail-react-app/app/hooks/use-inline-agent-widget'
14
14
 
15
- const InlineAgentWidget = ({config}) => {
16
- const containerRef = useInlineAgentWidget(config)
15
+ const InlineAgentWidget = ({config, pdpQuestions, productName}) => {
16
+ const containerRef = useInlineAgentWidget(config, {pdpQuestions, productName})
17
17
 
18
18
  if (!isConfigured(config)) return null
19
19
 
@@ -34,7 +34,21 @@ InlineAgentWidget.propTypes = {
34
34
  orgId: PropTypes.string,
35
35
  esDeveloperName: PropTypes.string,
36
36
  placeholder: PropTypes.string
37
- })
37
+ }),
38
+ /**
39
+ * Opener questions for the current product — rendered as tappable pills
40
+ * above the widget input. Typically sourced from the SCAPI
41
+ * `c_pdpQuestions` custom attribute (parsed via `parsePdpQuestions`).
42
+ */
43
+ pdpQuestions: PropTypes.arrayOf(PropTypes.string),
44
+ /**
45
+ * Current product's display name. Templated into the InputBar
46
+ * placeholder — "Ask me anything about <productName>" — so the shopper
47
+ * sees product-grounded prompting on PDP load. Sourced from the SCAPI
48
+ * `name` standard field. When absent, the widget falls back to the
49
+ * generic "Ask me anything" string.
50
+ */
51
+ productName: PropTypes.string
38
52
  }
39
53
 
40
54
  export default InlineAgentWidget
@@ -917,13 +917,20 @@ const CommerceClientAgentWindow = ({
917
917
  : await getTokenWhenReadyRef.current()
918
918
  if (generation !== authLinkGenerationRef.current) return
919
919
 
920
- // Step 1: auth link key from SCRT. Called directly from the
921
- // browser against the configured SCRT2 origin — the authlink
922
- // endpoint authenticates with the Commerce Client JWT (Bearer)
923
- // alone, needing neither the siteId nor the conversationId.
924
- const authLinkResponse = await callAuthLink({commerceClientJWT, scrt2Url})
920
+ // Step 1: auth link key from SCRT. Use the widget's getAuthLinkKey()
921
+ // when present; older bundles lack it, so fall back to calling SCRT2
922
+ // directly. No fallback on rejection: both hit the same endpoint.
923
+ let authLinkResponse
924
+ if (typeof window.CimulateMessaging?.getAuthLinkKey === 'function') {
925
+ authLinkResponse = await window.CimulateMessaging.getAuthLinkKey()
926
+ } else {
927
+ authLinkResponse = await callAuthLink({commerceClientJWT, scrt2Url})
928
+ }
925
929
  if (generation !== authLinkGenerationRef.current) return
926
- const authLinkKey = authLinkResponse?.auth_link_key || authLinkResponse?.authLinkKey
930
+ const authLinkKey =
931
+ typeof authLinkResponse === 'string'
932
+ ? authLinkResponse
933
+ : authLinkResponse?.auth_link_key || authLinkResponse?.authLinkKey
927
934
  if (!authLinkKey || typeof authLinkKey !== 'string') {
928
935
  console.error(
929
936
  `[Commerce Client] performAuthLink(${reason}): no auth link key`,
@@ -2460,6 +2460,65 @@ describe('ShopperAgent Component', () => {
2460
2460
  })
2461
2461
  })
2462
2462
 
2463
+ test('uses the widget getAuthLinkKey when the bundle exposes it', async () => {
2464
+ seedAuthLinkStorage()
2465
+ const getAuthLinkKey = jest.fn().mockResolvedValue('widget-auth-link-key')
2466
+ window.CimulateMessaging = {injectMessagingWidget: jest.fn(), getAuthLinkKey}
2467
+
2468
+ renderCommerceClient()
2469
+
2470
+ await waitFor(() => expect(mockCallTokenBridge).toHaveBeenCalledTimes(1))
2471
+ expect(getAuthLinkKey).toHaveBeenCalledTimes(1)
2472
+ expect(mockCallAuthLink).not.toHaveBeenCalled()
2473
+ expect(mockCallTokenBridge).toHaveBeenCalledWith({
2474
+ authLinkKey: 'widget-auth-link-key',
2475
+ slasAccessToken: 'test-slas-access-token',
2476
+ siteId: 'RefArchGlobal'
2477
+ })
2478
+ })
2479
+
2480
+ test('falls back to the direct SCRT2 call when getAuthLinkKey is not a function', async () => {
2481
+ seedAuthLinkStorage()
2482
+ window.CimulateMessaging = {
2483
+ injectMessagingWidget: jest.fn(),
2484
+ getAuthLinkKey: 'not-a-function'
2485
+ }
2486
+
2487
+ renderCommerceClient()
2488
+
2489
+ await waitFor(() =>
2490
+ expect(mockCallAuthLink).toHaveBeenCalledWith({
2491
+ commerceClientJWT: 'commerce.jwt',
2492
+ scrt2Url: 'https://test.salesforce-scrt.com'
2493
+ })
2494
+ )
2495
+ await waitFor(() =>
2496
+ expect(mockCallTokenBridge).toHaveBeenCalledWith(
2497
+ expect.objectContaining({authLinkKey: 'commerce-auth-link-key'})
2498
+ )
2499
+ )
2500
+ })
2501
+
2502
+ test('does not fall back or bridge when the widget getAuthLinkKey rejects', async () => {
2503
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
2504
+ seedAuthLinkStorage()
2505
+ const rejection = new Error('not ready')
2506
+ const getAuthLinkKey = jest.fn().mockRejectedValue(rejection)
2507
+ window.CimulateMessaging = {injectMessagingWidget: jest.fn(), getAuthLinkKey}
2508
+
2509
+ renderCommerceClient()
2510
+
2511
+ await waitFor(() =>
2512
+ expect(errorSpy).toHaveBeenCalledWith(
2513
+ '[Commerce Client] performAuthLink(resumed-conversation) threw',
2514
+ rejection
2515
+ )
2516
+ )
2517
+ expect(mockCallAuthLink).not.toHaveBeenCalled()
2518
+ expect(mockCallTokenBridge).not.toHaveBeenCalled()
2519
+ errorSpy.mockRestore()
2520
+ })
2521
+
2463
2522
  test('does not auth-link without my_domain', async () => {
2464
2523
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
2465
2524
  mockedUseConfigurations.mockReturnValue({data: {configurations: []}})
@@ -42,10 +42,17 @@ const ensureWidget = () => {
42
42
  const isConfigured = (config) =>
43
43
  Boolean(config?.enabled && config?.scrt2Url && config?.orgId && config?.esDeveloperName)
44
44
 
45
- const useInlineAgentWidget = (config) => {
45
+ // Stable string key for the pdpQuestions array so the sync effect's dependency
46
+ // is by value, not identity. An empty/undefined list serializes to "" so it
47
+ // compares equal across renders and doesn't churn.
48
+ const pdpQuestionsKey = (pdpQuestions) =>
49
+ Array.isArray(pdpQuestions) && pdpQuestions.length > 0 ? JSON.stringify(pdpQuestions) : ''
50
+
51
+ const useInlineAgentWidget = (config, {pdpQuestions, productName} = {}) => {
46
52
  const [ready, setReady] = useState(false)
47
53
  const containerRef = useRef(null)
48
54
  const configured = isConfigured(config)
55
+ const questionsKey = pdpQuestionsKey(pdpQuestions)
49
56
 
50
57
  useEffect(() => {
51
58
  if (typeof window === 'undefined') return
@@ -72,6 +79,15 @@ const useInlineAgentWidget = (config) => {
72
79
  if (config.placeholder) el.setAttribute('placeholder', config.placeholder)
73
80
  if (config.persistSession !== false) el.setAttribute('persist-session', '')
74
81
  if (config.enableLogging) el.setAttribute('enable-logging', '')
82
+ // Seed pdp-questions at create time when we already have them, so the
83
+ // pills paint in the same frame the widget mounts (no visible pop-in
84
+ // from the attribute-sync effect below).
85
+ if (questionsKey) el.setAttribute('pdp-questions', questionsKey)
86
+ // Seed product-name at create time so the InputBar placeholder reads
87
+ // "Ask me anything about <productName>" from first paint. The sync
88
+ // effect below handles variant switches / SPA PDP navigation without
89
+ // tearing down the widget.
90
+ if (productName) el.setAttribute('product-name', productName)
75
91
 
76
92
  containerRef.current.appendChild(el)
77
93
 
@@ -80,6 +96,38 @@ const useInlineAgentWidget = (config) => {
80
96
  }
81
97
  }, [configured, ready, config])
82
98
 
99
+ // Keep the pdp-questions attribute in sync with the current product. Runs
100
+ // separately from element creation so a variant switch / SPA nav to a new
101
+ // PDP updates the pills without tearing down the widget (which would drop
102
+ // the SCRT2 session).
103
+ useEffect(() => {
104
+ if (!configured || !ready) return
105
+ if (!containerRef.current) return
106
+ const el = containerRef.current.querySelector('inline-agent-widget')
107
+ if (!el) return
108
+ if (questionsKey) {
109
+ el.setAttribute('pdp-questions', questionsKey)
110
+ } else {
111
+ el.removeAttribute('pdp-questions')
112
+ }
113
+ }, [configured, ready, questionsKey])
114
+
115
+ // Keep the product-name attribute in sync. Same rationale as pdp-questions
116
+ // above: variant switch / SPA PDP nav re-templates the InputBar
117
+ // placeholder without a widget teardown. Removing the attribute when the
118
+ // name is absent lets the widget fall back to the generic placeholder.
119
+ useEffect(() => {
120
+ if (!configured || !ready) return
121
+ if (!containerRef.current) return
122
+ const el = containerRef.current.querySelector('inline-agent-widget')
123
+ if (!el) return
124
+ if (productName) {
125
+ el.setAttribute('product-name', productName)
126
+ } else {
127
+ el.removeAttribute('product-name')
128
+ }
129
+ }, [configured, ready, productName])
130
+
83
131
  return containerRef
84
132
  }
85
133
 
@@ -23,3 +23,29 @@ export const mockImageWithText = {
23
23
  '<p><em>Text</em> <strong>Overlay</strong> <s>test</s> <u>Image</u> With Text <a href="https://zzrf-001.dx.commercecloud.salesforce.com/s/RefArchGlobal/mens/?lang=en_US" target="_self" data-link-type="category" data-link-label="Mens" data-category-id="mens" data-category-catalog-id="storefront-catalog-m-en">Component Link</a><img src="https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Library-Sites-RefArchSharedLibrary/default/dw2d9142bf/images/myaccount_registry.png" alt="alt text myaccount_registry image"></p>',
24
24
  alt: 'Alt Text test Image With Text Component'
25
25
  }
26
+
27
+ export const mockAnnouncementBanner = {
28
+ message: 'Free standard shipping on orders over $50',
29
+ linkUrl: '/sale',
30
+ linkText: 'Shop the sale',
31
+ colorScheme: 'primary',
32
+ height: 'md',
33
+ alignment: 'center'
34
+ }
35
+
36
+ export const mockEmbeddedHeader = {
37
+ id: 'header',
38
+ typeId: 'commerce_layouts.header',
39
+ regions: [
40
+ {
41
+ id: 'announcement',
42
+ components: [
43
+ {
44
+ id: 'announcement-banner-1',
45
+ typeId: 'commerce_assets.announcementBanner',
46
+ data: mockAnnouncementBanner
47
+ }
48
+ ]
49
+ }
50
+ ]
51
+ }