@salesforce/retail-react-app 8.1.0-preview.4 → 8.1.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,4 @@
1
- ## v8.1.0-preview.4 (Sep 23, 2025)
1
+ ## v8.1.0 (Sep 25, 2025)
2
2
  - Updated search UX - prices, images, suggestions new layout [#3271](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3271)
3
3
  - Updated the UI for StoreDisplay component which displays pickup in-store information on different pages. [#3248](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3248)
4
4
  - Added warning modal for guest users when toggling between multi ship and ship to one address. [#3280](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3280) [#3302](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3302)
@@ -44,7 +44,6 @@ const BonusProductViewModal = ({
44
44
  product,
45
45
  isOpen,
46
46
  onClose,
47
- bonusDiscountLineItemId,
48
47
  promotionId,
49
48
  onReturnToSelection,
50
49
  ...props
@@ -70,6 +69,22 @@ const BonusProductViewModal = ({
70
69
  const {data: basket} = useCurrentBasket()
71
70
  const navigate = useNavigation()
72
71
 
72
+ // Extract available bonus product IDs from basket for variant filtering
73
+ const availableBonusProductIds = useMemo(() => {
74
+ if (!basket?.bonusDiscountLineItems || !promotionId) return []
75
+
76
+ return basket.bonusDiscountLineItems
77
+ .filter((item) => item.promotionId === promotionId)
78
+ .flatMap((item) => item.bonusProducts || [])
79
+ .map((bonusProduct) => bonusProduct.productId)
80
+ .filter(Boolean)
81
+ }, [basket, promotionId])
82
+
83
+ // Check if we have promotion data to work with
84
+ const hasPromotionData = useMemo(() => {
85
+ return !!promotionId
86
+ }, [promotionId])
87
+
73
88
  const intl = useIntl()
74
89
  const {formatMessage} = intl
75
90
  const showToast = useToast()
@@ -222,31 +237,135 @@ const BonusProductViewModal = ({
222
237
  [messages.viewCart, handleViewCart]
223
238
  )
224
239
 
225
- // Clean product data but preserve variation attributes for size/color selectors
240
+ // Clean product data and pre-filter variants based on available bonus products
226
241
  const productToRender = useMemo(() => {
227
242
  const baseProduct = productViewModalData.product || safeProduct
228
- return {
243
+
244
+ // Always provide a fallback product for testing scenarios
245
+ if (!baseProduct) {
246
+ return null
247
+ }
248
+
249
+ // If no promotion data, just return the basic product without filtering
250
+ if (!hasPromotionData) {
251
+ return {
252
+ ...baseProduct,
253
+ variationAttributes: baseProduct.variationAttributes,
254
+ variants: baseProduct.variants,
255
+ variationParams: baseProduct.variationParams,
256
+ selectedVariationAttributes: baseProduct.selectedVariationAttributes,
257
+ type: baseProduct.type,
258
+ inventory: {
259
+ ...baseProduct.inventory,
260
+ orderable: true,
261
+ stockLevel: 999
262
+ },
263
+ minOrderQuantity: 1,
264
+ stepQuantity: 1,
265
+ orderable: true,
266
+ rating: baseProduct.rating,
267
+ reviewCount: baseProduct.reviewCount
268
+ }
269
+ }
270
+
271
+ // If no bonus products are available, still render but with original product
272
+ if (availableBonusProductIds.length === 0) {
273
+ return {
274
+ ...baseProduct,
275
+ variationAttributes: baseProduct.variationAttributes,
276
+ variants: baseProduct.variants,
277
+ variationParams: baseProduct.variationParams,
278
+ selectedVariationAttributes: baseProduct.selectedVariationAttributes,
279
+ type: baseProduct.type,
280
+ inventory: {
281
+ ...baseProduct.inventory,
282
+ orderable: true,
283
+ stockLevel: 999
284
+ },
285
+ minOrderQuantity: 1,
286
+ stepQuantity: 1,
287
+ orderable: true,
288
+ rating: baseProduct.rating,
289
+ reviewCount: baseProduct.reviewCount
290
+ }
291
+ }
292
+
293
+ // Check if we should filter variants
294
+ // Only treat it as base product ID if it's NOT found in the variants array
295
+ const isBaseProductId = !baseProduct.variants?.some((v) => v.productId === baseProduct.id)
296
+ const hasBaseProductId =
297
+ isBaseProductId && availableBonusProductIds.includes(baseProduct.id)
298
+ const hasVariantIds = availableBonusProductIds.some((id) =>
299
+ baseProduct.variants?.some((v) => v.productId === id)
300
+ )
301
+
302
+ let filteredVariants = baseProduct.variants || []
303
+ let filteredVariationAttributes = baseProduct.variationAttributes || []
304
+
305
+ // If we have specific variant IDs (not base product), filter variants and variation attributes
306
+ if (hasVariantIds && !hasBaseProductId) {
307
+ // Filter variants to only include available ones
308
+ filteredVariants =
309
+ baseProduct.variants?.filter((variant) =>
310
+ availableBonusProductIds.includes(variant.productId)
311
+ ) || []
312
+
313
+ // Filter variation attribute values to only show available combinations
314
+ filteredVariationAttributes =
315
+ baseProduct.variationAttributes
316
+ ?.map((attr) => {
317
+ const availableValues =
318
+ attr.values?.filter((value) => {
319
+ // Check if this value leads to an available variant
320
+ return filteredVariants.some(
321
+ (variant) => variant.variationValues?.[attr.id] === value.value
322
+ )
323
+ }) || []
324
+
325
+ return {
326
+ ...attr,
327
+ values: availableValues
328
+ }
329
+ })
330
+ .filter((attr) => attr.values.length > 0) || []
331
+ }
332
+
333
+ // KEY FIX: Ensure the correct variant is pre-selected to prevent flash
334
+ // When we have only one available variant, make sure it's the selected one
335
+ let finalProduct = {
229
336
  ...baseProduct,
230
- variationAttributes: baseProduct.variationAttributes,
231
- variants: baseProduct.variants,
337
+ variationAttributes: filteredVariationAttributes,
338
+ variants: filteredVariants,
232
339
  variationParams: baseProduct.variationParams,
233
340
  selectedVariationAttributes: baseProduct.selectedVariationAttributes,
234
341
  type: baseProduct.type,
235
- // Ensure proper inventory and quantity defaults for bonus products
236
342
  inventory: {
237
343
  ...baseProduct.inventory,
238
344
  orderable: true,
239
- stockLevel: 999 // High stock level for bonus products
345
+ stockLevel: 999
240
346
  },
241
347
  minOrderQuantity: 1,
242
348
  stepQuantity: 1,
243
- // Ensure the product is orderable
244
349
  orderable: true,
245
- // Add review data for display
246
350
  rating: baseProduct.rating,
247
351
  reviewCount: baseProduct.reviewCount
248
352
  }
249
- }, [productViewModalData.product, safeProduct])
353
+
354
+ // If we filtered to only one variant, ensure it's pre-selected
355
+ if (filteredVariants.length === 1 && filteredVariants[0].variationValues) {
356
+ const selectedVariant = filteredVariants[0]
357
+ finalProduct = {
358
+ ...finalProduct,
359
+ // Override the product ID to be the selected variant
360
+ id: selectedVariant.productId,
361
+ // Pre-set the variation values to match the selected variant
362
+ selectedVariant,
363
+ variationValues: selectedVariant.variationValues
364
+ }
365
+ }
366
+
367
+ return finalProduct
368
+ }, [productViewModalData.product, safeProduct, hasPromotionData, availableBonusProductIds])
250
369
 
251
370
  // Calculate max order quantity for UI
252
371
  const maxOrderQuantity = getRemainingBonusQuantity()
@@ -308,7 +427,8 @@ const BonusProductViewModal = ({
308
427
  }
309
428
  pb={productViewModalTheme.layout.body.paddingBottom}
310
429
  >
311
- {productViewModalData.isFetching && !productViewModalData.product ? (
430
+ {(productViewModalData.isFetching && !productViewModalData.product) ||
431
+ !productToRender ? (
312
432
  <Box p={8} textAlign="center">
313
433
  <Text>Loading product details...</Text>
314
434
  </Box>
@@ -348,7 +468,6 @@ BonusProductViewModal.propTypes = {
348
468
  onClose: PropTypes.func.isRequired,
349
469
  product: PropTypes.object,
350
470
  isLoading: PropTypes.bool,
351
- bonusDiscountLineItemId: PropTypes.string, // The 'id' from bonusDiscountLineItems
352
471
  promotionId: PropTypes.string, // The promotion ID to filter promotions in PromoCallout
353
472
  onReturnToSelection: PropTypes.func // Callback to return to SelectBonusProductModal
354
473
  }
@@ -1019,3 +1019,170 @@ describe('BonusProductViewModal - Quantity Distribution Across Multiple BonusDis
1019
1019
  })
1020
1020
  })
1021
1021
  })
1022
+
1023
+ describe('BonusProductViewModal - Variant Filtering Integration Tests', () => {
1024
+ const mockOnClose = jest.fn()
1025
+
1026
+ beforeEach(() => {
1027
+ jest.clearAllMocks()
1028
+
1029
+ // Setup default working mocks that don't conflict with our tests
1030
+ useShopperBasketsMutationHelper.mockReturnValue({
1031
+ addItemToNewOrExistingBasket: jest.fn()
1032
+ })
1033
+
1034
+ useBonusProductCounts.mockReturnValue({
1035
+ finalSelectedBonusItems: 1,
1036
+ finalMaxBonusItems: 3
1037
+ })
1038
+
1039
+ getRemainingAvailableBonusProductsForProduct.mockReturnValue(2)
1040
+ })
1041
+
1042
+ test('renders modal successfully with variant filtering enabled', () => {
1043
+ // Basic integration test: Modal renders without errors with filtering logic
1044
+ const mockBasket = {
1045
+ bonusDiscountLineItems: [
1046
+ {
1047
+ promotionId: 'test-promo',
1048
+ bonusProducts: [
1049
+ {productId: '793775370033M'} // Specific variant
1050
+ ]
1051
+ }
1052
+ ],
1053
+ productItems: []
1054
+ }
1055
+
1056
+ const mockProduct = {
1057
+ id: '793775370033',
1058
+ name: 'Test Product',
1059
+ variants: [
1060
+ {productId: '793775370033M', variationValues: {color: 'turquoise'}},
1061
+ {productId: '793775370033R', variationValues: {color: 'red'}}
1062
+ ],
1063
+ variationAttributes: [
1064
+ {
1065
+ id: 'color',
1066
+ values: [
1067
+ {value: 'turquoise', name: 'Turquoise'},
1068
+ {value: 'red', name: 'Red'}
1069
+ ]
1070
+ }
1071
+ ]
1072
+ }
1073
+
1074
+ useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
1075
+ useProductViewModal.mockReturnValue({product: mockProduct, isFetching: false})
1076
+
1077
+ expect(() => {
1078
+ renderWithProviders(
1079
+ <BonusProductViewModal
1080
+ product={mockProduct}
1081
+ isOpen={true}
1082
+ onClose={mockOnClose}
1083
+ promotionId="test-promo"
1084
+ />
1085
+ )
1086
+ }).not.toThrow()
1087
+
1088
+ // Verify modal renders
1089
+ expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()
1090
+ })
1091
+
1092
+ test('handles edge cases without errors', () => {
1093
+ // Edge case test: No bonus products available
1094
+ const mockBasket = {
1095
+ bonusDiscountLineItems: [
1096
+ {
1097
+ promotionId: 'different-promo',
1098
+ bonusProducts: []
1099
+ }
1100
+ ],
1101
+ productItems: []
1102
+ }
1103
+
1104
+ const mockProduct = {
1105
+ id: '793775370033',
1106
+ name: 'Test Product',
1107
+ variants: [],
1108
+ variationAttributes: []
1109
+ }
1110
+
1111
+ useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
1112
+ useProductViewModal.mockReturnValue({product: mockProduct, isFetching: false})
1113
+
1114
+ expect(() => {
1115
+ renderWithProviders(
1116
+ <BonusProductViewModal
1117
+ product={mockProduct}
1118
+ isOpen={true}
1119
+ onClose={mockOnClose}
1120
+ promotionId="test-promo"
1121
+ />
1122
+ )
1123
+ }).not.toThrow()
1124
+
1125
+ expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()
1126
+ })
1127
+
1128
+ test('handles missing bonus data gracefully', () => {
1129
+ // Edge case test: No bonusDiscountLineItems
1130
+ const mockBasket = {productItems: []}
1131
+ const mockProduct = {id: '123', name: 'Test'}
1132
+
1133
+ useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
1134
+ useProductViewModal.mockReturnValue({product: mockProduct, isFetching: false})
1135
+
1136
+ expect(() => {
1137
+ renderWithProviders(
1138
+ <BonusProductViewModal
1139
+ product={mockProduct}
1140
+ isOpen={true}
1141
+ onClose={mockOnClose}
1142
+ promotionId="test-promo"
1143
+ />
1144
+ )
1145
+ }).not.toThrow()
1146
+ })
1147
+
1148
+ test('filtering logic processes complex variant scenarios', () => {
1149
+ // Complex scenario test: Mixed base and variant IDs
1150
+ const mockBasket = {
1151
+ bonusDiscountLineItems: [
1152
+ {
1153
+ promotionId: 'test-promo',
1154
+ bonusProducts: [
1155
+ {productId: '793775370033'}, // Base product
1156
+ {productId: '793775370033M'}, // Variant
1157
+ {productId: '793775370033R'} // Another variant
1158
+ ]
1159
+ }
1160
+ ]
1161
+ }
1162
+
1163
+ const mockProduct = {
1164
+ id: '793775370033',
1165
+ variants: [
1166
+ {productId: '793775370033M', variationValues: {color: 'turquoise'}},
1167
+ {productId: '793775370033R', variationValues: {color: 'red'}},
1168
+ {productId: '793775370033B', variationValues: {color: 'blue'}}
1169
+ ],
1170
+ variationAttributes: [{id: 'color', values: []}]
1171
+ }
1172
+
1173
+ useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
1174
+ useProductViewModal.mockReturnValue({product: mockProduct, isFetching: false})
1175
+
1176
+ // Should handle complex filtering without errors
1177
+ expect(() => {
1178
+ renderWithProviders(
1179
+ <BonusProductViewModal
1180
+ product={mockProduct}
1181
+ isOpen={true}
1182
+ onClose={mockOnClose}
1183
+ promotionId="test-promo"
1184
+ />
1185
+ )
1186
+ }).not.toThrow()
1187
+ })
1188
+ })
@@ -74,10 +74,14 @@ const ProductItem = ({
74
74
  </Box>
75
75
  </HideOnDesktop>
76
76
  </Stack>
77
- {deliveryActions && <HideOnMobile>{deliveryActions}</HideOnMobile>}
77
+ {deliveryActions && !product.bonusProductLineItem && (
78
+ <HideOnMobile>{deliveryActions}</HideOnMobile>
79
+ )}
78
80
  </Flex>
79
81
 
80
- {deliveryActions && <HideOnDesktop>{deliveryActions}</HideOnDesktop>}
82
+ {deliveryActions && !product.bonusProductLineItem && (
83
+ <HideOnDesktop>{deliveryActions}</HideOnDesktop>
84
+ )}
81
85
 
82
86
  <Flex align="flex-end" justify="space-between">
83
87
  <Stack spacing={1}>
@@ -103,4 +103,41 @@ describe('ProductItem Component', () => {
103
103
  expect(screen.getByText(/Quantity:/i)).toBeInTheDocument()
104
104
  expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
105
105
  })
106
+
107
+ test('does not render delivery actions for bonus products', () => {
108
+ renderWithProviders(
109
+ <MockedComponent
110
+ product={mockBonusProduct}
111
+ deliveryActions={<button>Delivery Action</button>}
112
+ />
113
+ )
114
+
115
+ expect(screen.queryByText(/Delivery Action/i)).not.toBeInTheDocument()
116
+ })
117
+
118
+ test('renders delivery actions for regular products but not bonus products', () => {
119
+ // Test regular product first
120
+ const {unmount} = renderWithProviders(
121
+ <MockedComponent
122
+ product={mockProduct}
123
+ deliveryActions={<button>Delivery Action</button>}
124
+ />
125
+ )
126
+
127
+ // Regular product should show delivery actions (appears twice - mobile and desktop)
128
+ expect(screen.getAllByText(/Delivery Action/i)).toHaveLength(2)
129
+
130
+ // Cleanup completely
131
+ unmount()
132
+
133
+ // Test bonus product with fresh render
134
+ renderWithProviders(
135
+ <MockedComponent
136
+ product={mockBonusProduct}
137
+ deliveryActions={<button>Delivery Action</button>}
138
+ />
139
+ )
140
+
141
+ expect(screen.queryAllByText(/Delivery Action/i)).toHaveLength(0)
142
+ })
106
143
  })
@@ -42,6 +42,12 @@ import {
42
42
  } from '@salesforce/retail-react-app/app/utils/url'
43
43
  import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
44
44
  import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
45
+ import {useUsid} from '@salesforce/commerce-sdk-react'
46
+ import {useLocation} from 'react-router-dom'
47
+ import useRefreshToken from '@salesforce/retail-react-app/app/hooks/use-refresh-token'
48
+ import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
49
+ import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
50
+ import {normalizeLocaleToSalesforce} from '@salesforce/retail-react-app/app/hooks/use-miaw'
45
51
 
46
52
  const onClient = typeof window !== 'undefined'
47
53
 
@@ -106,6 +112,15 @@ const formatSuggestions = (searchSuggestions) => {
106
112
  */
107
113
  const Search = (props) => {
108
114
  const config = getConfig()
115
+
116
+ // Add new hooks for chat functionality
117
+ const {locale, siteId, commerceOrgId, buildUrl} = useMultiSite()
118
+ const {usid} = useUsid()
119
+ const refreshToken = useRefreshToken()
120
+ const location = useLocation()
121
+ const appOrigin = useAppOrigin()
122
+ const sfLanguage = normalizeLocaleToSalesforce(locale.id)
123
+
109
124
  const askAgentOnSearchEnabled = useMemo(() => {
110
125
  const {enabled, askAgentOnSearch} = getCommerceAgentConfig()
111
126
  return isAskAgentOnSearchEnabled(enabled, askAgentOnSearch)
@@ -183,6 +198,26 @@ const Search = (props) => {
183
198
  setIsOpen(false)
184
199
  }
185
200
 
201
+ // Function to set pre-chat fields only when launching a new chat session
202
+ const setPrechatFieldsForNewSession = () => {
203
+ // Only set pre-chat fields if this is a new chat launch (not already launched)
204
+ if (!miawChatRef.current.newChatLaunched) {
205
+ if (window.embeddedservice_bootstrap?.prechatAPI) {
206
+ window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields({
207
+ SiteId: siteId,
208
+ Locale: locale.id,
209
+ OrganizationId: commerceOrgId,
210
+ UsId: usid,
211
+ IsCartMgmtSupported: 'true',
212
+ RefreshToken: refreshToken,
213
+ Currency: locale.preferredCurrency,
214
+ Language: sfLanguage,
215
+ DomainUrl: `${appOrigin}${buildUrl(location.pathname)}`
216
+ })
217
+ }
218
+ }
219
+ }
220
+
186
221
  useEffect(() => {
187
222
  const handleEmbeddedMessageSent = (e) => {
188
223
  if (!miawChatRef.current.hasFired && miawChatRef.current.newChatLaunched) {
@@ -209,6 +244,9 @@ const Search = (props) => {
209
244
  }
210
245
  }, [])
211
246
  const launchChat = () => {
247
+ // Set pre-chat fields only for new sessions
248
+ setPrechatFieldsForNewSession()
249
+
212
250
  if (window.embeddedservice_bootstrap?.settings) {
213
251
  window.embeddedservice_bootstrap.settings.disableStreamingResponses = true
214
252
  window.embeddedservice_bootstrap.settings.enableUserInputForConversationWithBot = false