@salesforce/retail-react-app 10.3.0-nightly-20261008080708 → 10.3.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -5
- package/app/components/_app/index.jsx +3 -145
- package/app/components/_app/index.test.js +2 -100
- package/app/components/inline-agent-widget/index.jsx +3 -17
- package/app/components/shopper-agent/index.jsx +6 -13
- package/app/components/shopper-agent/index.test.js +0 -59
- package/app/hooks/use-inline-agent-widget.js +1 -49
- package/app/mocks/page-designer.js +0 -26
- package/app/page-designer/layouts/index.js +0 -1
- package/app/page-designer/registry.js +0 -8
- package/app/pages/product-detail/index.jsx +1 -10
- package/app/routes.jsx +0 -6
- package/app/ssr.js +0 -23
- package/app/static/inline-agent-widget.umd.js +28 -26
- package/package.json +8 -8
- package/app/page-designer/content/announcement-banner/index.jsx +0 -148
- package/app/page-designer/content/announcement-banner/index.test.js +0 -57
- package/app/page-designer/content/index.js +0 -8
- package/app/page-designer/layouts/header/index.jsx +0 -33
- package/app/page-designer/layouts/header/index.test.js +0 -38
- package/app/page-designer/preview-page.js +0 -28
- package/app/page-designer/preview-page.test.js +0 -26
- package/app/pages/component-preview/index.jsx +0 -73
- package/app/pages/component-preview/index.test.js +0 -117
- package/app/utils/inline-agent-widget-utils.js +0 -74
- package/app/utils/inline-agent-widget-utils.test.js +0 -87
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
## v10.3.0-
|
|
2
|
-
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
## v10.3.0-preview.1 (Oct 08, 2026)
|
|
2
|
+
- Version alignment release; no functional changes to `retail-react-app` since v10.3.0-preview.0.
|
|
3
|
+
|
|
4
|
+
## v10.3.0-preview.0 (Oct 05, 2026)
|
|
5
5
|
- [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)
|
|
6
6
|
- [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)
|
|
7
7
|
- [Feature] Add the `dw_attribution` first-touch marketing-attribution cookie (W-23493129). The app writes a sanitized, allowlist-only campaign cookie client-side on load so Salesforce CIP can attribute placed orders back to the campaign that drove a headless-storefront visit. Writing in the browser (not on SSR) means it also fires on a CDN document-cache hit — where the SSR handler never runs — and lets the app clear the cookie the instant a shopper opts out; campaign landing pages stay fully cacheable. The Secure + `SameSite=Lax` cookie has a 30-minute write-once TTL and stores percent-encoded `key=value` pairs (`utm_source`/`utm_medium`/`utm_campaign`/`utm_term`/`utm_content`, `gclid`, `fbclid`, `msclkid`, and a sanitized external `ref` — origin, path, and its own allowlisted params only); no free-text query params are captured. It is write-once (first touch wins) and honors Do Not Track (`dw_dnt=1`): while opted out it captures nothing and expires any existing cookie.
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
- [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)
|
|
17
17
|
- [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)
|
|
18
18
|
- [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.
|
|
19
|
-
- [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.
|
|
20
19
|
- [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)
|
|
21
20
|
- [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.
|
|
22
21
|
- [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
|
|
8
|
+
import React, {useState, useEffect, useMemo} 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,7 +16,6 @@ import {useQuery} from '@tanstack/react-query'
|
|
|
16
16
|
import {
|
|
17
17
|
useAccessToken,
|
|
18
18
|
useCategory,
|
|
19
|
-
useComponent,
|
|
20
19
|
useShopperBasketsMutation,
|
|
21
20
|
useUsid
|
|
22
21
|
} from '@salesforce/commerce-sdk-react'
|
|
@@ -72,9 +71,8 @@ import {useAttribution} from '@salesforce/retail-react-app/app/hooks/use-attribu
|
|
|
72
71
|
// HOCs
|
|
73
72
|
import {withCommerceSdkReact} from '@salesforce/retail-react-app/app/components/with-commerce-sdk-react/with-commerce-sdk-react'
|
|
74
73
|
|
|
75
|
-
import {PageDesignerProvider
|
|
74
|
+
import {PageDesignerProvider} from '@salesforce/commerce-sdk-react/page-designer'
|
|
76
75
|
import PageDesignerInit from '@salesforce/retail-react-app/app/components/page-designer-init'
|
|
77
|
-
import {EmbeddedSubtreeProvider} from '@salesforce/storefront-next-runtime/design/react/core'
|
|
78
76
|
|
|
79
77
|
// Localization
|
|
80
78
|
import {IntlProvider} from 'react-intl'
|
|
@@ -152,16 +150,12 @@ const ListMenuContentWithData = withCommerceSdkReact(
|
|
|
152
150
|
}
|
|
153
151
|
)
|
|
154
152
|
|
|
155
|
-
const
|
|
153
|
+
const App = (props) => {
|
|
156
154
|
const {children} = props
|
|
157
155
|
const {data: categoriesTree} = useCategory({
|
|
158
156
|
parameters: {id: CAT_MENU_DEFAULT_ROOT_CATEGORY, levels: CAT_MENU_DEFAULT_NAV_SSR_DEPTH}
|
|
159
157
|
})
|
|
160
158
|
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'}})
|
|
165
159
|
const {getTokenWhenReady} = useAccessToken()
|
|
166
160
|
const {usid} = useUsid()
|
|
167
161
|
const appOrigin = useAppOrigin()
|
|
@@ -434,26 +428,6 @@ const StorefrontApp = (props) => {
|
|
|
434
428
|
<Box {...styles.headerWrapper}>
|
|
435
429
|
{!isCheckout ? (
|
|
436
430
|
<>
|
|
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
|
-
)}
|
|
457
431
|
<AboveHeader />
|
|
458
432
|
<Header
|
|
459
433
|
onMenuClick={onOpen}
|
|
@@ -548,122 +522,6 @@ const StorefrontApp = (props) => {
|
|
|
548
522
|
)
|
|
549
523
|
}
|
|
550
524
|
|
|
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
|
-
|
|
667
525
|
App.propTypes = {
|
|
668
526
|
children: PropTypes.node
|
|
669
527
|
}
|
|
@@ -7,33 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import React from 'react'
|
|
9
9
|
import {screen, waitFor} from '@testing-library/react'
|
|
10
|
-
import {renderToString} from 'react-dom/server'
|
|
11
10
|
import {Helmet} from 'react-helmet'
|
|
12
11
|
import {rest} from 'msw'
|
|
13
12
|
|
|
14
13
|
import App from '@salesforce/retail-react-app/app/components/_app/index.jsx'
|
|
15
|
-
import {renderWithProviders
|
|
14
|
+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
|
|
16
15
|
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'
|
|
18
16
|
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
|
|
19
17
|
import messages from '@salesforce/retail-react-app/app/static/translations/compiled/en-GB.json'
|
|
20
18
|
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
|
|
21
19
|
import {prependHandlersToServer} from '@salesforce/retail-react-app/jest-setup'
|
|
22
20
|
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
|
-
})
|
|
37
21
|
|
|
38
22
|
jest.mock('../../hooks/use-multi-site', () => jest.fn())
|
|
39
23
|
jest.mock('../../hooks/use-update-shopper-context', () => ({
|
|
@@ -42,14 +26,6 @@ jest.mock('../../hooks/use-update-shopper-context', () => ({
|
|
|
42
26
|
jest.mock('../../page-designer/registry', () => ({
|
|
43
27
|
initializeRegistry: jest.fn()
|
|
44
28
|
}))
|
|
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
|
-
}))
|
|
53
29
|
|
|
54
30
|
let windowSpy
|
|
55
31
|
|
|
@@ -70,8 +46,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
|
|
|
70
46
|
useDNT: () => ({selectedDnt: undefined, updateDnt: mockUpdateDnt}),
|
|
71
47
|
useUsid: () => ({usid: 'test-usid', getUsidWhenReady: () => Promise.resolve('test-usid')}),
|
|
72
48
|
useGlobalAnchorBlock: jest.fn(),
|
|
73
|
-
registry: mockRegistry
|
|
74
|
-
useComponent: jest.fn(() => ({data: undefined, isLoading: false, error: null}))
|
|
49
|
+
registry: mockRegistry
|
|
75
50
|
}
|
|
76
51
|
})
|
|
77
52
|
|
|
@@ -96,11 +71,8 @@ beforeEach(() => {
|
|
|
96
71
|
|
|
97
72
|
afterEach(() => {
|
|
98
73
|
windowSpy.mockRestore()
|
|
99
|
-
StorefrontPreview.mockClear()
|
|
100
74
|
jest.restoreAllMocks()
|
|
101
75
|
jest.resetModules()
|
|
102
|
-
// Reset the jsdom URL so a route-dependent test can't leak its location into the next.
|
|
103
|
-
window.history.pushState({}, '', '/')
|
|
104
76
|
})
|
|
105
77
|
describe('App', () => {
|
|
106
78
|
const site = {
|
|
@@ -181,76 +153,6 @@ describe('App', () => {
|
|
|
181
153
|
expect(hreflangLinks.some((link) => link.hrefLang === 'x-default')).toBe(true)
|
|
182
154
|
})
|
|
183
155
|
|
|
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
|
-
|
|
254
156
|
test('App component updates the basket with correct currency and customer email', async () => {
|
|
255
157
|
const customerEmail = 'email@test.com'
|
|
256
158
|
|
|
@@ -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}) => {
|
|
16
|
+
const containerRef = useInlineAgentWidget(config)
|
|
17
17
|
|
|
18
18
|
if (!isConfigured(config)) return null
|
|
19
19
|
|
|
@@ -34,21 +34,7 @@ InlineAgentWidget.propTypes = {
|
|
|
34
34
|
orgId: PropTypes.string,
|
|
35
35
|
esDeveloperName: PropTypes.string,
|
|
36
36
|
placeholder: PropTypes.string
|
|
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
|
|
37
|
+
})
|
|
52
38
|
}
|
|
53
39
|
|
|
54
40
|
export default InlineAgentWidget
|
|
@@ -917,20 +917,13 @@ 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
|
-
|
|
925
|
-
authLinkResponse = await window.CimulateMessaging.getAuthLinkKey()
|
|
926
|
-
} else {
|
|
927
|
-
authLinkResponse = await callAuthLink({commerceClientJWT, scrt2Url})
|
|
928
|
-
}
|
|
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})
|
|
929
925
|
if (generation !== authLinkGenerationRef.current) return
|
|
930
|
-
const authLinkKey =
|
|
931
|
-
typeof authLinkResponse === 'string'
|
|
932
|
-
? authLinkResponse
|
|
933
|
-
: authLinkResponse?.auth_link_key || authLinkResponse?.authLinkKey
|
|
926
|
+
const authLinkKey = authLinkResponse?.auth_link_key || authLinkResponse?.authLinkKey
|
|
934
927
|
if (!authLinkKey || typeof authLinkKey !== 'string') {
|
|
935
928
|
console.error(
|
|
936
929
|
`[Commerce Client] performAuthLink(${reason}): no auth link key`,
|
|
@@ -2460,65 +2460,6 @@ 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
|
-
|
|
2522
2463
|
test('does not auth-link without my_domain', async () => {
|
|
2523
2464
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
2524
2465
|
mockedUseConfigurations.mockReturnValue({data: {configurations: []}})
|
|
@@ -42,17 +42,10 @@ const ensureWidget = () => {
|
|
|
42
42
|
const isConfigured = (config) =>
|
|
43
43
|
Boolean(config?.enabled && config?.scrt2Url && config?.orgId && config?.esDeveloperName)
|
|
44
44
|
|
|
45
|
-
|
|
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} = {}) => {
|
|
45
|
+
const useInlineAgentWidget = (config) => {
|
|
52
46
|
const [ready, setReady] = useState(false)
|
|
53
47
|
const containerRef = useRef(null)
|
|
54
48
|
const configured = isConfigured(config)
|
|
55
|
-
const questionsKey = pdpQuestionsKey(pdpQuestions)
|
|
56
49
|
|
|
57
50
|
useEffect(() => {
|
|
58
51
|
if (typeof window === 'undefined') return
|
|
@@ -79,15 +72,6 @@ const useInlineAgentWidget = (config, {pdpQuestions, productName} = {}) => {
|
|
|
79
72
|
if (config.placeholder) el.setAttribute('placeholder', config.placeholder)
|
|
80
73
|
if (config.persistSession !== false) el.setAttribute('persist-session', '')
|
|
81
74
|
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)
|
|
91
75
|
|
|
92
76
|
containerRef.current.appendChild(el)
|
|
93
77
|
|
|
@@ -96,38 +80,6 @@ const useInlineAgentWidget = (config, {pdpQuestions, productName} = {}) => {
|
|
|
96
80
|
}
|
|
97
81
|
}, [configured, ready, config])
|
|
98
82
|
|
|
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
|
-
|
|
131
83
|
return containerRef
|
|
132
84
|
}
|
|
133
85
|
|
|
@@ -23,29 +23,3 @@ 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
|
-
}
|
|
@@ -32,7 +32,6 @@ export function initializeRegistry() {
|
|
|
32
32
|
registry.registerImporter('commerce_assets.productTile', () =>
|
|
33
33
|
import('./assets/image-with-text')
|
|
34
34
|
)
|
|
35
|
-
registry.registerImporter('commerce_assets.imageTile', () => import('./assets/image-tile'))
|
|
36
35
|
|
|
37
36
|
// Commerce Layouts - Layout components
|
|
38
37
|
registry.registerImporter('commerce_layouts.mobileGrid1r1c', () =>
|
|
@@ -53,11 +52,4 @@ export function initializeRegistry() {
|
|
|
53
52
|
registry.registerImporter('commerce_layouts.mobileGrid3r2c', () =>
|
|
54
53
|
import('./layouts/mobileGrid3r2c')
|
|
55
54
|
)
|
|
56
|
-
registry.registerImporter('commerce_layouts.carousel', () => import('./layouts/carousel'))
|
|
57
|
-
registry.registerImporter('commerce_layouts.header', () => import('./layouts/header'))
|
|
58
|
-
|
|
59
|
-
// Content - Embedded content blocks
|
|
60
|
-
registry.registerImporter('commerce_assets.announcementBanner', () =>
|
|
61
|
-
import('./content/announcement-banner')
|
|
62
|
-
)
|
|
63
55
|
}
|