Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,9 @@ const CheckoutOneClick = () => {
}
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {addressId, creationDate, lastModified, preferred, phone, ...address} = billingAddress
const latestBasketId = currentBasketQuery.data?.basketId || basket.basketId
return await updateBillingAddressForBasket({
body: address,
body: billingAddress,
parameters: {basketId: latestBasketId}
})
}
Expand Down Expand Up @@ -429,14 +427,8 @@ const CheckoutOneClick = () => {
}
}

// Persist phone number as phoneHome from contact info, shipping address, or basket
// Priority: contactPhone (from contact info form) > shipping address phone > basket customerInfo phone
const phoneHome =
contactPhone && contactPhone.length > 0
? contactPhone
: deliveryShipments.length > 0
? deliveryShipments[0]?.shippingAddress?.phone
: basket?.customerInfo?.phone
// Persist phone number as phoneHome for newly registered guest shoppers
const phoneHome = basket?.billingAddress?.phone || contactPhone
if (phoneHome) {
await updateCustomer.mutateAsync({
parameters: {customerId},
Expand Down Expand Up @@ -613,6 +605,7 @@ const CheckoutOneClick = () => {
billingSameAsShipping={billingSameAsShipping}
setBillingSameAsShipping={setBillingSameAsShipping}
onOtpLoadingChange={setIsOtpLoading}
onBillingSubmit={onBillingSubmit}
/>

{step >= STEPS.PAYMENT && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket')
const transferBasket = useShopperBasketsMutation('transferBasket')
const updateCustomer = useShopperCustomersMutation('updateCustomer')
const updateBillingAddressForBasket = useShopperBasketsMutation('updateBillingAddressForBasket')
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
const {locale} = useMultiSite()
Expand All @@ -82,7 +83,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const form = useForm({
defaultValues: {
email: customer?.email || basket?.customerInfo?.email || '',
phone: customer?.phoneHome || '',
phone: customer?.phoneHome || basket?.billingAddress?.phone || '',
password: '',
otp: ''
}
Expand Down Expand Up @@ -261,12 +262,24 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const handleCheckoutAsGuest = async () => {
try {
const email = form.getValues('email')
const phone = form.getValues('phone')
// Update basket with guest email
await updateCustomerForBasket.mutateAsync({
parameters: {basketId: basket.basketId},
body: {email: email}
})

// Save phone number to basket billing address for guest shoppers
if (phone) {
await updateBillingAddressForBasket.mutateAsync({
parameters: {basketId: basket.basketId},
body: {
...basket?.billingAddress,
phone: phone
}
})
}

// Set the flag that "Checkout as Guest" was clicked
setRegisteredUserChoseGuest(true)
if (onRegisteredUserChoseGuest) {
Expand Down Expand Up @@ -321,7 +334,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
}

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

// Save phone number to basket billing address for guest shoppers
if (phone) {
await updateBillingAddressForBasket.mutateAsync({
parameters: {basketId: basket.basketId},
body: {
...basket?.billingAddress,
phone: phone
}
})
}

// Update basket and immediately advance to next step for smooth UX
goToNextStep()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const mockAuthHelperFunctions = {

const mockUpdateCustomerForBasket = {mutateAsync: jest.fn()}
const mockTransferBasket = {mutate: jest.fn(), mutateAsync: jest.fn()}
const mockUpdateBillingAddressForBasket = {mutateAsync: jest.fn()}
const mockUpdateCustomer = {mutateAsync: jest.fn()}

jest.mock('@salesforce/commerce-sdk-react', () => {
const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
Expand All @@ -34,10 +36,12 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => {
if (mutationType === 'updateCustomerForBasket') return mockUpdateCustomerForBasket
if (mutationType === 'transferBasket') return mockTransferBasket
if (mutationType === 'updateBillingAddressForBasket')
return mockUpdateBillingAddressForBasket
return {mutate: jest.fn()}
}),
useShopperCustomersMutation: jest.fn().mockImplementation((mutationType) => {
if (mutationType === 'updateCustomer') return {mutateAsync: jest.fn()}
if (mutationType === 'updateCustomer') return mockUpdateCustomer
return {mutateAsync: jest.fn()}
})
}
Expand All @@ -62,13 +66,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
useCurrentBasket: (...args) => mockUseCurrentBasket(...args)
}))

const mockUseCurrentCustomer = jest.fn(() => ({
data: {
email: null,
isRegistered: false
}
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
useCurrentCustomer: () => ({
data: {
email: null,
isRegistered: false
}
})
useCurrentCustomer: (...args) => mockUseCurrentCustomer(...args)
}))

const mockSetContactPhone = jest.fn()
Expand Down Expand Up @@ -134,11 +139,25 @@ beforeEach(() => {
basketId: 'test-basket-id',
customerInfo: {email: null},
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}]
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
billingAddress: null
},
derivedData: {hasBasket: true, totalItems: 1},
refetch: jest.fn()
})
// Reset billing address mutation mock
mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})
// Reset customer mock to default
mockUseCurrentCustomer.mockReturnValue({
data: {
email: null,
isRegistered: false
}
})
// Reset update customer mock
mockUpdateCustomer.mutateAsync.mockResolvedValue({})
// Reset useCustomerType mock to ensure phone input is not disabled
useCustomerType.mockReturnValue({isRegistered: false})
})

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

// The validation should prevent submission and show error
// Since the form doesn't have a visible submit button in this state,
// we test that the email field validation works on blur
await user.click(emailInput)
await user.tab()

expect(screen.getAllByText('Please enter your email address.').length).toBeGreaterThan(0)
await waitFor(() => {
expect(screen.getAllByText('Please enter your email address.').length).toBeGreaterThan(
0
)
})
})

test('validates email format on form submission', async () => {
Expand Down Expand Up @@ -699,4 +717,115 @@ describe('ContactInfo Component', () => {
})
// Updating basket email may occur asynchronously or be skipped if unchanged; don't hard-require it here
})

test('defaults phone number from basket billing address when customer phone is not available', () => {
// Mock basket with billing address phone
mockUseCurrentBasket.mockReturnValue({
data: {
basketId: 'test-basket-id',
customerInfo: {email: null},
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
billingAddress: {phone: '(555) 123-4567'}
},
derivedData: {hasBasket: true, totalItems: 1},
refetch: jest.fn()
})

renderWithProviders(<ContactInfo />)

const phoneInput = screen.getByLabelText('Phone')
expect(phoneInput.value).toBe('(555) 123-4567')
})

test('saves phone number to billing address when guest checks out via "Checkout as Guest" button', async () => {
// Mock successful OTP authorization to open modal
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})
mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})

const {user} = renderWithProviders(<ContactInfo />)

const emailInput = screen.getByLabelText('Email')
const phoneInput = screen.getByLabelText('Phone')

// Enter phone first - use fireEvent to ensure value is set
fireEvent.change(phoneInput, {target: {value: '(727) 555-1234'}})

// Enter email and wait for OTP modal to open
await user.type(emailInput, validEmail)
fireEvent.change(emailInput, {target: {value: validEmail}})
fireEvent.blur(emailInput)

// Wait for OTP modal to open
await screen.findByTestId('otp-verify')

// Click "Checkout as a guest" button
await user.click(screen.getByText(/Checkout as a guest/i))

await waitFor(() => {
expect(mockUpdateBillingAddressForBasket.mutateAsync).toHaveBeenCalled()
const callArgs = mockUpdateBillingAddressForBasket.mutateAsync.mock.calls[0]?.[0]
expect(callArgs?.parameters).toMatchObject({basketId: 'test-basket-id'})
expect(callArgs?.body?.phone).toMatch(/727/)
})
})

test('uses phone from billing address when persisting to customer profile after OTP verification', async () => {
// Mock basket with billing address phone
const billingPhone = '(555) 123-4567'
mockUseCurrentBasket.mockReturnValue({
data: {
basketId: 'test-basket-id',
customerInfo: {email: null},
shipments: [{shipmentId: 'shipment-1', shipmentType: 'delivery'}],
productItems: [{productId: 'product-1', shipmentId: 'shipment-1'}],
billingAddress: {phone: billingPhone}
},
derivedData: {hasBasket: true, totalItems: 1},
refetch: jest.fn().mockResolvedValue({
data: {
basketId: 'test-basket-id',
billingAddress: {phone: billingPhone}
}
})
})

// Mock OTP verification flow
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync.mockResolvedValue({})
mockTransferBasket.mutateAsync.mockResolvedValue({basketId: 'test-basket-id'})
mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})

// Mock customer with customerId after login - update mock to return customer with ID
mockUseCurrentCustomer.mockReturnValue({
data: {
email: validEmail,
isRegistered: true,
customerId: 'customer-123'
}
})

const {user} = renderWithProviders(<ContactInfo />)

const emailInput = screen.getByLabelText('Email')
await user.type(emailInput, validEmail)
fireEvent.change(emailInput, {target: {value: validEmail}})
fireEvent.blur(emailInput)

// Wait for OTP modal and verify
await screen.findByTestId('otp-verify')
await user.click(screen.getByTestId('otp-verify'))

// Simulate auth state change to registered
useCustomerType.mockReturnValue({isRegistered: true})

await waitFor(() => {
// Verify updateCustomer was called with phone from billing address
expect(mockUpdateCustomer.mutateAsync).toHaveBeenCalledWith({
parameters: {customerId: 'customer-123'},
body: {phoneHome: billingPhone}
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ const Payment = ({
onIsEditingChange,
billingSameAsShipping,
setBillingSameAsShipping,
onOtpLoadingChange
onOtpLoadingChange,
onBillingSubmit
}) => {
const {formatMessage} = useIntl()
const {data: basketForTotal} = useCurrentBasket()
Expand Down Expand Up @@ -382,27 +383,6 @@ const Payment = ({
}
}

const onBillingSubmit = async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fix for redundancy (we have this method in 2 different places, one-click-payment AND index.jsx - main entry code)

// When billing is same as shipping, skip form validation and use shipping address directly
let billingAddress
if (billingSameAsShipping) {
billingAddress = selectedShippingAddress
} else {
const isFormValid = await billingAddressForm.trigger()
if (!isFormValid) {
return
}
billingAddress = billingAddressForm.getValues()
}
// Using destructuring to remove properties from the object...
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {addressId, creationDate, lastModified, preferred, ...address} = billingAddress
return await updateBillingAddressForBasket({
body: address,
parameters: {basketId: activeBasketIdRef.current || basket.basketId}
})
}

const onPaymentRemoval = async () => {
try {
await removePaymentInstrumentFromBasket({
Expand Down Expand Up @@ -678,7 +658,9 @@ Payment.propTypes = {
/** Callback to set billing same as shipping state */
setBillingSameAsShipping: PropTypes.func.isRequired,
/** Callback when OTP loading state changes */
onOtpLoadingChange: PropTypes.func
onOtpLoadingChange: PropTypes.func,
/** Callback to submit billing address */
onBillingSubmit: PropTypes.func.isRequired
}

const PaymentCardSummary = ({payment}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ export default function ShippingAddress(props) {
postalCode,
stateCode
} = address
const phoneValue = customer?.isRegistered
? customer?.phoneHome || address?.phone || selectedShippingAddress?.phone
: contactPhone || address?.phone || selectedShippingAddress?.phone
const phoneValue =
(customer?.isRegistered
? customer?.phoneHome
: contactPhone || basket?.billingAddress?.phone) ||
address?.phone ||
selectedShippingAddress?.phone

const targetShipment = findExistingDeliveryShipment(basket)
const targetShipmentId = targetShipment?.shipmentId || DEFAULT_SHIPMENT_ID
Expand Down
Loading