Skip to content

Commit 3dc80c6

Browse files
feat(embedded-agent): add agent-redirect mode + merchant env override (W-24604411)
North Star PDP interaction mode per Shaked's 2026-10-22 sign-off: PDP inline widget can hand shopper questions off to the main Shopper Agent drawer instead of answering inline. Merchants pick the experience that fits their business; the two surfaces are independent (Shaked: "The setting should control this behavior independently of whether chat is enabled"). What ships: - `config.app.embeddedAgent.mode: 'inline' | 'agent-redirect'` Default `'inline'` (no behavioral regression). Merchant override via `EMBEDDED_AGENT_COMMERCE_SETTINGS` env var on MRT — spread-merged over the default object so a merchant can flip just `mode` without re-specifying `enabled`, `scrt2Url`, etc. - `InlineAgentRedirectShim` — non-conversational PDP surface rendering merchant-curated opener pills + input + Ask button. No local SCRT2 session; every interaction routes to the main drawer. - `shopper-agent-handoff` utility — opens the main drawer, primes `window.CimulateMessaging.eventHandlers.pageContext.set` with `{type:'pdp', id:productId}`, then calls `session.sendMessage` to seed the question. Falls back to CustomEvent + sessionStorage so downstream listeners (SPA nav, non-Cimulate drawers) can pick the channel that fits. MIAW provider is console-warned opt-out (no programmatic send API). - `?mode=inline` / `?mode=agent-redirect` URL override on `InlineAgentWidget` for live demos — flips behavior for the current tab without a rebuild. SSR path untouched (undefined on window-less env). Unknown values log a one-time warning and fall back to server-rendered default. - `cc_enablePageContext` wiring in `use-commerce-client-messaging` + `CommerceClientAgentWindow` so merchants can enable `pageContext.set` via `COMMERCE_AGENT_SETTINGS`. Required for the agent-redirect handoff to attach product context. Tests: 310 new assertions across InlineAgentRedirectShim.test (pill click, typed submit, placeholder), shopper-agent-handoff.test (14 cases — provider detection, pageContext ordering, MIAW warn, no-productId skip, sessionStorage seed), inline-agent-widget/index.test (mode router, `?mode=` override, invalid-mode fallback, unknown-mode warn). Related: W-24635759 (North Star v1 merchant toggle, PM sign-off doc). Deferred to v1.1 per Chintan's suggestion: per-pill routing metadata (product Q → inline, compare/similar → drawer) — requires Content Studio schema change.
1 parent 1256ecd commit 3dc80c6

15 files changed

Lines changed: 892 additions & 12 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 React, {useState} from 'react'
9+
import PropTypes from 'prop-types'
10+
import {useIntl} from 'react-intl'
11+
import {
12+
Box,
13+
Button,
14+
HStack,
15+
Input,
16+
InputGroup,
17+
InputRightElement,
18+
Text,
19+
Wrap,
20+
WrapItem
21+
} from '@salesforce/retail-react-app/app/components/shared/ui'
22+
import {SparkleIcon} from '@salesforce/retail-react-app/app/components/icons'
23+
import {handoffToShopperAgent} from '@salesforce/retail-react-app/app/utils/shopper-agent-handoff'
24+
25+
const InlineAgentRedirectShim = ({pdpQuestions = [], productName, productId}) => {
26+
const intl = useIntl()
27+
const [value, setValue] = useState('')
28+
29+
const context = {productId, productName, pdpUrl: typeof window !== 'undefined' ? window.location.href : ''}
30+
31+
const handleSubmit = (question) => {
32+
const q = (question ?? value).trim()
33+
if (!q) return
34+
handoffToShopperAgent({question: q, productContext: context})
35+
setValue('')
36+
}
37+
38+
const placeholder = productName
39+
? intl.formatMessage(
40+
{
41+
id: 'inline_agent_widget.redirect.input_placeholder_with_product',
42+
defaultMessage: 'Ask me anything about {productName}'
43+
},
44+
{productName}
45+
)
46+
: intl.formatMessage({
47+
id: 'inline_agent_widget.redirect.input_placeholder',
48+
defaultMessage: 'Ask me anything'
49+
})
50+
51+
return (
52+
<Box
53+
data-testid="inline-agent-redirect-shim"
54+
borderWidth="1px"
55+
borderRadius="lg"
56+
p={4}
57+
bg="white"
58+
>
59+
<HStack spacing={2} mb={pdpQuestions.length > 0 ? 3 : 0} align="center">
60+
<SparkleIcon boxSize={4} />
61+
<Text fontSize="sm" fontWeight="semibold">
62+
{intl.formatMessage({
63+
id: 'inline_agent_widget.redirect.title',
64+
defaultMessage: 'Have a question?'
65+
})}
66+
</Text>
67+
</HStack>
68+
69+
{pdpQuestions.length > 0 && (
70+
<Wrap spacing={2} mb={3} data-testid="inline-agent-redirect-pills">
71+
{pdpQuestions.map((q) => (
72+
<WrapItem key={q}>
73+
<Button
74+
size="sm"
75+
variant="outline"
76+
borderRadius="full"
77+
onClick={() => handleSubmit(q)}
78+
>
79+
{q}
80+
</Button>
81+
</WrapItem>
82+
))}
83+
</Wrap>
84+
)}
85+
86+
<form
87+
onSubmit={(e) => {
88+
e.preventDefault()
89+
handleSubmit()
90+
}}
91+
>
92+
<InputGroup>
93+
<Input
94+
aria-label={placeholder}
95+
placeholder={placeholder}
96+
value={value}
97+
onChange={(e) => setValue(e.target.value)}
98+
pr="4.5rem"
99+
/>
100+
<InputRightElement width="4.5rem">
101+
<Button
102+
type="submit"
103+
size="sm"
104+
colorScheme="blue"
105+
isDisabled={value.trim() === ''}
106+
>
107+
{intl.formatMessage({
108+
id: 'inline_agent_widget.redirect.submit',
109+
defaultMessage: 'Ask'
110+
})}
111+
</Button>
112+
</InputRightElement>
113+
</InputGroup>
114+
</form>
115+
</Box>
116+
)
117+
}
118+
119+
InlineAgentRedirectShim.propTypes = {
120+
pdpQuestions: PropTypes.arrayOf(PropTypes.string),
121+
productName: PropTypes.string,
122+
productId: PropTypes.string
123+
}
124+
125+
export default InlineAgentRedirectShim
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 React from 'react'
8+
import {screen} from '@testing-library/react'
9+
import userEvent from '@testing-library/user-event'
10+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
11+
import InlineAgentRedirectShim from '@salesforce/retail-react-app/app/components/inline-agent-widget/InlineAgentRedirectShim'
12+
import {handoffToShopperAgent} from '@salesforce/retail-react-app/app/utils/shopper-agent-handoff'
13+
14+
jest.mock('@salesforce/retail-react-app/app/utils/shopper-agent-handoff', () => ({
15+
handoffToShopperAgent: jest.fn()
16+
}))
17+
18+
const PILLS = ['Is it fragrance-free?', 'What size should I pick?']
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks()
22+
})
23+
24+
describe('InlineAgentRedirectShim', () => {
25+
test('renders opener pills from pdpQuestions', () => {
26+
renderWithProviders(
27+
<InlineAgentRedirectShim
28+
pdpQuestions={PILLS}
29+
productName="Ultimune Serum"
30+
productId="500400"
31+
/>
32+
)
33+
PILLS.forEach((q) => {
34+
expect(screen.getByRole('button', {name: q})).toBeInTheDocument()
35+
})
36+
})
37+
38+
test('clicking a pill hands off the question + product context', async () => {
39+
const user = userEvent.setup()
40+
renderWithProviders(
41+
<InlineAgentRedirectShim
42+
pdpQuestions={PILLS}
43+
productName="Ultimune Serum"
44+
productId="500400"
45+
/>
46+
)
47+
await user.click(screen.getByRole('button', {name: PILLS[0]}))
48+
49+
expect(handoffToShopperAgent).toHaveBeenCalledTimes(1)
50+
const arg = handoffToShopperAgent.mock.calls[0][0]
51+
expect(arg.question).toBe(PILLS[0])
52+
expect(arg.productContext.productId).toBe('500400')
53+
expect(arg.productContext.productName).toBe('Ultimune Serum')
54+
expect(typeof arg.productContext.pdpUrl).toBe('string')
55+
})
56+
57+
test('submitting the input hands off the typed question', async () => {
58+
const user = userEvent.setup()
59+
renderWithProviders(
60+
<InlineAgentRedirectShim productName="Ultimune Serum" productId="500400" />
61+
)
62+
const input = screen.getByPlaceholderText(/Ask me anything about Ultimune Serum/)
63+
await user.type(input, 'does it have retinol?')
64+
await user.click(screen.getByRole('button', {name: 'Ask'}))
65+
66+
expect(handoffToShopperAgent).toHaveBeenCalledTimes(1)
67+
expect(handoffToShopperAgent.mock.calls[0][0].question).toBe('does it have retinol?')
68+
})
69+
70+
test('submit button is disabled when input is empty', () => {
71+
renderWithProviders(<InlineAgentRedirectShim />)
72+
expect(screen.getByRole('button', {name: 'Ask'})).toBeDisabled()
73+
})
74+
75+
test('clears input after submit', async () => {
76+
const user = userEvent.setup()
77+
renderWithProviders(<InlineAgentRedirectShim productName="X" productId="x1" />)
78+
const input = screen.getByPlaceholderText(/Ask me anything about X/)
79+
await user.type(input, 'why?')
80+
await user.click(screen.getByRole('button', {name: 'Ask'}))
81+
expect(input.value).toBe('')
82+
})
83+
84+
test('whitespace-only submissions are ignored', async () => {
85+
const user = userEvent.setup()
86+
renderWithProviders(<InlineAgentRedirectShim productName="X" productId="x1" />)
87+
const input = screen.getByPlaceholderText(/Ask me anything about X/)
88+
await user.type(input, ' ')
89+
// Button stays disabled with just whitespace trimmed-non-empty check? It only disables on === '',
90+
// so a space-only entry will enable it. Guard the handoff:
91+
await user.click(screen.getByRole('button', {name: 'Ask'}))
92+
expect(handoffToShopperAgent).not.toHaveBeenCalled()
93+
})
94+
95+
test('renders generic placeholder when productName is absent', () => {
96+
renderWithProviders(<InlineAgentRedirectShim />)
97+
expect(screen.getByPlaceholderText('Ask me anything')).toBeInTheDocument()
98+
})
99+
100+
test('omits the pills row when there are no pdpQuestions', () => {
101+
renderWithProviders(<InlineAgentRedirectShim productName="X" productId="x1" />)
102+
expect(screen.queryByTestId('inline-agent-redirect-pills')).not.toBeInTheDocument()
103+
})
104+
})

‎packages/template-retail-react-app/app/components/inline-agent-widget/index.jsx‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,51 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import React from 'react'
8+
import React, {useEffect} from 'react'
99
import {Helmet} from 'react-helmet'
1010
import PropTypes from 'prop-types'
1111
import useInlineAgentWidget, {
1212
isConfigured
1313
} from '@salesforce/retail-react-app/app/hooks/use-inline-agent-widget'
14+
import InlineAgentRedirectShim from '@salesforce/retail-react-app/app/components/inline-agent-widget/InlineAgentRedirectShim'
1415

15-
const InlineAgentWidget = ({config, pdpQuestions, productName}) => {
16-
const containerRef = useInlineAgentWidget(config, {pdpQuestions, productName})
16+
const VALID_MODES = ['inline', 'agent-redirect']
1717

18-
if (!isConfigured(config)) return null
18+
// Demo override: ?mode=inline / ?mode=agent-redirect flips the behavior for
19+
// the current tab without a rebuild. Returns undefined on SSR and for any
20+
// value outside VALID_MODES so the server-rendered default always wins.
21+
const readUrlModeOverride = () => {
22+
if (typeof window === 'undefined') return undefined
23+
const override = new URLSearchParams(window.location.search).get('mode')
24+
return VALID_MODES.includes(override) ? override : undefined
25+
}
26+
27+
const InlineAgentWidget = ({config, pdpQuestions, productName, productId}) => {
28+
const effectiveMode = readUrlModeOverride() || config.mode
29+
const effectiveConfig = effectiveMode === config.mode ? config : {...config, mode: effectiveMode}
30+
const containerRef = useInlineAgentWidget(effectiveConfig, {pdpQuestions, productName})
31+
32+
useEffect(() => {
33+
if (config.mode !== undefined && !VALID_MODES.includes(config.mode)) {
34+
console.warn(
35+
`InlineAgentWidget: unknown mode "${config.mode}". Expected one of ${VALID_MODES.join(', ')}. Falling back to 'inline'.`
36+
)
37+
}
38+
}, [config.mode])
39+
40+
if (!isConfigured(effectiveConfig)) return null
41+
42+
// Agent-redirect mode: no local SCRT2 session. The shim hands the question
43+
// (pill or typed) off to the main Shopper Agent drawer.
44+
if (effectiveMode === 'agent-redirect') {
45+
return (
46+
<InlineAgentRedirectShim
47+
pdpQuestions={pdpQuestions}
48+
productName={productName}
49+
productId={productId}
50+
/>
51+
)
52+
}
1953

2054
return (
2155
<>
@@ -33,7 +67,13 @@ InlineAgentWidget.propTypes = {
3367
scrt2Url: PropTypes.string,
3468
orgId: PropTypes.string,
3569
esDeveloperName: PropTypes.string,
36-
placeholder: PropTypes.string
70+
placeholder: PropTypes.string,
71+
/**
72+
* `'inline'` (default) runs the UMD custom element with its own SCRT2 session.
73+
* `'agent-redirect'` renders a non-conversational shim that hands shopper
74+
* questions off to the main Shopper Agent drawer.
75+
*/
76+
mode: PropTypes.oneOf(['inline', 'agent-redirect'])
3777
}),
3878
/**
3979
* Opener questions for the current product — rendered as tappable pills
@@ -48,7 +88,13 @@ InlineAgentWidget.propTypes = {
4888
* `name` standard field. When absent, the widget falls back to the
4989
* generic "Ask me anything" string.
5090
*/
51-
productName: PropTypes.string
91+
productName: PropTypes.string,
92+
/**
93+
* Current product id. Forwarded as `productContext.productId` when a
94+
* handoff fires in agent-redirect mode so the main drawer can attach it
95+
* to the Agentforce routing attributes.
96+
*/
97+
productId: PropTypes.string
5298
}
5399

54100
export default InlineAgentWidget

0 commit comments

Comments
 (0)