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 @@ -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, useEffect} from 'react'
import React, {useRef, useState} from 'react'
import PropTypes from 'prop-types'
import {
Alert,
Expand Down Expand Up @@ -43,13 +43,13 @@ import {
AuthHelpers,
useAuthHelper,
useShopperBasketsMutation,
useCustomerType,
useConfig
useCustomerType
} from '@salesforce/commerce-sdk-react'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'
import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
import {isValidEmail} from '@salesforce/retail-react-app/app/utils/email-utils'

const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseGuest}) => {
const {formatMessage} = useIntl()
Expand All @@ -59,9 +59,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const currentBasketQuery = useCurrentBasket()
const {data: basket} = currentBasketQuery
const {isRegistered} = useCustomerType()
const config = useConfig()

const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
const logout = useAuthHelper(AuthHelpers.Logout)
const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket')
const mergeBasket = useShopperBasketsMutation('mergeBasket')
Expand Down Expand Up @@ -93,31 +91,13 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
? passwordlessConfigCallback
: `${appOrigin}${passwordlessConfigCallback}`

// Reset guest checkout flag when user registration status changes
useEffect(() => {
Copy link
Author

Choose a reason for hiding this comment

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

I don't believe this was necessary and the usage of useEffect causes unnecessary re-renders

if (isRegistered) {
setRegisteredUserChoseGuest(false)
if (onRegisteredUserChoseGuest) {
onRegisteredUserChoseGuest(false)
}
}
}, [isRegistered, onRegisteredUserChoseGuest])

// Modal controls for OtpAuth
const {
isOpen: isOtpModalOpen,
onOpen: onOtpModalOpen,
onClose: onOtpModalClose
} = useDisclosure()

// Helper function to validate email format
const isValidEmail = (email) => {
Copy link
Author

Choose a reason for hiding this comment

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

I can see this being needed in another component so I just moved it to a util that has it's own dedicated testing

const emailRegex =
/^[\p{L}\p{N}._!#$%&'*+/=?^`{|}~-]+@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?$/u

return emailRegex.test(email)
}

// Handle email field blur/focus events
const handleEmailBlur = async (e) => {
// Call original React Hook Form blur handler if it exists
Expand Down Expand Up @@ -280,7 +260,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
}

setShowContinueButton(false)
goToNextStep()
handleSendEmailOtp(data.email)
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,86 +171,9 @@ describe('ContactInfo Component', () => {
await user.tab()

expect(screen.getByText('Please enter a valid email address.')).toBeInTheDocument()
})

test('validates different types of valid emails correctly', async () => {
Copy link
Author

Choose a reason for hiding this comment

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

The tests validates email format on form submission and 'validates email is required on form submission' validates the case an invalid email is submitted. renders continue button for guest checkout and others exercise a valid email so we have coverage of both scenarios at the component level. For the specific email validation I added lots of test cases specifically in the utility

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

// Test various valid email formats
const validEmails = [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'josé@mañana.com',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'email@mañana.com',
'josé@example.españa',
'email@bücher.de',
'用户@例子.中国',
'!#$%&*+/=?^_{|}[email protected]'
]

for (const email of validEmails) {
const {user: testUser} = renderWithProviders(<ContactInfo />)
const emailInput = screen.getByLabelText('Email')

await testUser.type(emailInput, email)

// Trigger blur event to validate
await testUser.tab()

// Should not show email format error for valid emails
expect(
screen.queryByText('Please enter a valid email address.')
).not.toBeInTheDocument()

// Should not show required email error
expect(screen.queryByText('Please enter your email address.')).not.toBeInTheDocument()

// Clean up
cleanup()
}
})

test('validates different types of invalid emails correctly', async () => {
// Test various invalid email formats that are definitely rejected by the current regex
const invalidEmails = [
'plainaddress', // Missing @ symbol
'@missinglocal.com', // Missing local part
'missingdomain@', // Missing domain
'user@', // Missing domain completely
'[email protected]', // Domain starting with dot
'[email protected].', // Domain ending with dot
'[email protected]', // Domain starting with hyphen
'[email protected]' // Domain ending with hyphen
]

for (const email of invalidEmails) {
const {user: testUser} = renderWithProviders(<ContactInfo />)
const emailInput = screen.getByLabelText('Email')

await testUser.type(emailInput, email)

// Trigger blur event to validate
await testUser.tab()

// Should show email format error for invalid emails
expect(screen.getByText('Please enter a valid email address.')).toBeInTheDocument()

// Clean up
cleanup()
}
// Should not show required email error
expect(screen.queryByText('Please enter your email address.')).not.toBeInTheDocument()
})

test('allows guest checkout with valid email', async () => {
Expand Down Expand Up @@ -285,9 +208,6 @@ describe('ContactInfo Component', () => {
})
})

// Note: The OTP modal opens on email blur after successful authorization
// Submitting the form directly progresses the flow instead of opening the modal.

test('renders continue button for guest checkout', async () => {
// Mock the passwordless login to fail (email not found)
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockRejectedValue(
Expand Down Expand Up @@ -376,4 +296,34 @@ describe('ContactInfo Component', () => {
expect(screen.getByText('Checkout as a guest')).toBeInTheDocument()
expect(screen.getByText('Resend code')).toBeInTheDocument()
})

test('opens OTP modal when form is submitted by clicking submit button', async () => {
// Mock successful OTP authorization
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({
success: true
})

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

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

// Find and click the submit button
const submitButton = screen.getByRole('button', {
name: /continue to shipping address/i
})
await user.click(submitButton)

// Wait for OTP modal to appear after form submission
await waitFor(() => {
expect(screen.getByText("Confirm it's you")).toBeInTheDocument()
})

// Verify modal content is present
expect(
screen.getByText('To use your account information enter the code sent to your email.')
).toBeInTheDocument()
expect(screen.getByText('Checkout as a guest')).toBeInTheDocument()
expect(screen.getByText('Resend code')).toBeInTheDocument()
})
})
12 changes: 12 additions & 0 deletions packages/template-retail-react-app/app/utils/email-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* 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
*/
export const isValidEmail = (email) => {
const emailRegex =
/^[\p{L}\p{N}._!#$%&'*+/=?^`{|}~-]+@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?$/u

return emailRegex.test(email)
}
154 changes: 154 additions & 0 deletions packages/template-retail-react-app/app/utils/email-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* 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 {isValidEmail} from '@salesforce/retail-react-app/app/utils/email-utils'

describe('isValidEmail', () => {
describe('valid email addresses', () => {
test('should return true for basic email format', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with subdomain', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with numbers', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with special characters', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with international characters', () => {
expect(isValidEmail('tëst@éxämplé.com')).toBe(true)
})

test('should return true for email with various special characters', () => {
expect(isValidEmail("user!#$%&'*+/=?^`{|}[email protected]")).toBe(true)
})

test('should return true for email with long domain', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with single character local part', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with single character domain', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for very long email addresses', () => {
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com'
expect(isValidEmail(longEmail)).toBe(true)
})

test('should return true for email with maximum valid length', () => {
const maxLengthEmail = 'a'.repeat(64) + '@' + 'b'.repeat(63) + '.com'
expect(isValidEmail(maxLengthEmail)).toBe(true)
})

test('should return true for email with mixed case', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with numbers in domain', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with hyphen in domain', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email with consecutive dots (regex allows this)', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email starting with dot (regex allows this)', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})

test('should return true for email ending with dot (regex allows this)', () => {
expect(isValidEmail('[email protected]')).toBe(true)
})
})

describe('invalid email addresses', () => {
test('should return false for empty string', () => {
expect(isValidEmail('')).toBe(false)
})

test('should return false for null', () => {
expect(isValidEmail(null)).toBe(false)
})

test('should return false for undefined', () => {
expect(isValidEmail(undefined)).toBe(false)
})

test('should return false for email without @ symbol', () => {
expect(isValidEmail('testexample.com')).toBe(false)
})

test('should return false for email with multiple @ symbols', () => {
expect(isValidEmail('test@@example.com')).toBe(false)
})

test('should return false for email without domain', () => {
expect(isValidEmail('test@')).toBe(false)
})

test('should return false for email without local part', () => {
expect(isValidEmail('@example.com')).toBe(false)
})

test('should return false for email with spaces', () => {
expect(isValidEmail('test @example.com')).toBe(false)
})

test('should return false for email with invalid characters', () => {
expect(isValidEmail('test()@example.com')).toBe(false)
})

test('should return false for domain without TLD', () => {
expect(isValidEmail('test@example')).toBe(false)
})

test('should return false for domain with invalid TLD', () => {
expect(isValidEmail('test@example.')).toBe(false)
})

test('should return false for domain with consecutive dots', () => {
expect(isValidEmail('[email protected]')).toBe(false)
})

test('should return false for domain starting with dot', () => {
expect(isValidEmail('[email protected]')).toBe(false)
})

test('should return false for domain ending with dot', () => {
expect(isValidEmail('[email protected].')).toBe(false)
})

test('should return false for non-string input', () => {
expect(isValidEmail(123)).toBe(false)
expect(isValidEmail({})).toBe(false)
expect(isValidEmail([])).toBe(false)
})

test('should return false for email with hyphen at start of domain part', () => {
expect(isValidEmail('[email protected]')).toBe(false)
})

test('should return false for email with hyphen at end of domain part', () => {
expect(isValidEmail('[email protected]')).toBe(false)
})
})
})
Loading