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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {openShopperAgent} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'

const PlaceholderComponent = () => (
<Center p="2">
Expand Down Expand Up @@ -285,6 +286,11 @@ const App = (props) => {
history.push(path)
}

const onAgentClick = () => {
// Open the shopper agent chat window
openShopperAgent()
}

const trackPage = () => {
activeData.trackPage(site.id, locale.id, currency)
}
Expand Down Expand Up @@ -393,6 +399,7 @@ const App = (props) => {
onMyAccountClick={onAccountClick}
onWishlistClick={onWishlistClick}
onStoreLocatorClick={onOpenStoreLocator}
onAgentClick={onAgentClick}
>
<HideOnDesktop>
<DrawerMenu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {
ChevronDownIcon,
HeartIcon,
SignoutIcon,
StoreIcon
StoreIcon,
SparkleIcon
} from '@salesforce/retail-react-app/app/components/icons'

import {navLinks, messages} from '@salesforce/retail-react-app/app/pages/account/constant'
Expand All @@ -52,6 +53,7 @@ import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/comp
import {isHydrated, noop} from '@salesforce/retail-react-app/app/utils/utils'
import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
const IconButtonWithRegistration = withRegistration(IconButton)

/**
Expand Down Expand Up @@ -97,6 +99,7 @@ const SearchBar = (props) => {
* @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 = ({
Expand All @@ -107,6 +110,7 @@ const Header = ({
onMyCartClick = noop,
onWishlistClick = noop,
onStoreLocatorClick = noop,
onAgentClick = noop,
...props
}) => {
const intl = useIntl()
Expand All @@ -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()
Copy link
Contributor

@kevinxh kevinxh Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears the code style is slightly inconsistent, most of the component access the global config object via getConfig().xxxxxx, and it seems shopper agent introduced its own utility getCommerceAgentConfig. I wonder if we want to be consistent, either create utilities for all other features (not just shopper agent), otherwise it looks inconsistent coding style

    const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED
    const commerceAgentConfig = getCommerceAgentConfig()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a good point, this is where they decided to change it into utility functions, #3230

const showLaunchAgentButton = commerceAgentConfig?.enableAgentFromHeader === 'true'
const {
getButtonProps: getAccountMenuButtonProps,
getDisclosureProps: getAccountMenuDisclosureProps,
Expand Down Expand Up @@ -191,6 +197,18 @@ const Header = ({
<HideOnMobile>
<SearchBar />
</HideOnMobile>
{showLaunchAgentButton && (
<IconButton
icon={<SparkleIcon />}
aria-label={intl.formatMessage({
id: 'header.button.assistive_msg.ask_shopping_agent',
defaultMessage: 'Ask Shopping Agent'
})}
variant="unstyled"
{...styles.icons}
onClick={onAgentClick}
/>
)}
<IconButtonWithRegistration
icon={<AccountIcon />}
aria-label={intl.formatMessage({
Expand Down Expand Up @@ -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})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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'))
Expand All @@ -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()
Expand Down Expand Up @@ -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(<Header />)

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(<Header />)

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(<Header />)

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(<Header />)

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(<Header onAgentClick={onAgentClick} />)

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(<Header />)

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(<Header />)

await waitFor(() => {
expect(getCommerceAgentConfig).toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,12 @@
"value": "View"
}
],
"header.button.assistive_msg.ask_shopping_agent": [
{
"type": 0,
"value": "Ask Shopping Agent"
}
],
"header.button.assistive_msg.logo": [
{
"type": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,12 @@
"value": "View"
}
],
"header.button.assistive_msg.ask_shopping_agent": [
{
"type": 0,
"value": "Ask Shopping Agent"
}
],
"header.button.assistive_msg.logo": [
{
"type": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3959,6 +3959,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const getCommerceAgentConfig = () => {
commerceOrgId: '',
siteId: '',
enableConversationContext: 'false',
conversationContext: []
conversationContext: [],
enableAgentFromHeader: 'false'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any idea why the booleans are implemented as strings? I understand this is already there before your change but I'm curious to know if you happen to know the answer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aggreed its cleaner to use boolean, I'm not sure why the API/Config design uses strings for flags.
I just opted for consistency

this is the official help doc Shopper Agent documentation.

}
return getConfig().app.commerceAgent ?? defaults
}
Original file line number Diff line number Diff line change
@@ -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 &&
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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need both launchChat and openShopperAgent function?
We can remove openShopperAgent and just have launchChat. Post that, renaming launchChat maybe a good option too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openShopperAgent will have more conditions once we introduce flag to hide the FAB icon thats why added two seperate functions

if (!onClient) return

try {
launchChat()
} catch (error) {
console.error('Shopper Agent: Error opening agent', error)
}
}
Comment on lines +7 to +49
Copy link
Contributor

@kevinxh kevinxh Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered to make this a React hook? There are many React hooks in this code base and it 's more consistent with the style and the technologies we used. something like below.

import { useCallback } from 'react'

declare global {
    interface Window {
        embeddedservice_bootstrap?: {
            utilAPI: {
                launchChat: () => void
            }
        }
    }
}

/**
 * Hook for launching the Salesforce Embedded Service chat
 */
export function useShopperAgent() {
    const openChat = useCallback(() => {
        if (typeof window === 'undefined') return

        try {
            window.embeddedservice_bootstrap?.utilAPI.launchChat()
        } catch (error) {
            console.error('Shopper Agent: Error launching chat', error)
        }
    }, [])

    return { openChat }
}

usage example

function ChatButton() {
    const { openChat } = useShopperAgent()

    return <button onClick={openChat}>Chat with us</button>
}

Loading
Loading