Skip to content

Commit e0a10ac

Browse files
Merge pull request #3300 from SalesforceCommerceCloud/develop
update the feature branch with latest changes in `develop` branch
2 parents e00d3a4 + 57bb51e commit e0a10ac

File tree

12 files changed

+765
-70
lines changed

12 files changed

+765
-70
lines changed

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## v8.1.0-dev (Sep 04, 2025)
22
- Updated the UI for StoreDisplay component which displays pickup in-store information on different pages. [#3248](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3248)
3+
- 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)
34

45
## v8.0.0 (Sep 04, 2025)
56
- Add support for environment level base paths on /mobify routes [#2892](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2892)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 from 'react'
9+
import PropTypes from 'prop-types'
10+
import {useIntl, defineMessage} from 'react-intl'
11+
import {
12+
Button,
13+
AlertDialog,
14+
AlertDialogBody,
15+
AlertDialogFooter,
16+
AlertDialogHeader,
17+
AlertDialogContent,
18+
AlertDialogOverlay,
19+
Text
20+
} from '@salesforce/retail-react-app/app/components/shared/ui'
21+
22+
const dialogTitle = defineMessage({
23+
defaultMessage: 'Switch to one address?',
24+
id: 'multi_ship_warning_modal.title.switch_to_one_address'
25+
})
26+
27+
const confirmationMessage = defineMessage({
28+
defaultMessage:
29+
'If you switch to one address, the shipping addresses you added for the items will be removed.',
30+
id: 'multi_ship_warning_modal.message.addresses_will_be_removed'
31+
})
32+
33+
const continueButtonLabel = defineMessage({
34+
defaultMessage: 'Switch to one address',
35+
id: 'multi_ship_warning_modal.action.switch_to_one_address'
36+
})
37+
38+
const cancelButtonLabel = defineMessage({
39+
defaultMessage: 'Cancel',
40+
id: 'multi_ship_warning_modal.action.cancel'
41+
})
42+
43+
const SingleAddressToggleModal = ({isOpen, onClose, onConfirm, onCancel}) => {
44+
const {formatMessage} = useIntl()
45+
46+
const handleConfirm = () => {
47+
onConfirm()
48+
onClose()
49+
}
50+
51+
const handleCancel = () => {
52+
onCancel()
53+
onClose()
54+
}
55+
56+
return (
57+
<AlertDialog
58+
isOpen={isOpen}
59+
isCentered
60+
onClose={handleCancel}
61+
closeOnEsc={true}
62+
closeOnOverlayClick={true}
63+
>
64+
<AlertDialogOverlay />
65+
<AlertDialogContent maxW="448px" w="448px" minH="196px" borderRadius="6px">
66+
<AlertDialogHeader>{formatMessage(dialogTitle)}</AlertDialogHeader>
67+
<AlertDialogBody>
68+
<Text>{formatMessage(confirmationMessage)}</Text>
69+
</AlertDialogBody>
70+
<AlertDialogFooter>
71+
<Button
72+
variant="ghost"
73+
mr={3}
74+
onClick={handleCancel}
75+
aria-label={formatMessage(cancelButtonLabel)}
76+
>
77+
{formatMessage(cancelButtonLabel)}
78+
</Button>
79+
<Button
80+
variant="solid"
81+
onClick={handleConfirm}
82+
aria-label={formatMessage(continueButtonLabel)}
83+
>
84+
{formatMessage(continueButtonLabel)}
85+
</Button>
86+
</AlertDialogFooter>
87+
</AlertDialogContent>
88+
</AlertDialog>
89+
)
90+
}
91+
92+
SingleAddressToggleModal.propTypes = {
93+
isOpen: PropTypes.bool.isRequired,
94+
onClose: PropTypes.func.isRequired,
95+
onConfirm: PropTypes.func.isRequired,
96+
onCancel: PropTypes.func.isRequired
97+
}
98+
99+
export default SingleAddressToggleModal
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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, fireEvent} from '@testing-library/react'
9+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
10+
import SingleAddressToggleModal from '@salesforce/retail-react-app/app/components/single-address-toggle-modal'
11+
12+
describe('SingleAddressToggleModal', () => {
13+
const mockProps = {
14+
isOpen: true,
15+
onClose: jest.fn(),
16+
onConfirm: jest.fn(),
17+
onCancel: jest.fn()
18+
}
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks()
22+
})
23+
24+
it('renders modal when open', () => {
25+
renderWithProviders(<SingleAddressToggleModal {...mockProps} />)
26+
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
27+
expect(screen.getByText('Switch to one address?')).toBeInTheDocument()
28+
expect(
29+
screen.getByText(
30+
/If you switch to one address, the shipping addresses you added for the items will be removed/
31+
)
32+
).toBeInTheDocument()
33+
})
34+
35+
it('does not render when closed', () => {
36+
renderWithProviders(<SingleAddressToggleModal {...mockProps} isOpen={false} />)
37+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
38+
expect(screen.queryByText('Switch to one address?')).not.toBeInTheDocument()
39+
})
40+
41+
it('calls onConfirm when Switch to one address button is clicked', () => {
42+
renderWithProviders(<SingleAddressToggleModal {...mockProps} />)
43+
const continueButton = screen.getByRole('button', {name: /switch to one address/i})
44+
fireEvent.click(continueButton)
45+
expect(mockProps.onConfirm).toHaveBeenCalledTimes(1)
46+
})
47+
48+
it('calls onCancel when Cancel button is clicked', () => {
49+
renderWithProviders(<SingleAddressToggleModal {...mockProps} />)
50+
const cancelButton = screen.getByRole('button', {name: /cancel/i})
51+
fireEvent.click(cancelButton)
52+
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
53+
})
54+
55+
it('has proper accessibility attributes', () => {
56+
renderWithProviders(<SingleAddressToggleModal {...mockProps} />)
57+
const dialog = screen.getByRole('alertdialog')
58+
expect(dialog).toHaveAttribute('aria-labelledby')
59+
expect(dialog).toHaveAttribute('aria-describedby')
60+
})
61+
62+
it('handles keyboard navigation', () => {
63+
renderWithProviders(<SingleAddressToggleModal {...mockProps} />)
64+
const continueButton = screen.getByRole('button', {name: /switch to one address/i})
65+
const cancelButton = screen.getByRole('button', {name: /cancel/i})
66+
continueButton.focus()
67+
expect(document.activeElement).toBe(continueButton)
68+
cancelButton.focus()
69+
expect(document.activeElement).toBe(cancelButton)
70+
})
71+
})

packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx

Lines changed: 104 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ToggleCardEdit,
1414
ToggleCardSummary
1515
} from '@salesforce/retail-react-app/app/components/toggle-card'
16-
import {Text} from '@salesforce/retail-react-app/app/components/shared/ui'
16+
import {Text, useDisclosure} from '@salesforce/retail-react-app/app/components/shared/ui'
1717
import ShippingAddressSelection from '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address-selection'
1818
import AddressDisplay from '@salesforce/retail-react-app/app/components/address-display'
1919
import {
@@ -36,6 +36,7 @@ import {
3636
findExistingDeliveryShipment,
3737
isPickupShipment
3838
} from '@salesforce/retail-react-app/app/utils/shipment-utils'
39+
import SingleAddressToggleModal from '@salesforce/retail-react-app/app/components/single-address-toggle-modal'
3940

4041
const submitButtonMessage = defineMessage({
4142
defaultMessage: 'Continue to Shipping Method',
@@ -77,6 +78,12 @@ export default function ShippingAddress() {
7778

7879
// Initialize multi-shipping state based on existing basket shipments
7980
const [isMultiShipping, setIsMultiShipping] = useState(hasMultipleDeliveryShipments)
81+
const {
82+
isOpen: showWarningModal,
83+
onOpen: openWarningModal,
84+
onClose: closeWarningModal
85+
} = useDisclosure()
86+
const [hasUnpersistedGuestAddresses, sethasUnpersistedGuestAddresses] = useState(false)
8087
const {step, STEPS, goToStep} = useCheckout()
8188
const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress')
8289
const updateCustomerAddress = useShopperCustomersMutation('updateCustomerAddress')
@@ -90,6 +97,30 @@ export default function ShippingAddress() {
9097
setIsMultiShipping(hasMultipleDeliveryShipments)
9198
}, [hasMultipleDeliveryShipments])
9299

100+
// handle unpersisted address status from ShippingMultiAddress
101+
const handleUnsavedGuestAddressesToggleWarning = (hasUnsaved) => {
102+
sethasUnpersistedGuestAddresses(hasUnsaved)
103+
}
104+
105+
// Handle toggle between single and multi-shipping
106+
const handleToggleShippingMode = () => {
107+
if (isMultiShipping && customer?.isGuest && hasUnpersistedGuestAddresses) {
108+
openWarningModal()
109+
} else {
110+
setIsMultiShipping(!isMultiShipping)
111+
}
112+
}
113+
114+
// handle confirmation to single address
115+
const handleConfirmSwitchToSingle = () => {
116+
setIsMultiShipping(false)
117+
closeWarningModal()
118+
}
119+
120+
const handleCancelSwitch = () => {
121+
closeWarningModal()
122+
}
123+
93124
const submitAndContinue = async (address) => {
94125
setIsLoading(true)
95126
try {
@@ -162,72 +193,79 @@ export default function ShippingAddress() {
162193
const isEditingShippingAddress = step === STEPS.SHIPPING_ADDRESS
163194

164195
return (
165-
<ToggleCard
166-
id="step-1"
167-
title={formatMessage({
168-
defaultMessage: 'Shipping Address',
169-
id: 'shipping_address.title.shipping_address'
170-
})}
171-
editing={isEditingShippingAddress}
172-
isLoading={isLoading}
173-
disabled={step === STEPS.CONTACT_INFO && !selectedShippingAddress}
174-
onEdit={() => goToStep(STEPS.SHIPPING_ADDRESS)}
175-
editLabel={
176-
isMultiShipping
177-
? formatMessage({
178-
defaultMessage: 'Edit Shipping Addresses',
179-
id: 'toggle_card.action.editShippingAddresses'
180-
})
181-
: formatMessage({
182-
defaultMessage: 'Edit Shipping Address',
183-
id: 'toggle_card.action.editShippingAddress'
184-
})
185-
}
186-
editAction={
187-
multishipEnabled
188-
? isMultiShipping
189-
? formatMessage(shipToOneAddressLabel)
190-
: formatMessage(deliverToMultipleAddressesLabel)
191-
: null
192-
}
193-
onEditActionClick={
194-
multishipEnabled
195-
? async () => {
196-
setIsMultiShipping(!isMultiShipping)
197-
}
198-
: null
199-
}
200-
>
201-
<ToggleCardEdit>
202-
{!isMultiShipping ? (
203-
<ShippingAddressSelection
204-
selectedAddress={selectedShippingAddress}
205-
submitButtonLabel={submitButtonMessage}
206-
onSubmit={submitAndContinue}
207-
formTitleAriaLabel={shippingAddressAriaLabel}
208-
/>
209-
) : (
210-
<ShippingMultiAddress
211-
basket={basket}
212-
submitButtonLabel={submitButtonMessage}
213-
noItemsInBasketMessage={noItemsInBasketMessage}
214-
/>
215-
)}
216-
</ToggleCardEdit>
217-
{isAddressFilled && (
218-
<ToggleCardSummary>
219-
{hasMultipleDeliveryShipments ? (
220-
<Text>
221-
{formatMessage({
222-
defaultMessage: 'Your items will be shipped to multiple addresses.',
223-
id: 'shipping_address.summary.multiple_addresses'
224-
})}
225-
</Text>
196+
<>
197+
<ToggleCard
198+
id="step-1"
199+
title={formatMessage({
200+
defaultMessage: 'Shipping Address',
201+
id: 'shipping_address.title.shipping_address'
202+
})}
203+
editing={isEditingShippingAddress}
204+
isLoading={isLoading}
205+
disabled={step === STEPS.CONTACT_INFO && !selectedShippingAddress}
206+
onEdit={() => goToStep(STEPS.SHIPPING_ADDRESS)}
207+
editLabel={
208+
isMultiShipping
209+
? formatMessage({
210+
defaultMessage: 'Edit Shipping Addresses',
211+
id: 'toggle_card.action.editShippingAddresses'
212+
})
213+
: formatMessage({
214+
defaultMessage: 'Edit Shipping Address',
215+
id: 'toggle_card.action.editShippingAddress'
216+
})
217+
}
218+
editAction={
219+
multishipEnabled
220+
? isMultiShipping
221+
? formatMessage(shipToOneAddressLabel)
222+
: formatMessage(deliverToMultipleAddressesLabel)
223+
: null
224+
}
225+
onEditActionClick={multishipEnabled ? handleToggleShippingMode : null}
226+
>
227+
<ToggleCardEdit>
228+
{!isMultiShipping ? (
229+
<ShippingAddressSelection
230+
selectedAddress={selectedShippingAddress}
231+
submitButtonLabel={submitButtonMessage}
232+
onSubmit={submitAndContinue}
233+
formTitleAriaLabel={shippingAddressAriaLabel}
234+
/>
226235
) : (
227-
<AddressDisplay address={selectedShippingAddress} />
236+
<ShippingMultiAddress
237+
basket={basket}
238+
submitButtonLabel={submitButtonMessage}
239+
noItemsInBasketMessage={noItemsInBasketMessage}
240+
onUnsavedGuestAddressesToggleWarning={
241+
handleUnsavedGuestAddressesToggleWarning
242+
}
243+
/>
228244
)}
229-
</ToggleCardSummary>
230-
)}
231-
</ToggleCard>
245+
</ToggleCardEdit>
246+
{isAddressFilled && (
247+
<ToggleCardSummary>
248+
{hasMultipleDeliveryShipments ? (
249+
<Text>
250+
{formatMessage({
251+
defaultMessage:
252+
'Your items will be shipped to multiple addresses.',
253+
id: 'shipping_address.summary.multiple_addresses'
254+
})}
255+
</Text>
256+
) : (
257+
<AddressDisplay address={selectedShippingAddress} />
258+
)}
259+
</ToggleCardSummary>
260+
)}
261+
</ToggleCard>
262+
263+
<SingleAddressToggleModal
264+
isOpen={showWarningModal}
265+
onClose={closeWarningModal}
266+
onConfirm={handleConfirmSwitchToSingle}
267+
onCancel={handleCancelSwitch}
268+
/>
269+
</>
232270
)
233271
}

0 commit comments

Comments
 (0)