@salesforce/retail-react-app 10.1.0-nightly-20260708084921 → 10.1.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,5 @@
1
- ## v10.1.0-dev
1
+ ## v10.1.0-preview.0
2
+ - [Feature] Enable customer context for Shopper Agent: on conversation start, call Core's Token Bridge via a same-origin PWA Kit proxy that forwards the shopper's SLAS session (access token in the body for non-HttpOnly mode; both tokens read server-side from `cc-at_*` / `cc-nx_*` cookies in HttpOnly mode) and reset the embedded messaging session on guest ↔ registered transitions so the agent never picks up a stale shopper identity.
2
3
  - [Bugfix] Add explicit `rel="noopener noreferrer"` to external carrier tracking links so the order page URL doesn't leak as the `Referer` (Chakra `isExternal` alone only emits `noopener`).
3
4
  - Bump `vendor.js` bundle-size budget from 397 kB to 398 kB to accommodate the `commerce-sdk-isomorphic` 5.4.0 upgrade.
4
5
  - [Feature] Add item-level order returns on the order detail page. Registered shoppers who own an OMS-managed order can return eligible items (OMS-driven via `quantityAvailableToReturn`) through a modal with per-item quantity and reason selection, inline success/error feedback, and a status badge that reflects return progress (including partially-returned multi-unit lines). [#3904](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3904)
@@ -53,6 +53,12 @@ const OrderTracking = ({
53
53
  if (!value) return null
54
54
  const date = new Date(value)
55
55
  if (isNaN(date.getTime())) return null
56
+ // Defensive guard against an epoch-era SOM sentinel: a truthy date string like
57
+ // "1970-01-01T00:00:00Z" parses to a valid Date and would render "1 Jan 1970".
58
+ // (The `!value` guard above already covers null/0/""; JS parses "0" as year 2000,
59
+ // not the epoch, so this specifically catches truthy 1970-era strings.) SOM does
60
+ // not currently send such a sentinel — this is cheap insurance if it ever does.
61
+ if (date.getUTCFullYear() <= 1970) return null
56
62
  return formatDate(date, {year: 'numeric', month: 'short', day: 'numeric'})
57
63
  }
58
64
 
@@ -154,4 +154,31 @@ describe('OrderTracking component', () => {
154
154
  // The rest of the block still renders.
155
155
  expect(screen.getByText('FedEx Ground')).toBeInTheDocument()
156
156
  })
157
+
158
+ test('omits the date line for a truthy epoch-era sentinel string (no "1 Jan 1970")', () => {
159
+ // A truthy string like "1970-01-01T00:00:00Z" parses to a *valid* Date (unlike
160
+ // null/"" which the falsy check already handles), so without the epoch-year guard
161
+ // it would render "1 Jan 1970" to the shopper. SOM does not currently send such a
162
+ // sentinel, but the guard is cheap insurance if it ever does.
163
+ renderWithProviders(
164
+ <OrderTracking
165
+ {...baseProps}
166
+ expectedDeliveryDate="1970-01-01T00:00:00Z"
167
+ actualDeliveryDate="1970-01-01T00:00:00Z"
168
+ />
169
+ )
170
+ expect(screen.queryByText(/Expected delivery/i)).not.toBeInTheDocument()
171
+ expect(screen.queryByText(/Delivered/i)).not.toBeInTheDocument()
172
+ expect(screen.queryByText(/1970/)).not.toBeInTheDocument()
173
+ // The rest of the block still renders.
174
+ expect(screen.getByText('FedEx Ground')).toBeInTheDocument()
175
+ })
176
+
177
+ test('still renders a legitimate recent date (the epoch guard does not over-suppress)', () => {
178
+ const {container} = renderWithProviders(
179
+ <OrderTracking {...baseProps} expectedDeliveryDate="2026-06-12T00:00:00.000Z" />
180
+ )
181
+ expect(screen.getByText(/Expected delivery/i)).toBeInTheDocument()
182
+ expect(container.textContent).toMatch(/Expected delivery:\s*\d{1,2} Jun 2026/)
183
+ })
157
184
  })
@@ -4,21 +4,40 @@
4
4
  * SPDX-License-Identifier: BSD-3-Clause
5
5
  * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
  */
7
- import React, {useEffect, useMemo} from 'react'
7
+
8
+ import React, {useEffect, useRef, useMemo} from 'react'
9
+ import {defineMessage, useIntl} from 'react-intl'
8
10
  import useScript from '@salesforce/retail-react-app/app/hooks/use-script'
9
- import {useUsid} from '@salesforce/commerce-sdk-react'
11
+ import {
12
+ useAccessToken,
13
+ useConfig,
14
+ useConfigurations,
15
+ useCustomerType,
16
+ useUsid
17
+ } from '@salesforce/commerce-sdk-react'
10
18
  import PropTypes from 'prop-types'
11
19
  import {useTheme} from '@salesforce/retail-react-app/app/components/shared/ui'
12
20
  import useMiaw, {normalizeLocaleToSalesforce} from '@salesforce/retail-react-app/app/hooks/use-miaw'
13
21
  import useCommerceClientMessaging from '@salesforce/retail-react-app/app/hooks/use-commerce-client-messaging'
14
22
  import {DEFAULT_COMMERCE_CLIENT_ELEMENT_ID} from '@salesforce/retail-react-app/app/constants'
15
- import useRefreshToken from '@salesforce/retail-react-app/app/hooks/use-refresh-token'
16
23
  import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
17
24
  import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
18
- import {validateCommerceClientAgentSettings} from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
25
+ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
26
+ import {
27
+ resetEmbeddedMessagingForCommerceSessionChange,
28
+ validateCommerceClientAgentSettings
29
+ } from '@salesforce/retail-react-app/app/utils/shopper-agent-utils'
30
+ import {callTokenBridge} from '@salesforce/retail-react-app/app/components/shopper-agent/token-bridge'
19
31
 
20
32
  const onClient = typeof window !== 'undefined'
21
33
 
34
+ const HTTP_OK = 200
35
+
36
+ const SESSION_INIT_ERROR_MESSAGE = defineMessage({
37
+ id: 'shopper_agent.error.session_init_failed',
38
+ defaultMessage: 'Something went wrong. Try again.'
39
+ })
40
+
22
41
  /**
23
42
  * Validates that a URL is from a trusted Salesforce domain.
24
43
  *
@@ -125,9 +144,11 @@ const isEnabled = (enabled) => {
125
144
  * Key responsibilities:
126
145
  * - Loads the embedded messaging script using useScript hook
127
146
  * - Initializes the MIAW service using useMiaw hook
128
- * - Sets up prechat fields with current locale, currency, and user context
147
+ * - Sets up prechat fields with current locale, currency, and user context on embedded messaging ready
148
+ * - Calls Core's Token Bridge proxy when a conversation starts (`onEmbeddedMessagingConversationStarted`)
129
149
  * - Manages event listeners for messaging lifecycle events
130
150
  * - Handles z-index management for maximized chat windows
151
+ * - On guest ↔ registered Commerce session transitions, resets MIAW (FAB) so shoppers start a fresh agent session
131
152
  * - Cleans up resources on unmount
132
153
  *
133
154
  * @param {Object} props - Component props
@@ -151,19 +172,20 @@ const isEnabled = (enabled) => {
151
172
  * @see {@link useScript} - For script loading functionality
152
173
  * @see {@link useMiaw} - For MIAW initialization
153
174
  * @see {@link useMultiSite} - For locale and currency information
154
- * @see {@link useRefreshToken} - For authentication token
155
175
  * @see {@link useUsid} - For user session identifier
156
176
  */
157
177
  const ShopperAgentWindow = ({commerceAgentConfiguration, domainUrl}) => {
158
178
  // Theme hook for z-index management
159
179
  const theme = useTheme()
160
180
 
181
+ const {formatMessage} = useIntl()
182
+ const toast = useToast()
183
+ const toastRef = useRef(toast)
184
+ toastRef.current = toast
185
+
161
186
  // Multi-site hook for locale and currency information
162
187
  const {locale} = useMultiSite()
163
188
 
164
- // Authentication hook for refresh token
165
- const refreshToken = useRefreshToken()
166
-
167
189
  // Normalize locale to Salesforce language format
168
190
  const sfLanguage = normalizeLocaleToSalesforce(locale.id)
169
191
 
@@ -183,6 +205,60 @@ const ShopperAgentWindow = ({commerceAgentConfiguration, domainUrl}) => {
183
205
 
184
206
  // User session identifier hook
185
207
  const {usid} = useUsid()
208
+ const {customerType} = useCustomerType()
209
+ const {organizationId, siteId: configSiteId} = useConfig()
210
+
211
+ // Fetch my_domain from Shopper Configurations API
212
+ const {data: configurationsData} = useConfigurations({})
213
+ const myDomain = configurationsData?.configurations?.find(
214
+ (config) => config.configurationType === 'globalConfiguration' && config.id === 'my_domain'
215
+ )?.value
216
+
217
+ // SLAS access token — needed to call Core's Token Bridge directly.
218
+ const {getTokenWhenReady} = useAccessToken()
219
+ const getTokenWhenReadyRef = useRef(getTokenWhenReady)
220
+ getTokenWhenReadyRef.current = getTokenWhenReady
221
+
222
+ const prevCommerceCustomerTypeRef = useRef(undefined)
223
+
224
+ /**
225
+ * Reset embedded messaging whenever customerType changes (login, logout, registration).
226
+ * This ensures the chat context is cleared when user authentication state changes.
227
+ */
228
+ useEffect(() => {
229
+ const prev = prevCommerceCustomerTypeRef.current
230
+ prevCommerceCustomerTypeRef.current = customerType
231
+
232
+ // Skip initial mount
233
+ if (prev === undefined) {
234
+ return
235
+ }
236
+
237
+ // Reset on any customerType change (login, logout, register)
238
+ if (prev !== customerType) {
239
+ resetEmbeddedMessagingForCommerceSessionChange()
240
+ }
241
+ }, [customerType])
242
+
243
+ const formatMessageRef = useRef(formatMessage)
244
+ formatMessageRef.current = formatMessage
245
+
246
+ /** Latest values for embedded messaging handlers (stable window listeners). */
247
+ const embeddedLifecycleRef = useRef({})
248
+ embeddedLifecycleRef.current = {
249
+ siteId,
250
+ localeId: locale.id,
251
+ preferredCurrency: locale.preferredCurrency,
252
+ commerceOrgId,
253
+ usid,
254
+ sfLanguage,
255
+ domainUrl,
256
+ organizationId,
257
+ configSiteId,
258
+ myDomain
259
+ }
260
+
261
+ const lastConversationSessionInitRef = useRef(null)
186
262
 
187
263
  /**
188
264
  * Retrieves conversation context data based on configuration.
@@ -284,30 +360,116 @@ const ShopperAgentWindow = ({commerceAgentConfiguration, domainUrl}) => {
284
360
  }
285
361
  }, [])
286
362
 
363
+ /**
364
+ * Register embedded messaging window listeners once. Handlers read latest values from refs so we do not
365
+ * remove/re-add listeners when auth or config updates (which would fight the widget and loop loading).
366
+ */
287
367
  useEffect(() => {
288
- /**
289
- * Sets up hidden prechat fields when the embedded messaging service is ready.
290
- * These fields provide context to the chat agent about the current user session,
291
- * site configuration, and locale settings.
292
- */
293
- const handleEmbeddedMessagingReady = () => {
294
- window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields({
295
- SiteId: siteId,
296
- Locale: locale.id,
297
- OrganizationId: commerceOrgId,
298
- UsId: usid,
368
+ const applyHiddenPrechatFields = () => {
369
+ const bootstrap = window.embeddedservice_bootstrap
370
+ if (!bootstrap?.prechatAPI?.setHiddenPrechatFields) {
371
+ return
372
+ }
373
+ const s = embeddedLifecycleRef.current
374
+ bootstrap.prechatAPI.setHiddenPrechatFields({
375
+ SiteId: s.siteId,
376
+ Locale: s.localeId,
377
+ OrganizationId: s.commerceOrgId,
378
+ UsId: s.usid,
299
379
  IsCartMgmtSupported: 'true',
300
- RefreshToken: refreshToken,
301
- Currency: locale.preferredCurrency,
302
- Language: sfLanguage,
303
- DomainUrl: domainUrl
380
+ Currency: s.preferredCurrency,
381
+ Language: s.sfLanguage,
382
+ DomainUrl: s.domainUrl
304
383
  })
305
384
  }
306
385
 
307
- /**
308
- * Manages z-index for maximized chat windows to ensure proper layering
309
- * above other page elements while maintaining accessibility.
310
- */
386
+ const handleEmbeddedMessagingReady = () => {
387
+ applyHiddenPrechatFields()
388
+ }
389
+
390
+ // Reset the embedded messaging session and surface a localized error
391
+ // toast. Called from every failure branch of the conversation-started
392
+ // flow so the shopper sees consistent feedback and the next attempt
393
+ // starts from a clean state.
394
+ const failSessionInit = () => {
395
+ resetEmbeddedMessagingForCommerceSessionChange()
396
+ toastRef.current({
397
+ title: formatMessageRef.current(SESSION_INIT_ERROR_MESSAGE),
398
+ status: 'error'
399
+ })
400
+ }
401
+
402
+ const handleEmbeddedMessagingConversationStarted = (event) => {
403
+ const {
404
+ organizationId: orgId,
405
+ configSiteId: sid,
406
+ myDomain: domain
407
+ } = embeddedLifecycleRef.current
408
+
409
+ if (!orgId || !sid) return
410
+
411
+ if (!domain) return
412
+
413
+ // Prevents refiring of the event if already call has been done
414
+ const conversationId = event?.detail?.conversationId
415
+ if (!conversationId || typeof conversationId !== 'string' || !conversationId.trim()) {
416
+ return
417
+ }
418
+ const normalizedConversationId = conversationId.trim()
419
+ if (lastConversationSessionInitRef.current === normalizedConversationId) return
420
+ lastConversationSessionInitRef.current = normalizedConversationId
421
+
422
+ const getAuthLinkKey =
423
+ window.embeddedservice_bootstrap?.userVerificationAPI?.getAuthLinkKey
424
+ if (typeof getAuthLinkKey !== 'function') {
425
+ console.error('Shopper Agent: getAuthLinkKey is not available')
426
+ return
427
+ }
428
+
429
+ getAuthLinkKey()
430
+ .then(async (authLinkKey) => {
431
+ // Direct callout to Core's Token Bridge via the same-origin
432
+ // PWA Kit proxy. Replaces the prior postSessionInit SCAPI call.
433
+ try {
434
+ // Check if HttpOnly mode is enabled by reading the flag directly
435
+ // (same source as CommerceApiProvider's enableHttpOnlySessionCookies)
436
+ const isHttpOnly =
437
+ typeof window !== 'undefined'
438
+ ? window.__MRT_ENABLE_HTTPONLY_SESSION_COOKIES__ === 'true'
439
+ : process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES === 'true'
440
+
441
+ // In non-HttpOnly mode, fetch the access token from localStorage
442
+ const slasAccessToken = isHttpOnly
443
+ ? undefined
444
+ : await getTokenWhenReadyRef.current()
445
+
446
+ const result = await callTokenBridge({
447
+ authLinkKey,
448
+ // Only send access token in non-HttpOnly mode (from localStorage)
449
+ // In HttpOnly mode, server reads from cc-at_{siteId} cookie
450
+ slasAccessToken,
451
+ siteId: sid
452
+ })
453
+
454
+ if (result.status !== HTTP_OK) {
455
+ const errorCode = result.body?.error || `HTTP_${result.status}`
456
+ console.error('Token Bridge failed', {
457
+ status: result.status,
458
+ error: errorCode
459
+ })
460
+ failSessionInit()
461
+ }
462
+ } catch (error) {
463
+ console.error('Token Bridge threw', error)
464
+ failSessionInit()
465
+ }
466
+ })
467
+ .catch((error) => {
468
+ console.error('Shopper Agent: getAuthLinkKey failed', error)
469
+ failSessionInit()
470
+ })
471
+ }
472
+
311
473
  const handleEmbeddedMessagingWindowMaximized = () => {
312
474
  const zIndex = theme.zIndices.sticky + 1
313
475
  const embeddedMessagingFrame = document.body.querySelector(
@@ -318,31 +480,31 @@ const ShopperAgentWindow = ({commerceAgentConfiguration, domainUrl}) => {
318
480
  }
319
481
  }
320
482
 
321
- // Set up event listeners for messaging lifecycle events
322
483
  window.addEventListener('onEmbeddedMessagingReady', handleEmbeddedMessagingReady)
484
+ window.addEventListener(
485
+ 'onEmbeddedMessagingConversationStarted',
486
+ handleEmbeddedMessagingConversationStarted
487
+ )
323
488
  window.addEventListener(
324
489
  'onEmbeddedMessagingWindowMaximized',
325
490
  handleEmbeddedMessagingWindowMaximized
326
491
  )
327
492
 
328
- // Cleanup function to remove event listeners on unmount
329
493
  return () => {
330
494
  window.removeEventListener('onEmbeddedMessagingReady', handleEmbeddedMessagingReady)
495
+ window.removeEventListener(
496
+ 'onEmbeddedMessagingConversationStarted',
497
+ handleEmbeddedMessagingConversationStarted
498
+ )
331
499
  window.removeEventListener(
332
500
  'onEmbeddedMessagingWindowMaximized',
333
501
  handleEmbeddedMessagingWindowMaximized
334
502
  )
335
503
  }
336
- }, [
337
- siteId,
338
- locale.id,
339
- locale.preferredCurrency,
340
- commerceOrgId,
341
- usid,
342
- theme.zIndices.sticky,
343
- refreshToken,
344
- domainUrl
345
- ])
504
+ // All dynamic config/auth values are read from embeddedLifecycleRef inside
505
+ // the handlers, so we only re-register listeners when the z-index token
506
+ // (used directly in the maximize handler's DOM mutation) changes.
507
+ }, [theme.zIndices.sticky])
346
508
 
347
509
  // Load the embedded messaging script asynchronously
348
510
  const scriptLoadStatus = useScript(scriptSourceUrl)
@@ -355,7 +517,6 @@ const ShopperAgentWindow = ({commerceAgentConfiguration, domainUrl}) => {
355
517
  embeddedServiceEndpoint,
356
518
  scrt2Url,
357
519
  locale.id,
358
- refreshToken,
359
520
  enableAgentFromFloatingButton
360
521
  )
361
522
 
@@ -579,8 +740,11 @@ const ShopperAgent = ({commerceAgentConfiguration, basketDoneLoading}) => {
579
740
  // Build the current domain URL
580
741
  const domainUrl = `${appOrigin}${buildUrl('')}`
581
742
 
582
- // Only render when the agent is enabled (client-side) and the basket has loaded.
583
- if (!isShopperAgentEnabled || !basketDoneLoading) {
743
+ // Fetch configurations to ensure myDomain is resolved before rendering
744
+ const {isLoading: isConfigurationsLoading} = useConfigurations({})
745
+
746
+ // Only render when the agent is enabled (client-side), the basket has loaded, and configurations API has completed.
747
+ if (!isShopperAgentEnabled || !basketDoneLoading || isConfigurationsLoading) {
584
748
  return null
585
749
  }
586
750