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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'

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

const {actions: shopperAgentActions} = useShopperAgent()

const trackPage = () => {
activeData.trackPage(site.id, locale.id, currency)
}
Expand Down Expand Up @@ -393,6 +396,7 @@ const App = (props) => {
onMyAccountClick={onAccountClick}
onWishlistClick={onWishlistClick}
onStoreLocatorClick={onOpenStoreLocator}
onAgentClick={shopperAgentActions.open}
>
<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
@@ -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}}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you need the key "actions" because it's already an extensible object.

Suggested change
return {actions: {open}}
return { open }

This way you can directly deconstruct it, usage example

const {open} = useShopperAgent()

i'm just nitpicking.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd prefer to keep the actions wrapper to add explicit namespace for executable functions. What you think?

}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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
}
Loading
Loading