@salesforce/retail-react-app 9.0.0-preview.0 → 9.0.0-preview.1

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,3 +1,6 @@
1
+ ## v9.0.0-preview.1 (Feb 09, 2026)
2
+ - [Bugfix] 1CC Bug Fixes [#3638](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3638)
3
+
1
4
  ## v9.0.0-preview.0 (Feb 06, 2026)
2
5
  - [Feature] One Click Checkout [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
3
6
  - [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
@@ -31,7 +31,9 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
31
31
  import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
32
32
  import {
33
33
  useCheckout,
34
- CheckoutProvider
34
+ CheckoutProvider,
35
+ getCheckoutGuestChoiceFromStorage,
36
+ setCheckoutGuestChoiceInStorage
35
37
  } from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context'
36
38
  import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info'
37
39
  import PickupAddress from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address'
@@ -55,16 +57,17 @@ import {
55
57
  getPaymentInstrumentCardType,
56
58
  getMaskCreditCardNumber
57
59
  } from '@salesforce/retail-react-app/app/utils/cc-utils'
58
- import {nanoid} from 'nanoid'
59
60
 
60
61
  const CheckoutOneClick = () => {
61
62
  const {formatMessage} = useIntl()
62
63
  const navigate = useNavigation()
63
- const {step, STEPS, contactPhone} = useCheckout()
64
+ const {step, STEPS} = useCheckout()
64
65
  const showToast = useToast()
65
66
  const [isLoading, setIsLoading] = useState(false)
66
67
  const [enableUserRegistration, setEnableUserRegistration] = useState(false)
67
- const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false)
68
+ const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(
69
+ getCheckoutGuestChoiceFromStorage
70
+ )
68
71
  const [shouldSavePaymentMethod, setShouldSavePaymentMethod] = useState(false)
69
72
  const [isOtpLoading, setIsOtpLoading] = useState(false)
70
73
  const [isPlacingOrder, setIsPlacingOrder] = useState(false)
@@ -119,8 +122,6 @@ const CheckoutOneClick = () => {
119
122
  ShopperBasketsMutations.UpdateBillingAddressForBasket
120
123
  )
121
124
  const {mutateAsync: createOrder} = useShopperOrdersMutation(ShopperOrdersMutations.CreateOrder)
122
- const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress')
123
- const updateCustomer = useShopperCustomersMutation('updateCustomer')
124
125
 
125
126
  const handleSavePreferenceChange = (shouldSave) => {
126
127
  setShouldSavePaymentMethod(shouldSave)
@@ -382,87 +383,9 @@ const CheckoutOneClick = () => {
382
383
  fullCardDetails
383
384
  )
384
385
  }
385
-
386
- // For newly registered guests only, persist shipping address when billing same as shipping
387
- // Skip saving pickup/store addresses - only save delivery addresses
388
- // For multi-shipment orders, save all delivery addresses with the first one as default
389
- if (
390
- enableUserRegistration &&
391
- currentCustomer?.isRegistered &&
392
- !registeredUserChoseGuest
393
- ) {
394
- try {
395
- const customerId = order.customerInfo?.customerId
396
- if (!customerId) return
397
-
398
- // Get all delivery shipments (not pickup) from the order
399
- // This handles both single delivery and multi-shipment orders
400
- // For BOPIS orders, pickup shipments are filtered out
401
- const deliveryShipments =
402
- order?.shipments?.filter(
403
- (shipment) =>
404
- !isPickupShipment(shipment) && shipment.shippingAddress
405
- ) || []
406
-
407
- if (deliveryShipments.length > 0) {
408
- // Save all delivery addresses, with the first one as preferred
409
- for (let i = 0; i < deliveryShipments.length; i++) {
410
- const shipment = deliveryShipments[i]
411
- const shipping = shipment.shippingAddress
412
- if (!shipping) continue
413
-
414
- // Whitelist fields and strip non-customer fields (e.g., id, _type)
415
- const {
416
- address1,
417
- address2,
418
- city,
419
- countryCode,
420
- firstName,
421
- lastName,
422
- phone,
423
- postalCode,
424
- stateCode
425
- } = shipping || {}
426
-
427
- await createCustomerAddress.mutateAsync({
428
- parameters: {customerId},
429
- body: {
430
- addressId: nanoid(),
431
- preferred: i === 0, // First address is preferred
432
- address1,
433
- address2,
434
- city,
435
- countryCode,
436
- firstName,
437
- lastName,
438
- phone,
439
- postalCode,
440
- stateCode
441
- }
442
- })
443
- }
444
- }
445
-
446
- // Persist phone number as phoneHome for newly registered guest shoppers
447
- const phoneHome = basket?.billingAddress?.phone || contactPhone
448
- if (phoneHome) {
449
- await updateCustomer.mutateAsync({
450
- parameters: {customerId},
451
- body: {phoneHome}
452
- })
453
- }
454
- } catch (_e) {
455
- // Only surface error if shopper opted to register/save details; otherwise fail silently
456
- showError(
457
- formatMessage({
458
- id: 'checkout.error.cannot_save_address',
459
- defaultMessage: 'Could not save shipping address.'
460
- })
461
- )
462
- }
463
- }
464
386
  }
465
387
 
388
+ setCheckoutGuestChoiceInStorage(false)
466
389
  navigate(`/checkout/confirmation/${order.orderNo}`)
467
390
  } catch (error) {
468
391
  const message = formatMessage({
@@ -26,7 +26,10 @@ import {
26
26
  } from '@salesforce/retail-react-app/app/components/shared/ui'
27
27
  import {useForm} from 'react-hook-form'
28
28
  import {FormattedMessage, useIntl} from 'react-intl'
29
- import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context'
29
+ import {
30
+ useCheckout,
31
+ setCheckoutGuestChoiceInStorage
32
+ } from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context'
30
33
  import useLoginFields from '@salesforce/retail-react-app/app/components/forms/useLoginFields'
31
34
  import {
32
35
  ToggleCard,
@@ -110,7 +113,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
110
113
  const [isCheckingEmail, setIsCheckingEmail] = useState(false)
111
114
  const [isSubmitting, setIsSubmitting] = useState(false)
112
115
  const [isBlurChecking, setIsBlurChecking] = useState(false)
113
- const [, setRegisteredUserChoseGuest] = useState(false)
116
+ const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false)
114
117
  const [emailError, setEmailError] = useState('')
115
118
 
116
119
  // Auto-focus the email field when the component mounts
@@ -270,37 +273,11 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
270
273
  }
271
274
 
272
275
  // Handle checkout as guest from OTP modal
273
- const handleCheckoutAsGuest = async () => {
274
- try {
275
- const email = form.getValues('email')
276
- const phone = form.getValues('phone')
277
- // Update basket with guest email
278
- await updateCustomerForBasket.mutateAsync({
279
- parameters: {basketId: basket.basketId},
280
- body: {email: email}
281
- })
282
-
283
- // Save phone number to basket billing address for guest shoppers
284
- if (phone) {
285
- await updateBillingAddressForBasket.mutateAsync({
286
- parameters: {basketId: basket.basketId},
287
- body: {
288
- ...basket?.billingAddress,
289
- phone: phone
290
- }
291
- })
292
- }
293
-
294
- // Set the flag that "Checkout as Guest" was clicked
295
- setRegisteredUserChoseGuest(true)
296
- if (onRegisteredUserChoseGuest) {
297
- onRegisteredUserChoseGuest(true)
298
- }
299
-
300
- // Proceed to next step (shipping address)
301
- goToNextStep()
302
- } catch (error) {
303
- setError(error.message)
276
+ const handleCheckoutAsGuest = () => {
277
+ setRegisteredUserChoseGuest(true)
278
+ setCheckoutGuestChoiceInStorage(true)
279
+ if (onRegisteredUserChoseGuest) {
280
+ onRegisteredUserChoseGuest(true)
304
281
  }
305
282
  }
306
283
 
@@ -359,6 +336,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
359
336
 
360
337
  // Reset guest checkout flag since user is now logged in
361
338
  setRegisteredUserChoseGuest(false)
339
+ setCheckoutGuestChoiceInStorage(false)
362
340
  if (onRegisteredUserChoseGuest) {
363
341
  onRegisteredUserChoseGuest(false)
364
342
  }
@@ -470,7 +448,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
470
448
  return
471
449
  }
472
450
 
473
- if (!result.isRegistered) {
451
+ if (!result.isRegistered || registeredUserChoseGuest) {
474
452
  // Guest shoppers must provide phone number before proceeding
475
453
  const phone = (formData.phone || '').trim()
476
454
  if (!phone) {
@@ -7,6 +7,7 @@
7
7
  import React from 'react'
8
8
  import {screen, waitFor, fireEvent, act} from '@testing-library/react'
9
9
  import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info'
10
+ import {setCheckoutGuestChoiceInStorage} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context'
10
11
  import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
11
12
  import {rest} from 'msw'
12
13
  import {AuthHelpers, useCustomerType} from '@salesforce/commerce-sdk-react'
@@ -79,6 +80,7 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => (
79
80
  const mockSetContactPhone = jest.fn()
80
81
  const mockGoToNextStep = jest.fn()
81
82
  jest.mock('@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context', () => {
83
+ const setCheckoutGuestChoiceInStorage = jest.fn()
82
84
  return {
83
85
  useCheckout: jest.fn().mockReturnValue({
84
86
  customer: null,
@@ -91,7 +93,8 @@ jest.mock('@salesforce/retail-react-app/app/pages/checkout-one-click/util/checko
91
93
  goToStep: null,
92
94
  goToNextStep: mockGoToNextStep,
93
95
  setContactPhone: mockSetContactPhone
94
- })
96
+ }),
97
+ setCheckoutGuestChoiceInStorage
95
98
  }
96
99
  })
97
100
 
@@ -110,13 +113,17 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({
110
113
  // Mock OtpAuth to expose a verify trigger
111
114
  jest.mock('@salesforce/retail-react-app/app/components/otp-auth', () => {
112
115
  // eslint-disable-next-line react/prop-types
113
- return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest}) {
116
+ return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest, onClose}) {
117
+ const handleGuestClick = () => {
118
+ onCheckoutAsGuest?.()
119
+ onClose?.()
120
+ }
114
121
  return isOpen ? (
115
122
  <div>
116
123
  <div>Confirm it&apos;s you</div>
117
124
  <p>To log in to your account, enter the code sent to your email.</p>
118
125
  <div>
119
- <button type="button" onClick={onCheckoutAsGuest}>
126
+ <button type="button" onClick={handleGuestClick}>
120
127
  Checkout as a guest
121
128
  </button>
122
129
  <button type="button">Resend Code</button>
@@ -289,7 +296,8 @@ describe('ContactInfo Component', () => {
289
296
  goToStep: jest.fn(),
290
297
  goToNextStep: jest.fn(),
291
298
  setContactPhone: jest.fn()
292
- })
299
+ }),
300
+ setCheckoutGuestChoiceInStorage: jest.fn()
293
301
  }
294
302
  }
295
303
  )
@@ -606,11 +614,10 @@ describe('ContactInfo Component', () => {
606
614
  expect(screen.getByText(/Resend Code/i)).toBeInTheDocument()
607
615
  })
608
616
 
609
- test('shows error message when updateCustomerForBasket fails', async () => {
610
- // Mock OTP authorization to succeed so modal opens
617
+ test('clicking "Checkout as a guest" does not update basket or advance step', async () => {
618
+ // "Checkout as Guest" only closes the modal and sets registeredUserChoseGuest state;
619
+ // basket is updated when the user later submits the form with phone and clicks Continue.
611
620
  mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
612
- // Mock update to fail when choosing guest
613
- mockUpdateCustomerForBasket.mutateAsync.mockRejectedValue(new Error('API Error'))
614
621
 
615
622
  const {user} = renderWithProviders(<ContactInfo />)
616
623
  const emailInput = screen.getByLabelText('Email')
@@ -625,22 +632,17 @@ describe('ContactInfo Component', () => {
625
632
  await user.click(submitButton)
626
633
  await screen.findByTestId('otp-verify')
627
634
 
628
- // Click "Checkout as a guest" which triggers updateCustomerForBasket and should set error
635
+ // Click "Checkout as a guest" should not call basket mutations or goToNextStep
629
636
  await user.click(screen.getByText(/Checkout as a guest/i))
630
637
 
631
638
  await waitFor(() => {
632
- expect(mockUpdateCustomerForBasket.mutateAsync).toHaveBeenCalled()
633
- })
634
- // Error alert should be rendered; component maps errors via getPasswordlessErrorMessage to generic message
635
- await waitFor(() => {
636
- const alerts = screen.queryAllByRole('alert')
637
- const hasError = alerts.some(
638
- (n) =>
639
- n.textContent?.includes('Something went wrong') ||
640
- n.textContent?.includes('API Error')
641
- )
642
- expect(hasError).toBe(true)
639
+ expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled()
640
+ expect(mockGoToNextStep).not.toHaveBeenCalled()
643
641
  })
642
+ // Modal closes; user stays on Contact Info (Continue button visible again)
643
+ expect(
644
+ screen.getByRole('button', {name: /continue to shipping address/i})
645
+ ).toBeInTheDocument()
644
646
  })
645
647
 
646
648
  test('does not proceed to next step when OTP modal is already open on form submission', async () => {
@@ -747,37 +749,41 @@ describe('ContactInfo Component', () => {
747
749
  expect(phoneInput.value).toBe('(555) 123-4567')
748
750
  })
749
751
 
750
- test('saves phone number to billing address when guest checks out via "Checkout as Guest" button', async () => {
751
- // Mock successful OTP authorization to open modal
752
+ test('notifies parent when guest chooses "Checkout as Guest" and stays on Contact Info', async () => {
753
+ // Open OTP modal (registered email), click "Checkout as a guest" — modal closes,
754
+ // parent is notified via onRegisteredUserChoseGuest(true), user stays on Contact Info.
752
755
  mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
753
- mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})
754
- mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})
755
756
 
756
- const {user} = renderWithProviders(<ContactInfo />)
757
+ const onRegisteredUserChoseGuestSpy = jest.fn()
758
+ const {user} = renderWithProviders(
759
+ <ContactInfo onRegisteredUserChoseGuest={onRegisteredUserChoseGuestSpy} />
760
+ )
757
761
 
758
762
  const emailInput = screen.getByLabelText('Email')
759
- const phoneInput = screen.getByLabelText('Phone')
760
-
761
- // Enter phone first - use fireEvent to ensure value is set
762
- fireEvent.change(phoneInput, {target: {value: '(727) 555-1234'}})
763
763
 
764
- // Enter email and wait for OTP modal to open
764
+ // Enter email and open OTP modal (blur triggers registered-user check)
765
765
  await user.type(emailInput, validEmail)
766
766
  fireEvent.change(emailInput, {target: {value: validEmail}})
767
767
  fireEvent.blur(emailInput)
768
768
 
769
- // Wait for OTP modal to open
770
769
  await screen.findByTestId('otp-verify')
771
770
 
772
- // Click "Checkout as a guest" button
771
+ // Click "Checkout as a guest" — modal closes; parent is notified; no basket update
773
772
  await user.click(screen.getByText(/Checkout as a guest/i))
774
773
 
774
+ expect(onRegisteredUserChoseGuestSpy).toHaveBeenCalledWith(true)
775
+ expect(setCheckoutGuestChoiceInStorage).toHaveBeenCalledWith(true)
776
+ expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled()
777
+ expect(mockGoToNextStep).not.toHaveBeenCalled()
778
+
779
+ // Modal closes; user stays on Contact Info (Continue button visible for entering phone)
775
780
  await waitFor(() => {
776
- expect(mockUpdateBillingAddressForBasket.mutateAsync).toHaveBeenCalled()
777
- const callArgs = mockUpdateBillingAddressForBasket.mutateAsync.mock.calls[0]?.[0]
778
- expect(callArgs?.parameters).toMatchObject({basketId: 'test-basket-id'})
779
- expect(callArgs?.body?.phone).toMatch(/727/)
781
+ expect(screen.queryByText("Confirm it's you")).not.toBeInTheDocument()
780
782
  })
783
+ expect(
784
+ screen.getByRole('button', {name: /continue to shipping address/i})
785
+ ).toBeInTheDocument()
786
+ expect(screen.getByLabelText('Phone')).toBeInTheDocument()
781
787
  })
782
788
 
783
789
  test('uses phone from billing address when persisting to customer profile after OTP verification', async () => {
@@ -836,5 +842,8 @@ describe('ContactInfo Component', () => {
836
842
  body: {phoneHome: billingPhone}
837
843
  })
838
844
  })
845
+
846
+ // Guest choice storage should be cleared when user signs in via OTP
847
+ expect(setCheckoutGuestChoiceInStorage).toHaveBeenCalledWith(false)
839
848
  })
840
849
  })
@@ -528,27 +528,28 @@ const Payment = ({
528
528
  isBillingAddress
529
529
  />
530
530
  )}
531
- {(isGuest || showRegistrationNotice) && (
532
- <UserRegistration
533
- enableUserRegistration={enableUserRegistration}
534
- setEnableUserRegistration={onUserRegistrationToggle}
535
- onLoadingChange={onOtpLoadingChange}
536
- isGuestCheckout={registeredUserChoseGuest}
537
- isDisabled={
538
- !(
539
- appliedPayment ||
540
- paymentMethodForm.formState.isValid ||
541
- (isPickupOnly &&
542
- billingAddressForm.formState.isValid)
543
- ) ||
544
- (!effectiveBillingSameAsShipping &&
545
- !billingAddressForm.formState.isValid)
546
- }
547
- onSavePreferenceChange={onSavePreferenceChange}
548
- onRegistered={handleRegistrationSuccess}
549
- showNotice={showRegistrationNotice}
550
- />
551
- )}
531
+ {(isGuest || showRegistrationNotice) &&
532
+ !registeredUserChoseGuest && (
533
+ <UserRegistration
534
+ enableUserRegistration={enableUserRegistration}
535
+ setEnableUserRegistration={onUserRegistrationToggle}
536
+ onLoadingChange={onOtpLoadingChange}
537
+ isGuestCheckout={registeredUserChoseGuest}
538
+ isDisabled={
539
+ !(
540
+ appliedPayment ||
541
+ paymentMethodForm.formState.isValid ||
542
+ (isPickupOnly &&
543
+ billingAddressForm.formState.isValid)
544
+ ) ||
545
+ (!effectiveBillingSameAsShipping &&
546
+ !billingAddressForm.formState.isValid)
547
+ }
548
+ onSavePreferenceChange={onSavePreferenceChange}
549
+ onRegistered={handleRegistrationSuccess}
550
+ showNotice={showRegistrationNotice}
551
+ />
552
+ )}
552
553
  </Stack>
553
554
  </>
554
555
  ) : null}
@@ -594,7 +595,7 @@ const Payment = ({
594
595
  </Stack>
595
596
  )}
596
597
 
597
- {(isGuest || showRegistrationNotice) && (
598
+ {(isGuest || showRegistrationNotice) && !registeredUserChoseGuest && (
598
599
  <UserRegistration
599
600
  enableUserRegistration={enableUserRegistration}
600
601
  setEnableUserRegistration={setEnableUserRegistration}
@@ -109,13 +109,12 @@ export default function ShippingOptions() {
109
109
  const defaultMethodId = shippingMethods?.defaultShippingMethodId
110
110
  return methods.find((m) => m.id === defaultMethodId) || methods[0]
111
111
  },
112
+ // Skip auto-apply when a valid method is already selected.
113
+ // When the shopper clicked "Change" to edit shippingoptions, stay on the edit view.
112
114
  shouldSkip: () => {
113
115
  if (selectedShippingMethod?.id && !isPickupMethod(selectedShippingMethod)) {
114
116
  const stillValid = deliveryMethods.some((m) => m.id === selectedShippingMethod.id)
115
- if (stillValid) {
116
- goToNextStep()
117
- return true
118
- }
117
+ if (stillValid) return true
119
118
  }
120
119
  return false
121
120
  },
@@ -160,8 +159,11 @@ export default function ShippingOptions() {
160
159
  )
161
160
  }, [step, customer, selectedShippingMethod, shippingMethods, STEPS.SHIPPING_OPTIONS])
162
161
 
163
- // Use calculated loading state or auto-select loading state
164
- const effectiveIsLoading = Boolean(isAutoSelectLoading) || Boolean(shouldShowInitialLoading)
162
+ // Use calculated loading state or auto-select loading state only for single-shipment.
163
+ // For multi-shipment, each ShipmentMethods fetches its own methods
164
+ const effectiveIsLoading = hasMultipleDeliveryShipments
165
+ ? false
166
+ : Boolean(isAutoSelectLoading) || Boolean(shouldShowInitialLoading)
165
167
 
166
168
  const form = useForm({
167
169
  shouldUnregister: false,
@@ -5,7 +5,7 @@
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
7
  import React from 'react'
8
- import {screen, waitFor} from '@testing-library/react'
8
+ import {screen, waitFor, within} from '@testing-library/react'
9
9
  import ShippingOptions from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options'
10
10
  import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
11
11
 
@@ -341,7 +341,7 @@ describe('ShippingOptions Component', () => {
341
341
  })
342
342
 
343
343
  describe('for registered users with auto-selection', () => {
344
- test('skips shipping method update when existing method is still valid', async () => {
344
+ test('skips shipping method update when existing method is still valid and stays on edit view', async () => {
345
345
  jest.resetModules()
346
346
 
347
347
  jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
@@ -376,10 +376,14 @@ describe('ShippingOptions Component', () => {
376
376
  localRenderWithProviders(<module.default />)
377
377
 
378
378
  await waitFor(() => {
379
- expect(mockGoToNextStep).toHaveBeenCalled()
379
+ expect(mockUpdateShippingMethod.mutateAsync).not.toHaveBeenCalled()
380
380
  })
381
381
 
382
- expect(mockUpdateShippingMethod.mutateAsync).not.toHaveBeenCalled()
382
+ // Does not auto-advance so user can change option or click Continue (fixes "Change" flicker)
383
+ expect(mockGoToNextStep).not.toHaveBeenCalled()
384
+ expect(
385
+ screen.getAllByRole('button', {name: /continue to payment/i}).length
386
+ ).toBeGreaterThan(0)
383
387
  })
384
388
 
385
389
  test('auto-selects default method when existing method is no longer valid', async () => {
@@ -798,6 +802,93 @@ describe('ShippingOptions Component', () => {
798
802
  expect(screen.getByText('Continue to Payment')).toBeInTheDocument()
799
803
  })
800
804
 
805
+ test('multi-shipment edit view shows shipping options for each shipment', async () => {
806
+ jest.resetModules()
807
+
808
+ jest.doMock(
809
+ '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context',
810
+ () => ({
811
+ useCheckout: jest.fn().mockReturnValue({
812
+ step: 3,
813
+ STEPS: {
814
+ CONTACT_INFO: 0,
815
+ PICKUP_ADDRESS: 1,
816
+ SHIPPING_ADDRESS: 2,
817
+ SHIPPING_OPTIONS: 3,
818
+ PAYMENT: 4
819
+ },
820
+ goToStep: mockGoToStep,
821
+ goToNextStep: mockGoToNextStep
822
+ })
823
+ })
824
+ )
825
+ jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
826
+ useCurrentCustomer: () => ({
827
+ data: {customerId: 'test-customer-id', isRegistered: true}
828
+ })
829
+ }))
830
+ jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
831
+ useCurrentBasket: () => ({
832
+ data: {
833
+ basketId: 'test-basket-id',
834
+ shipments: [
835
+ {
836
+ shipmentId: 'ship1',
837
+ shippingAddress: {
838
+ firstName: 'Oscar',
839
+ lastName: 'Robertson',
840
+ address1: '333 South St',
841
+ city: 'West Lafayette',
842
+ stateCode: 'IN',
843
+ postalCode: '98103'
844
+ },
845
+ shippingMethod: {id: 'std', name: 'Standard'}
846
+ },
847
+ {
848
+ shipmentId: 'ship2',
849
+ shippingAddress: {
850
+ firstName: 'Lee',
851
+ lastName: 'Robertson',
852
+ address1: '158 South St',
853
+ city: 'West Lafayette',
854
+ stateCode: 'IN',
855
+ postalCode: '98103'
856
+ },
857
+ shippingMethod: {id: 'std2', name: 'Standard 2'}
858
+ }
859
+ ],
860
+ shippingItems: [
861
+ {shipmentId: 'ship1', price: 0},
862
+ {shipmentId: 'ship2', price: 0}
863
+ ]
864
+ },
865
+ derivedData: {hasBasket: true, totalItems: 2, totalShippingCost: 0}
866
+ })
867
+ }))
868
+
869
+ const sdk = await import('@salesforce/commerce-sdk-react')
870
+ sdk.useShippingMethodsForShipment.mockImplementation(({parameters}) => {
871
+ if (parameters.shipmentId === 'ship1') return {data: multiShipMethods1}
872
+ if (parameters.shipmentId === 'ship2') return {data: multiShipMethods2}
873
+ return {data: multiShipMethods1}
874
+ })
875
+
876
+ const {renderWithProviders: localRenderWithProviders} = await import(
877
+ '@salesforce/retail-react-app/app/utils/test-utils'
878
+ )
879
+ const module = await import(
880
+ '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options'
881
+ )
882
+
883
+ localRenderWithProviders(<module.default />)
884
+
885
+ const cards = screen.getAllByTestId('sf-toggle-card-step-2')
886
+ expect(cards.length).toBeGreaterThan(0)
887
+ expect(within(cards[0]).queryByTestId('loading')).toBeNull()
888
+ expect(screen.getAllByText('Shipment 1:').length).toBeGreaterThan(0)
889
+ expect(screen.getAllByText('Shipment 2:').length).toBeGreaterThan(0)
890
+ })
891
+
801
892
  test('auto-selects default method when no method is set on shipment', async () => {
802
893
  jest.resetModules()
803
894
 
@@ -5,7 +5,7 @@
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
7
  import React, {useRef, useState, useEffect} from 'react'
8
- import {FormattedMessage} from 'react-intl'
8
+ import {FormattedMessage, useIntl} from 'react-intl'
9
9
  import PropTypes from 'prop-types'
10
10
  import {
11
11
  Box,
@@ -23,7 +23,12 @@ import {
23
23
  import OtpAuth from '@salesforce/retail-react-app/app/components/otp-auth'
24
24
  import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
25
25
  import {useCustomerType, useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react'
26
+ import {useShopperCustomersMutation} from '@salesforce/commerce-sdk-react'
26
27
  import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
28
+ import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context'
29
+ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
30
+ import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils'
31
+ import {nanoid} from 'nanoid'
27
32
 
28
33
  export default function UserRegistration({
29
34
  enableUserRegistration,
@@ -36,15 +41,28 @@ export default function UserRegistration({
36
41
  onLoadingChange
37
42
  }) {
38
43
  const {data: basket} = useCurrentBasket()
44
+ const {contactPhone} = useCheckout()
39
45
  const {isGuest} = useCustomerType()
40
46
  const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
41
47
  const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
42
48
  const {locale} = useMultiSite()
49
+ const {formatMessage} = useIntl()
50
+ const showToast = useToast()
51
+ const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress')
52
+ const updateCustomer = useShopperCustomersMutation('updateCustomer')
53
+
43
54
  const {isOpen: isOtpOpen, onOpen: onOtpOpen, onClose: onOtpClose} = useDisclosure()
44
55
  const otpSentRef = useRef(false)
45
56
  const [registrationSucceeded, setRegistrationSucceeded] = useState(false)
46
57
  const [isLoadingOtp, setIsLoadingOtp] = useState(false)
47
58
 
59
+ const showError = (message) => {
60
+ showToast({
61
+ title: message,
62
+ status: 'error'
63
+ })
64
+ }
65
+
48
66
  const handleOtpClose = () => {
49
67
  otpSentRef.current = false
50
68
  onOtpClose()
@@ -92,13 +110,79 @@ export default function UserRegistration({
92
110
  }
93
111
  }, [isOtpOpen, isLoadingOtp, onLoadingChange])
94
112
 
113
+ const saveAddressesAndPhoneToProfile = async (customerId) => {
114
+ if (!basket || !customerId) return
115
+ const deliveryShipments =
116
+ basket.shipments?.filter(
117
+ (shipment) => !isPickupShipment(shipment) && shipment.shippingAddress
118
+ ) || []
119
+ try {
120
+ if (deliveryShipments.length > 0) {
121
+ for (let i = 0; i < deliveryShipments.length; i++) {
122
+ const shipment = deliveryShipments[i]
123
+ const shipping = shipment.shippingAddress
124
+ if (!shipping) continue
125
+
126
+ const {
127
+ address1,
128
+ address2,
129
+ city,
130
+ countryCode,
131
+ firstName,
132
+ lastName,
133
+ phone,
134
+ postalCode,
135
+ stateCode
136
+ } = shipping || {}
137
+
138
+ await createCustomerAddress.mutateAsync({
139
+ parameters: {customerId},
140
+ body: {
141
+ addressId: nanoid(),
142
+ preferred: i === 0,
143
+ address1,
144
+ address2,
145
+ city,
146
+ countryCode,
147
+ firstName,
148
+ lastName,
149
+ phone,
150
+ postalCode,
151
+ stateCode
152
+ }
153
+ })
154
+ }
155
+ }
156
+
157
+ const phoneHome = basket.billingAddress?.phone || contactPhone
158
+ if (phoneHome) {
159
+ await updateCustomer.mutateAsync({
160
+ parameters: {customerId},
161
+ body: {phoneHome}
162
+ })
163
+ }
164
+ } catch (_e) {
165
+ showError(
166
+ formatMessage({
167
+ id: 'checkout.error.cannot_save_address',
168
+ defaultMessage: 'Could not save shipping address.'
169
+ })
170
+ )
171
+ }
172
+ }
173
+
95
174
  const handleOtpVerification = async (otpCode) => {
96
175
  try {
97
- await loginPasswordless.mutateAsync({
176
+ const token = await loginPasswordless.mutateAsync({
98
177
  pwdlessLoginToken: otpCode,
99
178
  register_customer: true
100
179
  })
101
180
 
181
+ const customerId = token?.customer_id || token?.customerId
182
+ if (customerId && basket) {
183
+ await saveAddressesAndPhoneToProfile(customerId)
184
+ }
185
+
102
186
  if (onRegistered) {
103
187
  await onRegistered(basket?.basketId)
104
188
  }
@@ -15,6 +15,13 @@ import useAuthContext from '@salesforce/commerce-sdk-react/hooks/useAuthContext'
15
15
 
16
16
  jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket')
17
17
 
18
+ jest.mock(
19
+ '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context',
20
+ () => ({
21
+ useCheckout: () => ({contactPhone: ''})
22
+ })
23
+ )
24
+
18
25
  const {AuthHelpers} = jest.requireActual('@salesforce/commerce-sdk-react')
19
26
 
20
27
  const TEST_MESSAGES = {
@@ -29,12 +36,23 @@ const mockAuthHelperFunctions = {
29
36
  [AuthHelpers.LoginPasswordlessUser]: {mutateAsync: jest.fn()}
30
37
  }
31
38
 
39
+ const mockCreateCustomerAddress = {mutateAsync: jest.fn().mockResolvedValue({})}
40
+ const mockUpdateCustomer = {mutateAsync: jest.fn().mockResolvedValue({})}
41
+ const mockCreateCustomerPaymentInstrument = {mutateAsync: jest.fn().mockResolvedValue({})}
42
+
32
43
  jest.mock('@salesforce/commerce-sdk-react', () => {
33
44
  const original = jest.requireActual('@salesforce/commerce-sdk-react')
34
45
  return {
35
46
  ...original,
36
47
  useCustomerType: jest.fn(),
37
- useAuthHelper: jest.fn((helper) => mockAuthHelperFunctions[helper])
48
+ useAuthHelper: jest.fn((helper) => mockAuthHelperFunctions[helper]),
49
+ useShopperCustomersMutation: jest.fn((mutationType) => {
50
+ if (mutationType === 'createCustomerAddress') return mockCreateCustomerAddress
51
+ if (mutationType === 'updateCustomer') return mockUpdateCustomer
52
+ if (mutationType === 'createCustomerPaymentInstrument')
53
+ return mockCreateCustomerPaymentInstrument
54
+ return {mutateAsync: jest.fn()}
55
+ })
38
56
  }
39
57
  })
40
58
  jest.mock('@salesforce/commerce-sdk-react/hooks/useAuthContext', () =>
@@ -10,6 +10,26 @@ import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein'
10
10
  import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
11
11
  import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
12
12
  import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils'
13
+ import {
14
+ getSessionJSONItem,
15
+ setSessionJSONItem,
16
+ clearSessionJSONItem
17
+ } from '@salesforce/retail-react-app/app/utils/utils'
18
+
19
+ /** SessionStorage key for "checkout as guest" choice so it persists when shopper navigates away and returns */
20
+ export const CHECKOUT_GUEST_CHOICE_STORAGE_KEY = 'sf_checkout_one_click_guest_choice'
21
+
22
+ export const getCheckoutGuestChoiceFromStorage = () => {
23
+ return getSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY) === true
24
+ }
25
+
26
+ export const setCheckoutGuestChoiceInStorage = (value) => {
27
+ if (value) {
28
+ setSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY, true)
29
+ } else {
30
+ clearSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY)
31
+ }
32
+ }
13
33
 
14
34
  const CheckoutContext = React.createContext()
15
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/retail-react-app",
3
- "version": "9.0.0-preview.0",
3
+ "version": "9.0.0-preview.1",
4
4
  "license": "See license in LICENSE",
5
5
  "author": "cc-pwa-kit@salesforce.com",
6
6
  "ccExtensibility": {
@@ -46,10 +46,10 @@
46
46
  "@loadable/component": "^5.15.3",
47
47
  "@peculiar/webcrypto": "^1.4.2",
48
48
  "@salesforce/cc-datacloud-typescript": "1.1.2",
49
- "@salesforce/commerce-sdk-react": "5.0.0-preview.0",
50
- "@salesforce/pwa-kit-dev": "3.16.0-preview.0",
51
- "@salesforce/pwa-kit-react-sdk": "3.16.0-preview.0",
52
- "@salesforce/pwa-kit-runtime": "3.16.0-preview.0",
49
+ "@salesforce/commerce-sdk-react": "5.0.0-preview.1",
50
+ "@salesforce/pwa-kit-dev": "3.16.0-preview.1",
51
+ "@salesforce/pwa-kit-react-sdk": "3.16.0-preview.1",
52
+ "@salesforce/pwa-kit-runtime": "3.16.0-preview.1",
53
53
  "@tanstack/react-query": "^4.28.0",
54
54
  "@tanstack/react-query-devtools": "^4.29.1",
55
55
  "@testing-library/dom": "^9.0.1",
@@ -108,5 +108,5 @@
108
108
  "maxSize": "366 kB"
109
109
  }
110
110
  ],
111
- "gitHead": "f14c1e991a7657031ac73de7c8a4445e6a66a33d"
111
+ "gitHead": "4401444aab376bf9091cda08d636702556c48323"
112
112
  }