Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## v10.4.0-dev (Oct 20, 2026)
- [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 last path segment contains `-`, so it may be a name slug and id, or an id that 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. [#4076](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4076)
- [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)
- [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`.
- [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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {defineMessage, useIntl} from 'react-intl'
import useScript from '@salesforce/retail-react-app/app/hooks/use-script'
import {
useAccessToken,
useCommerceApi,
useConfig,
useConfigurations,
useCustomerType,
Expand All @@ -27,6 +28,7 @@ import {
} from '@salesforce/retail-react-app/app/constants'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {toStorefrontNavigatePath} from '@salesforce/retail-react-app/app/utils/url'
import {attachAgentProductLinkRouting} from '@salesforce/retail-react-app/app/utils/shopper-agent-product-links'
import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import {
Expand Down Expand Up @@ -719,18 +721,48 @@ const CommerceClientAgentWindow = ({
[navigate]
)

// The backend sends product links as SFRA `.html` URLs on the backend origin, which `onNavigate`
// never sees. Point the widget's product links at the PWA product page instead. When the id is
// not certain from the URL, Shopper SEO resolves it with the site's own URL rules.
const {buildUrl} = useMultiSite()
const shopperSeo = useCommerceApi('shopperSeo')
const {getTokenWhenReady} = useAccessToken()
const getTokenWhenReadyRef = useRef(getTokenWhenReady)
getTokenWhenReadyRef.current = getTokenWhenReady
useEffect(() => {
// The id comes from the backend URL, so it is encoded as one path segment. `productUrlBuilder`
// uses `encodeURI`, which leaves `/`, `?` and `#` in place (`../account` would leave the PDP),
// and would encode an already encoded id twice.
const toProductPath = (productId) => `/product/${encodeURIComponent(productId)}`
return attachAgentProductLinkRouting({
navigateToProduct: (productId) => navigate(toProductPath(productId)),
getProductHref: (productId) => buildUrl(toProductPath(productId)),
lookupProductId: async (urlSegment) => {
const token = await getTokenWhenReadyRef.current()
let mapping
try {
mapping = await shopperSeo.getUrlMapping({
parameters: {urlSegment},
headers: {Authorization: `Bearer ${token}`}
})
} catch (error) {
// 404 means the segment maps to nothing; anything else is a failed lookup
if (error?.response?.status === 404) return undefined
throw error
}
return mapping?.resourceType === 'PRODUCT' ? mapping.resourceId : undefined
}
})
}, [navigate, buildUrl, shopperSeo])

// Loads the Commerce Client messaging UMD bundle, which exposes window.CimulateMessaging.
const scriptLoadStatus = useScript(resolveCommerceClientScriptUrl(commerceAgentConfiguration))
const {formatMessage} = useIntl()
const toast = useToast()
const toastRef = useRef(toast)
toastRef.current = toast

// Customer details and auth tokens for Commerce Client session init
const {getTokenWhenReady} = useAccessToken()
const getTokenWhenReadyRef = useRef(getTokenWhenReady)
getTokenWhenReadyRef.current = getTokenWhenReady

// Customer details for Commerce Client session init
const {organizationId, siteId: configSiteId} = useConfig()

// Fetch my_domain from the Shopper Configurations API. Auth-linking calls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-miaw', () => ({

// Mock commerce-sdk-react hooks. useAccessToken returns a stable object so the
// component can read getTokenWhenReady() during the conversation-started flow.
const mockGetUrlMapping = jest.fn()
jest.mock('@salesforce/commerce-sdk-react', () => ({
useAccessToken: jest.fn(),
useCommerceApi: () => ({getUrlMapping: mockGetUrlMapping}),
useUsid: jest.fn(),
useConfig: jest.fn(),
useCustomerType: jest.fn(),
Expand All @@ -129,6 +131,17 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({
default: jest.fn()
}))

const mockNavigate = jest.fn()
jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({
__esModule: true,
default: () => mockNavigate
}))

const mockStopProductLinkRouting = jest.fn()
jest.mock('@salesforce/retail-react-app/app/utils/shopper-agent-product-links', () => ({
attachAgentProductLinkRouting: jest.fn(() => mockStopProductLinkRouting)
}))

// Mock the useTheme hook
jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => ({
useTheme: jest.fn()
Expand All @@ -147,6 +160,7 @@ import {
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {useTheme} from '@salesforce/retail-react-app/app/components/shared/ui'
import useCommerceClientMessaging from '@salesforce/retail-react-app/app/hooks/use-commerce-client-messaging'
import {attachAgentProductLinkRouting} from '@salesforce/retail-react-app/app/utils/shopper-agent-product-links'

// Get mocked functions
const mockedUseScript = useScript
Expand Down Expand Up @@ -2059,6 +2073,77 @@ describe('ShopperAgent Component', () => {
expect(typeof widgetOptions.onNavigate).toBe('function')
})

test('points agent product links at the storefront product page and stops on unmount', () => {
const buildUrl = jest.fn((path) => `/us/en-US${path}`)
mockedUseMultiSite.mockReturnValue({
locale: {id: 'en-US', preferredCurrency: 'USD'},
buildUrl
})

const {unmount} = renderCommerceClient()

const calls = attachAgentProductLinkRouting.mock.calls
const {navigateToProduct, getProductHref} = calls[calls.length - 1][0]
expect(getProductHref('DH-387')).toBe('/us/en-US/product/DH-387')
navigateToProduct('2040793A5H')
expect(mockNavigate).toHaveBeenCalledWith('/product/2040793A5H')

unmount()
expect(mockStopProductLinkRouting).toHaveBeenCalled()
})

test('escapes the product id as one path segment', () => {
mockedUseMultiSite.mockReturnValue({
locale: {id: 'en-US', preferredCurrency: 'USD'},
buildUrl: (path) => `/us/en-US${path}`
})
renderCommerceClient()

const calls = attachAgentProductLinkRouting.mock.calls
const {navigateToProduct, getProductHref} = calls[calls.length - 1][0]
expect(getProductHref('../account')).toBe('/us/en-US/product/..%2Faccount')
expect(getProductHref('a?b#c')).toBe('/us/en-US/product/a%3Fb%23c')
navigateToProduct('red shoe')
expect(mockNavigate).toHaveBeenCalledWith('/product/red%20shoe')
})

test('looks agent product links up through Shopper SEO', async () => {
renderCommerceClient()
const calls = attachAgentProductLinkRouting.mock.calls
const {lookupProductId} = calls[calls.length - 1][0]

mockGetUrlMapping.mockResolvedValueOnce({resourceType: 'PRODUCT', resourceId: 'DH-387'})
await expect(lookupProductId('chairs/DH-387.html')).resolves.toBe('DH-387')
expect(mockGetUrlMapping).toHaveBeenLastCalledWith({
parameters: {urlSegment: 'chairs/DH-387.html'},
headers: {Authorization: 'Bearer test-slas-access-token'}
})

mockGetUrlMapping.mockResolvedValueOnce({
resourceType: 'CATEGORY',
resourceId: 'chairs'
})
await expect(lookupProductId('chairs.html')).resolves.toBeUndefined()
})

test('treats a Shopper SEO 404 as no product, and lets any other failure reject', async () => {
renderCommerceClient()
const calls = attachAgentProductLinkRouting.mock.calls
const {lookupProductId} = calls[calls.length - 1][0]

const notFound = Object.assign(new Error('Not Found'), {response: {status: 404}})
mockGetUrlMapping.mockRejectedValueOnce(notFound)
await expect(lookupProductId('unknown.html')).resolves.toBeUndefined()

const serverError = Object.assign(new Error('Bad Gateway'), {response: {status: 502}})
mockGetUrlMapping.mockRejectedValueOnce(serverError)
await expect(lookupProductId('chairs/DH-387.html')).rejects.toBe(serverError)

const offline = new TypeError('Failed to fetch')
mockGetUrlMapping.mockRejectedValueOnce(offline)
await expect(lookupProductId('chairs/DH-387.html')).rejects.toBe(offline)
})

test('leaves the new optional widget fields undefined when not configured so the widget default wins', () => {
renderCommerceClient()

Expand Down
Loading
Loading