@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 +6 -0
- package/app/components/_app/index.jsx +145 -3
- package/app/components/_app/index.test.js +100 -2
- package/app/components/inline-agent-widget/index.jsx +17 -3
- package/app/components/shopper-agent/index.jsx +13 -6
- package/app/components/shopper-agent/index.test.js +59 -0
- package/app/hooks/use-inline-agent-widget.js +49 -1
- package/app/mocks/page-designer.js +26 -0
- package/app/page-designer/content/announcement-banner/index.jsx +148 -0
- package/app/page-designer/content/announcement-banner/index.test.js +57 -0
- package/app/page-designer/content/index.js +8 -0
- package/app/page-designer/layouts/header/index.jsx +33 -0
- package/app/page-designer/layouts/header/index.test.js +38 -0
- package/app/page-designer/layouts/index.js +1 -0
- package/app/page-designer/preview-page.js +28 -0
- package/app/page-designer/preview-page.test.js +26 -0
- package/app/page-designer/registry.js +8 -0
- package/app/pages/component-preview/index.jsx +73 -0
- package/app/pages/component-preview/index.test.js +117 -0
- package/app/pages/product-detail/index.jsx +10 -1
- package/app/routes.jsx +6 -0
- package/app/ssr.js +23 -0
- package/app/static/inline-agent-widget.umd.js +26 -28
- package/app/utils/inline-agent-widget-utils.js +74 -0
- package/app/utils/inline-agent-widget-utils.test.js +87 -0
- package/package.json +8 -8
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
|
|
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.
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
|
|
924
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|