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 @@ -112,7 +112,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isBlurChecking, setIsBlurChecking] = useState(false)
const [, setRegisteredUserChoseGuest] = useState(false)
const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false)
const [emailError, setEmailError] = useState('')

// Auto-focus the email field when the component mounts
Expand Down Expand Up @@ -244,8 +244,15 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
lastEmailSentRef.current = normalizedEmail
return {isRegistered: true}
} catch (error) {
const message = formatMessage(getPasswordlessErrorMessage(error.message))
setError(message)
const errMsg = error?.message || ''
const isUserNotFound =
/user not found|email not found|unknown user|no account|no user|error getting user info/i.test(
errMsg
)
if (!isUserNotFound) {
const message = formatMessage(getPasswordlessErrorMessage(errMsg))
setError(message)
}
// Keep continue button visible if email is valid (for unregistered users)
if (isValidEmail(email)) {
setShowContinueButton(true)
Expand All @@ -272,37 +279,10 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
}

// Handle checkout as guest from OTP modal
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) {
onRegisteredUserChoseGuest(true)
}

// Proceed to next step (shipping address)
goToNextStep()
} catch (error) {
setError(error.message)
const handleCheckoutAsGuest = () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only closing the modal for now (and set state)

setRegisteredUserChoseGuest(true)
if (onRegisteredUserChoseGuest) {
onRegisteredUserChoseGuest(true)
}
}

Expand Down Expand Up @@ -472,7 +452,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
return
}

if (!result.isRegistered) {
if (!result.isRegistered || registeredUserChoseGuest) {
Copy link
Contributor Author

@dannyphan2000 dannyphan2000 Jan 30, 2026

Choose a reason for hiding this comment

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

All the information are saved to basket upon Continue to Shipping Address (ie submit the form) just like a guest shopper

// Guest shoppers must provide phone number before proceeding
const phone = (formData.phone || '').trim()
if (!phone) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,17 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({
// Mock OtpAuth to expose a verify trigger
jest.mock('@salesforce/retail-react-app/app/components/otp-auth', () => {
// eslint-disable-next-line react/prop-types
return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest}) {
return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest, onClose}) {
const handleGuestClick = () => {
onCheckoutAsGuest?.()
onClose?.()
}
return isOpen ? (
<div>
<div>Confirm it&apos;s you</div>
<p>To log in to your account, enter the code sent to your email.</p>
<div>
<button type="button" onClick={onCheckoutAsGuest}>
<button type="button" onClick={handleGuestClick}>
Checkout as a guest
</button>
<button type="button">Resend Code</button>
Expand Down Expand Up @@ -523,6 +527,27 @@ describe('ContactInfo Component', () => {
})
})

test('does not show error message when login returns user/email not found (guest flow)', async () => {
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockRejectedValue(
new Error('Email not found')
)

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

const emailInput = screen.getByLabelText('Email')
await user.type(emailInput, validEmail)
fireEvent.blur(emailInput)

await waitFor(() => {
expect(
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
).toHaveBeenCalled()
})

// Red error banner should NOT be shown when user is not found (guest can continue)
expect(screen.queryByText(/Something went wrong\. Try again!/i)).not.toBeInTheDocument()
})

test('renders contact info title', () => {
renderWithProviders(<ContactInfo />)

Expand Down Expand Up @@ -606,11 +631,10 @@ describe('ContactInfo Component', () => {
expect(screen.getByText(/Resend Code/i)).toBeInTheDocument()
})

test('shows error message when updateCustomerForBasket fails', async () => {
// Mock OTP authorization to succeed so modal opens
test('clicking "Checkout as a guest" does not update basket or advance step', async () => {
// "Checkout as Guest" only closes the modal and sets registeredUserChoseGuest state;
// basket is updated when the user later submits the form with phone and clicks Continue.
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
// Mock update to fail when choosing guest
mockUpdateCustomerForBasket.mutateAsync.mockRejectedValue(new Error('API Error'))

const {user} = renderWithProviders(<ContactInfo />)
const emailInput = screen.getByLabelText('Email')
Expand All @@ -625,22 +649,17 @@ describe('ContactInfo Component', () => {
await user.click(submitButton)
await screen.findByTestId('otp-verify')

// Click "Checkout as a guest" which triggers updateCustomerForBasket and should set error
// Click "Checkout as a guest" — should not call basket mutations or goToNextStep
await user.click(screen.getByText(/Checkout as a guest/i))

await waitFor(() => {
expect(mockUpdateCustomerForBasket.mutateAsync).toHaveBeenCalled()
})
// Error alert should be rendered; component maps errors via getPasswordlessErrorMessage to generic message
await waitFor(() => {
const alerts = screen.queryAllByRole('alert')
const hasError = alerts.some(
(n) =>
n.textContent?.includes('Something went wrong') ||
n.textContent?.includes('API Error')
)
expect(hasError).toBe(true)
expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled()
expect(mockGoToNextStep).not.toHaveBeenCalled()
})
// Modal closes; user stays on Contact Info (Continue button visible again)
expect(
screen.getByRole('button', {name: /continue to shipping address/i})
).toBeInTheDocument()
})

test('does not proceed to next step when OTP modal is already open on form submission', async () => {
Expand Down Expand Up @@ -747,37 +766,40 @@ describe('ContactInfo Component', () => {
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
test('notifies parent when guest chooses "Checkout as Guest" and stays on Contact Info', async () => {
// Open OTP modal (registered email), click "Checkout as a guest" — modal closes,
// parent is notified via onRegisteredUserChoseGuest(true), user stays on Contact Info.
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({})
mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({})
mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({})

const {user} = renderWithProviders(<ContactInfo />)
const onRegisteredUserChoseGuestSpy = jest.fn()
const {user} = renderWithProviders(
<ContactInfo onRegisteredUserChoseGuest={onRegisteredUserChoseGuestSpy} />
)

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
// Enter email and open OTP modal (blur triggers registered-user check)
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
// Click "Checkout as a guest" — modal closes; parent is notified; no basket update
await user.click(screen.getByText(/Checkout as a guest/i))

expect(onRegisteredUserChoseGuestSpy).toHaveBeenCalledWith(true)
expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled()
expect(mockGoToNextStep).not.toHaveBeenCalled()

// Modal closes; user stays on Contact Info (Continue button visible for entering phone)
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/)
expect(screen.queryByText("Confirm it's you")).not.toBeInTheDocument()
})
expect(
screen.getByRole('button', {name: /continue to shipping address/i})
).toBeInTheDocument()
expect(screen.getByLabelText('Phone')).toBeInTheDocument()
})

test('uses phone from billing address when persisting to customer profile after OTP verification', async () => {
Expand Down
Loading