@salesforce/retail-react-app 9.0.0-preview.0 → 9.0.0-preview.2
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 +4 -1
- package/app/components/otp-auth/index.test.js +0 -1
- package/app/components/passwordless-login/index.jsx +1 -1
- package/app/pages/checkout-one-click/index.jsx +8 -85
- package/app/pages/checkout-one-click/index.test.js +1 -10
- package/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +33 -48
- package/app/pages/checkout-one-click/partials/one-click-contact-info.test.js +45 -36
- package/app/pages/checkout-one-click/partials/one-click-payment.jsx +27 -29
- package/app/pages/checkout-one-click/partials/one-click-payment.test.js +1 -1
- package/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx +10 -8
- package/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js +95 -4
- package/app/pages/checkout-one-click/partials/one-click-user-registration.jsx +86 -2
- package/app/pages/checkout-one-click/partials/one-click-user-registration.test.js +19 -1
- package/app/pages/checkout-one-click/util/checkout-context.js +20 -0
- package/app/static/translations/compiled/en-GB.json +14 -2
- package/app/static/translations/compiled/en-US.json +14 -2
- package/app/static/translations/compiled/en-XA.json +30 -2
- package/config/default.js +3 -0
- package/package.json +6 -6
- package/translations/en-GB.json +8 -2
- package/translations/en-US.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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
|
-
- [Feature] One Click Checkout [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
|
|
5
|
+
- [Feature] One Click Checkout (in Developer Preview) [#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)
|
|
4
7
|
- [Bugfix] Fix Passwordless Login landingPath, Reset Password landingPath, and Social Login redirectUri value in config not being used [#3560](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3560)
|
|
5
8
|
- [Feature] PWA Integration with OMS
|
|
@@ -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
|
|
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(
|
|
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({
|
|
@@ -751,11 +751,6 @@ describe('Checkout One Click', () => {
|
|
|
751
751
|
})
|
|
752
752
|
).toBeInTheDocument()
|
|
753
753
|
|
|
754
|
-
// Billing address should default to the shipping address
|
|
755
|
-
|
|
756
|
-
// Should display billing address that matches shipping address
|
|
757
|
-
expect(step3Content.getByText('123 Main St')).toBeInTheDocument()
|
|
758
|
-
|
|
759
754
|
// Edit billing address
|
|
760
755
|
// Toggle to edit billing address (not via same-as-shipping label in this flow)
|
|
761
756
|
// Click the checkbox by role if present; otherwise skip
|
|
@@ -1208,10 +1203,6 @@ describe('Checkout One Click', () => {
|
|
|
1208
1203
|
})
|
|
1209
1204
|
).toBeInTheDocument()
|
|
1210
1205
|
|
|
1211
|
-
// Verify billing address is displayed (it shows John Smith from the mock)
|
|
1212
|
-
expect(step3Content.getByText('John Smith')).toBeInTheDocument()
|
|
1213
|
-
expect(step3Content.getByText('123 Main St')).toBeInTheDocument()
|
|
1214
|
-
|
|
1215
1206
|
// Verify UserRegistration component is hidden for registered customers
|
|
1216
1207
|
expect(screen.queryByTestId('sf-user-registration-content')).not.toBeInTheDocument()
|
|
1217
1208
|
|
|
@@ -2650,7 +2641,7 @@ describe('Checkout One Click', () => {
|
|
|
2650
2641
|
|
|
2651
2642
|
// Click "Edit Payment Info" button
|
|
2652
2643
|
const editPaymentButton = screen.getByRole('button', {
|
|
2653
|
-
name: /toggle_card.action.
|
|
2644
|
+
name: /toggle_card.action.changePaymentInfo|Change/i
|
|
2654
2645
|
})
|
|
2655
2646
|
await user.click(editPaymentButton)
|
|
2656
2647
|
|
|
@@ -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 {
|
|
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
|
|
@@ -242,11 +245,19 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
242
245
|
lastEmailSentRef.current = normalizedEmail
|
|
243
246
|
return {isRegistered: true}
|
|
244
247
|
} catch (error) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
// 404 = email not registered (guest); treat as guest and continue
|
|
249
|
+
const isGuestNotFound = String(error?.message || '').includes('404')
|
|
250
|
+
if (isGuestNotFound && isValidEmail(email)) {
|
|
251
|
+
setError('')
|
|
249
252
|
setShowContinueButton(true)
|
|
253
|
+
} else {
|
|
254
|
+
const message = formatMessage(
|
|
255
|
+
getAuthorizePasswordlessErrorMessage(error.message)
|
|
256
|
+
)
|
|
257
|
+
setError(message)
|
|
258
|
+
if (isValidEmail(email)) {
|
|
259
|
+
setShowContinueButton(true)
|
|
260
|
+
}
|
|
250
261
|
}
|
|
251
262
|
// Update the last email sent ref even on error to prevent retrying immediately
|
|
252
263
|
lastEmailSentRef.current = normalizedEmail
|
|
@@ -270,37 +281,11 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
// Handle checkout as guest from OTP modal
|
|
273
|
-
const handleCheckoutAsGuest =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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)
|
|
284
|
+
const handleCheckoutAsGuest = () => {
|
|
285
|
+
setRegisteredUserChoseGuest(true)
|
|
286
|
+
setCheckoutGuestChoiceInStorage(true)
|
|
287
|
+
if (onRegisteredUserChoseGuest) {
|
|
288
|
+
onRegisteredUserChoseGuest(true)
|
|
304
289
|
}
|
|
305
290
|
}
|
|
306
291
|
|
|
@@ -359,6 +344,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
359
344
|
|
|
360
345
|
// Reset guest checkout flag since user is now logged in
|
|
361
346
|
setRegisteredUserChoseGuest(false)
|
|
347
|
+
setCheckoutGuestChoiceInStorage(false)
|
|
362
348
|
if (onRegisteredUserChoseGuest) {
|
|
363
349
|
onRegisteredUserChoseGuest(false)
|
|
364
350
|
}
|
|
@@ -470,7 +456,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
470
456
|
return
|
|
471
457
|
}
|
|
472
458
|
|
|
473
|
-
if (!result.isRegistered) {
|
|
459
|
+
if (!result.isRegistered || registeredUserChoseGuest) {
|
|
474
460
|
// Guest shoppers must provide phone number before proceeding
|
|
475
461
|
const phone = (formData.phone || '').trim()
|
|
476
462
|
if (!phone) {
|
|
@@ -534,6 +520,9 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
534
520
|
}
|
|
535
521
|
}
|
|
536
522
|
|
|
523
|
+
const customerEmail = customer?.email || form.getValues('email')
|
|
524
|
+
const customerPhone = customer?.phoneHome || form.getValues('phone')
|
|
525
|
+
|
|
537
526
|
return (
|
|
538
527
|
<>
|
|
539
528
|
<ToggleCard
|
|
@@ -557,8 +546,8 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
557
546
|
id: 'checkout_contact_info.action.sign_out'
|
|
558
547
|
})
|
|
559
548
|
: formatMessage({
|
|
560
|
-
defaultMessage: '
|
|
561
|
-
id: 'checkout_contact_info.action.
|
|
549
|
+
defaultMessage: 'Change',
|
|
550
|
+
id: 'checkout_contact_info.action.change'
|
|
562
551
|
})
|
|
563
552
|
}
|
|
564
553
|
>
|
|
@@ -688,18 +677,14 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
|
|
|
688
677
|
</Container>
|
|
689
678
|
</ToggleCardEdit>
|
|
690
679
|
|
|
691
|
-
{
|
|
680
|
+
{customerEmail ? (
|
|
692
681
|
<ToggleCardSummary>
|
|
693
682
|
<Stack spacing={1}>
|
|
694
|
-
<Text>{
|
|
695
|
-
{
|
|
696
|
-
<Text fontSize="sm" color="gray.600">
|
|
697
|
-
{customer?.phoneHome || form.getValues('phone')}
|
|
698
|
-
</Text>
|
|
699
|
-
)}
|
|
683
|
+
<Text>{customerEmail}</Text>
|
|
684
|
+
{customerPhone && <Text>{customerPhone}</Text>}
|
|
700
685
|
</Stack>
|
|
701
686
|
</ToggleCardSummary>
|
|
702
|
-
)}
|
|
687
|
+
) : null}
|
|
703
688
|
</ToggleCard>
|
|
704
689
|
|
|
705
690
|
{/* Sign Out Confirmation Dialog */}
|
|
@@ -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'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={
|
|
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('
|
|
610
|
-
//
|
|
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"
|
|
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('
|
|
751
|
-
//
|
|
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
|
|
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
|
|
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"
|
|
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(
|
|
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
|
})
|
|
@@ -441,8 +441,8 @@ const Payment = ({
|
|
|
441
441
|
disabled={appliedPayment == null}
|
|
442
442
|
onEdit={handleEditPayment}
|
|
443
443
|
editLabel={formatMessage({
|
|
444
|
-
defaultMessage: '
|
|
445
|
-
id: 'toggle_card.action.
|
|
444
|
+
defaultMessage: 'Change',
|
|
445
|
+
id: 'toggle_card.action.changePaymentInfo'
|
|
446
446
|
})}
|
|
447
447
|
>
|
|
448
448
|
<ToggleCardEdit>
|
|
@@ -528,27 +528,28 @@ const Payment = ({
|
|
|
528
528
|
isBillingAddress
|
|
529
529
|
/>
|
|
530
530
|
)}
|
|
531
|
-
{(isGuest || showRegistrationNotice) &&
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
!
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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}
|
|
@@ -579,8 +580,7 @@ const Payment = ({
|
|
|
579
580
|
|
|
580
581
|
<Divider borderColor="gray.100" />
|
|
581
582
|
|
|
582
|
-
{
|
|
583
|
-
(effectiveBillingSameAsShipping && selectedShippingAddress)) && (
|
|
583
|
+
{selectedBillingAddress && !effectiveBillingSameAsShipping && (
|
|
584
584
|
<Stack spacing={2}>
|
|
585
585
|
<Heading as="h3" fontSize="md">
|
|
586
586
|
<FormattedMessage
|
|
@@ -588,13 +588,11 @@ const Payment = ({
|
|
|
588
588
|
id="checkout_payment.heading.billing_address"
|
|
589
589
|
/>
|
|
590
590
|
</Heading>
|
|
591
|
-
<AddressDisplay
|
|
592
|
-
address={selectedBillingAddress || selectedShippingAddress}
|
|
593
|
-
/>
|
|
591
|
+
<AddressDisplay address={selectedBillingAddress} />
|
|
594
592
|
</Stack>
|
|
595
593
|
)}
|
|
596
594
|
|
|
597
|
-
{(isGuest || showRegistrationNotice) && (
|
|
595
|
+
{(isGuest || showRegistrationNotice) && !registeredUserChoseGuest && (
|
|
598
596
|
<UserRegistration
|
|
599
597
|
enableUserRegistration={enableUserRegistration}
|
|
600
598
|
setEnableUserRegistration={setEnableUserRegistration}
|
|
@@ -1310,7 +1310,7 @@ describe('Payment Component', () => {
|
|
|
1310
1310
|
// Click Edit Payment Info to enter edit mode
|
|
1311
1311
|
const summary = screen.getAllByTestId('toggle-card-summary').pop()
|
|
1312
1312
|
const editButton = within(summary).getByRole('button', {
|
|
1313
|
-
name: /toggle_card.action.
|
|
1313
|
+
name: /toggle_card.action.changePaymentInfo|Change/i
|
|
1314
1314
|
})
|
|
1315
1315
|
await user.click(editButton)
|
|
1316
1316
|
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -264,8 +266,8 @@ export default function ShippingOptions() {
|
|
|
264
266
|
}
|
|
265
267
|
onEdit={() => goToStep(STEPS.SHIPPING_OPTIONS)}
|
|
266
268
|
editLabel={formatMessage({
|
|
267
|
-
defaultMessage: '
|
|
268
|
-
id: 'toggle_card.action.
|
|
269
|
+
defaultMessage: 'Change',
|
|
270
|
+
id: 'toggle_card.action.changeShippingOptions'
|
|
269
271
|
})}
|
|
270
272
|
>
|
|
271
273
|
<ToggleCardEdit>
|
|
@@ -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(
|
|
379
|
+
expect(mockUpdateShippingMethod.mutateAsync).not.toHaveBeenCalled()
|
|
380
380
|
})
|
|
381
381
|
|
|
382
|
-
|
|
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
|
|
|
@@ -1249,10 +1249,10 @@
|
|
|
1249
1249
|
"value": " with your confirmation number and receipt shortly."
|
|
1250
1250
|
}
|
|
1251
1251
|
],
|
|
1252
|
-
"checkout_contact_info.action.
|
|
1252
|
+
"checkout_contact_info.action.change": [
|
|
1253
1253
|
{
|
|
1254
1254
|
"type": 0,
|
|
1255
|
-
"value": "
|
|
1255
|
+
"value": "Change"
|
|
1256
1256
|
}
|
|
1257
1257
|
],
|
|
1258
1258
|
"checkout_contact_info.action.sign_out": [
|
|
@@ -4681,6 +4681,18 @@
|
|
|
4681
4681
|
"value": "Change"
|
|
4682
4682
|
}
|
|
4683
4683
|
],
|
|
4684
|
+
"toggle_card.action.changePaymentInfo": [
|
|
4685
|
+
{
|
|
4686
|
+
"type": 0,
|
|
4687
|
+
"value": "Change"
|
|
4688
|
+
}
|
|
4689
|
+
],
|
|
4690
|
+
"toggle_card.action.changeShippingOptions": [
|
|
4691
|
+
{
|
|
4692
|
+
"type": 0,
|
|
4693
|
+
"value": "Change"
|
|
4694
|
+
}
|
|
4695
|
+
],
|
|
4684
4696
|
"toggle_card.action.edit": [
|
|
4685
4697
|
{
|
|
4686
4698
|
"type": 0,
|
|
@@ -1249,10 +1249,10 @@
|
|
|
1249
1249
|
"value": " with your confirmation number and receipt shortly."
|
|
1250
1250
|
}
|
|
1251
1251
|
],
|
|
1252
|
-
"checkout_contact_info.action.
|
|
1252
|
+
"checkout_contact_info.action.change": [
|
|
1253
1253
|
{
|
|
1254
1254
|
"type": 0,
|
|
1255
|
-
"value": "
|
|
1255
|
+
"value": "Change"
|
|
1256
1256
|
}
|
|
1257
1257
|
],
|
|
1258
1258
|
"checkout_contact_info.action.sign_out": [
|
|
@@ -4681,6 +4681,18 @@
|
|
|
4681
4681
|
"value": "Change"
|
|
4682
4682
|
}
|
|
4683
4683
|
],
|
|
4684
|
+
"toggle_card.action.changePaymentInfo": [
|
|
4685
|
+
{
|
|
4686
|
+
"type": 0,
|
|
4687
|
+
"value": "Change"
|
|
4688
|
+
}
|
|
4689
|
+
],
|
|
4690
|
+
"toggle_card.action.changeShippingOptions": [
|
|
4691
|
+
{
|
|
4692
|
+
"type": 0,
|
|
4693
|
+
"value": "Change"
|
|
4694
|
+
}
|
|
4695
|
+
],
|
|
4684
4696
|
"toggle_card.action.edit": [
|
|
4685
4697
|
{
|
|
4686
4698
|
"type": 0,
|
|
@@ -2473,14 +2473,14 @@
|
|
|
2473
2473
|
"value": "]"
|
|
2474
2474
|
}
|
|
2475
2475
|
],
|
|
2476
|
-
"checkout_contact_info.action.
|
|
2476
|
+
"checkout_contact_info.action.change": [
|
|
2477
2477
|
{
|
|
2478
2478
|
"type": 0,
|
|
2479
2479
|
"value": "["
|
|
2480
2480
|
},
|
|
2481
2481
|
{
|
|
2482
2482
|
"type": 0,
|
|
2483
|
-
"value": "
|
|
2483
|
+
"value": "Ƈħȧȧƞɠḗḗ"
|
|
2484
2484
|
},
|
|
2485
2485
|
{
|
|
2486
2486
|
"type": 0,
|
|
@@ -9841,6 +9841,34 @@
|
|
|
9841
9841
|
"value": "]"
|
|
9842
9842
|
}
|
|
9843
9843
|
],
|
|
9844
|
+
"toggle_card.action.changePaymentInfo": [
|
|
9845
|
+
{
|
|
9846
|
+
"type": 0,
|
|
9847
|
+
"value": "["
|
|
9848
|
+
},
|
|
9849
|
+
{
|
|
9850
|
+
"type": 0,
|
|
9851
|
+
"value": "Ƈħȧȧƞɠḗḗ"
|
|
9852
|
+
},
|
|
9853
|
+
{
|
|
9854
|
+
"type": 0,
|
|
9855
|
+
"value": "]"
|
|
9856
|
+
}
|
|
9857
|
+
],
|
|
9858
|
+
"toggle_card.action.changeShippingOptions": [
|
|
9859
|
+
{
|
|
9860
|
+
"type": 0,
|
|
9861
|
+
"value": "["
|
|
9862
|
+
},
|
|
9863
|
+
{
|
|
9864
|
+
"type": 0,
|
|
9865
|
+
"value": "Ƈħȧȧƞɠḗḗ"
|
|
9866
|
+
},
|
|
9867
|
+
{
|
|
9868
|
+
"type": 0,
|
|
9869
|
+
"value": "]"
|
|
9870
|
+
}
|
|
9871
|
+
],
|
|
9844
9872
|
"toggle_card.action.edit": [
|
|
9845
9873
|
{
|
|
9846
9874
|
"type": 0,
|
package/config/default.js
CHANGED
|
@@ -74,6 +74,9 @@ module.exports = {
|
|
|
74
74
|
appSourceId: '7ae070a6-f4ec-4def-a383-d9cacc3f20a1',
|
|
75
75
|
tenantId: 'g82wgnrvm-ywk9dggrrw8mtggy.pc-rnd'
|
|
76
76
|
},
|
|
77
|
+
// Note: this feature is in Developer Preview at this time. To use One Click Checkout,
|
|
78
|
+
// enable the oneClickCheckout flag and configure private SLAS client. For more details, please
|
|
79
|
+
// check https://github.com/SalesforceCommerceCloud/pwa-kit/releases/tag/v3.16.0
|
|
77
80
|
oneClickCheckout: {
|
|
78
81
|
enabled: false
|
|
79
82
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/retail-react-app",
|
|
3
|
-
"version": "9.0.0-preview.
|
|
3
|
+
"version": "9.0.0-preview.2",
|
|
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.
|
|
50
|
-
"@salesforce/pwa-kit-dev": "3.16.0-preview.
|
|
51
|
-
"@salesforce/pwa-kit-react-sdk": "3.16.0-preview.
|
|
52
|
-
"@salesforce/pwa-kit-runtime": "3.16.0-preview.
|
|
49
|
+
"@salesforce/commerce-sdk-react": "5.0.0-preview.2",
|
|
50
|
+
"@salesforce/pwa-kit-dev": "3.16.0-preview.2",
|
|
51
|
+
"@salesforce/pwa-kit-react-sdk": "3.16.0-preview.2",
|
|
52
|
+
"@salesforce/pwa-kit-runtime": "3.16.0-preview.2",
|
|
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": "
|
|
111
|
+
"gitHead": "70ef0ffc94a2538cbf735883eb6061d410980438"
|
|
112
112
|
}
|
package/translations/en-GB.json
CHANGED
|
@@ -460,8 +460,8 @@
|
|
|
460
460
|
"checkout_confirmation.message.will_email_shortly": {
|
|
461
461
|
"defaultMessage": "We will send an email to <b>{email}</b> with your confirmation number and receipt shortly."
|
|
462
462
|
},
|
|
463
|
-
"checkout_contact_info.action.
|
|
464
|
-
"defaultMessage": "
|
|
463
|
+
"checkout_contact_info.action.change": {
|
|
464
|
+
"defaultMessage": "Change"
|
|
465
465
|
},
|
|
466
466
|
"checkout_contact_info.action.sign_out": {
|
|
467
467
|
"defaultMessage": "Sign Out"
|
|
@@ -1949,6 +1949,12 @@
|
|
|
1949
1949
|
"toggle_card.action.change": {
|
|
1950
1950
|
"defaultMessage": "Change"
|
|
1951
1951
|
},
|
|
1952
|
+
"toggle_card.action.changePaymentInfo": {
|
|
1953
|
+
"defaultMessage": "Change"
|
|
1954
|
+
},
|
|
1955
|
+
"toggle_card.action.changeShippingOptions": {
|
|
1956
|
+
"defaultMessage": "Change"
|
|
1957
|
+
},
|
|
1952
1958
|
"toggle_card.action.edit": {
|
|
1953
1959
|
"defaultMessage": "Edit"
|
|
1954
1960
|
},
|
package/translations/en-US.json
CHANGED
|
@@ -460,8 +460,8 @@
|
|
|
460
460
|
"checkout_confirmation.message.will_email_shortly": {
|
|
461
461
|
"defaultMessage": "We will send an email to <b>{email}</b> with your confirmation number and receipt shortly."
|
|
462
462
|
},
|
|
463
|
-
"checkout_contact_info.action.
|
|
464
|
-
"defaultMessage": "
|
|
463
|
+
"checkout_contact_info.action.change": {
|
|
464
|
+
"defaultMessage": "Change"
|
|
465
465
|
},
|
|
466
466
|
"checkout_contact_info.action.sign_out": {
|
|
467
467
|
"defaultMessage": "Sign Out"
|
|
@@ -1949,6 +1949,12 @@
|
|
|
1949
1949
|
"toggle_card.action.change": {
|
|
1950
1950
|
"defaultMessage": "Change"
|
|
1951
1951
|
},
|
|
1952
|
+
"toggle_card.action.changePaymentInfo": {
|
|
1953
|
+
"defaultMessage": "Change"
|
|
1954
|
+
},
|
|
1955
|
+
"toggle_card.action.changeShippingOptions": {
|
|
1956
|
+
"defaultMessage": "Change"
|
|
1957
|
+
},
|
|
1952
1958
|
"toggle_card.action.edit": {
|
|
1953
1959
|
"defaultMessage": "Edit"
|
|
1954
1960
|
},
|