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" },