Skip to content

Commit 8a25361

Browse files
@W-21038080: Rework to use billing address phone number for guest shoppers (#3618)
Signed-off-by: d.phan <d.phan@salesforce.com>
1 parent 4f04c56 commit 8a25361

File tree

5 files changed

+184
-53
lines changed

5 files changed

+184
-53
lines changed

packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,9 @@ const CheckoutOneClick = () => {
266266
}
267267
}
268268

269-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
270-
const {addressId, creationDate, lastModified, preferred, phone, ...address} = billingAddress
271269
const latestBasketId = currentBasketQuery.data?.basketId || basket.basketId
272270
return await updateBillingAddressForBasket({
273-
body: address,
271+
body: billingAddress,
274272
parameters: {basketId: latestBasketId}
275273
})
276274
}
@@ -429,14 +427,8 @@ const CheckoutOneClick = () => {
429427
}
430428
}
431429

432-
// Persist phone number as phoneHome from contact info, shipping address, or basket
433-
// Priority: contactPhone (from contact info form) > shipping address phone > basket customerInfo phone
434-
const phoneHome =
435-
contactPhone && contactPhone.length > 0
436-
? contactPhone
437-
: deliveryShipments.length > 0
438-
? deliveryShipments[0]?.shippingAddress?.phone
439-
: basket?.customerInfo?.phone
430+
// Persist phone number as phoneHome for newly registered guest shoppers
431+
const phoneHome = basket?.billingAddress?.phone || contactPhone
440432
if (phoneHome) {
441433
await updateCustomer.mutateAsync({
442434
parameters: {customerId},
@@ -613,6 +605,7 @@ const CheckoutOneClick = () => {
613605
billingSameAsShipping={billingSameAsShipping}
614606
setBillingSameAsShipping={setBillingSameAsShipping}
615607
onOtpLoadingChange={setIsOtpLoading}
608+
onBillingSubmit={onBillingSubmit}
616609
/>
617610

618611
{step >= STEPS.PAYMENT && (

packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
6565
const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket')
6666
const transferBasket = useShopperBasketsMutation('transferBasket')
6767
const updateCustomer = useShopperCustomersMutation('updateCustomer')
68+
const updateBillingAddressForBasket = useShopperBasketsMutation('updateBillingAddressForBasket')
6869
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
6970
const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
7071
const {locale} = useMultiSite()
@@ -82,7 +83,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
8283
const form = useForm({
8384
defaultValues: {
8485
email: customer?.email || basket?.customerInfo?.email || '',
85-
phone: customer?.phoneHome || '',
86+
phone: customer?.phoneHome || basket?.billingAddress?.phone || '',
8687
password: '',
8788
otp: ''
8889
}
@@ -261,12 +262,24 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
261262
const handleCheckoutAsGuest = async () => {
262263
try {
263264
const email = form.getValues('email')
265+
const phone = form.getValues('phone')
264266
// Update basket with guest email
265267
await updateCustomerForBasket.mutateAsync({
266268
parameters: {basketId: basket.basketId},
267269
body: {email: email}
268270
})
269271

272+
// Save phone number to basket billing address for guest shoppers
273+
if (phone) {
274+
await updateBillingAddressForBasket.mutateAsync({
275+
parameters: {basketId: basket.basketId},
276+
body: {
277+
...basket?.billingAddress,
278+
phone: phone
279+
}
280+
})
281+
}
282+
270283
// Set the flag that "Checkout as Guest" was clicked
271284
setRegisteredUserChoseGuest(true)
272285
if (onRegisteredUserChoseGuest) {
@@ -321,7 +334,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
321334
}
322335

323336
// Persist phone number to the newly registered customer's profile
324-
const phone = form.getValues('phone')
337+
const phone = basket?.billingAddress?.phone || form.getValues('phone')
325338
if (phone && customer?.customerId) {
326339
try {
327340
await updateCustomer.mutateAsync({
@@ -471,6 +484,17 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
471484
body: {email: formData.email}
472485
})
473486

487+
// Save phone number to basket billing address for guest shoppers
488+
if (phone) {
489+
await updateBillingAddressForBasket.mutateAsync({
490+
parameters: {basketId: basket.basketId},
491+
body: {
492+
...basket?.billingAddress,
493+
phone: phone
494+
}
495+
})
496+
}
497+
474498
// Update basket and immediately advance to next step for smooth UX
475499
goToNextStep()
476500

packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js

Lines changed: 143 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const mockAuthHelperFunctions = {
2222

2323
const mockUpdateCustomerForBasket = {mutateAsync: jest.fn()}
2424
const mockTransferBasket = {mutate: jest.fn(), mutateAsync: jest.fn()}
25+
const mockUpdateBillingAddressForBasket = {mutateAsync: jest.fn()}
26+
const mockUpdateCustomer = {mutateAsync: jest.fn()}
2527

2628
jest.mock('@salesforce/commerce-sdk-react', () => {
2729
const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
@@ -34,10 +36,12 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
3436
useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => {
3537
if (mutationType === 'updateCustomerForBasket') return mockUpdateCustomerForBasket
3638
if (mutationType === 'transferBasket') return mockTransferBasket
39+
if (mutationType === 'updateBillingAddressForBasket')
40+
return mockUpdateBillingAddressForBasket
3741
return {mutate: jest.fn()}
3842
}),
3943
useShopperCustomersMutation: jest.fn().mockImplementation((mutationType) => {
40-
if (mutationType === 'updateCustomer') return {mutateAsync: jest.fn()}
44+
if (mutationType === 'updateCustomer') return mockUpdateCustomer
4145
return {mutateAsync: jest.fn()}
4246
})
4347
}
@@ -62,13 +66,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
6266
useCurrentBasket: (...args) => mockUseCurrentBasket(...args)
6367
}))
6468

69+
const mockUseCurrentCustomer = jest.fn(() => ({
70+
data: {
71+
email: null,
72+
isRegistered: false
73+
}
74+
}))
6575
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
66-
useCurrentCustomer: () => ({
67-
data: {
68-
email: null,
69-
isRegistered: false
70-
}
71-
})
76+
useCurrentCustomer: (...args) => mockUseCurrentCustomer(...args)
7277
}))
7378

7479
const mockSetContactPhone = jest.fn()
@@ -134,11 +139,25 @@ beforeEach(() => {
134139
basketId: 'test-basket-id',
135140
customerInfo: {email: null},
136141
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
137-
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}]
142+
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
143+
billingAddress: null
138144
},
139145
derivedData: {hasBasket: true, totalItems: 1},
140146
refetch: jest.fn()
141147
})
148+
// Reset billing address mutation mock
149+
mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})
150+
// Reset customer mock to default
151+
mockUseCurrentCustomer.mockReturnValue({
152+
data: {
153+
email: null,
154+
isRegistered: false
155+
}
156+
})
157+
// Reset update customer mock
158+
mockUpdateCustomer.mutateAsync.mockResolvedValue({})
159+
// Reset useCustomerType mock to ensure phone input is not disabled
160+
useCustomerType.mockReturnValue({isRegistered: false})
142161
})
143162

144163
afterEach(() => {})
@@ -327,12 +346,11 @@ describe('ContactInfo Component', () => {
327346
await user.type(emailInput, '{enter}')
328347

329348
// The validation should prevent submission and show error
330-
// Since the form doesn't have a visible submit button in this state,
331-
// we test that the email field validation works on blur
332-
await user.click(emailInput)
333-
await user.tab()
334-
335-
expect(screen.getAllByText('Please enter your email address.').length).toBeGreaterThan(0)
349+
await waitFor(() => {
350+
expect(screen.getAllByText('Please enter your email address.').length).toBeGreaterThan(
351+
0
352+
)
353+
})
336354
})
337355

338356
test('validates email format on form submission', async () => {
@@ -699,4 +717,115 @@ describe('ContactInfo Component', () => {
699717
})
700718
// Updating basket email may occur asynchronously or be skipped if unchanged; don't hard-require it here
701719
})
720+
721+
test('defaults phone number from basket billing address when customer phone is not available', () => {
722+
// Mock basket with billing address phone
723+
mockUseCurrentBasket.mockReturnValue({
724+
data: {
725+
basketId: 'test-basket-id',
726+
customerInfo: {email: null},
727+
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
728+
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
729+
billingAddress: {phone: '(555) 123-4567'}
730+
},
731+
derivedData: {hasBasket: true, totalItems: 1},
732+
refetch: jest.fn()
733+
})
734+
735+
renderWithProviders(<ContactInfo />)
736+
737+
const phoneInput = screen.getByLabelText('Phone')
738+
expect(phoneInput.value).toBe('(555) 123-4567')
739+
})
740+
741+
test('saves phone number to billing address when guest checks out via "Checkout as Guest" button', async () => {
742+
// Mock successful OTP authorization to open modal
743+
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
744+
mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})
745+
mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})
746+
747+
const {user} = renderWithProviders(<ContactInfo />)
748+
749+
const emailInput = screen.getByLabelText('Email')
750+
const phoneInput = screen.getByLabelText('Phone')
751+
752+
// Enter phone first - use fireEvent to ensure value is set
753+
fireEvent.change(phoneInput, {target: {value: '(727) 555-1234'}})
754+
755+
// Enter email and wait for OTP modal to open
756+
await user.type(emailInput, validEmail)
757+
fireEvent.change(emailInput, {target: {value: validEmail}})
758+
fireEvent.blur(emailInput)
759+
760+
// Wait for OTP modal to open
761+
await screen.findByTestId('otp-verify')
762+
763+
// Click "Checkout as a guest" button
764+
await user.click(screen.getByText(/Checkout as a guest/i))
765+
766+
await waitFor(() => {
767+
expect(mockUpdateBillingAddressForBasket.mutateAsync).toHaveBeenCalled()
768+
const callArgs = mockUpdateBillingAddressForBasket.mutateAsync.mock.calls[0]?.[0]
769+
expect(callArgs?.parameters).toMatchObject({basketId: 'test-basket-id'})
770+
expect(callArgs?.body?.phone).toMatch(/727/)
771+
})
772+
})
773+
774+
test('uses phone from billing address when persisting to customer profile after OTP verification', async () => {
775+
// Mock basket with billing address phone
776+
const billingPhone = '(555) 123-4567'
777+
mockUseCurrentBasket.mockReturnValue({
778+
data: {
779+
basketId: 'test-basket-id',
780+
customerInfo: {email: null},
781+
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
782+
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
783+
billingAddress: {phone: billingPhone}
784+
},
785+
derivedData: {hasBasket: true, totalItems: 1},
786+
refetch: jest.fn().mockResolvedValue({
787+
data: {
788+
basketId: 'test-basket-id',
789+
billingAddress: {phone: billingPhone}
790+
}
791+
})
792+
})
793+
794+
// Mock OTP verification flow
795+
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
796+
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync.mockResolvedValue({})
797+
mockTransferBasket.mutateAsync.mockResolvedValue({basketId: 'test-basket-id'})
798+
mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})
799+
800+
// Mock customer with customerId after login - update mock to return customer with ID
801+
mockUseCurrentCustomer.mockReturnValue({
802+
data: {
803+
email: validEmail,
804+
isRegistered: true,
805+
customerId: 'customer-123'
806+
}
807+
})
808+
809+
const {user} = renderWithProviders(<ContactInfo />)
810+
811+
const emailInput = screen.getByLabelText('Email')
812+
await user.type(emailInput, validEmail)
813+
fireEvent.change(emailInput, {target: {value: validEmail}})
814+
fireEvent.blur(emailInput)
815+
816+
// Wait for OTP modal and verify
817+
await screen.findByTestId('otp-verify')
818+
await user.click(screen.getByTestId('otp-verify'))
819+
820+
// Simulate auth state change to registered
821+
useCustomerType.mockReturnValue({isRegistered: true})
822+
823+
await waitFor(() => {
824+
// Verify updateCustomer was called with phone from billing address
825+
expect(mockUpdateCustomer.mutateAsync).toHaveBeenCalledWith({
826+
parameters: {customerId: 'customer-123'},
827+
body: {phoneHome: billingPhone}
828+
})
829+
})
830+
})
702831
})

packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ const Payment = ({
5555
onIsEditingChange,
5656
billingSameAsShipping,
5757
setBillingSameAsShipping,
58-
onOtpLoadingChange
58+
onOtpLoadingChange,
59+
onBillingSubmit
5960
}) => {
6061
const {formatMessage} = useIntl()
6162
const {data: basketForTotal} = useCurrentBasket()
@@ -382,27 +383,6 @@ const Payment = ({
382383
}
383384
}
384385

385-
const onBillingSubmit = async () => {
386-
// When billing is same as shipping, skip form validation and use shipping address directly
387-
let billingAddress
388-
if (billingSameAsShipping) {
389-
billingAddress = selectedShippingAddress
390-
} else {
391-
const isFormValid = await billingAddressForm.trigger()
392-
if (!isFormValid) {
393-
return
394-
}
395-
billingAddress = billingAddressForm.getValues()
396-
}
397-
// Using destructuring to remove properties from the object...
398-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
399-
const {addressId, creationDate, lastModified, preferred, ...address} = billingAddress
400-
return await updateBillingAddressForBasket({
401-
body: address,
402-
parameters: {basketId: activeBasketIdRef.current || basket.basketId}
403-
})
404-
}
405-
406386
const onPaymentRemoval = async () => {
407387
try {
408388
await removePaymentInstrumentFromBasket({
@@ -678,7 +658,9 @@ Payment.propTypes = {
678658
/** Callback to set billing same as shipping state */
679659
setBillingSameAsShipping: PropTypes.func.isRequired,
680660
/** Callback when OTP loading state changes */
681-
onOtpLoadingChange: PropTypes.func
661+
onOtpLoadingChange: PropTypes.func,
662+
/** Callback to submit billing address */
663+
onBillingSubmit: PropTypes.func.isRequired
682664
}
683665

684666
const PaymentCardSummary = ({payment}) => {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,12 @@ export default function ShippingAddress(props) {
103103
postalCode,
104104
stateCode
105105
} = address
106-
const phoneValue = customer?.isRegistered
107-
? customer?.phoneHome || address?.phone || selectedShippingAddress?.phone
108-
: contactPhone || address?.phone || selectedShippingAddress?.phone
106+
const phoneValue =
107+
(customer?.isRegistered
108+
? customer?.phoneHome
109+
: contactPhone || basket?.billingAddress?.phone) ||
110+
address?.phone ||
111+
selectedShippingAddress?.phone
109112

110113
const targetShipment = findExistingDeliveryShipment(basket)
111114
const targetShipmentId = targetShipment?.shipmentId || DEFAULT_SHIPMENT_ID

0 commit comments

Comments
 (0)