diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx index a9196bd5f7..8dc896e1cf 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.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, useEffect} from 'react' +import React, {useRef, useState} from 'react' import PropTypes from 'prop-types' import { Alert, @@ -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() @@ -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') @@ -93,16 +91,6 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG ? passwordlessConfigCallback : `${appOrigin}${passwordlessConfigCallback}` - // Reset guest checkout flag when user registration status changes - useEffect(() => { - if (isRegistered) { - setRegisteredUserChoseGuest(false) - if (onRegisteredUserChoseGuest) { - onRegisteredUserChoseGuest(false) - } - } - }, [isRegistered, onRegisteredUserChoseGuest]) - // Modal controls for OtpAuth const { isOpen: isOtpModalOpen, @@ -110,14 +98,6 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG onClose: onOtpModalClose } = useDisclosure() - // Helper function to validate email format - 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) - } - // Handle email field blur/focus events const handleEmailBlur = async (e) => { // Call original React Hook Form blur handler if it exists @@ -280,7 +260,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG } setShowContinueButton(false) - goToNextStep() + handleSendEmailOtp(data.email) } return ( diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js index e34623dc36..f9459ffa34 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js @@ -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 () => { - const {user} = renderWithProviders() - - // Test various valid email formats - const validEmails = [ - 'simple@example.com', - 'user.name@domain.com', - 'user+tag@example.org', - 'user-name@subdomain.example.co.uk', - 'user123@domain123.net', - 'user.name+tag@example-domain.com', - 'user@example-domain.com', - 'user@subdomain1.subdomain2.example.com', - 'user.name@example.co.uk', - 'user@example-domain123.com', - 'josé@mañana.com', - 'firstname.lastname@example.co.uk', - 'email@subdomain.example.com', - 'user+mailbox@example.com', - 'user-name@example.org', - '12345@example.com', - 'email@mañana.com', - 'josé@example.españa', - 'email@bücher.de', - '用户@例子.中国', - '!#$%&*+/=?^_{|}~-@example.com' - ] - - for (const email of validEmails) { - const {user: testUser} = renderWithProviders() - 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 - 'user@.domain.com', // Domain starting with dot - 'user@domain.com.', // Domain ending with dot - 'user@-domain.com', // Domain starting with hyphen - 'user@domain-.com' // Domain ending with hyphen - ] - - for (const email of invalidEmails) { - const {user: testUser} = renderWithProviders() - 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 () => { @@ -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( @@ -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() + + 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() + }) }) diff --git a/packages/template-retail-react-app/app/utils/email-utils.js b/packages/template-retail-react-app/app/utils/email-utils.js new file mode 100644 index 0000000000..04add05042 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/email-utils.js @@ -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) +} diff --git a/packages/template-retail-react-app/app/utils/email-utils.test.js b/packages/template-retail-react-app/app/utils/email-utils.test.js new file mode 100644 index 0000000000..49351e378f --- /dev/null +++ b/packages/template-retail-react-app/app/utils/email-utils.test.js @@ -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('test@example.com')).toBe(true) + }) + + test('should return true for email with subdomain', () => { + expect(isValidEmail('user@mail.example.com')).toBe(true) + }) + + test('should return true for email with numbers', () => { + expect(isValidEmail('user123@example123.com')).toBe(true) + }) + + test('should return true for email with special characters', () => { + expect(isValidEmail('user.name+tag@example-domain.co.uk')).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!#$%&'*+/=?^`{|}~-@example.com")).toBe(true) + }) + + test('should return true for email with long domain', () => { + expect(isValidEmail('test@very-long-domain-name-that-is-still-valid.com')).toBe(true) + }) + + test('should return true for email with single character local part', () => { + expect(isValidEmail('a@example.com')).toBe(true) + }) + + test('should return true for email with single character domain', () => { + expect(isValidEmail('test@a.com')).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('Test.User@Example.COM')).toBe(true) + }) + + test('should return true for email with numbers in domain', () => { + expect(isValidEmail('test@example123.com')).toBe(true) + }) + + test('should return true for email with hyphen in domain', () => { + expect(isValidEmail('test@example-domain.com')).toBe(true) + }) + + test('should return true for email with consecutive dots (regex allows this)', () => { + expect(isValidEmail('test..user@example.com')).toBe(true) + }) + + test('should return true for email starting with dot (regex allows this)', () => { + expect(isValidEmail('.test@example.com')).toBe(true) + }) + + test('should return true for email ending with dot (regex allows this)', () => { + expect(isValidEmail('test.@example.com')).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('test@example..com')).toBe(false) + }) + + test('should return false for domain starting with dot', () => { + expect(isValidEmail('test@.example.com')).toBe(false) + }) + + test('should return false for domain ending with dot', () => { + expect(isValidEmail('test@example.com.')).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('test@-example.com')).toBe(false) + }) + + test('should return false for email with hyphen at end of domain part', () => { + expect(isValidEmail('test@example-.com')).toBe(false) + }) + }) +})