From 96c2a2d5d1e6844c957e24cf1d94d850a0592305 Mon Sep 17 00:00:00 2001 From: Ashwin Shanker Date: Fri, 23 Oct 2026 14:28:20 -0700 Subject: [PATCH 1/5] fix: keep shopper agent product links on the PWA storefront (W-23948585) The agent backend sends product links as SFRA URLs on the backend origin, which the widget's onNavigate never sees, so product card clicks left the PWA for the SFRA PDP. Read the product id from links in the widget's product slots, point their href at the PWA product page, and route plain clicks through the router. Port of storefront-next#2944. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../template-retail-react-app/CHANGELOG.md | 1 + .../app/components/shopper-agent/index.jsx | 18 +- .../components/shopper-agent/index.test.js | 31 ++ .../app/utils/shopper-agent-product-links.js | 210 ++++++++++++ .../utils/shopper-agent-product-links.test.js | 322 ++++++++++++++++++ 5 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 packages/template-retail-react-app/app/utils/shopper-agent-product-links.js create mode 100644 packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 4672c6d44e..205637fa5b 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -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/-.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. Port of storefront-next#2944. (W-23948585) - [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) - [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) - [Enhancement] Update inline agent widget UMD bundle: add follow-up suggestion pills from agent responses, and send `commerce_agent_context` as a TextValue JSON string. Bump the embedded agent `capabilitiesVersion` to `66` (new `DEFAULT_EMBEDDED_AGENT_CAPABILITIES_VERSION` constant, separate from the Commerce Client's `65`) for context-variable support. Built from [inline-agent-widget#17](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/17) and [inline-agent-widget#18](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/18). [#4061](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4061) diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx index 06ec93cb67..a551e90b60 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx @@ -26,7 +26,11 @@ import { COMMERCE_CLIENT_UI_STATE_EVENT } 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 { + productUrlBuilder, + 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 { @@ -719,6 +723,18 @@ 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. + const {buildUrl} = useMultiSite() + useEffect( + () => + attachAgentProductLinkRouting({ + navigateToProduct: (productId) => navigate(productUrlBuilder({id: productId})), + getProductHref: (productId) => buildUrl(productUrlBuilder({id: productId})) + }), + [navigate, buildUrl] + ) + // Loads the Commerce Client messaging UMD bundle, which exposes window.CimulateMessaging. const scriptLoadStatus = useScript(resolveCommerceClientScriptUrl(commerceAgentConfiguration)) const {formatMessage} = useIntl() diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js index bff7f29ecd..8084a0e783 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js @@ -129,6 +129,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() @@ -147,6 +158,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 @@ -2059,6 +2071,25 @@ 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('leaves the new optional widget fields undefined when not configured so the widget default wins', () => { renderCommerceClient() diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js new file mode 100644 index 0000000000..5d25ae8633 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Points the Shopper Agent's product links at the PWA Kit product page. + * + * The backend sends each product link in the legacy SFRA format on the backend origin, e.g. + * `https://.demandware.net/en_US/dipsea-tie-headband-2040793A5H.html`. + * The widget renders it as a plain ``, and its `onNavigate` hook only fires for same-origin + * links, so a click leaves the storefront for the SFRA page. This module reads the product id from + * that URL and: + * + * - rewrites the link's `href` to the storefront product address, so hover, copy-link, + * Cmd/Ctrl-click, middle-click and links set to open in a new tab all use it; + * - opens the product through the router on an unmodified, same-tab primary click. + * + * Only links in the widget's product slots are touched, never links in agent message text. Those + * slots are found by the widget's CSS classes (`cim-widget-*`). If a widget release renames them, + * links keep the backend URL and nothing breaks. + */ + +/** Widget markup that holds a product link. */ +const PRODUCT_LINK_SCOPES = [ + '.cim-widget-msg-product-card', // carousel card: image and name links + '.cim-widget-msg-pdp__title-link', // product detail card title (the link itself) + '.cim-widget-product-comparison__product-col' // comparison column: image and name links +] + +/** A link inside a scope, or the scope element when it is the link. */ +const PRODUCT_LINK_SELECTOR = PRODUCT_LINK_SCOPES.flatMap((scope) => [ + `${scope} a[href]`, + `a${scope}[href]` +]).join(',') + +/** Free text inside a card (the description) can hold any link, so it is never touched. */ +const FREE_TEXT_SCOPE = '.cim-widget-msg-product-card__description' + +/** Widget markup around a product link that also holds the product name. */ +const PRODUCT_NAME_SCOPE = + '.cim-widget-msg-product-card, .cim-widget-product-comparison__product-col' +const PRODUCT_NAME_SELECTOR = + '.cim-widget-msg-product-card__title-link, .cim-widget-msg-product-card__title, .cim-widget-product-comparison__name-link' + +/** The URL slug B2C derives from a product name, e.g. 'Dipsea Tie Headband' -> 'dipsea-tie-headband'. */ +const toSlug = (name) => + name + .toLowerCase() + .replace(/['\u2019]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +/** + * Returns the product id in a backend product URL. The last path segment minus `.html` is either the + * bare id (`//.html`) or the name slug and id joined by `-` (`/-.html`); in the + * latter case the slug of `productName` is stripped. Ids may themselves contain `-`, so the slug is + * only stripped when it matches the name. + * + * @param {string} href - Link address + * @param {string} [currentOrigin] - Storefront origin; links on it are already routable + * @param {string} [productName] - Product name shown with the link + * @returns {string|undefined} Product id, or undefined when the URL is not a product link on another origin + */ +export const getBackendProductId = (href, currentOrigin = window.location.origin, productName) => { + let url + try { + url = new URL(href, currentOrigin) + } catch { + return undefined + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin === currentOrigin) { + return undefined + } + + const lastSegment = url.pathname.split('/').filter(Boolean).pop() + if (!lastSegment || !/\.html$/i.test(lastSegment)) return undefined + let segment + try { + segment = decodeURIComponent(lastSegment.replace(/\.html$/i, '')) + } catch { + return undefined // malformed percent-escape + } + const slugPrefix = productName ? `${toSlug(productName)}-` : '' + if (slugPrefix.length > 1 && segment.toLowerCase().startsWith(slugPrefix)) { + segment = segment.slice(slugPrefix.length) + } + return segment || undefined +} + +/** The product name shown with a widget product link, or '' when none is found. */ +const getProductName = (anchor) => { + const scope = anchor.closest(PRODUCT_NAME_SCOPE) + const nameElement = scope ? scope.querySelector(PRODUCT_NAME_SELECTOR) : anchor + const image = anchor.querySelector('img[alt]') + return (nameElement?.textContent || '').trim() || (image?.getAttribute('alt') || '').trim() +} + +const isAgentProductLink = (anchor) => + anchor.matches(PRODUCT_LINK_SELECTOR) && !anchor.closest(FREE_TEXT_SCOPE) + +/** + * @param {EventTarget|null} target - Click target + * @returns {HTMLAnchorElement|null} The widget product link the click landed on, or null for any other link + */ +export const findAgentProductLink = (target) => { + if (!(target instanceof Element)) return null + const anchor = target.closest('a[href]') + return anchor instanceof HTMLAnchorElement && isAgentProductLink(anchor) ? anchor : null +} + +/** The widget product links in a subtree, including `node` itself. */ +const findAgentProductLinksIn = (node) => { + const links = Array.from(node.querySelectorAll(PRODUCT_LINK_SELECTOR)) + if (node instanceof HTMLAnchorElement) links.push(node) + return links.filter(isAgentProductLink) +} + +/** + * Links rewritten so far, with the id and address written. Tells our own `href` from one the widget + * set later (it reuses a link for another product), and still routes a link we already rewrote. + */ +const rewritten = new WeakMap() + +/** The product a link points at: the one we rewrote it for, else the one in a backend address. */ +const getLinkedProductId = (anchor) => { + const own = rewritten.get(anchor) + if (own && anchor.getAttribute('href') === own.href) return own.productId + return getBackendProductId(anchor.href, window.location.origin, getProductName(anchor)) +} + +/** + * Starts pointing agent product links at the storefront. Links already rewritten keep their + * storefront address after it stops. + * + * The click listener runs in the bubble phase, after the widget's own click handler, and skips an + * event that is already `defaultPrevented`. + * + * The observer watches the whole document because the widget renders its dialogs outside its own + * container. Its own `href` write triggers one more pass, which finds the address already correct + * and writes nothing, so it cannot loop. + * + * @param {Object} options + * @param {function(string): void} options.navigateToProduct - Opens the product page through the router + * @param {function(string): string} options.getProductHref - Storefront product address, site and locale prefix included + * @param {Document} [options.root] - Document to watch + * @returns {function(): void} Stops the routing + */ +export const attachAgentProductLinkRouting = ({ + navigateToProduct, + getProductHref, + root = document +}) => { + const rewrite = (anchor) => { + const productId = getLinkedProductId(anchor) + if (!productId) return + const href = getProductHref(productId) + rewritten.set(anchor, {productId, href}) + if (anchor.getAttribute('href') !== href) anchor.setAttribute('href', href) + } + + const onClick = (event) => { + if (event.defaultPrevented || event.button !== 0) return + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + const anchor = findAgentProductLink(event.target) + if (!anchor || (anchor.target && anchor.target !== '_self')) return + + const productId = getLinkedProductId(anchor) + if (!productId) return + + event.preventDefault() + navigateToProduct(productId) + } + + // The widget draws a link with the backend address each time it draws a card, and sets it again + // when it reuses a link for another product. + const observer = new MutationObserver((records) => { + records.forEach((record) => { + if (record.type === 'attributes') { + if ( + record.target instanceof HTMLAnchorElement && + isAgentProductLink(record.target) + ) { + rewrite(record.target) + } + } else { + record.addedNodes.forEach((node) => { + if (node instanceof Element) findAgentProductLinksIn(node).forEach(rewrite) + }) + } + }) + }) + + findAgentProductLinksIn(root).forEach(rewrite) + observer.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['href'] + }) + root.addEventListener('click', onClick) + + return () => { + observer.disconnect() + root.removeEventListener('click', onClick) + } +} diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js new file mode 100644 index 0000000000..94bbcb069e --- /dev/null +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { + attachAgentProductLinkRouting, + findAgentProductLink, + getBackendProductId +} from '@salesforce/retail-react-app/app/utils/shopper-agent-product-links' + +const ORIGIN = 'https://storefront.example.com' +const SFRA_URL = 'https://backend.example.com/en_US/dipsea-tie-headband-2040793A5H.html' +const SFRA_SITE_URL = 'https://backend.example.com/s/RefArch/chairs/DH-387.html?lang=en_US' + +// Markup matching what the Commerce Client widget renders for each product slot. +const WIDGET_MARKUP = ` + +Not in the widget` + +const byId = (id) => document.getElementById(id) +const hrefOf = (id) => byId(id).getAttribute('href') +const productHref = (productId) => `/us/en-US/product/${productId}` + +/** Lets the MutationObserver deliver its records. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** Dispatches a click and returns whether our listener prevented the browser's navigation. */ +const click = (element, init = {}) => { + let prevented = false + // `window` runs after `document`, so this sees the result of our listener; it then blocks jsdom's navigation. + const spy = (event) => { + prevented = event.defaultPrevented + event.preventDefault() + } + window.addEventListener('click', spy) + element.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true, button: 0, ...init}) + ) + window.removeEventListener('click', spy) + return prevented +} + +describe('getBackendProductId', () => { + test.each([ + [SFRA_SITE_URL, 'DH-387'], + ['https://backend.example.com/en_US/x-2040793A5H.html#reviews', 'x-2040793A5H'], + ['https://backend.example.com/s/RefArch/x/25589712M.HTML', '25589712M'], + ['https://backend.example.com/s/RefArch/x/red%20shoe.html', 'red shoe'] + ])('reads the product id from %s', (href, id) => { + expect(getBackendProductId(href, ORIGIN)).toBe(id) + }) + + test.each([ + [SFRA_URL, 'Dipsea Tie Headband', '2040793A5H'], + ['https://backend.example.com/en_US/womens-v-neck-DH-387.html', "Women's V-Neck", 'DH-387'], + [SFRA_SITE_URL, 'Dream Chair', 'DH-387'], + [SFRA_URL, 'Some Other Product', 'dipsea-tie-headband-2040793A5H'] + ])('strips the name slug from %s when the product is %s', (href, name, id) => { + expect(getBackendProductId(href, ORIGIN, name)).toBe(id) + }) + + test.each([ + ['a link on the storefront itself', `${ORIGIN}/us/en-US/product/25589712M`], + ['a same-origin .html link', `${ORIGIN}/legal/terms.html`], + ['a backend link that is not a product page', 'https://backend.example.com/s/RefArch/cart'], + ['an empty .html segment', 'https://backend.example.com/s/RefArch/.html'], + ['a non-http scheme', 'javascript:alert(1);//x.html'], + ['a malformed percent-escape', 'https://backend.example.com/x/%E0%A4%A.html'], + ['text that is not a URL', 'http://'] + ])('ignores %s', (_label, href) => { + expect(getBackendProductId(href, ORIGIN)).toBeUndefined() + }) +}) + +describe('findAgentProductLink', () => { + beforeEach(() => { + document.body.innerHTML = WIDGET_MARKUP + }) + + test.each(['card-image', 'card-name', 'pdp-title', 'comparison-image', 'comparison-name'])( + 'finds the product link %s', + (id) => { + expect(findAgentProductLink(byId(id))).toBe(byId(id)) + } + ) + + test('finds the link when the click lands on the image inside it', () => { + expect(findAgentProductLink(byId('card-img'))).toBe(byId('card-image')) + }) + + test.each(['message-link', 'card-description-link', 'page-link'])( + 'ignores %s, which is not a product link', + (id) => { + expect(findAgentProductLink(byId(id))).toBeNull() + } + ) + + test('ignores a missing target', () => { + expect(findAgentProductLink(null)).toBeNull() + }) +}) + +describe('attachAgentProductLinkRouting', () => { + let navigateToProduct + let stop + + beforeEach(() => { + document.body.innerHTML = WIDGET_MARKUP + navigateToProduct = jest.fn() + stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref: productHref}) + }) + + afterEach(() => { + stop() + document.body.innerHTML = '' + }) + + describe('clicks', () => { + test.each([ + ['card-image', '2040793A5H'], + ['card-img', '2040793A5H'], + ['card-name', '2040793A5H'], + ['pdp-title', 'DH-387'], + ['comparison-image', 'DH-387'], + ['comparison-name', 'DH-387'] + ])('sends a plain click on %s to the product page', (id, productId) => { + expect(click(byId(id))).toBe(true) + expect(navigateToProduct).toHaveBeenCalledTimes(1) + expect(navigateToProduct).toHaveBeenCalledWith(productId) + }) + + test.each([ + ['ctrl', {ctrlKey: true}], + ['cmd', {metaKey: true}], + ['shift', {shiftKey: true}], + ['alt', {altKey: true}], + ['middle button', {button: 1}] + ])('leaves a %s click to the browser', (_label, init) => { + expect(click(byId('card-name'), init)).toBe(false) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + + test('leaves a link that opens in a new tab to the browser', () => { + byId('card-name').setAttribute('target', '_blank') + expect(click(byId('card-name'))).toBe(false) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + + test('leaves a click another handler already handled', () => { + document.body.addEventListener('click', (event) => event.preventDefault(), { + once: true + }) + click(byId('card-name')) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + + test('ignores links that are not agent product links', () => { + ;['message-link', 'card-description-link', 'page-link'].forEach((id) => { + expect(click(byId(id))).toBe(false) + }) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + + test('ignores a product link that points at another storefront page', () => { + byId('card-name').setAttribute('href', `${window.location.origin}/us/en-US/cart`) + expect(click(byId('card-name'))).toBe(false) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + + test("runs after the widget's own click handler", () => { + let preventedWhenWidgetRan + document.body.addEventListener( + 'click', + (event) => { + preventedWhenWidgetRan = event.defaultPrevented + }, + {once: true} + ) + expect(click(byId('card-name'))).toBe(true) + expect(preventedWhenWidgetRan).toBe(false) + }) + + test('routes a link the widget just pointed at another product, before the rewrite has run', () => { + byId('card-name').setAttribute('href', SFRA_SITE_URL) + expect(click(byId('card-name'))).toBe(true) + expect(navigateToProduct).toHaveBeenCalledTimes(1) + expect(navigateToProduct).toHaveBeenCalledWith('DH-387') + }) + + test('stops routing once detached', () => { + stop() + expect(click(byId('card-name'))).toBe(false) + expect(navigateToProduct).not.toHaveBeenCalled() + }) + }) + + describe('link addresses', () => { + test.each([ + ['card-image', '2040793A5H'], + ['card-name', '2040793A5H'], + ['pdp-title', 'DH-387'], + ['comparison-image', 'DH-387'], + ['comparison-name', 'DH-387'] + ])('points %s at the storefront product page as soon as it attaches', (id, productId) => { + expect(hrefOf(id)).toBe(`/us/en-US/product/${productId}`) + }) + + test('leaves links that are not agent product links alone', async () => { + await flush() + expect(hrefOf('message-link')).toBe('https://help.example.com/returns.html') + expect(hrefOf('card-description-link')).toBe('https://help.example.com/care-guide.html') + expect(hrefOf('page-link')).toBe(SFRA_URL) + }) + + test('points a card the widget draws later at the storefront', async () => { + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(hrefOf('late-card')).toBe('/us/en-US/product/DH-387') + }) + + test('points a link added to a card that is already on the page at the storefront', async () => { + byId('card-name').parentElement.insertAdjacentHTML( + 'beforeend', + `Dream Chair` + ) + await flush() + expect(hrefOf('late-link')).toBe('/us/en-US/product/DH-387') + }) + + test('points a product detail title the widget draws later at the storefront', async () => { + byId('widget').insertAdjacentHTML( + 'beforeend', + `Dream Chair` + ) + await flush() + expect(hrefOf('late-pdp')).toBe('/us/en-US/product/DH-387') + }) + + test('leaves links the widget draws later outside the product slots alone', async () => { + byId('widget').insertAdjacentHTML( + 'beforeend', + ` + ` + ) + await flush() + expect(hrefOf('late-text')).toBe(SFRA_SITE_URL) + expect(hrefOf('late-description')).toBe(SFRA_SITE_URL) + }) + + test('follows a link the widget reuses for another product', async () => { + byId('card-name').setAttribute('href', SFRA_SITE_URL) + await flush() + expect(hrefOf('card-name')).toBe('/us/en-US/product/DH-387') + }) + + test('settles instead of rewriting forever', async () => { + const getProductHref = jest.fn(productHref) + stop() + stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref}) + + byId('card-name').setAttribute('href', SFRA_SITE_URL) // the widget reuses the link + await flush() + const callsOnceSettled = getProductHref.mock.calls.length + expect(hrefOf('card-name')).toBe('/us/en-US/product/DH-387') + + await flush() + await flush() + expect(getProductHref).toHaveBeenCalledTimes(callsOnceSettled) + }) + + test('keeps the storefront address once detached, and leaves links drawn after that alone', async () => { + stop() + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(hrefOf('card-name')).toBe('/us/en-US/product/2040793A5H') + expect(hrefOf('after-stop')).toBe(SFRA_SITE_URL) + }) + + test('rewrites links again, and still routes them, when attached with a new address (locale switch)', () => { + stop() + stop = attachAgentProductLinkRouting({ + navigateToProduct, + getProductHref: (productId) => `/fr/fr-FR/product/${productId}` + }) + + expect(hrefOf('card-name')).toBe('/fr/fr-FR/product/2040793A5H') + expect(hrefOf('pdp-title')).toBe('/fr/fr-FR/product/DH-387') + expect(click(byId('card-name'))).toBe(true) + expect(navigateToProduct).toHaveBeenCalledWith('2040793A5H') + }) + }) +}) From baeb1735126109beb078a1e1c0f2f873461a4790 Mon Sep 17 00:00:00 2001 From: Ashwin Shanker Date: Fri, 23 Oct 2026 14:28:54 -0700 Subject: [PATCH 2/5] chore: add PR link to changelog Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/template-retail-react-app/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 205637fa5b..8052970d23 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +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/-.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. Port of storefront-next#2944. (W-23948585) +- [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/-.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. Port of storefront-next#2944. [#4076](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4076) (W-23948585) - [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) - [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) - [Enhancement] Update inline agent widget UMD bundle: add follow-up suggestion pills from agent responses, and send `commerce_agent_context` as a TextValue JSON string. Bump the embedded agent `capabilitiesVersion` to `66` (new `DEFAULT_EMBEDDED_AGENT_CAPABILITIES_VERSION` constant, separate from the Commerce Client's `65`) for context-variable support. Built from [inline-agent-widget#17](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/17) and [inline-agent-widget#18](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/18). [#4061](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4061) From 0f131246af5e386d262e33abba9d546b133e2c52 Mon Sep 17 00:00:00 2001 From: Ashwin Shanker Date: Thu, 29 Oct 2026 12:58:42 -0700 Subject: [PATCH 3/5] fix(shopper-agent): resolve ambiguous product ids via Shopper SEO; changelog nit --- .../template-retail-react-app/CHANGELOG.md | 2 +- .../app/components/shopper-agent/index.jsx | 26 ++- .../components/shopper-agent/index.test.js | 21 ++ .../app/utils/shopper-agent-product-links.js | 163 ++++++++++++--- .../utils/shopper-agent-product-links.test.js | 190 +++++++++++++++++- 5 files changed, 352 insertions(+), 50 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 5b73c5a7c3..8fe0586063 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +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/-.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. Port of storefront-next#2944. [#4076](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4076) (W-23948585) +- [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/-.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) - [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) - [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) diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx index a551e90b60..23eb2e45c5 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx @@ -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, @@ -724,15 +725,28 @@ const CommerceClientAgentWindow = ({ ) // 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. + // 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( () => attachAgentProductLinkRouting({ navigateToProduct: (productId) => navigate(productUrlBuilder({id: productId})), - getProductHref: (productId) => buildUrl(productUrlBuilder({id: productId})) + getProductHref: (productId) => buildUrl(productUrlBuilder({id: productId})), + lookupProductId: async (urlSegment) => { + const token = await getTokenWhenReadyRef.current() + const mapping = await shopperSeo.getUrlMapping({ + parameters: {urlSegment}, + headers: {Authorization: `Bearer ${token}`} + }) + return mapping?.resourceType === 'PRODUCT' ? mapping.resourceId : undefined + } }), - [navigate, buildUrl] + [navigate, buildUrl, shopperSeo] ) // Loads the Commerce Client messaging UMD bundle, which exposes window.CimulateMessaging. @@ -742,11 +756,7 @@ const CommerceClientAgentWindow = ({ 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 diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js index 8084a0e783..4e2c8a7ac5 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js @@ -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(), @@ -2090,6 +2092,25 @@ describe('ShopperAgent Component', () => { expect(mockStopProductLinkRouting).toHaveBeenCalled() }) + 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('leaves the new optional widget fields undefined when not configured so the widget default wins', () => { renderCommerceClient() diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js index 5d25ae8633..5f4f23fffa 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js @@ -18,6 +18,11 @@ * Cmd/Ctrl-click, middle-click and links set to open in a new tab all use it; * - opens the product through the router on an unmodified, same-tab primary click. * + * When the id cannot be read from the URL with certainty, it is looked up through Shopper SEO + * `getUrlMapping`, which applies the site's own URL rules. Until the lookup answers, and when it finds + * no product, the link keeps the backend URL: the shopper lands on the backend product page, never on + * a storefront 404. + * * Only links in the widget's product slots are touched, never links in agent message text. Those * slots are found by the widget's CSS classes (`cim-widget-*`). If a widget release renames them, * links keep the backend URL and nothing breaks. @@ -45,26 +50,20 @@ const PRODUCT_NAME_SCOPE = const PRODUCT_NAME_SELECTOR = '.cim-widget-msg-product-card__title-link, .cim-widget-msg-product-card__title, .cim-widget-product-comparison__name-link' -/** The URL slug B2C derives from a product name, e.g. 'Dipsea Tie Headband' -> 'dipsea-tie-headband'. */ -const toSlug = (name) => - name +/** Text reduced to unaccented letters and digits, e.g. 'Café Chair' and 'cafe-chair' -> 'cafechair'. */ +const toKey = (text) => + text + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') .toLowerCase() - .replace(/['\u2019]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') + .replace(/[^a-z0-9]/g, '') /** - * Returns the product id in a backend product URL. The last path segment minus `.html` is either the - * bare id (`//.html`) or the name slug and id joined by `-` (`/-.html`); in the - * latter case the slug of `productName` is stripped. Ids may themselves contain `-`, so the slug is - * only stripped when it matches the name. - * - * @param {string} href - Link address - * @param {string} [currentOrigin] - Storefront origin; links on it are already routable - * @param {string} [productName] - Product name shown with the link - * @returns {string|undefined} Product id, or undefined when the URL is not a product link on another origin + * @returns {{url: URL, segment: string, isProductId: boolean}|undefined} The URL and its product + * segment (the `pid` parameter, or the last path segment minus `.html`), or undefined when `href` + * is not a product link on another origin */ -export const getBackendProductId = (href, currentOrigin = window.location.origin, productName) => { +const parseBackendProductUrl = (href, currentOrigin) => { let url try { url = new URL(href, currentOrigin) @@ -74,6 +73,8 @@ export const getBackendProductId = (href, currentOrigin = window.location.origin if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin === currentOrigin) { return undefined } + const pid = url.searchParams.get('pid') // `Product-Show?pid=` when URL rules are off + if (pid) return {url, segment: pid, isProductId: true} const lastSegment = url.pathname.split('/').filter(Boolean).pop() if (!lastSegment || !/\.html$/i.test(lastSegment)) return undefined @@ -83,11 +84,64 @@ export const getBackendProductId = (href, currentOrigin = window.location.origin } catch { return undefined // malformed percent-escape } - const slugPrefix = productName ? `${toSlug(productName)}-` : '' - if (slugPrefix.length > 1 && segment.toLowerCase().startsWith(slugPrefix)) { - segment = segment.slice(slugPrefix.length) + return segment ? {url, segment, isProductId: false} : undefined +} + +/** + * Returns the product id in a backend product URL, when the URL alone tells it for certain. + * + * The id is the `pid` parameter, or comes from the last path segment minus `.html`, which is either + * the bare id (`//.html`) or the name slug and id joined by `-` (`/-.html`). Ids + * may themselves contain `-`, and the slug follows the site's URL rules, so a segment with a `-` is + * only split where the part before it matches `productName`, both reduced to unaccented letters and + * digits. Any other segment with a `-` is ambiguous; look it up with `getProductUrlSegments`. + * + * @param {string} href - Link address + * @param {string} [currentOrigin] - Storefront origin; links on it are already routable + * @param {string} [productName] - Product name shown with the link + * @returns {string|undefined} Product id, or undefined when it is ambiguous or the URL is not a product link on another origin + */ +export const getBackendProductId = (href, currentOrigin = window.location.origin, productName) => { + const parsed = parseBackendProductUrl(href, currentOrigin) + if (!parsed) return undefined + const {segment, isProductId} = parsed + if (isProductId || !segment.includes('-')) return segment + + const nameKey = productName ? toKey(productName) : '' + if (!nameKey) return undefined + // The last split that matches wins, so a repeated separator after the name stays out of the id. + let productId + for (let i = segment.indexOf('-'); i !== -1; i = segment.indexOf('-', i + 1)) { + if (i + 1 < segment.length && toKey(segment.slice(0, i)) === nameKey) { + productId = segment.slice(i + 1) + } } - return segment || undefined + return productId +} + +/** + * The URL segments to look a backend product URL up with in Shopper SEO `getUrlMapping`, most likely + * first: the path without an SFRA `/s/` prefix, then that path without a leading locale. + * + * @param {string} href - Link address + * @param {string} [currentOrigin] - Storefront origin + * @returns {string[]} Decoded URL segments; none when `href` is not a backend product link or already names the id + */ +export const getProductUrlSegments = (href, currentOrigin = window.location.origin) => { + const parsed = parseBackendProductUrl(href, currentOrigin) + if (!parsed || parsed.isProductId) return [] + let parts + try { + parts = parsed.url.pathname.split('/').filter(Boolean).map(decodeURIComponent) + } catch { + return [] + } + if (parts[0] === 's' && parts.length > 2) parts = parts.slice(2) + const segments = [parts.join('/')] + if (parts.length > 1 && /^[a-z]{2}([_-][a-z]{2})?$/i.test(parts[0])) { + segments.push(parts.slice(1).join('/')) + } + return segments } /** The product name shown with a widget product link, or '' when none is found. */ @@ -124,17 +178,15 @@ const findAgentProductLinksIn = (node) => { */ const rewritten = new WeakMap() -/** The product a link points at: the one we rewrote it for, else the one in a backend address. */ -const getLinkedProductId = (anchor) => { - const own = rewritten.get(anchor) - if (own && anchor.getAttribute('href') === own.href) return own.productId - return getBackendProductId(anchor.href, window.location.origin, getProductName(anchor)) -} - /** * Starts pointing agent product links at the storefront. Links already rewritten keep their * storefront address after it stops. * + * A link whose id the URL does not tell for certain is looked up once per address with + * `lookupProductId`, trying each of `getProductUrlSegments` in turn, and rewritten when the answer + * comes back if it still has that address. A click before then, or on a link no lookup resolved, + * is left to the browser. + * * The click listener runs in the bubble phase, after the widget's own click handler, and skips an * event that is already `defaultPrevented`. * @@ -145,22 +197,72 @@ const getLinkedProductId = (anchor) => { * @param {Object} options * @param {function(string): void} options.navigateToProduct - Opens the product page through the router * @param {function(string): string} options.getProductHref - Storefront product address, site and locale prefix included + * @param {function(string): Promise} [options.lookupProductId] - Product id a URL segment maps to, if any * @param {Document} [options.root] - Document to watch * @returns {function(): void} Stops the routing */ export const attachAgentProductLinkRouting = ({ navigateToProduct, getProductHref, + lookupProductId, root = document }) => { - const rewrite = (anchor) => { - const productId = getLinkedProductId(anchor) - if (!productId) return + let stopped = false + /** Backend address -> product id, or null when it maps to none; set once its lookup answers. */ + const lookedUp = new Map() + /** Backend addresses with a lookup in flight. */ + const pending = new Set() + + /** The product a link points at: the one we rewrote it for, else the one its backend address names. */ + const getLinkedProductId = (anchor) => { + const own = rewritten.get(anchor) + if (own && anchor.getAttribute('href') === own.href) return own.productId + return ( + getBackendProductId(anchor.href, window.location.origin, getProductName(anchor)) || + lookedUp.get(anchor.href) || + undefined + ) + } + + const writeHref = (anchor, productId) => { const href = getProductHref(productId) rewritten.set(anchor, {productId, href}) if (anchor.getAttribute('href') !== href) anchor.setAttribute('href', href) } + const lookUp = async (backendHref) => { + for (const urlSegment of getProductUrlSegments(backendHref)) { + try { + const productId = await lookupProductId(urlSegment) + if (productId) return productId + } catch { + // Not a known URL; try the next segment + } + } + return undefined + } + + const rewrite = (anchor) => { + const productId = getLinkedProductId(anchor) + if (productId) { + writeHref(anchor, productId) + return + } + const backendHref = anchor.href + if (!lookupProductId || lookedUp.has(backendHref) || pending.has(backendHref)) return + if (!getProductUrlSegments(backendHref).length) return + pending.add(backendHref) + lookUp(backendHref).then((foundId) => { + pending.delete(backendHref) + lookedUp.set(backendHref, foundId || null) + if (stopped || !foundId) return + // Every link still on that address, including ones drawn while the lookup ran + findAgentProductLinksIn(root) + .filter((link) => link.href === backendHref) + .forEach((link) => writeHref(link, foundId)) + }) + } + const onClick = (event) => { if (event.defaultPrevented || event.button !== 0) return if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return @@ -204,6 +306,7 @@ export const attachAgentProductLinkRouting = ({ root.addEventListener('click', onClick) return () => { + stopped = true observer.disconnect() root.removeEventListener('click', onClick) } diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js index 94bbcb069e..126a2a188d 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js @@ -7,7 +7,8 @@ import { attachAgentProductLinkRouting, findAgentProductLink, - getBackendProductId + getBackendProductId, + getProductUrlSegments } from '@salesforce/retail-react-app/app/utils/shopper-agent-product-links' const ORIGIN = 'https://storefront.example.com' @@ -43,7 +44,10 @@ const byId = (id) => document.getElementById(id) const hrefOf = (id) => byId(id).getAttribute('href') const productHref = (productId) => `/us/en-US/product/${productId}` -/** Lets the MutationObserver deliver its records. */ +/** What Shopper SEO `getUrlMapping` maps each URL segment to in these tests. */ +const SEO_PRODUCTS = {'chairs/DH-387.html': 'DH-387', 'cafe-chair-DH-388.html': 'DH-388'} + +/** Lets the MutationObserver deliver its records and pending lookups answer. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) /** Dispatches a click and returns whether our listener prevented the browser's navigation. */ @@ -64,10 +68,14 @@ const click = (element, init = {}) => { describe('getBackendProductId', () => { test.each([ - [SFRA_SITE_URL, 'DH-387'], - ['https://backend.example.com/en_US/x-2040793A5H.html#reviews', 'x-2040793A5H'], + ['https://backend.example.com/en_US/x2040793A5H.html#reviews', 'x2040793A5H'], ['https://backend.example.com/s/RefArch/x/25589712M.HTML', '25589712M'], - ['https://backend.example.com/s/RefArch/x/red%20shoe.html', 'red shoe'] + ['https://backend.example.com/s/RefArch/x/red%20shoe.html', 'red shoe'], + ['https://backend.example.com/s/RefArch/Product-Show?pid=DH-387', 'DH-387'], + [ + 'https://backend.example.com/on/demandware.store/Sites-RefArch-Site/en_US/Product-Show?pid=25589712M', + '25589712M' + ] ])('reads the product id from %s', (href, id) => { expect(getBackendProductId(href, ORIGIN)).toBe(id) }) @@ -75,12 +83,32 @@ describe('getBackendProductId', () => { test.each([ [SFRA_URL, 'Dipsea Tie Headband', '2040793A5H'], ['https://backend.example.com/en_US/womens-v-neck-DH-387.html', "Women's V-Neck", 'DH-387'], - [SFRA_SITE_URL, 'Dream Chair', 'DH-387'], - [SFRA_URL, 'Some Other Product', 'dipsea-tie-headband-2040793A5H'] + ['https://backend.example.com/en_US/cafe-chair-DH-387.html', 'Café Chair', 'DH-387'], + ['https://backend.example.com/en_US/caf-chair-DH-387.html', 'Caf Chair', 'DH-387'], + [ + 'https://backend.example.com/en_US/dream--chair--25589712M.html', + 'Dream Chair', + '25589712M' + ], + ['https://backend.example.com/en_US/t-shirt-TS-1.html', 'T-Shirt', 'TS-1'] ])('strips the name slug from %s when the product is %s', (href, name, id) => { expect(getBackendProductId(href, ORIGIN, name)).toBe(id) }) + test.each([ + ['the name does not match the slug', SFRA_URL, 'Some Other Product'], + ['there is no name', SFRA_URL, undefined], + ['the name reduces to nothing', SFRA_URL, '—'], + ['the id itself has a dash', SFRA_SITE_URL, undefined], + [ + 'the name is the whole segment', + 'https://backend.example.com/en_US/dream-chair.html', + 'Dream Chair' + ] + ])('gives no id, to look up instead, when %s', (_label, href, name) => { + expect(getBackendProductId(href, ORIGIN, name)).toBeUndefined() + }) + test.each([ ['a link on the storefront itself', `${ORIGIN}/us/en-US/product/25589712M`], ['a same-origin .html link', `${ORIGIN}/legal/terms.html`], @@ -94,6 +122,35 @@ describe('getBackendProductId', () => { }) }) +describe('getProductUrlSegments', () => { + test.each([ + [ + SFRA_URL, + ['en_US/dipsea-tie-headband-2040793A5H.html', 'dipsea-tie-headband-2040793A5H.html'] + ], + [SFRA_SITE_URL, ['chairs/DH-387.html']], + [ + 'https://backend.example.com/s/RefArch/en-GB/caf%C3%A9-chair-1.html', + ['en-GB/café-chair-1.html', 'café-chair-1.html'] + ], + ['https://backend.example.com/dream-chair-1.html', ['dream-chair-1.html']] + ])('looks %s up as %j', (href, segments) => { + expect(getProductUrlSegments(href, ORIGIN)).toEqual(segments) + }) + + test.each([ + ['a link on the storefront itself', `${ORIGIN}/x/dream-chair-1.html`], + [ + 'a link that names the id', + 'https://backend.example.com/s/RefArch/Product-Show?pid=DH-387' + ], + ['a backend link that is not a product page', 'https://backend.example.com/s/RefArch/cart'], + ['a malformed percent-escape', 'https://backend.example.com/%E0%A4/x-1.html'] + ])('looks nothing up for %s', (_label, href) => { + expect(getProductUrlSegments(href, ORIGIN)).toEqual([]) + }) +}) + describe('findAgentProductLink', () => { beforeEach(() => { document.body.innerHTML = WIDGET_MARKUP @@ -126,10 +183,18 @@ describe('attachAgentProductLinkRouting', () => { let navigateToProduct let stop - beforeEach(() => { + let lookupProductId + + beforeEach(async () => { document.body.innerHTML = WIDGET_MARKUP navigateToProduct = jest.fn() - stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref: productHref}) + lookupProductId = jest.fn(async (urlSegment) => SEO_PRODUCTS[urlSegment]) + stop = attachAgentProductLinkRouting({ + navigateToProduct, + getProductHref: productHref, + lookupProductId + }) + await flush() // the product detail and comparison links (`DH-387`) are looked up }) afterEach(() => { @@ -223,7 +288,7 @@ describe('attachAgentProductLinkRouting', () => { ['pdp-title', 'DH-387'], ['comparison-image', 'DH-387'], ['comparison-name', 'DH-387'] - ])('points %s at the storefront product page as soon as it attaches', (id, productId) => { + ])('points %s at the storefront product page once attached', (id, productId) => { expect(hrefOf(id)).toBe(`/us/en-US/product/${productId}`) }) @@ -283,7 +348,11 @@ describe('attachAgentProductLinkRouting', () => { test('settles instead of rewriting forever', async () => { const getProductHref = jest.fn(productHref) stop() - stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref}) + stop = attachAgentProductLinkRouting({ + navigateToProduct, + getProductHref, + lookupProductId + }) byId('card-name').setAttribute('href', SFRA_SITE_URL) // the widget reuses the link await flush() @@ -306,6 +375,105 @@ describe('attachAgentProductLinkRouting', () => { expect(hrefOf('after-stop')).toBe(SFRA_SITE_URL) }) + test('reads the id from the URL without a lookup when the name matches', () => { + expect(lookupProductId).not.toHaveBeenCalledWith( + expect.stringContaining('dipsea-tie-headband') + ) + }) + + test('looks each backend address up once, however many links share it', () => { + expect(lookupProductId).toHaveBeenCalledTimes(1) + expect(lookupProductId).toHaveBeenCalledWith('chairs/DH-387.html') + }) + + test('looks up a card whose name does not match its slug, and points it at the product found', async () => { + const href = 'https://backend.example.com/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(lookupProductId).toHaveBeenCalledWith('cafe-chair-DH-388.html') + expect(hrefOf('late-cafe')).toBe('/us/en-US/product/DH-388') + expect(click(byId('late-cafe'))).toBe(true) + expect(navigateToProduct).toHaveBeenCalledWith('DH-388') + }) + + test('tries the path without its locale when the full path is not known', async () => { + const href = 'https://backend.example.com/en_US/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(lookupProductId).toHaveBeenCalledWith('en_US/cafe-chair-DH-388.html') + expect(lookupProductId).toHaveBeenCalledWith('cafe-chair-DH-388.html') + expect(hrefOf('late-cafe')).toBe('/us/en-US/product/DH-388') + }) + + test('leaves a link to the browser while its lookup runs', async () => { + let answer + lookupProductId.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const href = 'https://backend.example.com/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(click(byId('late-cafe'))).toBe(false) + expect(hrefOf('late-cafe')).toBe(href) + + answer('DH-388') + await flush() + expect(hrefOf('late-cafe')).toBe('/us/en-US/product/DH-388') + }) + + test.each([ + ['finds no product', async () => undefined], + ['fails', async () => Promise.reject(new Error('404'))] + ])( + 'keeps the backend address, never a storefront 404, when the lookup %s', + async (_label, impl) => { + lookupProductId.mockImplementation(impl) + const href = 'https://backend.example.com/en_US/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(hrefOf('late-cafe')).toBe(href) + expect(click(byId('late-cafe'))).toBe(false) + expect(navigateToProduct).not.toHaveBeenCalled() + } + ) + + test('keeps the backend address of an ambiguous link when there is no lookup', () => { + stop() + document.body.innerHTML = WIDGET_MARKUP + stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref: productHref}) + expect(hrefOf('card-name')).toBe('/us/en-US/product/2040793A5H') + expect(hrefOf('pdp-title')).toBe(SFRA_SITE_URL) + }) + + test('does not rewrite links once detached, even when a lookup answers later', async () => { + let answer + lookupProductId.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const href = 'https://backend.example.com/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + stop() + answer('DH-388') + await flush() + expect(hrefOf('late-cafe')).toBe(href) + }) + test('rewrites links again, and still routes them, when attached with a new address (locale switch)', () => { stop() stop = attachAgentProductLinkRouting({ From a5d0c14cbc3c8e26ce3bd832e96121b6dc5b7351 Mon Sep 17 00:00:00 2001 From: Ashwin Shanker Date: Fri, 30 Oct 2026 09:36:00 -0700 Subject: [PATCH 4/5] fix(shopper-agent): retry failed product id lookups A failed Shopper SEO lookup (offline, token not ready, 5xx) was cached the same as "no product found", so the link kept its backend URL for as long as routing stayed attached. Only a not-found answer is cached now; a failed lookup leaves the address retryable the next time the widget draws it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/components/shopper-agent/index.jsx | 15 +++++-- .../components/shopper-agent/index.test.js | 18 ++++++++ .../app/utils/shopper-agent-product-links.js | 45 ++++++++++--------- .../utils/shopper-agent-product-links.test.js | 44 +++++++++++++++++- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx index 23eb2e45c5..319710f569 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx @@ -739,10 +739,17 @@ const CommerceClientAgentWindow = ({ getProductHref: (productId) => buildUrl(productUrlBuilder({id: productId})), lookupProductId: async (urlSegment) => { const token = await getTokenWhenReadyRef.current() - const mapping = await shopperSeo.getUrlMapping({ - parameters: {urlSegment}, - headers: {Authorization: `Bearer ${token}`} - }) + 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 } }), diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js index 4e2c8a7ac5..6e748f86d0 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js @@ -2111,6 +2111,24 @@ describe('ShopperAgent Component', () => { 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() diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js index 5f4f23fffa..5fa628c22a 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js @@ -182,10 +182,11 @@ const rewritten = new WeakMap() * Starts pointing agent product links at the storefront. Links already rewritten keep their * storefront address after it stops. * - * A link whose id the URL does not tell for certain is looked up once per address with - * `lookupProductId`, trying each of `getProductUrlSegments` in turn, and rewritten when the answer - * comes back if it still has that address. A click before then, or on a link no lookup resolved, - * is left to the browser. + * A link whose id the URL does not tell for certain is looked up with `lookupProductId`, trying each + * of `getProductUrlSegments` in turn, and rewritten when the answer comes back if it still has that + * address. An address no segment maps to is not looked up again; one whose lookup failed is retried + * the next time the widget draws it. A click before then, or on a link no lookup resolved, is left + * to the browser. * * The click listener runs in the bubble phase, after the widget's own click handler, and skips an * event that is already `defaultPrevented`. @@ -197,7 +198,8 @@ const rewritten = new WeakMap() * @param {Object} options * @param {function(string): void} options.navigateToProduct - Opens the product page through the router * @param {function(string): string} options.getProductHref - Storefront product address, site and locale prefix included - * @param {function(string): Promise} [options.lookupProductId] - Product id a URL segment maps to, if any + * @param {function(string): Promise} [options.lookupProductId] - Product id a URL + * segment maps to, or undefined when it maps to none; rejects when the lookup fails * @param {Document} [options.root] - Document to watch * @returns {function(): void} Stops the routing */ @@ -230,14 +232,11 @@ export const attachAgentProductLinkRouting = ({ if (anchor.getAttribute('href') !== href) anchor.setAttribute('href', href) } + /** Resolves to the product id, or undefined when no segment maps to one; rejects when a lookup fails. */ const lookUp = async (backendHref) => { for (const urlSegment of getProductUrlSegments(backendHref)) { - try { - const productId = await lookupProductId(urlSegment) - if (productId) return productId - } catch { - // Not a known URL; try the next segment - } + const productId = await lookupProductId(urlSegment) + if (productId) return productId } return undefined } @@ -252,15 +251,21 @@ export const attachAgentProductLinkRouting = ({ if (!lookupProductId || lookedUp.has(backendHref) || pending.has(backendHref)) return if (!getProductUrlSegments(backendHref).length) return pending.add(backendHref) - lookUp(backendHref).then((foundId) => { - pending.delete(backendHref) - lookedUp.set(backendHref, foundId || null) - if (stopped || !foundId) return - // Every link still on that address, including ones drawn while the lookup ran - findAgentProductLinksIn(root) - .filter((link) => link.href === backendHref) - .forEach((link) => writeHref(link, foundId)) - }) + lookUp(backendHref).then( + (foundId) => { + pending.delete(backendHref) + lookedUp.set(backendHref, foundId || null) + if (stopped || !foundId) return + // Every link still on that address, including ones drawn while the lookup ran + findAgentProductLinksIn(root) + .filter((link) => link.href === backendHref) + .forEach((link) => writeHref(link, foundId)) + }, + () => { + // Offline, token not ready or a server error: keep the backend URL, and look it up again next time + pending.delete(backendHref) + } + ) } const onClick = (event) => { diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js index 126a2a188d..8a8e5c5507 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js @@ -432,7 +432,7 @@ describe('attachAgentProductLinkRouting', () => { test.each([ ['finds no product', async () => undefined], - ['fails', async () => Promise.reject(new Error('404'))] + ['fails', async () => Promise.reject(new Error('offline'))] ])( 'keeps the backend address, never a storefront 404, when the lookup %s', async (_label, impl) => { @@ -449,6 +449,48 @@ describe('attachAgentProductLinkRouting', () => { } ) + test('does not look up again an address no segment maps to', async () => { + lookupProductId.mockResolvedValue(undefined) + const href = 'https://backend.example.com/cafe-chair-DH-388.html' + const card = `` + byId('widget').insertAdjacentHTML('beforeend', card) + await flush() + const callsAfterFirst = lookupProductId.mock.calls.length + + byId('widget').insertAdjacentHTML('beforeend', card) // the widget draws it again + await flush() + expect(lookupProductId).toHaveBeenCalledTimes(callsAfterFirst) + }) + + test('looks up again, the next time the widget draws it, an address whose lookup failed', async () => { + lookupProductId.mockRejectedValueOnce(new Error('offline')) + const href = 'https://backend.example.com/cafe-chair-DH-388.html' + const card = (id) => + `` + byId('widget').insertAdjacentHTML('beforeend', card('first-try')) + await flush() + expect(hrefOf('first-try')).toBe(href) + expect(lookupProductId).toHaveBeenCalledTimes(2) // `chairs/DH-387.html`, then this one + + byId('widget').insertAdjacentHTML('beforeend', card('second-try')) + await flush() + expect(lookupProductId).toHaveBeenCalledTimes(3) + expect(hrefOf('first-try')).toBe('/us/en-US/product/DH-388') + expect(hrefOf('second-try')).toBe('/us/en-US/product/DH-388') + }) + + test('stops at the first segment whose lookup fails, so it is retried whole', async () => { + lookupProductId.mockRejectedValueOnce(new Error('offline')) + const href = 'https://backend.example.com/en_US/cafe-chair-DH-388.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(lookupProductId).toHaveBeenLastCalledWith('en_US/cafe-chair-DH-388.html') + expect(lookupProductId).not.toHaveBeenCalledWith('cafe-chair-DH-388.html') + }) + test('keeps the backend address of an ambiguous link when there is no lookup', () => { stop() document.body.innerHTML = WIDGET_MARKUP From 0cd9df0bc654194519bb26837283a08494f3fcf3 Mon Sep 17 00:00:00 2001 From: Ashwin Shanker Date: Mon, 2 Nov 2026 11:32:46 -0800 Subject: [PATCH 5/5] fix(shopper-agent): look up dashed product ids, escape ids in links - Any last path segment with `-` is ambiguous (`-` or an id like `t-shirt-123`), so it is resolved through Shopper SEO instead of split by the product name shown. - Encode the product id as one path segment when building the PDP link. - Changelog: drop the ticket number and port note. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../template-retail-react-app/CHANGELOG.md | 2 +- .../app/components/shopper-agent/index.jsx | 51 +++++++------- .../components/shopper-agent/index.test.js | 15 +++++ .../app/utils/shopper-agent-product-links.js | 53 ++------------- .../utils/shopper-agent-product-links.test.js | 67 +++++++++---------- 5 files changed, 78 insertions(+), 110 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 5ff52b355c..584e9b7b28 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +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/-.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) +- [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/-.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) diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx index 319710f569..77613abea2 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.jsx +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.jsx @@ -27,10 +27,7 @@ import { COMMERCE_CLIENT_UI_STATE_EVENT } from '@salesforce/retail-react-app/app/constants' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' -import { - productUrlBuilder, - toStorefrontNavigatePath -} from '@salesforce/retail-react-app/app/utils/url' +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' @@ -732,29 +729,31 @@ const CommerceClientAgentWindow = ({ const {getTokenWhenReady} = useAccessToken() const getTokenWhenReadyRef = useRef(getTokenWhenReady) getTokenWhenReadyRef.current = getTokenWhenReady - useEffect( - () => - attachAgentProductLinkRouting({ - navigateToProduct: (productId) => navigate(productUrlBuilder({id: productId})), - getProductHref: (productId) => buildUrl(productUrlBuilder({id: 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 + 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 } - }), - [navigate, buildUrl, shopperSeo] - ) + 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)) diff --git a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js index 6e748f86d0..1bde04f489 100644 --- a/packages/template-retail-react-app/app/components/shopper-agent/index.test.js +++ b/packages/template-retail-react-app/app/components/shopper-agent/index.test.js @@ -2092,6 +2092,21 @@ describe('ShopperAgent Component', () => { 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 diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js index 5fa628c22a..7f70c087c1 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.js @@ -44,20 +44,6 @@ const PRODUCT_LINK_SELECTOR = PRODUCT_LINK_SCOPES.flatMap((scope) => [ /** Free text inside a card (the description) can hold any link, so it is never touched. */ const FREE_TEXT_SCOPE = '.cim-widget-msg-product-card__description' -/** Widget markup around a product link that also holds the product name. */ -const PRODUCT_NAME_SCOPE = - '.cim-widget-msg-product-card, .cim-widget-product-comparison__product-col' -const PRODUCT_NAME_SELECTOR = - '.cim-widget-msg-product-card__title-link, .cim-widget-msg-product-card__title, .cim-widget-product-comparison__name-link' - -/** Text reduced to unaccented letters and digits, e.g. 'Café Chair' and 'cafe-chair' -> 'cafechair'. */ -const toKey = (text) => - text - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]/g, '') - /** * @returns {{url: URL, segment: string, isProductId: boolean}|undefined} The URL and its product * segment (the `pid` parameter, or the last path segment minus `.html`), or undefined when `href` @@ -90,33 +76,20 @@ const parseBackendProductUrl = (href, currentOrigin) => { /** * Returns the product id in a backend product URL, when the URL alone tells it for certain. * - * The id is the `pid` parameter, or comes from the last path segment minus `.html`, which is either - * the bare id (`//.html`) or the name slug and id joined by `-` (`/-.html`). Ids - * may themselves contain `-`, and the slug follows the site's URL rules, so a segment with a `-` is - * only split where the part before it matches `productName`, both reduced to unaccented letters and - * digits. Any other segment with a `-` is ambiguous; look it up with `getProductUrlSegments`. + * The id is the `pid` parameter, or the last path segment minus `.html` when that segment has no + * `-`, i.e. the bare id of `//.html`. A segment with a `-` may be the name slug and id + * joined by `-` (`/-.html`), or an id that itself contains `-` (`t-shirt-123`); no split + * tells them apart, so it is ambiguous. Look it up with `getProductUrlSegments` instead. * * @param {string} href - Link address * @param {string} [currentOrigin] - Storefront origin; links on it are already routable - * @param {string} [productName] - Product name shown with the link * @returns {string|undefined} Product id, or undefined when it is ambiguous or the URL is not a product link on another origin */ -export const getBackendProductId = (href, currentOrigin = window.location.origin, productName) => { +export const getBackendProductId = (href, currentOrigin = window.location.origin) => { const parsed = parseBackendProductUrl(href, currentOrigin) if (!parsed) return undefined const {segment, isProductId} = parsed - if (isProductId || !segment.includes('-')) return segment - - const nameKey = productName ? toKey(productName) : '' - if (!nameKey) return undefined - // The last split that matches wins, so a repeated separator after the name stays out of the id. - let productId - for (let i = segment.indexOf('-'); i !== -1; i = segment.indexOf('-', i + 1)) { - if (i + 1 < segment.length && toKey(segment.slice(0, i)) === nameKey) { - productId = segment.slice(i + 1) - } - } - return productId + return isProductId || !segment.includes('-') ? segment : undefined } /** @@ -144,14 +117,6 @@ export const getProductUrlSegments = (href, currentOrigin = window.location.orig return segments } -/** The product name shown with a widget product link, or '' when none is found. */ -const getProductName = (anchor) => { - const scope = anchor.closest(PRODUCT_NAME_SCOPE) - const nameElement = scope ? scope.querySelector(PRODUCT_NAME_SELECTOR) : anchor - const image = anchor.querySelector('img[alt]') - return (nameElement?.textContent || '').trim() || (image?.getAttribute('alt') || '').trim() -} - const isAgentProductLink = (anchor) => anchor.matches(PRODUCT_LINK_SELECTOR) && !anchor.closest(FREE_TEXT_SCOPE) @@ -219,11 +184,7 @@ export const attachAgentProductLinkRouting = ({ const getLinkedProductId = (anchor) => { const own = rewritten.get(anchor) if (own && anchor.getAttribute('href') === own.href) return own.productId - return ( - getBackendProductId(anchor.href, window.location.origin, getProductName(anchor)) || - lookedUp.get(anchor.href) || - undefined - ) + return getBackendProductId(anchor.href) || lookedUp.get(anchor.href) || undefined } const writeHref = (anchor, productId) => { diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js index 8a8e5c5507..f1b56a010c 100644 --- a/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js +++ b/packages/template-retail-react-app/app/utils/shopper-agent-product-links.test.js @@ -45,7 +45,12 @@ const hrefOf = (id) => byId(id).getAttribute('href') const productHref = (productId) => `/us/en-US/product/${productId}` /** What Shopper SEO `getUrlMapping` maps each URL segment to in these tests. */ -const SEO_PRODUCTS = {'chairs/DH-387.html': 'DH-387', 'cafe-chair-DH-388.html': 'DH-388'} +const SEO_PRODUCTS = { + 'en_US/dipsea-tie-headband-2040793A5H.html': '2040793A5H', + 'chairs/DH-387.html': 'DH-387', + 'cafe-chair-DH-388.html': 'DH-388', + 't-shirt-123.html': 't-shirt-123' +} /** Lets the MutationObserver deliver its records and pending lookups answer. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -81,32 +86,12 @@ describe('getBackendProductId', () => { }) test.each([ - [SFRA_URL, 'Dipsea Tie Headband', '2040793A5H'], - ['https://backend.example.com/en_US/womens-v-neck-DH-387.html', "Women's V-Neck", 'DH-387'], - ['https://backend.example.com/en_US/cafe-chair-DH-387.html', 'Café Chair', 'DH-387'], - ['https://backend.example.com/en_US/caf-chair-DH-387.html', 'Caf Chair', 'DH-387'], - [ - 'https://backend.example.com/en_US/dream--chair--25589712M.html', - 'Dream Chair', - '25589712M' - ], - ['https://backend.example.com/en_US/t-shirt-TS-1.html', 'T-Shirt', 'TS-1'] - ])('strips the name slug from %s when the product is %s', (href, name, id) => { - expect(getBackendProductId(href, ORIGIN, name)).toBe(id) - }) - - test.each([ - ['the name does not match the slug', SFRA_URL, 'Some Other Product'], - ['there is no name', SFRA_URL, undefined], - ['the name reduces to nothing', SFRA_URL, '—'], - ['the id itself has a dash', SFRA_SITE_URL, undefined], - [ - 'the name is the whole segment', - 'https://backend.example.com/en_US/dream-chair.html', - 'Dream Chair' - ] - ])('gives no id, to look up instead, when %s', (_label, href, name) => { - expect(getBackendProductId(href, ORIGIN, name)).toBeUndefined() + ['a name slug and id', SFRA_URL], + ['an id that has a dash', SFRA_SITE_URL], + ['an id made of dashed words', 'https://backend.example.com/t-shirt-123.html'], + ['a name slug alone', 'https://backend.example.com/en_US/dream-chair.html'] + ])('gives no id, to look up instead, for a segment with a dash: %s', (_label, href) => { + expect(getBackendProductId(href, ORIGIN)).toBeUndefined() }) test.each([ @@ -194,7 +179,7 @@ describe('attachAgentProductLinkRouting', () => { getProductHref: productHref, lookupProductId }) - await flush() // the product detail and comparison links (`DH-387`) are looked up + await flush() // every product link here has a `-` in its id segment, so all are looked up }) afterEach(() => { @@ -375,15 +360,23 @@ describe('attachAgentProductLinkRouting', () => { expect(hrefOf('after-stop')).toBe(SFRA_SITE_URL) }) - test('reads the id from the URL without a lookup when the name matches', () => { - expect(lookupProductId).not.toHaveBeenCalledWith( - expect.stringContaining('dipsea-tie-headband') + test('looks each backend address up once, however many links share it', () => { + expect(lookupProductId).toHaveBeenCalledTimes(2) + expect(lookupProductId).toHaveBeenCalledWith( + 'en_US/dipsea-tie-headband-2040793A5H.html' ) + expect(lookupProductId).toHaveBeenCalledWith('chairs/DH-387.html') }) - test('looks each backend address up once, however many links share it', () => { - expect(lookupProductId).toHaveBeenCalledTimes(1) - expect(lookupProductId).toHaveBeenCalledWith('chairs/DH-387.html') + test('looks up an id that has dashes, rather than splitting it', async () => { + const href = 'https://backend.example.com/t-shirt-123.html' + byId('widget').insertAdjacentHTML( + 'beforeend', + `` + ) + await flush() + expect(lookupProductId).toHaveBeenCalledWith('t-shirt-123.html') + expect(hrefOf('late-shirt')).toBe('/us/en-US/product/t-shirt-123') }) test('looks up a card whose name does not match its slug, and points it at the product found', async () => { @@ -470,11 +463,11 @@ describe('attachAgentProductLinkRouting', () => { byId('widget').insertAdjacentHTML('beforeend', card('first-try')) await flush() expect(hrefOf('first-try')).toBe(href) - expect(lookupProductId).toHaveBeenCalledTimes(2) // `chairs/DH-387.html`, then this one + expect(lookupProductId).toHaveBeenCalledTimes(3) // the two addresses on the page, then this one byId('widget').insertAdjacentHTML('beforeend', card('second-try')) await flush() - expect(lookupProductId).toHaveBeenCalledTimes(3) + expect(lookupProductId).toHaveBeenCalledTimes(4) expect(hrefOf('first-try')).toBe('/us/en-US/product/DH-388') expect(hrefOf('second-try')).toBe('/us/en-US/product/DH-388') }) @@ -495,7 +488,7 @@ describe('attachAgentProductLinkRouting', () => { stop() document.body.innerHTML = WIDGET_MARKUP stop = attachAgentProductLinkRouting({navigateToProduct, getProductHref: productHref}) - expect(hrefOf('card-name')).toBe('/us/en-US/product/2040793A5H') + expect(hrefOf('card-name')).toBe(SFRA_URL) expect(hrefOf('pdp-title')).toBe(SFRA_SITE_URL) })