Skip to content

Commit bff4b48

Browse files
@W-20975885 New launch agent location in header component (#3606)
* feat: new sparkles icon in the header to launch shopper agent * feat: update the build size
1 parent d82757a commit bff4b48

File tree

16 files changed

+485
-3
lines changed

16 files changed

+485
-3
lines changed
Lines changed: 3 additions & 0 deletions
Loading

packages/template-retail-react-app/app/components/_app/index.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import Seo from '@salesforce/retail-react-app/app/components/seo'
8787
import ShopperAgent from '@salesforce/retail-react-app/app/components/shopper-agent'
8888
import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
8989
import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
90+
import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
9091

9192
const PlaceholderComponent = () => (
9293
<Center p="2">
@@ -285,6 +286,8 @@ const App = (props) => {
285286
history.push(path)
286287
}
287288

289+
const {actions: shopperAgentActions} = useShopperAgent()
290+
288291
const trackPage = () => {
289292
activeData.trackPage(site.id, locale.id, currency)
290293
}
@@ -393,6 +396,7 @@ const App = (props) => {
393396
onMyAccountClick={onAccountClick}
394397
onWishlistClick={onWishlistClick}
395398
onStoreLocatorClick={onOpenStoreLocator}
399+
onAgentClick={shopperAgentActions.open}
396400
>
397401
<HideOnDesktop>
398402
<DrawerMenu

packages/template-retail-react-app/app/components/header/index.jsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import {
4242
ChevronDownIcon,
4343
HeartIcon,
4444
SignoutIcon,
45-
StoreIcon
45+
StoreIcon,
46+
SparkleIcon
4647
} from '@salesforce/retail-react-app/app/components/icons'
4748

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

5759
/**
@@ -97,6 +99,7 @@ const SearchBar = (props) => {
9799
* @param {object} props.searchInputRef reference of the search input
98100
* @param {func} props.onMyAccountClick click event handler for my account button
99101
* @param {func} props.onMyCartClick click event handler for my cart button
102+
* @param {func} props.onAgentClick click event handler for agent button
100103
* @return {React.ReactElement} - Header component
101104
*/
102105
const Header = ({
@@ -107,6 +110,7 @@ const Header = ({
107110
onMyCartClick = noop,
108111
onWishlistClick = noop,
109112
onStoreLocatorClick = noop,
113+
onAgentClick = noop,
110114
...props
111115
}) => {
112116
const intl = useIntl()
@@ -119,6 +123,8 @@ const Header = ({
119123
const logout = useAuthHelper(AuthHelpers.Logout)
120124
const navigate = useNavigation()
121125
const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED
126+
const commerceAgentConfig = getCommerceAgentConfig()
127+
const showLaunchAgentButton = commerceAgentConfig?.enableAgentFromHeader === 'true'
122128
const {
123129
getButtonProps: getAccountMenuButtonProps,
124130
getDisclosureProps: getAccountMenuDisclosureProps,
@@ -191,6 +197,18 @@ const Header = ({
191197
<HideOnMobile>
192198
<SearchBar />
193199
</HideOnMobile>
200+
{showLaunchAgentButton && (
201+
<IconButton
202+
icon={<SparkleIcon />}
203+
aria-label={intl.formatMessage({
204+
id: 'header.button.assistive_msg.ask_shopping_agent',
205+
defaultMessage: 'Ask Shopping Agent'
206+
})}
207+
variant="unstyled"
208+
{...styles.icons}
209+
onClick={onAgentClick}
210+
/>
211+
)}
194212
<IconButtonWithRegistration
195213
icon={<AccountIcon />}
196214
aria-label={intl.formatMessage({
@@ -362,6 +380,7 @@ Header.propTypes = {
362380
onWishlistClick: PropTypes.func,
363381
onMyCartClick: PropTypes.func,
364382
onStoreLocatorClick: PropTypes.func,
383+
onAgentClick: PropTypes.func,
365384
searchInputRef: PropTypes.oneOfType([
366385
PropTypes.func,
367386
PropTypes.shape({current: PropTypes.elementType})

packages/template-retail-react-app/app/components/header/index.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
mockedRegisteredCustomer
2121
} from '@salesforce/retail-react-app/app/mocks/mock-data'
2222
import {useMediaQuery} from '@salesforce/retail-react-app/app/components/shared/ui'
23+
import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
2324

2425
jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => {
2526
const originalModule = jest.requireActual(
@@ -30,6 +31,10 @@ jest.mock('@salesforce/retail-react-app/app/components/shared/ui', () => {
3031
useMediaQuery: jest.fn().mockReturnValue([true])
3132
}
3233
})
34+
35+
jest.mock('@salesforce/retail-react-app/app/utils/config-utils', () => ({
36+
getCommerceAgentConfig: jest.fn()
37+
}))
3338
const MockedComponent = ({history}) => {
3439
const onAccountClick = () => {
3540
history.push(createPathWithDefaults('/account'))
@@ -55,6 +60,10 @@ beforeEach(() => {
5560
return res(ctx.delay(0), ctx.status(200), ctx.json(mockCustomerBaskets))
5661
})
5762
)
63+
// Default mock for getCommerceAgentConfig
64+
getCommerceAgentConfig.mockReturnValue({
65+
enableAgentFromHeader: 'false'
66+
})
5867
})
5968
afterEach(() => {
6069
localStorage.clear()
@@ -326,3 +335,102 @@ test('handles search functionality', async () => {
326335
fireEvent.change(searchInput, {target: {value: 'test search'}})
327336
expect(searchInput.value).toBe('test search')
328337
})
338+
339+
describe('Agent button (SparkleIcon)', () => {
340+
test('renders agent button when enableAgentFromHeader is true', async () => {
341+
getCommerceAgentConfig.mockReturnValue({
342+
enableAgentFromHeader: 'true'
343+
})
344+
345+
renderWithProviders(<Header />)
346+
347+
await waitFor(() => {
348+
const agentButton = screen.getByLabelText('Ask Shopping Agent')
349+
expect(agentButton).toBeInTheDocument()
350+
})
351+
})
352+
353+
test('does not render agent button when enableAgentFromHeader is false', async () => {
354+
getCommerceAgentConfig.mockReturnValue({
355+
enableAgentFromHeader: 'false'
356+
})
357+
358+
renderWithProviders(<Header />)
359+
360+
await waitFor(() => {
361+
const agentButton = screen.queryByLabelText('Ask Shopping Agent')
362+
expect(agentButton).not.toBeInTheDocument()
363+
})
364+
})
365+
366+
test('does not render agent button when enableAgentFromHeader is undefined', async () => {
367+
getCommerceAgentConfig.mockReturnValue({
368+
enableAgentFromHeader: undefined
369+
})
370+
371+
renderWithProviders(<Header />)
372+
373+
await waitFor(() => {
374+
const agentButton = screen.queryByLabelText('Ask Shopping Agent')
375+
expect(agentButton).not.toBeInTheDocument()
376+
})
377+
})
378+
379+
test('does not render agent button when enableAgentFromHeader is not "true"', async () => {
380+
getCommerceAgentConfig.mockReturnValue({
381+
enableAgentFromHeader: 'someOtherValue'
382+
})
383+
384+
renderWithProviders(<Header />)
385+
386+
await waitFor(() => {
387+
const agentButton = screen.queryByLabelText('Ask Shopping Agent')
388+
expect(agentButton).not.toBeInTheDocument()
389+
})
390+
})
391+
392+
test('calls onAgentClick when agent button is clicked', async () => {
393+
const onAgentClick = jest.fn()
394+
getCommerceAgentConfig.mockReturnValue({
395+
enableAgentFromHeader: 'true'
396+
})
397+
398+
renderWithProviders(<Header onAgentClick={onAgentClick} />)
399+
400+
await waitFor(() => {
401+
const agentButton = screen.getByLabelText('Ask Shopping Agent')
402+
expect(agentButton).toBeInTheDocument()
403+
})
404+
405+
const agentButton = screen.getByLabelText('Ask Shopping Agent')
406+
fireEvent.click(agentButton)
407+
408+
expect(onAgentClick).toHaveBeenCalledTimes(1)
409+
})
410+
411+
test('agent button has correct aria-label', async () => {
412+
getCommerceAgentConfig.mockReturnValue({
413+
enableAgentFromHeader: 'true'
414+
})
415+
416+
renderWithProviders(<Header />)
417+
418+
await waitFor(() => {
419+
const agentButton = screen.getByLabelText('Ask Shopping Agent')
420+
expect(agentButton).toBeInTheDocument()
421+
expect(agentButton).toHaveAttribute('aria-label', 'Ask Shopping Agent')
422+
})
423+
})
424+
425+
test('calls getCommerceAgentConfig to check configuration', async () => {
426+
getCommerceAgentConfig.mockReturnValue({
427+
enableAgentFromHeader: 'true'
428+
})
429+
430+
renderWithProviders(<Header />)
431+
432+
await waitFor(() => {
433+
expect(getCommerceAgentConfig).toHaveBeenCalled()
434+
})
435+
})
436+
})

packages/template-retail-react-app/app/components/icons/index.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import '@salesforce/retail-react-app/app/assets/svg/visibility-off.svg'
5959
import '@salesforce/retail-react-app/app/assets/svg/heart.svg'
6060
import '@salesforce/retail-react-app/app/assets/svg/heart-solid.svg'
6161
import '@salesforce/retail-react-app/app/assets/svg/close.svg'
62+
import '@salesforce/retail-react-app/app/assets/svg/sparkle.svg'
6263

6364
// For non-square SVGs, we can use the symbol data from the import to set the
6465
// proper viewBox attribute on the Icon wrapper.
@@ -199,6 +200,7 @@ export const SocialPinterestIcon = icon('social-pinterest', {
199200
})
200201
export const SocialTwitterIcon = icon('social-twitter')
201202
export const SocialYoutubeIcon = icon('social-youtube')
203+
export const SparkleIcon = icon('sparkle')
202204
export const StoreIcon = icon('store')
203205
export const SignoutIcon = icon('signout')
204206
export const UserIcon = icon('user')
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {useCallback} from 'react'
8+
import {launchChat} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
9+
10+
/**
11+
* React hook that returns shopper agent actions.
12+
* Uses the embedded service bootstrap API. Structured for future extension (e.g. close, sendMessage).
13+
*/
14+
export function useShopperAgent() {
15+
const open = useCallback(() => {
16+
launchChat()
17+
}, [])
18+
19+
return {actions: {open}}
20+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import {renderHook, act} from '@testing-library/react'
9+
import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
10+
import {launchChat} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
11+
12+
jest.mock('@salesforce/retail-react-app/app/utils/shopper-agent-utils', () => ({
13+
launchChat: jest.fn()
14+
}))
15+
16+
describe('useShopperAgent', () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks()
19+
})
20+
21+
test('should return an object with actions', () => {
22+
const {result} = renderHook(() => useShopperAgent())
23+
24+
expect(result.current).toEqual({actions: expect.any(Object)})
25+
expect(result.current.actions).toHaveProperty('open')
26+
expect(typeof result.current.actions.open).toBe('function')
27+
})
28+
29+
test('should call launchChat when actions.open is invoked', () => {
30+
const {result} = renderHook(() => useShopperAgent())
31+
32+
act(() => {
33+
result.current.actions.open()
34+
})
35+
36+
expect(launchChat).toHaveBeenCalledTimes(1)
37+
})
38+
39+
test('should call launchChat each time actions.open is invoked', () => {
40+
const {result} = renderHook(() => useShopperAgent())
41+
42+
act(() => {
43+
result.current.actions.open()
44+
result.current.actions.open()
45+
})
46+
47+
expect(launchChat).toHaveBeenCalledTimes(2)
48+
})
49+
50+
test('should return a stable open callback reference across re-renders', () => {
51+
const {result, rerender} = renderHook(() => useShopperAgent())
52+
53+
const firstOpen = result.current.actions.open
54+
rerender()
55+
const secondOpen = result.current.actions.open
56+
57+
expect(firstOpen).toBe(secondOpen)
58+
})
59+
})

packages/template-retail-react-app/app/static/translations/compiled/en-GB.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,6 +1955,12 @@
19551955
"value": "View"
19561956
}
19571957
],
1958+
"header.button.assistive_msg.ask_shopping_agent": [
1959+
{
1960+
"type": 0,
1961+
"value": "Ask Shopping Agent"
1962+
}
1963+
],
19581964
"header.button.assistive_msg.logo": [
19591965
{
19601966
"type": 0,

packages/template-retail-react-app/app/static/translations/compiled/en-US.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,6 +1955,12 @@
19551955
"value": "View"
19561956
}
19571957
],
1958+
"header.button.assistive_msg.ask_shopping_agent": [
1959+
{
1960+
"type": 0,
1961+
"value": "Ask Shopping Agent"
1962+
}
1963+
],
19581964
"header.button.assistive_msg.logo": [
19591965
{
19601966
"type": 0,

packages/template-retail-react-app/app/static/translations/compiled/en-XA.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3987,6 +3987,20 @@
39873987
"value": "]"
39883988
}
39893989
],
3990+
"header.button.assistive_msg.ask_shopping_agent": [
3991+
{
3992+
"type": 0,
3993+
"value": "["
3994+
},
3995+
{
3996+
"type": 0,
3997+
"value": "Ȧşķ Şħǿǿƥƥīƞɠ Ȧɠḗḗƞŧ"
3998+
},
3999+
{
4000+
"type": 0,
4001+
"value": "]"
4002+
}
4003+
],
39904004
"header.button.assistive_msg.logo": [
39914005
{
39924006
"type": 0,

0 commit comments

Comments
 (0)