diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx index 7a6618f272..073141b2ec 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx @@ -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 @@ -501,6 +502,7 @@ const CheckoutOneClick = () => { onIsEditingChange={setIsEditingPayment} billingSameAsShipping={billingSameAsShipping} setBillingSameAsShipping={setBillingSameAsShipping} + onOtpLoadingChange={setIsOtpLoading} /> {step >= STEPS.PAYMENT && ( @@ -510,6 +512,7 @@ const CheckoutOneClick = () => { w="full" onClick={onPlaceOrder} isLoading={isLoading} + disabled={isOtpLoading} data-testid="place-order-button" size="lg" px={8} diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx index fe37beb6ca..641f6ca7eb 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx @@ -54,7 +54,8 @@ const Payment = ({ onSelectedPaymentMethodChange, onIsEditingChange, billingSameAsShipping, - setBillingSameAsShipping + setBillingSameAsShipping, + onOtpLoadingChange }) => { const {formatMessage} = useIntl() const {data: basketForTotal} = useCurrentBasket() @@ -572,6 +573,7 @@ const Payment = ({ { diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx index 14257b2d11..ca6612ce4a 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx @@ -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 { @@ -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' @@ -29,7 +32,8 @@ export default function UserRegistration({ isDisabled = false, onSavePreferenceChange, onRegistered, - showNotice = false + showNotice = false, + onLoadingChange }) { const {data: basket} = useCurrentBasket() const {isGuest} = useCustomerType() @@ -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 @@ -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, @@ -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({ @@ -182,6 +201,26 @@ export default function UserRegistration({ + {/* Loading overlay when OTP is being initialized */} + {isLoadingOtp && ( + + +
+ +
+
+
+ )} + {/* OTP modal lives with registration now */} { 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( @@ -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 ( + + + + ) + } + + 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() + + 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})