diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 0ef1d77a0f..4ddeb1704e 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.0.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-preview.1", + "commerce-sdk-isomorphic": "5.0.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -920,9 +920,9 @@ "license": "MIT" }, "node_modules/commerce-sdk-isomorphic": { - "version": "5.0.0-preview.1", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0-preview.1.tgz", - "integrity": "sha512-VsGHgj+OCorouoE6b3+JL/qw6xiBnrspk0DAeyNdIttMVNpdXLKEuXjbruw4LC7F0C9Da7GloSR/NiNMYcE+Bg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0.tgz", + "integrity": "sha512-9E0wEKq3pBoAdmjLByBdDVfcNbmo+G61WdxpMpHKtRzIlzbVhjLTNIrxxzRGP+267iTCbJg9sgB8SJLjG2hxTg==", "license": "BSD-3-Clause", "dependencies": { "nanoid": "^3.3.8", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index d6a4fb68b2..d090f2bee1 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-preview.1", + "commerce-sdk-isomorphic": "5.0.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index ed38132b6f..886c83ce52 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -37,7 +37,8 @@ const OtpAuth = ({ handleOtpVerification, onCheckoutAsGuest, isGuestRegistration = false, - hideCheckoutAsGuestButton = false + hideCheckoutAsGuestButton = false, + resendCooldownDuration = 30 }) => { const {tokenLength} = getConfig().app.login const parsedLength = Number(tokenLength) @@ -99,6 +100,8 @@ const OtpAuth = ({ otpInputs.clear() setError('') form.setValue('otp', '') + // Start resend cooldown when modal opens + setResendTimer(resendCooldownDuration) // Track OTP modal view activity track('/otp-authentication', { @@ -108,10 +111,10 @@ const OtpAuth = ({ setTimeout(() => otpInputs.inputRefs.current[0]?.focus(), 100) } - }, [isOpen]) + }, [isOpen, resendCooldownDuration]) const handleVerify = async (code = otpInputs.values.join('')) => { - if (code.length !== OTP_LENGTH) return + if (isVerifying || code.length !== OTP_LENGTH) return setIsVerifying(true) setError('') @@ -147,14 +150,17 @@ const OtpAuth = ({ } const handleResend = async () => { - setResendTimer(5) + // No action while verifying or during cooldown; button stays visible/enabled + if (isVerifying || resendTimer > 0) return + + setResendTimer(resendCooldownDuration) try { await track('/otp-resend', { activity: 'otp_code_resent', context: 'authentication', resendAttempt: true }) - await handleSendEmailOtp(form.getValues('email')) + await handleSendEmailOtp(form.getValues('email'), true) } catch (error) { setResendTimer(0) await track('/otp-resend-failed', { @@ -167,6 +173,8 @@ const OtpAuth = ({ } const handleCheckoutAsGuest = async () => { + if (isVerifying) return + // Track checkout as guest selection await track('/checkout-as-guest', { activity: 'checkout_as_guest_selected', @@ -191,8 +199,6 @@ const OtpAuth = ({ } } - const isResendDisabled = resendTimer > 0 || isVerifying - return ( @@ -213,7 +219,7 @@ const OtpAuth = ({ - + {isGuestRegistration ? ( - {/* OTP Input */} - - {Array.from({length: OTP_LENGTH}).map((_, index) => ( - (otpInputs.inputRefs.current[index] = el)} - value={otpInputs.values[index]} - onChange={(e) => handleInputChange(index, e.target.value)} - onKeyDown={(e) => otpInputs.handleKeyDown(index, e)} - onPaste={otpInputs.handlePaste} - type="text" - inputMode="numeric" - maxLength={1} - textAlign="center" - fontSize="lg" - fontWeight="bold" - size="lg" - width="48px" - height="56px" - borderRadius="md" - borderColor="gray.300" - borderWidth="2px" - disabled={isVerifying} - _focus={{ - borderColor: 'blue.500', - boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)' - }} - _hover={{ - borderColor: 'gray.400' - }} - /> - ))} - + + {/* OTP Input */} + + {Array.from({length: OTP_LENGTH}).map((_, index) => ( + (otpInputs.inputRefs.current[index] = el)} + value={otpInputs.values[index]} + onChange={(e) => handleInputChange(index, e.target.value)} + onKeyDown={(e) => otpInputs.handleKeyDown(index, e)} + onPaste={otpInputs.handlePaste} + type="text" + inputMode="numeric" + maxLength={1} + textAlign="center" + fontSize="lg" + fontWeight="bold" + size="lg" + width={12} + height={14} + borderRadius="md" + borderColor={error ? 'red.500' : 'gray.300'} + borderWidth={2} + disabled={isVerifying} + _focus={{ + borderColor: error ? 'red.500' : 'blue.500', + boxShadow: error + ? '0 0 0 1px var(--chakra-colors-red-500)' + : '0 0 0 1px var(--chakra-colors-blue-500)' + }} + _hover={{ + borderColor: error ? 'red.500' : 'gray.400' + }} + /> + ))} + - {/* Loading indicator during verification */} - {isVerifying && ( - - - - )} + {/* Error message */} + {error && ( + + {error} + + )} - {/* Error message */} - {error && ( - - {error} - - )} + {/* Countdown message */} + {resendTimer > 0 && ( + + + + )} + + {/* Buttons */} + + {!hideCheckoutAsGuestButton && ( + + )} - {/* Buttons */} - - {!hideCheckoutAsGuestButton && ( - )} - - - + + + @@ -353,7 +354,9 @@ OtpAuth.propTypes = { handleOtpVerification: PropTypes.func.isRequired, onCheckoutAsGuest: PropTypes.func, isGuestRegistration: PropTypes.bool, - hideCheckoutAsGuestButton: PropTypes.bool + hideCheckoutAsGuestButton: PropTypes.bool, + /** Resend cooldown (in seconds). Default 30. */ + resendCooldownDuration: PropTypes.number } export default OtpAuth diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.test.js b/packages/template-retail-react-app/app/components/otp-auth/index.test.js index dd5054aa0b..06e2a2d049 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.test.js +++ b/packages/template-retail-react-app/app/components/otp-auth/index.test.js @@ -102,7 +102,7 @@ describe('OtpAuth', () => { describe('Component Rendering', () => { test('renders OTP form with all elements', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText("Confirm it's you")).toBeInTheDocument() expect( @@ -127,7 +127,7 @@ describe('OtpAuth', () => { }) test('renders buttons with correct styling', () => { - renderWithProviders() + renderWithProviders() const guestButton = screen.getByRole('button', {name: /Checkout as a Guest/i}) const resendButton = screen.getByRole('button', {name: /Resend Code/i}) @@ -464,16 +464,51 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) const resendButton = screen.getByText(/Resend code/i) await user.click(resendButton) - expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com') + expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com', true) + }) + + test('countdown message is shown on initial open and both buttons are always visible', () => { + renderWithProviders( + + ) + + // FormattedMessage may render "30 second(s)." (ICU plural) + expect(screen.getByText(/You can request a new code in 30 second/i)).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Checkout as a Guest/i})).toBeInTheDocument() + }) + + test('countdown message uses custom cooldown duration when provided', () => { + renderWithProviders( + + ) + + // FormattedMessage may render "15 second(s)." (ICU plural) + expect(screen.getByText(/You can request a new code in 15 second/i)).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() }) - test('resend button is disabled during countdown', async () => { + test('clicking Resend during cooldown does not send OTP', async () => { const user = userEvent.setup() renderWithProviders( { /> ) - // Click the resend button - await user.click(screen.getByRole('button', {name: /Resend Code/i})) + // Resend button is always visible; during initial 30s cooldown it should not send + const resendButton = screen.getByRole('button', {name: /Resend Code/i}) + await user.click(resendButton) + await user.click(resendButton) - // Wait for the timer text to appear and assert the parent button is disabled - const timerText = await screen.findByText(/Resend code in/i) - const disabledResendButton = timerText.closest('button') - expect(disabledResendButton).toBeDisabled() + expect(mockHandleSendEmailOtp).not.toHaveBeenCalled() }) - test('resend button becomes enabled after countdown', async () => { + test('clicking Resend when cooldown is complete sends OTP and shows countdown', async () => { const user = userEvent.setup() renderWithProviders( { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) - const resendButton = screen.getByText(/Resend code/i) + const resendButton = screen.getByRole('button', {name: /Resend Code/i}) await user.click(resendButton) - // Wait for countdown to complete (mocked timers would be ideal here) - await waitFor(() => { - expect(resendButton).toBeDisabled() - }) + expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com', true) + }) + + test('clicking Resend again during cooldown after first send does not send again', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + // Wait for initial 2s cooldown to expire (text is "X second(s)." from FormattedMessage) + await waitFor( + () => { + expect( + screen.queryByText(/You can request a new code in \d+ second/i) + ).not.toBeInTheDocument() + }, + {timeout: 3000} + ) + + await user.click(screen.getByRole('button', {name: /Resend Code/i})) + expect(mockHandleSendEmailOtp).toHaveBeenCalledTimes(1) + + // Countdown is showing; click Resend again - should not send + expect( + screen.getByText(/You can request a new code in \d+ second/i) + ).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: /Resend Code/i})) + + expect(mockHandleSendEmailOtp).toHaveBeenCalledTimes(1) }) }) @@ -530,6 +597,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtpError} + resendCooldownDuration={0} /> ) @@ -555,7 +623,7 @@ describe('OtpAuth', () => { }) test('buttons have accessible text', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByRole('button', {name: /Checkout as a Guest/i})).toBeInTheDocument() expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() @@ -783,6 +851,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -812,6 +881,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -920,6 +990,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -989,6 +1060,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) 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 bb5f1aece4..062f544911 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 @@ -205,12 +205,12 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG } // Handle sending OTP email - const handleSendEmailOtp = async (email) => { + const handleSendEmailOtp = async (email, isResend = false) => { // Normalize email for comparison (trim and lowercase) const normalizedEmail = email?.trim().toLowerCase() || '' - // Skip if email hasn't changed from the last one we sent - if (lastEmailSentRef.current === normalizedEmail) { + // Skip if email hasn't changed from the last one we sent (unless user requested) + if (!isResend && lastEmailSentRef.current === normalizedEmail) { // Return cached result if we have one if (otpSendPromiseRef.current) { return otpSendPromiseRef.current @@ -219,8 +219,8 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG return {isRegistered: false} } - // Reuse in-flight request (single-flight) across blur and submit - if (otpSendPromiseRef.current) { + // Reuse in-flight request (single-flight) across blur and submit (but not for explicit resend) + if (!isResend && otpSendPromiseRef.current) { return otpSendPromiseRef.current } diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index a16ca22de6..11a84743c8 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -3079,20 +3079,6 @@ "value": "Resend Code" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "Resend code in " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " seconds..." - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -3119,10 +3105,46 @@ "value": "To log in to your account, enter the code sent to your email." } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, - "value": "Verifying code..." + "value": "You can request a new code in " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "second" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "seconds" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." } ], "otp.title.confirm_its_you": [ diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index a16ca22de6..11a84743c8 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -3079,20 +3079,6 @@ "value": "Resend Code" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "Resend code in " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " seconds..." - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -3119,10 +3105,46 @@ "value": "To log in to your account, enter the code sent to your email." } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, - "value": "Verifying code..." + "value": "You can request a new code in " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "second" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "seconds" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." } ], "otp.title.confirm_its_you": [ diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index e84e21e7b3..cf20e0344c 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -6511,28 +6511,6 @@ "value": "]" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Řḗḗşḗḗƞḓ ƈǿǿḓḗḗ īƞ " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " şḗḗƈǿǿƞḓş..." - }, - { - "type": 0, - "value": "]" - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -6583,14 +6561,50 @@ "value": "]" } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, "value": "[" }, { "type": 0, - "value": "Ṽḗḗřīƒẏīƞɠ ƈǿǿḓḗḗ..." + "value": "Ẏǿǿŭŭ ƈȧȧƞ řḗḗɋŭŭḗḗşŧ ȧȧ ƞḗḗẇ ƈǿǿḓḗḗ īƞ " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "şḗḗƈǿǿƞḓ" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "şḗḗƈǿǿƞḓş" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." }, { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index a5629d84f1..30ed890c49 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1294,9 +1294,6 @@ "otp.button.resend_code": { "defaultMessage": "Resend Code" }, - "otp.button.resend_timer": { - "defaultMessage": "Resend code in {timer} seconds..." - }, "otp.error.invalid_code": { "defaultMessage": "The code is invalid or expired. Click Resend Code and try again." }, @@ -1306,8 +1303,8 @@ "otp.message.enter_code_for_account_returning": { "defaultMessage": "To log in to your account, enter the code sent to your email." }, - "otp.message.verifying": { - "defaultMessage": "Verifying code..." + "otp.message.resend_cooldown": { + "defaultMessage": "You can request a new code in {timer} {timer, plural, one {second} other {seconds}}." }, "otp.title.confirm_its_you": { "defaultMessage": "Confirm it's you" diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index a5629d84f1..30ed890c49 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1294,9 +1294,6 @@ "otp.button.resend_code": { "defaultMessage": "Resend Code" }, - "otp.button.resend_timer": { - "defaultMessage": "Resend code in {timer} seconds..." - }, "otp.error.invalid_code": { "defaultMessage": "The code is invalid or expired. Click Resend Code and try again." }, @@ -1306,8 +1303,8 @@ "otp.message.enter_code_for_account_returning": { "defaultMessage": "To log in to your account, enter the code sent to your email." }, - "otp.message.verifying": { - "defaultMessage": "Verifying code..." + "otp.message.resend_cooldown": { + "defaultMessage": "You can request a new code in {timer} {timer, plural, one {second} other {seconds}}." }, "otp.title.confirm_its_you": { "defaultMessage": "Confirm it's you"