@salesforce/retail-react-app 7.1.0-preview.0 → 8.0.0-dev

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +8 -4
  2. package/app/components/_app/index.jsx +9 -7
  3. package/app/components/_app/index.test.js +2 -2
  4. package/app/components/_app-config/index.jsx +9 -3
  5. package/app/components/drawer-menu/drawer-menu.jsx +3 -1
  6. package/app/components/footer/index.jsx +3 -1
  7. package/app/components/header/index.jsx +3 -1
  8. package/app/components/header/index.test.js +2 -2
  9. package/app/components/island/README.md +1 -1
  10. package/app/components/island/index.jsx +3 -1
  11. package/app/components/island/index.test.js +94 -5
  12. package/app/components/item-variant/item-attributes.jsx +12 -3
  13. package/app/components/multiship/multiship-order-summary.jsx +137 -0
  14. package/app/components/multiship/multiship-order-summary.test.js +121 -0
  15. package/app/components/order-summary/index.jsx +2 -4
  16. package/app/components/pickup-or-delivery/index.jsx +80 -0
  17. package/app/components/pickup-or-delivery/index.test.jsx +182 -0
  18. package/app/components/product-item/index.jsx +26 -16
  19. package/app/components/product-item/index.test.js +29 -2
  20. package/app/components/product-item-list/index.jsx +10 -0
  21. package/app/components/product-item-list/index.test.jsx +14 -0
  22. package/app/components/product-view/index.jsx +9 -6
  23. package/app/components/product-view/index.test.js +25 -21
  24. package/app/components/quantity-picker/index.test.jsx +12 -12
  25. package/app/components/reset-password/index.test.js +1 -1
  26. package/app/components/shared/ui/AlertDescription/index.jsx +8 -0
  27. package/app/components/shared/ui/index.jsx +1 -0
  28. package/app/components/store-display/index.jsx +28 -4
  29. package/app/components/store-display/index.test.js +71 -0
  30. package/app/components/store-locator/form.test.jsx +16 -4
  31. package/app/components/store-locator/list.jsx +9 -4
  32. package/app/components/toggle-card/index.jsx +14 -0
  33. package/app/components/unavailable-product-confirmation-modal/index.jsx +19 -5
  34. package/app/components/unavailable-product-confirmation-modal/index.test.js +122 -1
  35. package/app/constants.js +20 -6
  36. package/app/contexts/store-locator-provider.jsx +7 -1
  37. package/app/contexts/store-locator-provider.test.jsx +36 -1
  38. package/app/hooks/use-address-form.js +155 -0
  39. package/app/hooks/use-address-form.test.js +501 -0
  40. package/app/hooks/use-auth-modal.js +2 -6
  41. package/app/hooks/use-current-basket.js +71 -2
  42. package/app/hooks/use-current-basket.test.js +37 -1
  43. package/app/hooks/use-dnt-notification.js +4 -4
  44. package/app/hooks/use-dnt-notification.test.js +5 -5
  45. package/app/hooks/use-item-shipment-management.js +233 -0
  46. package/app/hooks/use-item-shipment-management.test.js +696 -0
  47. package/app/hooks/use-multiship.js +589 -0
  48. package/app/hooks/use-multiship.test.js +776 -0
  49. package/app/hooks/use-pickup-shipment.js +70 -106
  50. package/app/hooks/use-pickup-shipment.test.js +345 -209
  51. package/app/hooks/use-product-address-assignment.js +280 -0
  52. package/app/hooks/use-product-address-assignment.test.js +414 -0
  53. package/app/hooks/use-product-inventory.js +100 -0
  54. package/app/hooks/use-product-inventory.test.js +254 -0
  55. package/app/hooks/use-shipment-operations.js +168 -0
  56. package/app/hooks/use-shipment-operations.test.js +385 -0
  57. package/app/hooks/use-store-locator.js +24 -2
  58. package/app/hooks/use-store-locator.test.jsx +109 -1
  59. package/app/pages/account/index.test.js +1 -1
  60. package/app/pages/account/profile.test.js +0 -2
  61. package/app/pages/cart/index.jsx +397 -157
  62. package/app/pages/cart/index.test.js +353 -2
  63. package/app/pages/cart/partials/bonus-products-title.jsx +10 -8
  64. package/app/pages/cart/partials/cart-secondary-button-group.test.js +1 -1
  65. package/app/pages/cart/partials/order-type-display.jsx +68 -0
  66. package/app/pages/cart/partials/order-type-display.test.js +241 -0
  67. package/app/pages/checkout/confirmation.jsx +79 -158
  68. package/app/pages/checkout/index.jsx +34 -9
  69. package/app/pages/checkout/index.test.js +245 -118
  70. package/app/pages/checkout/partials/contact-info.jsx +2 -6
  71. package/app/pages/checkout/partials/contact-info.test.js +93 -7
  72. package/app/pages/checkout/partials/payment.jsx +19 -5
  73. package/app/pages/checkout/partials/pickup-address.jsx +340 -70
  74. package/app/pages/checkout/partials/pickup-address.test.js +1075 -82
  75. package/app/pages/checkout/partials/product-shipping-address-card.jsx +382 -0
  76. package/app/pages/checkout/partials/shipment-details.jsx +209 -0
  77. package/app/pages/checkout/partials/shipment-details.test.js +246 -0
  78. package/app/pages/checkout/partials/shipping-address.jsx +156 -68
  79. package/app/pages/checkout/partials/shipping-address.test.js +673 -0
  80. package/app/pages/checkout/partials/shipping-method-options.jsx +180 -0
  81. package/app/pages/checkout/partials/shipping-methods.jsx +403 -0
  82. package/app/pages/checkout/partials/shipping-methods.test.js +472 -0
  83. package/app/pages/checkout/partials/shipping-multi-address.jsx +259 -0
  84. package/app/pages/checkout/partials/shipping-multi-address.test.js +2088 -0
  85. package/app/pages/checkout/partials/shipping-product-cards.jsx +101 -0
  86. package/app/pages/checkout/util/checkout-context.js +25 -18
  87. package/app/pages/login/index.jsx +2 -6
  88. package/app/pages/product-detail/index.jsx +96 -81
  89. package/app/pages/product-detail/index.test.js +103 -19
  90. package/app/pages/product-list/index.jsx +3 -1
  91. package/app/pages/product-list/partials/inventory-filter.jsx +18 -21
  92. package/app/pages/product-list/partials/inventory-filter.test.js +15 -17
  93. package/app/pages/product-list/partials/selected-refinements.jsx +3 -1
  94. package/app/ssr.js +1 -5
  95. package/app/static/translations/compiled/en-GB.json +316 -30
  96. package/app/static/translations/compiled/en-US.json +316 -30
  97. package/app/static/translations/compiled/en-XA.json +673 -75
  98. package/app/utils/address-utils.js +112 -0
  99. package/app/utils/address-utils.test.js +484 -0
  100. package/app/utils/product-utils.js +17 -5
  101. package/app/utils/product-utils.test.js +17 -8
  102. package/app/utils/sfdc-user-agent-utils.js +32 -0
  103. package/app/utils/sfdc-user-agent-utils.test.js +82 -0
  104. package/app/utils/shipment-utils.js +196 -0
  105. package/app/utils/shipment-utils.test.js +458 -0
  106. package/app/utils/test-utils.js +4 -4
  107. package/app/utils/utils.js +6 -1
  108. package/config/default.js +4 -1
  109. package/config/mocks/default.js +3 -1
  110. package/package.json +9 -9
  111. package/translations/en-GB.json +127 -10
  112. package/translations/en-US.json +127 -10
  113. package/app/pages/checkout/partials/shipping-options.jsx +0 -269
@@ -90,7 +90,7 @@ test('ProductView Component renders properly', async () => {
90
90
  expect(screen.getAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)).toHaveLength(2)
91
91
  expect(screen.getAllByText(/299\.99/)).toHaveLength(4)
92
92
  expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2)
93
- expect(screen.getAllByRole('radiogroup')).toHaveLength(4)
93
+ expect(screen.getAllByRole('radiogroup')).toHaveLength(3)
94
94
  expect(screen.getAllByText(/add to cart/i)).toHaveLength(2)
95
95
  })
96
96
 
@@ -365,7 +365,7 @@ describe('Product Sets', () => {
365
365
  name: /add set to wishlist/i
366
366
  })[0]
367
367
  const variationAttributes = screen
368
- .getAllByRole('radiogroup')
368
+ .queryAllByRole('radiogroup')
369
369
  .filter(
370
370
  (rg) =>
371
371
  !rg.textContent.includes('Ship to Address') &&
@@ -395,7 +395,7 @@ describe('Product Sets', () => {
395
395
  const addToCartButton = screen.getAllByRole('button', {name: /add to cart/i})[0]
396
396
  const addToWishlistButton = screen.getAllByRole('button', {name: /add to wishlist/i})[0]
397
397
  const variationAttributes = screen
398
- .getAllByRole('radiogroup')
398
+ .queryAllByRole('radiogroup')
399
399
  .filter(
400
400
  (rg) =>
401
401
  !rg.textContent.includes('Ship to Address') &&
@@ -433,7 +433,7 @@ describe('Product Bundles', () => {
433
433
  })[0]
434
434
  const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i})
435
435
  const variationAttributes = screen
436
- .getAllByRole('radiogroup')
436
+ .queryAllByRole('radiogroup')
437
437
  .filter(
438
438
  (rg) =>
439
439
  !rg.textContent.includes('Ship to Address') &&
@@ -464,7 +464,7 @@ describe('Product Bundles', () => {
464
464
  const addToCartButton = screen.queryByRole('button', {name: /add to cart/i})
465
465
  const addToWishlistButton = screen.queryByRole('button', {name: /add to wishlist/i})
466
466
  const variationAttributes = screen
467
- .getAllByRole('radiogroup')
467
+ .queryAllByRole('radiogroup')
468
468
  .filter(
469
469
  (rg) =>
470
470
  !rg.textContent.includes('Ship to Address') &&
@@ -488,7 +488,7 @@ describe('Product Bundles', () => {
488
488
  inventories: [{id: mockStoreData.inventoryId, orderable: true, stockLevel: 10}]
489
489
  }
490
490
 
491
- renderWithProviders(<MockComponent product={mockProduct} />)
491
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
492
492
 
493
493
  // Assert: Radio is enabled
494
494
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
@@ -496,7 +496,9 @@ describe('Product Bundles', () => {
496
496
  })
497
497
 
498
498
  test('Pickup in store radio is disabled when inventoryId is NOT present in localStorage', async () => {
499
- renderWithProviders(<MockComponent product={mockProductDetail} />)
499
+ renderWithProviders(
500
+ <MockComponent product={mockProductDetail} showDeliveryOptions={true} />
501
+ )
500
502
 
501
503
  // Assert: Radio is disabled
502
504
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
@@ -512,7 +514,7 @@ describe('Product Bundles', () => {
512
514
  inventories: [{id: mockStoreData.inventoryId, orderable: false}]
513
515
  }
514
516
 
515
- renderWithProviders(<MockComponent product={mockProduct} />)
517
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
516
518
 
517
519
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
518
520
  // Chakra UI does not set a semantic disabled attribute, so we test for unclickability
@@ -529,7 +531,9 @@ describe('Product Bundles', () => {
529
531
  hasSelectedStore: false
530
532
  })
531
533
 
532
- renderWithProviders(<MockComponent product={mockProductDetail} />)
534
+ renderWithProviders(
535
+ <MockComponent product={mockProductDetail} showDeliveryOptions={true} />
536
+ )
533
537
 
534
538
  const label = await screen.findByTestId('pickup-select-store-msg')
535
539
  expect(label).toBeInTheDocument()
@@ -548,7 +552,7 @@ describe('Product Bundles', () => {
548
552
  inventories: [{id: mockStoreData.inventoryId, orderable: true, stockLevel: 10}],
549
553
  name: 'Test Product'
550
554
  }
551
- renderWithProviders(<MockComponent product={mockProduct} />)
555
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
552
556
  const msg = await screen.findByText(/In Stock at/i)
553
557
  expect(msg).toBeInTheDocument()
554
558
  expect(msg).toHaveTextContent(storeName)
@@ -564,7 +568,7 @@ describe('Product Bundles', () => {
564
568
  inventories: [{id: mockStoreData.inventoryId, orderable: false}],
565
569
  name: 'Test Product'
566
570
  }
567
- renderWithProviders(<MockComponent product={mockProduct} />)
571
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
568
572
  const msg = await screen.findByText(/Out of Stock at/i)
569
573
  expect(msg).toBeInTheDocument()
570
574
  expect(msg).toHaveTextContent(storeName)
@@ -600,13 +604,13 @@ describe('Product Bundles', () => {
600
604
  expect(screen.queryByTestId('pickup-select-store-msg')).not.toBeInTheDocument()
601
605
  })
602
606
 
603
- test('shows delivery options when showDeliveryOptions is not provided (defaults to true)', async () => {
607
+ test('hides delivery options when showDeliveryOptions is not provided (defaults to false)', async () => {
604
608
  renderWithProviders(<MockComponent product={mockProductDetail} />)
605
609
 
606
- // Delivery options should be visible by default
607
- expect(screen.getByText(/Delivery:/i)).toBeInTheDocument()
608
- expect(screen.getByRole('radio', {name: /ship to address/i})).toBeInTheDocument()
609
- expect(screen.getByRole('radio', {name: /pick up in store/i})).toBeInTheDocument()
610
+ // Delivery options should not be visible by default
611
+ expect(screen.queryByText(/Delivery:/i)).not.toBeInTheDocument()
612
+ expect(screen.queryByRole('radio', {name: /ship to address/i})).not.toBeInTheDocument()
613
+ expect(screen.queryByRole('radio', {name: /pick up in store/i})).not.toBeInTheDocument()
610
614
  })
611
615
  })
612
616
  })
@@ -619,7 +623,7 @@ test('Pick up in store radio is enabled when selected store is set', async () =>
619
623
  inventories: [{id: mockStoreData.inventoryId, orderable: true, stockLevel: 10}]
620
624
  }
621
625
 
622
- renderWithProviders(<MockComponent product={mockProduct} />)
626
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
623
627
 
624
628
  // Assert: Radio is enabled
625
629
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
@@ -627,7 +631,7 @@ test('Pick up in store radio is enabled when selected store is set', async () =>
627
631
  })
628
632
 
629
633
  test('Pick up in store radio is disabled when inventoryId is NOT present in selected store', async () => {
630
- renderWithProviders(<MockComponent product={mockProductDetail} />)
634
+ renderWithProviders(<MockComponent product={mockProductDetail} showDeliveryOptions={true} />)
631
635
 
632
636
  // Assert: Radio is disabled
633
637
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
@@ -643,7 +647,7 @@ test('Pick up in store radio is disabled when inventoryId is present but product
643
647
  inventories: [{id: mockStoreData.inventoryId, orderable: false}]
644
648
  }
645
649
 
646
- renderWithProviders(<MockComponent product={mockProduct} />)
650
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
647
651
 
648
652
  const pickupRadio = await screen.findByRole('radio', {name: /pick up in store/i})
649
653
  // Chakra UI does not set a semantic disabled attribute, so we test for unclickability
@@ -660,7 +664,7 @@ test('shows "Pick up in Select Store" label when pickup is disabled due to no st
660
664
  hasSelectedStore: false
661
665
  })
662
666
 
663
- renderWithProviders(<MockComponent product={mockProductDetail} />)
667
+ renderWithProviders(<MockComponent product={mockProductDetail} showDeliveryOptions={true} />)
664
668
 
665
669
  const label = await screen.findByTestId('pickup-select-store-msg')
666
670
  expect(label).toBeInTheDocument()
@@ -676,7 +680,7 @@ test('shows "In stock at {storeName}" when store has inventory', async () => {
676
680
  inventories: [{id: mockStoreData.inventoryId, orderable: true, stockLevel: 10}]
677
681
  }
678
682
 
679
- renderWithProviders(<MockComponent product={mockProduct} />)
683
+ renderWithProviders(<MockComponent product={mockProduct} showDeliveryOptions={true} />)
680
684
  const msg = await screen.findByText(/In stock at/i)
681
685
  expect(msg).toBeInTheDocument()
682
686
  expect(msg).toHaveTextContent('Test Store')
@@ -21,36 +21,35 @@ describe('QuantityPicker', () => {
21
21
  test('clicking plus increments value', async () => {
22
22
  const user = userEvent.setup()
23
23
  renderWithProviders(<MockComponent />)
24
- const input = screen.getByRole('spinbutton')
25
24
  const button = screen.getByText('+')
26
25
  await user.click(button)
26
+ const input = screen.getByRole('spinbutton')
27
27
  expect(input.value).toBe('6')
28
28
  })
29
29
  test('clicking minus decrements value', async () => {
30
30
  const user = userEvent.setup()
31
31
  renderWithProviders(<MockComponent />)
32
- const input = screen.getByRole('spinbutton')
33
32
  const button = screen.getByText(MINUS)
34
33
  await user.click(button)
34
+ const input = screen.getByRole('spinbutton')
35
35
  expect(input.value).toBe('4')
36
36
  })
37
37
  test('typing enter/space on plus increments value', async () => {
38
38
  const user = userEvent.setup()
39
39
  renderWithProviders(<MockComponent />)
40
- const input = screen.getByRole('spinbutton')
41
40
  const button = screen.getByText('+')
42
41
  await user.type(button, '{enter}')
42
+ const input = screen.getByRole('spinbutton')
43
43
  expect(input.value).toBe('6')
44
44
  await user.type(button, '{space}')
45
45
  expect(input.value).toBe('7')
46
46
  })
47
47
 
48
48
  test('keydown enter/space on plus increments value', async () => {
49
- const user = userEvent.setup()
50
49
  renderWithProviders(<MockComponent />)
51
- const input = screen.getByRole('spinbutton')
52
50
  const button = screen.getByText('+')
53
51
  fireEvent.keyDown(button, {key: 'Enter'})
52
+ const input = screen.getByRole('spinbutton')
54
53
  expect(input.value).toBe('6')
55
54
  fireEvent.keyDown(button, {key: ' '})
56
55
  expect(input.value).toBe('7')
@@ -59,20 +58,19 @@ describe('QuantityPicker', () => {
59
58
  test('typing space on minus decrements value', async () => {
60
59
  const user = userEvent.setup()
61
60
  renderWithProviders(<MockComponent />)
62
- const input = screen.getByRole('spinbutton')
63
61
  const button = screen.getByText(MINUS)
64
62
  await user.type(button, '{enter}')
63
+ const input = screen.getByRole('spinbutton')
65
64
  expect(input.value).toBe('4')
66
65
  await user.type(button, '{space}')
67
66
  expect(input.value).toBe('3')
68
67
  })
69
68
 
70
69
  test('keydown enter/space on minus decrements value', async () => {
71
- const user = userEvent.setup()
72
70
  renderWithProviders(<MockComponent />)
73
- const input = screen.getByRole('spinbutton')
74
71
  const button = screen.getByText(MINUS)
75
72
  fireEvent.keyDown(button, {key: 'Enter'})
73
+ const input = screen.getByRole('spinbutton')
76
74
  expect(input.value).toBe('4')
77
75
  fireEvent.keyDown(button, {key: ' '})
78
76
  expect(input.value).toBe('3')
@@ -81,16 +79,18 @@ describe('QuantityPicker', () => {
81
79
  test('plus button is tabbable', async () => {
82
80
  const user = userEvent.setup()
83
81
  renderWithProviders(<MockComponent />)
84
- const input = screen.getByRole('spinbutton')
85
- await user.type(input, '{tab}')
82
+ await user.tab()
83
+ await user.tab() // Tab twice to get to the plus button
84
+ await user.tab() // Tab once more to get to the plus button
86
85
  const button = screen.getByText('+')
87
86
  expect(button).toHaveFocus()
88
87
  })
89
88
  test('minus button is tabbable', async () => {
90
89
  const user = userEvent.setup()
91
90
  renderWithProviders(<MockComponent />)
92
- const input = screen.getByRole('spinbutton')
93
- await user.type(input, '{shift>}{tab}') // > modifier in {shift>} means "keep key pressed"
91
+ await user.tab({shift: true})
92
+ await user.tab({shift: true}) // Shift+tab twice to get to the minus button
93
+ await user.tab({shift: true}) // Shift+tab once more to get to the minus button
94
94
  const button = screen.getByText(MINUS)
95
95
  expect(button).toHaveFocus()
96
96
  })
@@ -49,7 +49,7 @@ const MockedErrorComponent = () => {
49
49
  }
50
50
 
51
51
  test('Allows customer to generate password token and see success message', async () => {
52
- const mockSubmitForm = jest.fn(async (data) => ({
52
+ const mockSubmitForm = jest.fn(async () => ({
53
53
  password: jest.fn(async (passwordData) => {
54
54
  // Mock behavior inside the password function
55
55
  console.log('Password function called with:', passwordData)
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Copyright (c) 2023, salesforce.com, 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
+ export {AlertDescription} from '@chakra-ui/react'
@@ -19,6 +19,7 @@ export {AlertDialogHeader} from './AlertDialogHeader'
19
19
  export {AlertDialogOverlay} from './AlertDialogOverlay'
20
20
  export {AlertIcon} from './AlertIcon'
21
21
  export {AlertTitle} from './AlertTitle'
22
+ export {AlertDescription} from './AlertDescription'
22
23
  export {AspectRatio} from './AspectRatio'
23
24
  export {Badge} from './Badge'
24
25
  export {Box} from './Box'
@@ -6,9 +6,11 @@
6
6
  */
7
7
  import React from 'react'
8
8
  import PropTypes from 'prop-types'
9
- import {useIntl} from 'react-intl'
9
+ import {useIntl, FormattedMessage} from 'react-intl'
10
10
  import {
11
11
  Box,
12
+ Button,
13
+ Flex,
12
14
  Accordion,
13
15
  AccordionItem,
14
16
  AccordionButton,
@@ -25,7 +27,8 @@ const StoreDisplay = ({
25
27
  nameStyle = {fontSize: 'md', fontWeight: 'bold'},
26
28
  textSize = 'sm',
27
29
  accordionButtonStyle = {},
28
- accordionPanelStyle = {}
30
+ accordionPanelStyle = {},
31
+ onChangeStore
29
32
  }) => {
30
33
  const intl = useIntl()
31
34
 
@@ -35,7 +38,26 @@ const StoreDisplay = ({
35
38
 
36
39
  return (
37
40
  <Box id={`store-info-${store.id}`}>
38
- {store.name && <Box {...nameStyle}>{store.name}</Box>}
41
+ {store.name && (
42
+ <Flex justify="space-between" align="flex-start" mb={1}>
43
+ <Box {...nameStyle}>{store.name}</Box>
44
+ {onChangeStore && (
45
+ <Button
46
+ variant="link"
47
+ size="sm"
48
+ fontWeight="normal"
49
+ onClick={onChangeStore}
50
+ data-testid="change-store-button"
51
+ ml={2}
52
+ >
53
+ <FormattedMessage
54
+ defaultMessage="Use Recent Store"
55
+ id="store_display.button.use_recent_store"
56
+ />
57
+ </Button>
58
+ )}
59
+ </Flex>
60
+ )}
39
61
  <Box fontSize={textSize} color="gray.600">
40
62
  {store.address1}
41
63
  </Box>
@@ -161,7 +183,9 @@ StoreDisplay.propTypes = {
161
183
  /** Custom style props for accordion button */
162
184
  accordionButtonStyle: PropTypes.object,
163
185
  /** Custom style props for accordion panel */
164
- accordionPanelStyle: PropTypes.object
186
+ accordionPanelStyle: PropTypes.object,
187
+ /** Callback function to handle change store action */
188
+ onChangeStore: PropTypes.func
165
189
  }
166
190
 
167
191
  export default StoreDisplay
@@ -266,4 +266,75 @@ describe('StoreDisplay component', () => {
266
266
  expect(screen.queryByText(/Phone:/)).not.toBeInTheDocument()
267
267
  expect(screen.queryByText(/Email:/)).not.toBeInTheDocument()
268
268
  })
269
+
270
+ describe('Change Store Button', () => {
271
+ test('renders Change Store button when onChangeStore is provided', () => {
272
+ const mockOnChangeStore = jest.fn()
273
+ renderWithProviders(
274
+ <StoreDisplay store={mockStore} onChangeStore={mockOnChangeStore} />
275
+ )
276
+
277
+ const changeStoreButton = screen.getByTestId('change-store-button')
278
+ expect(changeStoreButton).toBeInTheDocument()
279
+ expect(changeStoreButton).toHaveTextContent('Use Recent Store')
280
+ })
281
+
282
+ test('does not render Change Store button when onChangeStore is not provided', () => {
283
+ renderWithProviders(<StoreDisplay store={mockStore} />)
284
+
285
+ expect(screen.queryByTestId('change-store-button')).not.toBeInTheDocument()
286
+ })
287
+
288
+ test('calls onChangeStore when Change Store button is clicked', async () => {
289
+ const mockOnChangeStore = jest.fn()
290
+ const user = userEvent.setup()
291
+ renderWithProviders(
292
+ <StoreDisplay store={mockStore} onChangeStore={mockOnChangeStore} />
293
+ )
294
+
295
+ const changeStoreButton = screen.getByTestId('change-store-button')
296
+ await user.click(changeStoreButton)
297
+
298
+ expect(mockOnChangeStore).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ test('does not render Change Store button when store has no name', () => {
302
+ const storeWithoutName = {
303
+ ...mockStore,
304
+ name: null
305
+ }
306
+ const mockOnChangeStore = jest.fn()
307
+ renderWithProviders(
308
+ <StoreDisplay store={storeWithoutName} onChangeStore={mockOnChangeStore} />
309
+ )
310
+
311
+ expect(screen.queryByTestId('change-store-button')).not.toBeInTheDocument()
312
+ })
313
+
314
+ test('renders Change Store button with correct styling', () => {
315
+ const mockOnChangeStore = jest.fn()
316
+ renderWithProviders(
317
+ <StoreDisplay store={mockStore} onChangeStore={mockOnChangeStore} />
318
+ )
319
+
320
+ const changeStoreButton = screen.getByTestId('change-store-button')
321
+ expect(changeStoreButton).toBeInTheDocument()
322
+
323
+ // Check that it's rendered as a button
324
+ expect(changeStoreButton.tagName).toBe('BUTTON')
325
+ })
326
+
327
+ test('positions Change Store button next to store name', () => {
328
+ const mockOnChangeStore = jest.fn()
329
+ renderWithProviders(
330
+ <StoreDisplay store={mockStore} onChangeStore={mockOnChangeStore} />
331
+ )
332
+
333
+ const storeName = screen.getByText('Downtown Store')
334
+ const changeStoreButton = screen.getByTestId('change-store-button')
335
+
336
+ // Both should be in the same parent container (Flex)
337
+ expect(storeName.parentElement).toBe(changeStoreButton.parentElement)
338
+ })
339
+ })
269
340
  })
@@ -9,6 +9,7 @@ import React from 'react'
9
9
  import {render, screen, waitFor} from '@testing-library/react'
10
10
  import userEvent from '@testing-library/user-event'
11
11
  import {IntlProvider} from 'react-intl'
12
+ import {MemoryRouter} from 'react-router-dom'
12
13
  import {StoreLocatorForm} from '@salesforce/retail-react-app/app/components/store-locator/form'
13
14
  import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
14
15
  import {useGeolocation} from '@salesforce/retail-react-app/app/hooks/use-geo-location'
@@ -75,7 +76,9 @@ describe('StoreLocatorForm', () => {
75
76
  const TestWrapper = ({children}) => (
76
77
  <IntlProvider locale="en" messages={messages}>
77
78
  <MultiSiteProvider site={mockSite}>
78
- <StoreLocatorProvider config={mockConfig}>{children}</StoreLocatorProvider>
79
+ <MemoryRouter>
80
+ <StoreLocatorProvider config={mockConfig}>{children}</StoreLocatorProvider>
81
+ </MemoryRouter>
79
82
  </MultiSiteProvider>
80
83
  </IntlProvider>
81
84
  )
@@ -92,7 +95,10 @@ describe('StoreLocatorForm', () => {
92
95
  config: mockConfig,
93
96
  formValues: {countryCode: '', postalCode: ''},
94
97
  setFormValues: mockSetFormValues,
95
- setDeviceCoordinates: mockSetDeviceCoordinates
98
+ setDeviceCoordinates: mockSetDeviceCoordinates,
99
+ isOpen: false,
100
+ onOpen: jest.fn(),
101
+ onClose: jest.fn()
96
102
  }))
97
103
 
98
104
  useGeolocation.mockImplementation(() => ({
@@ -197,7 +203,10 @@ describe('StoreLocatorForm', () => {
197
203
  config: {...mockConfig, supportedCountries: []},
198
204
  formValues: {countryCode: '', postalCode: ''},
199
205
  setFormValues: mockSetFormValues,
200
- setDeviceCoordinates: mockSetDeviceCoordinates
206
+ setDeviceCoordinates: mockSetDeviceCoordinates,
207
+ isOpen: false,
208
+ onOpen: jest.fn(),
209
+ onClose: jest.fn()
201
210
  }))
202
211
 
203
212
  renderWithProviders(<StoreLocatorForm />)
@@ -220,7 +229,10 @@ describe('StoreLocatorForm', () => {
220
229
  config: mockConfig,
221
230
  formValues: {countryCode: '', postalCode: ''},
222
231
  setFormValues: mockSetFormValues,
223
- setDeviceCoordinates: mockSetDeviceCoordinates
232
+ setDeviceCoordinates: mockSetDeviceCoordinates,
233
+ isOpen: false,
234
+ onOpen: jest.fn(),
235
+ onClose: jest.fn()
224
236
  }))
225
237
 
226
238
  renderWithProviders(<StoreLocatorForm />)
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2024, salesforce.com, inc.
2
+ * Copyright (c) 2025, salesforce.com, inc.
3
3
  * All rights reserved.
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
@@ -12,6 +12,7 @@ import {StoreLocatorListItem} from '@salesforce/retail-react-app/app/components/
12
12
  import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
13
13
  import {useSelectedStore} from '@salesforce/retail-react-app/app/hooks/use-selected-store'
14
14
  import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
15
+ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
15
16
 
16
17
  export const StoreLocatorList = () => {
17
18
  const intl = useIntl()
@@ -22,7 +23,11 @@ export const StoreLocatorList = () => {
22
23
  const [page, setPage] = useState(1)
23
24
  const [initialSelectedStoreId, setInitialSelectedStoreId] = useState(selectedStoreId)
24
25
 
26
+ // with BOPIS enabled: store locator selector can't be changed unless basket is emty
27
+ // with BOPIS and MULTI_SHIP enabled, store locator can be changed any time
25
28
  const hasItemsInBasket = derivedData?.totalItems > 0
29
+ const multishipEnabled = getConfig()?.app?.multishipEnabled ?? true
30
+ const storeSelectionDisabled = multishipEnabled ? false : hasItemsInBasket
26
31
 
27
32
  useEffect(() => {
28
33
  setPage(1)
@@ -31,7 +36,7 @@ export const StoreLocatorList = () => {
31
36
  }, [data])
32
37
 
33
38
  const handleChange = (selectedStoreId) => {
34
- if (!hasItemsInBasket) {
39
+ if (!storeSelectionDisabled) {
35
40
  setSelectedStoreId(selectedStoreId)
36
41
  }
37
42
  }
@@ -47,7 +52,7 @@ export const StoreLocatorList = () => {
47
52
  id: 'store_locator.description.no_locations',
48
53
  defaultMessage: 'Sorry, there are no locations in this area.'
49
54
  })
50
- if (hasItemsInBasket) {
55
+ if (storeSelectionDisabled && hasItemsInBasket) {
51
56
  return intl.formatMessage({
52
57
  id: 'store_locator.error.items_in_basket',
53
58
  defaultMessage: 'To change your selected store, remove all items from your cart.'
@@ -145,7 +150,7 @@ export const StoreLocatorList = () => {
145
150
  value: store.id,
146
151
  isChecked: selectedStoreId === store.id,
147
152
  'aria-describedby': `store-info-${store.id}`,
148
- isDisabled: !store.inventoryId || hasItemsInBasket
153
+ isDisabled: !store.inventoryId || storeSelectionDisabled
149
154
  }}
150
155
  />
151
156
  ))}
@@ -31,8 +31,10 @@ export const ToggleCard = ({
31
31
  disableEdit,
32
32
  onEdit,
33
33
  editLabel,
34
+ editAction,
34
35
  isLoading,
35
36
  children,
37
+ onEditActionClick,
36
38
  ...props
37
39
  }) => {
38
40
  const titleRef = useRef()
@@ -79,6 +81,16 @@ export const ToggleCard = ({
79
81
  )}
80
82
  </Button>
81
83
  )}
84
+ {editing && editAction && onEditActionClick && (
85
+ <Button
86
+ variant="link"
87
+ size="sm"
88
+ onClick={onEditActionClick}
89
+ aria-label={editAction}
90
+ >
91
+ {editAction}
92
+ </Button>
93
+ )}
82
94
  </Flex>
83
95
  <Box data-testid={`sf-toggle-card-${id}-content`}>{children}</Box>
84
96
  </Stack>
@@ -108,6 +120,8 @@ ToggleCard.propTypes = {
108
120
  disabled: PropTypes.bool,
109
121
  disableEdit: PropTypes.bool,
110
122
  onEdit: PropTypes.func,
123
+ editAction: PropTypes.string,
124
+ onEditActionClick: PropTypes.func,
111
125
  children: PropTypes.any
112
126
  }
113
127
 
@@ -31,14 +31,24 @@ const UnavailableProductConfirmationModal = ({
31
31
  }) => {
32
32
  const unavailableProductIdsRef = useRef(null)
33
33
  const ids = productIds.length ? productIds : productItems.map((i) => i.productId)
34
+ const uniqueInventoryIds = [
35
+ ...new Set(productItems.map((i) => i.inventoryId).filter(Boolean))
36
+ ].join(',')
37
+
34
38
  useProducts(
35
- {parameters: {ids: ids?.join(','), allImages: true}},
39
+ {
40
+ parameters: {
41
+ ids: ids?.join(','),
42
+ allImages: true,
43
+ ...(uniqueInventoryIds ? {inventoryIds: uniqueInventoryIds} : {})
44
+ }
45
+ },
36
46
  {
37
47
  enabled: ids?.length > 0,
38
48
  onSuccess: (result) => {
39
49
  const resProductIds = []
40
50
  const unOrderableIds = []
41
- result.data?.forEach(({id, inventory}) => {
51
+ result.data?.forEach(({id, inventory, inventories}) => {
42
52
  // when a product is unavailable, the getProducts will not return its product detail.
43
53
  // we compare the response ids with the ones in basket to figure which product has become unavailable
44
54
  resProductIds.push(id)
@@ -50,11 +60,15 @@ const UnavailableProductConfirmationModal = ({
50
60
  const productItem = productItems.find((item) => item.productId === id)
51
61
  // wishlist item will have the property type
52
62
  const isWishlist = !!productItem?.type
63
+ // inventory for the product's pickup store or the delivery inventory
64
+ const productItemInventory =
65
+ inventories?.find((entry) => entry.id === productItem.inventoryId) ||
66
+ inventory
53
67
  if (
54
68
  !isWishlist &&
55
- (!inventory?.orderable ||
56
- (inventory?.orderable &&
57
- productItem?.quantity > inventory.stockLevel))
69
+ (!productItemInventory?.orderable ||
70
+ (productItemInventory?.orderable &&
71
+ productItem?.quantity > productItemInventory.stockLevel))
58
72
  ) {
59
73
  unOrderableIds.push(id)
60
74
  }