diff --git a/packages/template-retail-react-app/app/assets/svg/sparkle.svg b/packages/template-retail-react-app/app/assets/svg/sparkle.svg
new file mode 100644
index 0000000000..cc2d9c5bdb
--- /dev/null
+++ b/packages/template-retail-react-app/app/assets/svg/sparkle.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx
index 34e310bfb0..565418dd43 100644
--- a/packages/template-retail-react-app/app/components/_app/index.jsx
+++ b/packages/template-retail-react-app/app/components/_app/index.jsx
@@ -87,6 +87,7 @@ import Seo from '@salesforce/retail-react-app/app/components/seo'
import ShopperAgent from '@salesforce/retail-react-app/app/components/shopper-agent'
import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
+import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
const PlaceholderComponent = () => (
@@ -285,6 +286,8 @@ const App = (props) => {
history.push(path)
}
+ const {actions: shopperAgentActions} = useShopperAgent()
+
const trackPage = () => {
activeData.trackPage(site.id, locale.id, currency)
}
@@ -393,6 +396,7 @@ const App = (props) => {
onMyAccountClick={onAccountClick}
onWishlistClick={onWishlistClick}
onStoreLocatorClick={onOpenStoreLocator}
+ onAgentClick={shopperAgentActions.open}
>
{
* @param {object} props.searchInputRef reference of the search input
* @param {func} props.onMyAccountClick click event handler for my account button
* @param {func} props.onMyCartClick click event handler for my cart button
+ * @param {func} props.onAgentClick click event handler for agent button
* @return {React.ReactElement} - Header component
*/
const Header = ({
@@ -107,6 +110,7 @@ const Header = ({
onMyCartClick = noop,
onWishlistClick = noop,
onStoreLocatorClick = noop,
+ onAgentClick = noop,
...props
}) => {
const intl = useIntl()
@@ -119,6 +123,8 @@ const Header = ({
const logout = useAuthHelper(AuthHelpers.Logout)
const navigate = useNavigation()
const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED
+ const commerceAgentConfig = getCommerceAgentConfig()
+ const showLaunchAgentButton = commerceAgentConfig?.enableAgentFromHeader === 'true'
const {
getButtonProps: getAccountMenuButtonProps,
getDisclosureProps: getAccountMenuDisclosureProps,
@@ -191,6 +197,18 @@ const Header = ({
+ {showLaunchAgentButton && (
+ }
+ aria-label={intl.formatMessage({
+ id: 'header.button.assistive_msg.ask_shopping_agent',
+ defaultMessage: 'Ask Shopping Agent'
+ })}
+ variant="unstyled"
+ {...styles.icons}
+ onClick={onAgentClick}
+ />
+ )}
}
aria-label={intl.formatMessage({
@@ -362,6 +380,7 @@ Header.propTypes = {
onWishlistClick: PropTypes.func,
onMyCartClick: PropTypes.func,
onStoreLocatorClick: PropTypes.func,
+ onAgentClick: PropTypes.func,
searchInputRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({current: PropTypes.elementType})
diff --git a/packages/template-retail-react-app/app/components/header/index.test.js b/packages/template-retail-react-app/app/components/header/index.test.js
index 345d9d3d45..4c93aba742 100644
--- a/packages/template-retail-react-app/app/components/header/index.test.js
+++ b/packages/template-retail-react-app/app/components/header/index.test.js
@@ -20,6 +20,7 @@ import {
mockedRegisteredCustomer
} from '@salesforce/retail-react-app/app/mocks/mock-data'
import {useMediaQuery} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => {
const originalModule = jest.requireActual(
@@ -30,6 +31,10 @@ jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => {
useMediaQuery: jest.fn().mockReturnValue([true])
}
})
+
+jest.mock('@salesforce/retail-react-app/app/utils/config-utils', () => ({
+ getCommerceAgentConfig: jest.fn()
+}))
const MockedComponent = ({history}) => {
const onAccountClick = () => {
history.push(createPathWithDefaults('/account'))
@@ -55,6 +60,10 @@ beforeEach(() => {
return res(ctx.delay(0), ctx.status(200), ctx.json(mockCustomerBaskets))
})
)
+ // Default mock for getCommerceAgentConfig
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'false'
+ })
})
afterEach(() => {
localStorage.clear()
@@ -326,3 +335,102 @@ test('handles search functionality', async () => {
fireEvent.change(searchInput, {target: {value: 'test search'}})
expect(searchInput.value).toBe('test search')
})
+
+describe('Agent button (SparkleIcon)', () => {
+ test('renders agent button when enableAgentFromHeader is true', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'true'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.getByLabelText('Ask Shopping Agent')
+ expect(agentButton).toBeInTheDocument()
+ })
+ })
+
+ test('does not render agent button when enableAgentFromHeader is false', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'false'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.queryByLabelText('Ask Shopping Agent')
+ expect(agentButton).not.toBeInTheDocument()
+ })
+ })
+
+ test('does not render agent button when enableAgentFromHeader is undefined', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: undefined
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.queryByLabelText('Ask Shopping Agent')
+ expect(agentButton).not.toBeInTheDocument()
+ })
+ })
+
+ test('does not render agent button when enableAgentFromHeader is not "true"', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'someOtherValue'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.queryByLabelText('Ask Shopping Agent')
+ expect(agentButton).not.toBeInTheDocument()
+ })
+ })
+
+ test('calls onAgentClick when agent button is clicked', async () => {
+ const onAgentClick = jest.fn()
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'true'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.getByLabelText('Ask Shopping Agent')
+ expect(agentButton).toBeInTheDocument()
+ })
+
+ const agentButton = screen.getByLabelText('Ask Shopping Agent')
+ fireEvent.click(agentButton)
+
+ expect(onAgentClick).toHaveBeenCalledTimes(1)
+ })
+
+ test('agent button has correct aria-label', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'true'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const agentButton = screen.getByLabelText('Ask Shopping Agent')
+ expect(agentButton).toBeInTheDocument()
+ expect(agentButton).toHaveAttribute('aria-label', 'Ask Shopping Agent')
+ })
+ })
+
+ test('calls getCommerceAgentConfig to check configuration', async () => {
+ getCommerceAgentConfig.mockReturnValue({
+ enableAgentFromHeader: 'true'
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(getCommerceAgentConfig).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/template-retail-react-app/app/components/icons/index.jsx b/packages/template-retail-react-app/app/components/icons/index.jsx
index 2c2711af5e..3764d4cd3f 100644
--- a/packages/template-retail-react-app/app/components/icons/index.jsx
+++ b/packages/template-retail-react-app/app/components/icons/index.jsx
@@ -59,6 +59,7 @@ import '@salesforce/retail-react-app/app/assets/svg/visibility-off.svg'
import '@salesforce/retail-react-app/app/assets/svg/heart.svg'
import '@salesforce/retail-react-app/app/assets/svg/heart-solid.svg'
import '@salesforce/retail-react-app/app/assets/svg/close.svg'
+import '@salesforce/retail-react-app/app/assets/svg/sparkle.svg'
// For non-square SVGs, we can use the symbol data from the import to set the
// proper viewBox attribute on the Icon wrapper.
@@ -199,6 +200,7 @@ export const SocialPinterestIcon = icon('social-pinterest', {
})
export const SocialTwitterIcon = icon('social-twitter')
export const SocialYoutubeIcon = icon('social-youtube')
+export const SparkleIcon = icon('sparkle')
export const StoreIcon = icon('store')
export const SignoutIcon = icon('signout')
export const UserIcon = icon('user')
diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-agent.js b/packages/template-retail-react-app/app/hooks/use-shopper-agent.js
new file mode 100644
index 0000000000..4036c6af3a
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-shopper-agent.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2025, 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 {useCallback} from 'react'
+import {launchChat} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
+
+/**
+ * React hook that returns shopper agent actions.
+ * Uses the embedded service bootstrap API. Structured for future extension (e.g. close, sendMessage).
+ */
+export function useShopperAgent() {
+ const open = useCallback(() => {
+ launchChat()
+ }, [])
+
+ return {actions: {open}}
+}
diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-agent.test.js b/packages/template-retail-react-app/app/hooks/use-shopper-agent.test.js
new file mode 100644
index 0000000000..f0da3c885e
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-shopper-agent.test.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025, 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 {renderHook, act} from '@testing-library/react'
+import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
+import {launchChat} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
+
+jest.mock('@salesforce/retail-react-app/app/utils/shopper-agent-utils', () => ({
+ launchChat: jest.fn()
+}))
+
+describe('useShopperAgent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('should return an object with actions', () => {
+ const {result} = renderHook(() => useShopperAgent())
+
+ expect(result.current).toEqual({actions: expect.any(Object)})
+ expect(result.current.actions).toHaveProperty('open')
+ expect(typeof result.current.actions.open).toBe('function')
+ })
+
+ test('should call launchChat when actions.open is invoked', () => {
+ const {result} = renderHook(() => useShopperAgent())
+
+ act(() => {
+ result.current.actions.open()
+ })
+
+ expect(launchChat).toHaveBeenCalledTimes(1)
+ })
+
+ test('should call launchChat each time actions.open is invoked', () => {
+ const {result} = renderHook(() => useShopperAgent())
+
+ act(() => {
+ result.current.actions.open()
+ result.current.actions.open()
+ })
+
+ expect(launchChat).toHaveBeenCalledTimes(2)
+ })
+
+ test('should return a stable open callback reference across re-renders', () => {
+ const {result, rerender} = renderHook(() => useShopperAgent())
+
+ const firstOpen = result.current.actions.open
+ rerender()
+ const secondOpen = result.current.actions.open
+
+ expect(firstOpen).toBe(secondOpen)
+ })
+})
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
index 8602db1910..4ce9b44bb4 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json
@@ -1955,6 +1955,12 @@
"value": "View"
}
],
+ "header.button.assistive_msg.ask_shopping_agent": [
+ {
+ "type": 0,
+ "value": "Ask Shopping Agent"
+ }
+ ],
"header.button.assistive_msg.logo": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
index 8602db1910..4ce9b44bb4 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
@@ -1955,6 +1955,12 @@
"value": "View"
}
],
+ "header.button.assistive_msg.ask_shopping_agent": [
+ {
+ "type": 0,
+ "value": "Ask Shopping Agent"
+ }
+ ],
"header.button.assistive_msg.logo": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
index 4ff6d60d85..6ddce40648 100644
--- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
+++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
@@ -3987,6 +3987,20 @@
"value": "]"
}
],
+ "header.button.assistive_msg.ask_shopping_agent": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ȧşķ Şħǿǿƥƥīƞɠ Ȧɠḗḗƞŧ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
"header.button.assistive_msg.logo": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/utils/config-utils.js b/packages/template-retail-react-app/app/utils/config-utils.js
index ecb6425140..6c6541ba1e 100644
--- a/packages/template-retail-react-app/app/utils/config-utils.js
+++ b/packages/template-retail-react-app/app/utils/config-utils.js
@@ -19,7 +19,8 @@ export const getCommerceAgentConfig = () => {
commerceOrgId: '',
siteId: '',
enableConversationContext: 'false',
- conversationContext: []
+ conversationContext: [],
+ enableAgentFromHeader: 'false'
}
return getConfig().app.commerceAgent ?? defaults
}
diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-utils.js b/packages/template-retail-react-app/app/utils/shopper-agent-utils.js
new file mode 100644
index 0000000000..726f3d911c
--- /dev/null
+++ b/packages/template-retail-react-app/app/utils/shopper-agent-utils.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2025, 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
+ */
+const onClient = typeof window !== 'undefined'
+
+/**
+ * Launch the chat using the embedded service bootstrap API
+ *
+ * @function launchChat
+ * @returns {void}
+ */
+export function launchChat() {
+ if (!onClient) return
+
+ try {
+ // Launch chat using the embedded service bootstrap API
+ if (
+ window.embeddedservice_bootstrap?.utilAPI &&
+ typeof window.embeddedservice_bootstrap.utilAPI.launchChat === 'function'
+ ) {
+ window.embeddedservice_bootstrap.utilAPI.launchChat()
+ }
+ } catch (error) {
+ console.error('Shopper Agent: Error launching chat', error)
+ }
+}
+
+/**
+ * Open the shopper agent chat window
+ *
+ * Programmatically opens the embedded messaging widget by finding and clicking
+ * the embedded service chat button. This function can be called from custom
+ * UI elements like header buttons.
+ *
+ * @function openShopperAgent
+ * @returns {void}
+ */
+export function openShopperAgent() {
+ if (!onClient) return
+
+ try {
+ launchChat()
+ } catch (error) {
+ console.error('Shopper Agent: Error opening agent', error)
+ }
+}
diff --git a/packages/template-retail-react-app/app/utils/shopper-agent-utils.test.js b/packages/template-retail-react-app/app/utils/shopper-agent-utils.test.js
new file mode 100644
index 0000000000..b3c650a60d
--- /dev/null
+++ b/packages/template-retail-react-app/app/utils/shopper-agent-utils.test.js
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2025, 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 {
+ launchChat,
+ openShopperAgent
+} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
+
+describe('shopper-agent-utils', () => {
+ let originalWindow
+ let consoleErrorSpy
+
+ beforeEach(() => {
+ // Save original window
+ originalWindow = global.window
+ // Mock console methods
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ // Restore console methods
+ consoleErrorSpy.mockRestore()
+ jest.clearAllMocks()
+ // Restore window after clearing mocks
+ global.window = originalWindow
+ })
+
+ describe('launchChat', () => {
+ test('should return early if not on client side', () => {
+ delete global.window
+
+ const result = launchChat()
+
+ expect(result).toBeUndefined()
+ })
+
+ test('should launch chat when embeddedservice_bootstrap is available', () => {
+ const mockLaunchChat = jest.fn()
+
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: mockLaunchChat
+ }
+ }
+ }
+
+ launchChat()
+
+ expect(mockLaunchChat).toHaveBeenCalledTimes(1)
+ })
+
+ test('should not launch chat when embeddedservice_bootstrap is missing', () => {
+ global.window = {}
+
+ expect(() => launchChat()).not.toThrow()
+ })
+
+ test('should not launch chat when utilAPI is missing', () => {
+ global.window = {
+ embeddedservice_bootstrap: {}
+ }
+
+ expect(() => launchChat()).not.toThrow()
+ })
+
+ test('should not launch chat when launchChat is not a function', () => {
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: 'not a function'
+ }
+ }
+ }
+
+ expect(() => launchChat()).not.toThrow()
+ })
+
+ test('should handle errors and log error', () => {
+ const mockLaunchChat = jest.fn(() => {
+ throw new Error('Launch error')
+ })
+
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: mockLaunchChat
+ }
+ }
+ }
+
+ launchChat()
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Shopper Agent: Error launching chat',
+ expect.any(Error)
+ )
+ })
+ })
+
+ describe('openShopperAgent', () => {
+ test('should return early if not on client side', () => {
+ delete global.window
+
+ const result = openShopperAgent()
+
+ expect(result).toBeUndefined()
+ })
+
+ test('should call launchChat', () => {
+ const mockLaunchChat = jest.fn()
+
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: mockLaunchChat
+ }
+ }
+ }
+
+ openShopperAgent()
+
+ expect(mockLaunchChat).toHaveBeenCalledTimes(1)
+ })
+
+ test('should handle errors from launchChat and log error', () => {
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: jest.fn(() => {
+ throw new Error('Launch error')
+ })
+ }
+ }
+ }
+
+ openShopperAgent()
+
+ // launchChat catches its own error and logs "Error launching chat"
+ // Since launchChat handles the error internally, openShopperAgent's try-catch
+ // doesn't catch it, so we only see the launchChat error log
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Shopper Agent: Error launching chat',
+ expect.any(Error)
+ )
+ })
+
+ test('should handle errors when launchChat throws and openShopperAgent catches it', () => {
+ // Simulate a scenario where launchChat itself throws an error
+ // that isn't caught internally (though in practice launchChat has try-catch)
+ const mockLaunchChat = jest.fn(() => {
+ throw new Error('Unexpected error')
+ })
+
+ global.window = {
+ embeddedservice_bootstrap: {
+ utilAPI: {
+ launchChat: mockLaunchChat
+ }
+ }
+ }
+
+ openShopperAgent()
+
+ // launchChat should have been called
+ expect(mockLaunchChat).toHaveBeenCalled()
+ // launchChat's internal try-catch should handle the error
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Shopper Agent: Error launching chat',
+ expect.any(Error)
+ )
+ })
+
+ test('should handle errors when embeddedservice_bootstrap is missing', () => {
+ global.window = {}
+
+ // Should not throw, launchChat handles missing bootstrap gracefully
+ expect(() => openShopperAgent()).not.toThrow()
+ })
+ })
+})
diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json
index 13075a4311..4fc66969ce 100644
--- a/packages/template-retail-react-app/package.json
+++ b/packages/template-retail-react-app/package.json
@@ -101,7 +101,7 @@
"bundlesize": [
{
"path": "build/main.js",
- "maxSize": "86 kB"
+ "maxSize": "88 kB"
},
{
"path": "build/vendor.js",
diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json
index b1e03cc0a9..cf82c656a1 100644
--- a/packages/template-retail-react-app/translations/en-GB.json
+++ b/packages/template-retail-react-app/translations/en-GB.json
@@ -765,6 +765,9 @@
"global.link.added_to_wishlist.view_wishlist": {
"defaultMessage": "View"
},
+ "header.button.assistive_msg.ask_shopping_agent": {
+ "defaultMessage": "Ask Shopping Agent"
+ },
"header.button.assistive_msg.logo": {
"defaultMessage": "Logo"
},
diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json
index b1e03cc0a9..cf82c656a1 100644
--- a/packages/template-retail-react-app/translations/en-US.json
+++ b/packages/template-retail-react-app/translations/en-US.json
@@ -765,6 +765,9 @@
"global.link.added_to_wishlist.view_wishlist": {
"defaultMessage": "View"
},
+ "header.button.assistive_msg.ask_shopping_agent": {
+ "defaultMessage": "Ask Shopping Agent"
+ },
"header.button.assistive_msg.logo": {
"defaultMessage": "Logo"
},