Skip to content

Commit 229336d

Browse files
Merge remote-tracking branch 'origin/develop' into fix/shopper-agent-product-links-pwa-route
# Conflicts: # packages/template-retail-react-app/CHANGELOG.md
2 parents 0f13124 + 1256ecd commit 229336d

32 files changed

Lines changed: 1640 additions & 8 deletions

‎packages/pwa-kit-create-app/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## v3.22.0-dev
2+
- [Feature] Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app` for Shopper Agent express payments: allow storefront-owned express-payment routes to be embedded in same-origin iframes. [#4063](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4063)
23
- Keep the generated `ssr.js.hbs` template in sync with `template-retail-react-app`: extract the passwordless-login callback handler to a named exported function (`passwordlessLoginCallbackHandler`), add `email_id` claim cross-validation against the request body to prevent email-relay attacks, and differentiate the notification type between `passwordless-registration` and `passwordless-login` based on the `?register=true` query parameter. [#4072](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4072)
34
- [Security] Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app`: fix the registration-verification callback guard to check `registrationVerification.callbackURI` instead of `passwordless.mode` (non-extended template variant), and bind the `email_id` claim in the SLAS callback token to the request body to prevent email-relay attacks. [#4067](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4067)
45
- [Bugfix] Temporarily pin transitive Vitest to 4.1.11 in the create-app package and generated extensible apps to avoid an npm 10 dependency resolver crash while the Vitest browser packages require 4.1.11.

‎packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,8 @@ const {handler} = runtime.createHandler(options, (app) => {
573573
'www.google.com/pay/'
574574
],
575575
'frame-src': [
576+
// The Shopper Agent embeds storefront-owned express-payment routes.
577+
"'self'",
576578
// Allow frames from Salesforce site.com (Needed for MIAW)
577579
'*.site.com',
578580
// Payment gateways

‎packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,8 @@ const {handler} = runtime.createHandler(options, (app) => {
608608
'www.google.com/pay/'
609609
],
610610
'frame-src': [
611+
// The Shopper Agent embeds storefront-owned express-payment routes.
612+
"'self'",
611613
// Allow frames from Salesforce site.com (Needed for MIAW)
612614
'*.site.com',
613615
// Payment gateways

‎packages/template-retail-react-app/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## v10.4.0-dev (Oct 20, 2026)
22
- [Bugfix] Keep Commerce Client shopper-agent product links on the PWA Kit storefront. The agent backend sends product links as SFRA URLs on the backend origin (e.g. `/en_US/<slug>-<id>.html`), which the widget's `onNavigate` never sees, so clicking a product card left for the SFRA PDP. The storefront now reads the product id from those links in the widget's product slots (cards, product detail title, comparison columns), rewrites their `href` to the PWA product page, and routes plain clicks through the router. Links in agent message text are left alone. When the product id is not certain from the URL (the name slug does not match, or the id contains `-`), it is resolved through Shopper SEO `getUrlMapping`; if that finds no product the link keeps its backend URL rather than pointing at a storefront 404. Port of storefront-next#2944. [#4076](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4076) (W-23948585)
3+
- [Feature] Add a neutral Shopper Agent express-payment iframe framework with cart and product routes, a merchant provider lifecycle contract, validated iframe-to-host events, and automatic iframe sizing. [#4063](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4063)
34
- [Changed] Rename `pwakit-notify` notification types to flow-based names: `passwordless-magic-link` → `passwordless-login`, `otp` → `password-registration`. Adds new `passwordless-registration` type for new-user magic-link delivery — intentionally skips the anti-enumeration account-existence check (correct for the new-user registration flow). The cartridge changes are aligned 1-to-1 with sf-next PR #2946. [Security] Cross-validate `email_id` from the SLAS callback JWT against the request body in the passwordless-login callback, preventing email relay via an arbitrary-recipient registration email when `register=true`.
45
- [Security] Cross-validate `email_id` and `token` claims in the SLAS callback JWT against the request body in the registration-verification callback (`/registration-verification-callback`), preventing email-relay attacks where a caller with a valid tenant-signed guest token could trigger OTP delivery to an arbitrary address. [#4067](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4067)
56
- [Bugfix] Gate the GLO magic-link auto-submit on `emailFromContext` (email present in router state) rather than the derived `email` variable, so arriving directly via magic link no longer attempts an auto-submit before the user enters their email address. [#4067](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4067)

‎packages/template-retail-react-app/app/components/_app-config/index.jsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/compon
4343
import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
4444
import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
4545
import {generateSfdcUserAgent} from '@salesforce/retail-react-app/app/utils/sfdc-user-agent-utils'
46+
import {isShopperAgentExpressPaymentPath} from '@salesforce/retail-react-app/app/components/shopper-agent/express-payment/routes'
4647
import {
4748
DEFAULT_DNT_STATE,
4849
STORE_LOCATOR_RADIUS,
@@ -102,6 +103,9 @@ const AppConfig = ({children, locals = {}}) => {
102103
const slasPublicClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPublicProxyPath}`
103104

104105
const pageDesignerParams = locals.pageDesignerParams || {}
106+
const requestPath =
107+
typeof window === 'undefined' ? locals.originalUrl : window.location.pathname
108+
const isShopperAgentExpressPaymentRoute = isShopperAgentExpressPaymentPath(requestPath)
105109

106110
return (
107111
<CommerceApiProvider
@@ -137,7 +141,8 @@ const AppConfig = ({children, locals = {}}) => {
137141
<ChakraProvider theme={theme}>{children}</ChakraProvider>
138142
</StoreLocatorProvider>
139143
</MultiSiteProvider>
140-
<ReactQueryDevtools />
144+
{/* The floating devtools launcher can obscure the compact express-payment iframe. */}
145+
{!isShopperAgentExpressPaymentRoute && <ReactQueryDevtools />}
141146
</CommerceApiProvider>
142147
)
143148
}

‎packages/template-retail-react-app/app/components/_app/index.jsx‎

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/con
101101
import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
102102
import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
103103
import {useCommerceClientPagePush} from '@salesforce/retail-react-app/app/hooks/use-commerce-client-page-push'
104+
import {isShopperAgentExpressPaymentPath} from '@salesforce/retail-react-app/app/components/shopper-agent/express-payment/routes'
104105

105106
// Code-split the optional, client-only Shopper Agent so the component and its messaging
106107
// integrations (MIAW / Commerce Client) ship in a separate async chunk instead of the
@@ -650,18 +651,89 @@ ComponentPreviewApp.propTypes = {
650651
}
651652

652653
/**
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.
654+
* Minimal app surface for express payment content embedded in the Shopper Agent.
655+
*
656+
* AppConfig still supplies the Commerce API, multisite, and Chakra providers. Keeping
657+
* this surface separate prevents the storefront shell and its shopper-state side
658+
* effects from running recursively inside the Commerce Client iframe.
659+
*/
660+
const ShopperAgentExpressPaymentApp = ({children}) => {
661+
const appOrigin = useAppOrigin()
662+
const {site, locale} = useMultiSite()
663+
664+
const targetLocale = getTargetLocale({
665+
getUserPreferredLocales: () => [locale?.id || DEFAULT_LOCALE],
666+
l10nConfig: site.l10n
667+
})
668+
669+
const {data: messages} = useQuery({
670+
queryKey: ['app', 'translations', 'messages', targetLocale],
671+
queryFn: () => fetchTranslations(targetLocale, appOrigin),
672+
enabled: isServer
673+
})
674+
675+
const currency = locale.preferredCurrency || site.l10n.defaultCurrency
676+
677+
return (
678+
<Box
679+
className="sf-app sf-app-shopper-agent-express-payment"
680+
data-testid="shopper-agent-express-payment-app"
681+
bg="transparent"
682+
width="100%"
683+
>
684+
<IntlProvider
685+
onError={(err) => {
686+
if (!messages) return
687+
if (err.code === 'MISSING_TRANSLATION') {
688+
logger.warn('Missing translation', {
689+
namespace: 'ShopperAgentExpressPaymentApp.IntlProvider',
690+
additionalProperties: {errorMessage: err.message}
691+
})
692+
return
693+
}
694+
throw err
695+
}}
696+
locale={targetLocale}
697+
messages={messages}
698+
defaultLocale={DEFAULT_LOCALE}
699+
>
700+
<CurrencyProvider currency={currency}>
701+
<Box as="main" id="app-main" role="main" width="100%">
702+
{children}
703+
</Box>
704+
</CurrencyProvider>
705+
</IntlProvider>
706+
</Box>
707+
)
708+
}
709+
710+
ShopperAgentExpressPaymentApp.propTypes = {
711+
children: PropTypes.node
712+
}
713+
714+
/**
715+
* Selects the app shell by route. Embedded iframe routes get their own chrome-free
716+
* surface; every other route gets the full storefront app. Uses `req.url` on the
717+
* server and `location.pathname` on the client.
657718
*/
658719
const App = (props) => {
659720
const location = useLocation()
660721
const {req} = useServerContext()
661722
const pathname = req?.url ? req.url.split('?')[0] : location?.pathname || ''
662723
const isComponentPreview = COMPONENT_PREVIEW_PATH_RE.test(pathname)
663724

664-
return isComponentPreview ? <ComponentPreviewApp {...props} /> : <StorefrontApp {...props} />
725+
if (isComponentPreview) {
726+
return <ComponentPreviewApp {...props} />
727+
}
728+
729+
if (
730+
getCommerceAgentConfig()?.enabled === 'true' &&
731+
isShopperAgentExpressPaymentPath(pathname)
732+
) {
733+
return <ShopperAgentExpressPaymentApp {...props} />
734+
}
735+
736+
return <StorefrontApp {...props} />
665737
}
666738

667739
App.propTypes = {

‎packages/template-retail-react-app/app/components/_app/index.test.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ beforeEach(() => {
9595
})
9696

9797
afterEach(() => {
98+
mockConfig.app.commerceAgent.enabled = 'false'
9899
windowSpy.mockRestore()
99100
StorefrontPreview.mockClear()
100101
jest.restoreAllMocks()
@@ -204,6 +205,38 @@ describe('App', () => {
204205
expect(StorefrontPreview).not.toHaveBeenCalled()
205206
})
206207

208+
test('stretches the Shopper Agent express payment surface to the iframe width', () => {
209+
useMultiSite.mockImplementation(() => resultUseMultiSite)
210+
mockConfig.app.commerceAgent.enabled = 'true'
211+
window.history.pushState({}, '', '/Payments-ExpressProduct?pid=product-1&quantity=1')
212+
213+
renderWithProviders(
214+
<App targetLocale={DEFAULT_LOCALE} defaultLocale={DEFAULT_LOCALE} messages={messages}>
215+
<p>Express payment child</p>
216+
</App>
217+
)
218+
219+
expect(screen.getByTestId('shopper-agent-express-payment-app')).toHaveStyle({
220+
width: '100%'
221+
})
222+
})
223+
224+
test('uses the normal storefront surface for an express-payment path when Shopper Agent is disabled', async () => {
225+
useMultiSite.mockImplementation(() => resultUseMultiSite)
226+
window.history.pushState({}, '', '/Payments-ExpressProduct?pid=product-1&quantity=1')
227+
228+
renderWithProviders(
229+
<App targetLocale={DEFAULT_LOCALE} defaultLocale={DEFAULT_LOCALE} messages={messages}>
230+
<p>Disabled express payment child</p>
231+
</App>
232+
)
233+
234+
await waitFor(() => {
235+
expect(screen.getByText('Disabled express payment child')).toBeInTheDocument()
236+
})
237+
expect(screen.queryByTestId('shopper-agent-express-payment-app')).not.toBeInTheDocument()
238+
})
239+
207240
test('SSR-renders the embedded header when its component is only lazily registered', () => {
208241
// The production regression: `initializeRegistry()` registers *lazy importers*, not
209242
// eager components, so during SSR `registry.getComponent()` returns null on first
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) 2026, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React, {useEffect, useState} from 'react'
9+
10+
import {Box, Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui'
11+
import {expressPaymentProviderPropTypes} from '@salesforce/retail-react-app/app/components/shopper-agent/express-payment/contract'
12+
13+
export const CONTRACT_TEST_ORDER_ID = 'CONTRACT-TEST-ORDER'
14+
15+
/**
16+
* Reference provider that demonstrates the iframe context and lifecycle contract.
17+
* It is not selected by default. Replace the export in provider.jsx to use it or
18+
* connect a merchant payment integration.
19+
*/
20+
const ContractTestProvider = ({context, lifecycle}) => {
21+
const [status, setStatus] = useState('Available')
22+
const [successSubmitted, setSuccessSubmitted] = useState(false)
23+
24+
useEffect(() => {
25+
lifecycle.available()
26+
}, [lifecycle])
27+
28+
const reportAvailable = () => {
29+
lifecycle.available()
30+
setStatus('Available')
31+
}
32+
33+
const reportUnavailable = () => {
34+
lifecycle.unavailable()
35+
setStatus('Unavailable')
36+
}
37+
38+
const reportFailure = () => {
39+
lifecycle.failure({shopperMessage: 'Contract-test failure'})
40+
setStatus('Failure reported; retry remains available')
41+
}
42+
43+
const reportCancellation = () => {
44+
lifecycle.cancel({shopperMessage: 'Contract-test cancellation'})
45+
setStatus('Cancellation reported; retry remains available')
46+
}
47+
48+
const reportSuccess = () => {
49+
lifecycle.success({orderId: CONTRACT_TEST_ORDER_ID})
50+
setSuccessSubmitted(true)
51+
setStatus('Simulated success submitted; fixture controls are now disabled')
52+
}
53+
54+
return (
55+
<Box
56+
data-testid="express-payment-contract-test-provider"
57+
borderWidth="1px"
58+
borderRadius="md"
59+
bg="white"
60+
p={3}
61+
>
62+
<Text fontWeight="semibold">Express payment iframe shell is ready</Text>
63+
<Text fontSize="sm">
64+
{context.surface === 'product'
65+
? `Product: ${context.productId}, quantity: ${context.quantity}`
66+
: `Cart payment surface, basket: ${context.basket.id || context.basket.status}`}
67+
</Text>
68+
<Text fontSize="xs">
69+
Site: {context.siteId}, locale: {context.locale}, currency: {context.currency}
70+
</Text>
71+
<Text role="status" aria-live="polite" fontSize="sm" mt={2}>
72+
{status}
73+
</Text>
74+
<Stack direction="row" flexWrap="wrap" spacing={2} mt={3}>
75+
<Button size="sm" onClick={reportAvailable} isDisabled={successSubmitted}>
76+
Report available
77+
</Button>
78+
<Button size="sm" onClick={reportUnavailable} isDisabled={successSubmitted}>
79+
Report unavailable
80+
</Button>
81+
<Button size="sm" onClick={reportFailure} isDisabled={successSubmitted}>
82+
Report failure
83+
</Button>
84+
<Button size="sm" onClick={reportCancellation} isDisabled={successSubmitted}>
85+
Report cancellation
86+
</Button>
87+
<Button size="sm" onClick={reportSuccess} isDisabled={successSubmitted}>
88+
Report simulated success
89+
</Button>
90+
</Stack>
91+
</Box>
92+
)
93+
}
94+
95+
ContractTestProvider.propTypes = expressPaymentProviderPropTypes
96+
97+
export default ContractTestProvider
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2026, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React from 'react'
9+
import {screen, waitFor} from '@testing-library/react'
10+
import userEvent from '@testing-library/user-event'
11+
12+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
13+
import ContractTestProvider, {
14+
CONTRACT_TEST_ORDER_ID
15+
} from '@salesforce/retail-react-app/app/components/shopper-agent/express-payment/contract-test-provider'
16+
17+
const context = {
18+
providerApiVersion: 1,
19+
mode: 'shopper-agent',
20+
surface: 'product',
21+
siteId: 'RefArchGlobal',
22+
locale: 'en-GB',
23+
currency: 'GBP',
24+
productId: 'sku-1',
25+
quantity: 2
26+
}
27+
28+
const createLifecycle = () => ({
29+
available: jest.fn(),
30+
unavailable: jest.fn(),
31+
success: jest.fn(),
32+
failure: jest.fn(),
33+
cancel: jest.fn()
34+
})
35+
36+
describe('ContractTestProvider', () => {
37+
test('reports initial availability and exposes the launch context', async () => {
38+
const lifecycle = createLifecycle()
39+
40+
renderWithProviders(<ContractTestProvider context={context} lifecycle={lifecycle} />)
41+
42+
expect(screen.getByText('Product: sku-1, quantity: 2')).toBeInTheDocument()
43+
await waitFor(() => expect(lifecycle.available).toHaveBeenCalledWith())
44+
})
45+
46+
test('exercises retryable unavailable, failure, and cancellation states', async () => {
47+
const user = userEvent.setup()
48+
const lifecycle = createLifecycle()
49+
renderWithProviders(<ContractTestProvider context={context} lifecycle={lifecycle} />)
50+
51+
await user.click(screen.getByRole('button', {name: 'Report unavailable'}))
52+
expect(lifecycle.unavailable).toHaveBeenCalledTimes(1)
53+
54+
await user.click(screen.getByRole('button', {name: 'Report failure'}))
55+
expect(lifecycle.failure).toHaveBeenCalledWith({shopperMessage: 'Contract-test failure'})
56+
57+
await user.click(screen.getByRole('button', {name: 'Report cancellation'}))
58+
expect(lifecycle.cancel).toHaveBeenCalledWith({
59+
shopperMessage: 'Contract-test cancellation'
60+
})
61+
62+
await user.click(screen.getByRole('button', {name: 'Report available'}))
63+
expect(lifecycle.available).toHaveBeenLastCalledWith()
64+
expect(screen.getByRole('status')).toHaveTextContent('Available')
65+
})
66+
67+
test('reports a clearly fake success and makes the fixture terminal', async () => {
68+
const user = userEvent.setup()
69+
const lifecycle = createLifecycle()
70+
renderWithProviders(<ContractTestProvider context={context} lifecycle={lifecycle} />)
71+
72+
await user.click(screen.getByRole('button', {name: 'Report simulated success'}))
73+
74+
expect(lifecycle.success).toHaveBeenCalledWith({orderId: CONTRACT_TEST_ORDER_ID})
75+
expect(screen.getByRole('status')).toHaveTextContent(
76+
'Simulated success submitted; fixture controls are now disabled'
77+
)
78+
expect(screen.getByRole('button', {name: 'Report available'})).toBeDisabled()
79+
})
80+
})

0 commit comments

Comments
 (0)