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 @@ -63,6 +63,7 @@ const CheckoutOneClick = () => {
const [enableUserRegistration, setEnableUserRegistration] = useState(false)
const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false)
const [shouldSavePaymentMethod, setShouldSavePaymentMethod] = useState(false)
const [isOtpLoading, setIsOtpLoading] = useState(false)

const currentBasketQuery = useCurrentBasket()
const {data: basket} = currentBasketQuery
Expand Down Expand Up @@ -501,6 +502,7 @@ const CheckoutOneClick = () => {
onIsEditingChange={setIsEditingPayment}
billingSameAsShipping={billingSameAsShipping}
setBillingSameAsShipping={setBillingSameAsShipping}
onOtpLoadingChange={setIsOtpLoading}
/>

{step >= STEPS.PAYMENT && (
Expand All @@ -510,6 +512,7 @@ const CheckoutOneClick = () => {
w="full"
onClick={onPlaceOrder}
isLoading={isLoading}
disabled={isOtpLoading}
data-testid="place-order-button"
size="lg"
px={8}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const Payment = ({
onSelectedPaymentMethodChange,
onIsEditingChange,
billingSameAsShipping,
setBillingSameAsShipping
setBillingSameAsShipping,
onOtpLoadingChange
}) => {
const {formatMessage} = useIntl()
const {data: basketForTotal} = useCurrentBasket()
Expand Down Expand Up @@ -572,6 +573,7 @@ const Payment = ({
<UserRegistration
enableUserRegistration={enableUserRegistration}
setEnableUserRegistration={onUserRegistrationToggle}
onLoadingChange={onOtpLoadingChange}
isGuestCheckout={registeredUserChoseGuest}
isDisabled={
!(
Expand Down Expand Up @@ -635,6 +637,7 @@ const Payment = ({
<UserRegistration
enableUserRegistration={enableUserRegistration}
setEnableUserRegistration={setEnableUserRegistration}
onLoadingChange={onOtpLoadingChange}
isGuestCheckout={registeredUserChoseGuest}
isDisabled={!appliedPayment && !paymentMethodForm.formState.isValid}
onSavePreferenceChange={onSavePreferenceChange}
Expand Down Expand Up @@ -678,7 +681,9 @@ Payment.propTypes = {
/** Whether billing address is same as shipping */
billingSameAsShipping: PropTypes.bool.isRequired,
/** Callback to set billing same as shipping state */
setBillingSameAsShipping: PropTypes.func.isRequired
setBillingSameAsShipping: PropTypes.func.isRequired,
/** Callback when OTP loading state changes */
onOtpLoadingChange: PropTypes.func
}

const PaymentCardSummary = ({payment}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useRef, useState} from 'react'
import React, {useRef, useState, useEffect} from 'react'
import {FormattedMessage} from 'react-intl'
import PropTypes from 'prop-types'
import {
Expand All @@ -15,7 +15,10 @@ import {
Heading,
Badge,
HStack,
useDisclosure
useDisclosure,
Portal,
Spinner,
Center
} from '@salesforce/retail-react-app/app/components/shared/ui'
import OtpAuth from '@salesforce/retail-react-app/app/components/otp-auth'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
Expand All @@ -29,7 +32,8 @@ export default function UserRegistration({
isDisabled = false,
onSavePreferenceChange,
onRegistered,
showNotice = false
showNotice = false,
onLoadingChange
}) {
const {data: basket} = useCurrentBasket()
const {isGuest} = useCustomerType()
Expand All @@ -39,6 +43,7 @@ export default function UserRegistration({
const {isOpen: isOtpOpen, onOpen: onOtpOpen, onClose: onOtpClose} = useDisclosure()
const otpSentRef = useRef(false)
const [registrationSucceeded, setRegistrationSucceeded] = useState(false)
const [isLoadingOtp, setIsLoadingOtp] = useState(false)

const handleOtpClose = () => {
otpSentRef.current = false
Expand All @@ -53,9 +58,13 @@ export default function UserRegistration({
// If user unchecks, allow OTP to be re-triggered upon re-check
if (!checked) {
otpSentRef.current = false
setIsLoadingOtp(false)
if (onLoadingChange) onLoadingChange(false)
}
// Kick off OTP for guests when they opt in
if (checked && isGuest && basket?.customerInfo?.email && !otpSentRef.current) {
setIsLoadingOtp(true)
if (onLoadingChange) onLoadingChange(true)
try {
await authorizePasswordlessLogin.mutateAsync({
userid: basket.customerInfo.email,
Expand All @@ -69,10 +78,20 @@ export default function UserRegistration({
onOtpOpen()
} catch (_e) {
// Silent failure; user can continue as guest
setIsLoadingOtp(false)
if (onLoadingChange) onLoadingChange(false)
}
}
}

// Clear loading state when OTP modal opens
useEffect(() => {
if (isOtpOpen && isLoadingOtp) {
setIsLoadingOtp(false)
if (onLoadingChange) onLoadingChange(false)
}
}, [isOtpOpen, isLoadingOtp, onLoadingChange])

const handleOtpVerification = async (otpCode) => {
try {
await loginPasswordless.mutateAsync({
Expand Down Expand Up @@ -182,6 +201,26 @@ export default function UserRegistration({
</Stack>
</Box>

{/* Loading overlay when OTP is being initialized */}
{isLoadingOtp && (
<Portal>
<Box
position="fixed"
top="0"
left="0"
right="0"
bottom="0"
bg="blackAlpha.600"
zIndex={9999}
data-testid="sf-otp-loading-overlay"
>
<Center h="100%">
<Spinner size="xl" color="white" thickness="4px" />
</Center>
</Box>
</Portal>
)}

{/* OTP modal lives with registration now */}
<OtpAuth
isOpen={isOtpOpen}
Expand Down Expand Up @@ -221,5 +260,7 @@ UserRegistration.propTypes = {
onSavePreferenceChange: PropTypes.func,
onRegistered: PropTypes.func,
/** When true, forces the success notice to show (e.g., after component would normally unmount) */
showNotice: PropTypes.bool
showNotice: PropTypes.bool,
/** Callback when loading state changes (for disabling Place Order button) */
onLoadingChange: PropTypes.func
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import React, {useState} from 'react'
import {IntlProvider} from 'react-intl'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
Expand Down Expand Up @@ -101,7 +101,8 @@ const setup = (overrides = {}) => {
isGuestCheckout: overrides.isGuestCheckout ?? false,
isDisabled: overrides.isDisabled ?? false,
onSavePreferenceChange: overrides.onSavePref ?? jest.fn(),
onRegistered: overrides.onRegistered ?? jest.fn()
onRegistered: overrides.onRegistered ?? jest.fn(),
onLoadingChange: overrides.onLoadingChange ?? jest.fn()
}

const utils = render(
Expand Down Expand Up @@ -400,6 +401,151 @@ describe('UserRegistration', () => {
expect(props.onRegistered).not.toHaveBeenCalled()
})

test('shows loading overlay when guest user clicks registration checkbox', async () => {
const user = userEvent.setup()
const onLoadingChange = jest.fn()
const {authorizePasswordlessLogin} = setup({
onLoadingChange,
authorizeMutate: jest.fn().mockImplementation(() => {
// Simulate async delay
return new Promise((resolve) => setTimeout(() => resolve({}), 100))
})
})

const checkbox = screen.getByRole('checkbox', {name: /Create an account/i})
await user.click(checkbox)

// Verify loading overlay appears
await waitFor(() => {
expect(screen.getByTestId('sf-otp-loading-overlay')).toBeInTheDocument()
})

// Verify onLoadingChange was called with true
expect(onLoadingChange).toHaveBeenCalledWith(true)

// Wait for OTP modal to open (which clears loading state)
await waitFor(
() => {
expect(screen.getByTestId('otp-guest')).toBeInTheDocument()
},
{timeout: 2000}
)

// Verify loading overlay disappears when OTP modal opens
await waitFor(() => {
expect(screen.queryByTestId('sf-otp-loading-overlay')).not.toBeInTheDocument()
})

// Verify onLoadingChange was called with false when modal opens
expect(onLoadingChange).toHaveBeenCalledWith(false)
})

test('hides loading overlay when OTP authorization fails', async () => {
const user = userEvent.setup()
const onLoadingChange = jest.fn()
// Make the error happen after a small delay to ensure overlay appears first
const authorizeMutate = jest.fn().mockImplementation(() => {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Authorization failed')), 50)
})
})
setup({
onLoadingChange,
authorizeMutate
})

const checkbox = screen.getByRole('checkbox', {name: /Create an account/i})
await user.click(checkbox)

// Verify loading overlay appears initially
await waitFor(() => {
expect(screen.getByTestId('sf-otp-loading-overlay')).toBeInTheDocument()
})
expect(onLoadingChange).toHaveBeenCalledWith(true)

// Wait for error to be handled
await waitFor(
() => {
expect(screen.queryByTestId('sf-otp-loading-overlay')).not.toBeInTheDocument()
},
{timeout: 2000}
)

// Verify onLoadingChange was called with false on error
expect(onLoadingChange).toHaveBeenCalledWith(false)
// OTP modal should not open on error
expect(screen.queryByTestId('otp-guest')).not.toBeInTheDocument()
})

test('does not show loading overlay for registered users', async () => {
const user = userEvent.setup()
const onLoadingChange = jest.fn()
setup({isGuest: false, onLoadingChange})

const checkbox = screen.getByRole('checkbox', {name: /Create an account/i})
await user.click(checkbox)

// Loading overlay should not appear for registered users
expect(screen.queryByTestId('sf-otp-loading-overlay')).not.toBeInTheDocument()
expect(onLoadingChange).not.toHaveBeenCalled()
})

test('clears loading state when checkbox is unchecked', async () => {
const user = userEvent.setup()
const onLoadingChange = jest.fn()
const authorizeMutate = jest.fn().mockImplementation(() => {
return new Promise((resolve) => setTimeout(() => resolve({}), 200))
})

// Wrapper to control the enableUserRegistration prop
const TestWrapper = () => {
const [enabled, setEnabled] = useState(false)
return (
<IntlProvider locale="en-GB" messages={TEST_MESSAGES}>
<UserRegistration
enableUserRegistration={enabled}
setEnableUserRegistration={setEnabled}
onLoadingChange={onLoadingChange}
/>
</IntlProvider>
)
}

useCurrentBasket.mockReturnValue({
data: {
basketId: 'basket-123',
customerInfo: {email: 'test@example.com'},
productItems: [{productId: 'sku-1', quantity: 1}],
shipments: [
{shippingAddress: {address1: '123 Main'}, shippingMethod: {id: 'Ground'}}
]
}
})
useCustomerType.mockReturnValue({isGuest: true})
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync = authorizeMutate

render(<TestWrapper />)

const checkbox = screen.getByRole('checkbox', {name: /Create an account/i})

// Check the checkbox
await user.click(checkbox)

// Wait for loading to start
await waitFor(() => {
expect(onLoadingChange).toHaveBeenCalledWith(true)
})

// Uncheck the checkbox before OTP modal opens
await user.click(checkbox)

// Verify loading state is cleared
await waitFor(() => {
expect(onLoadingChange).toHaveBeenCalledWith(false)
})
expect(screen.queryByTestId('sf-otp-loading-overlay')).not.toBeInTheDocument()
})

test('displays explanatory text when registration is enabled', () => {
// Test with registration disabled
const {utils} = setup({enable: false})
Expand Down
Loading