diff --git a/src/ReturnUserExperience/OTPInput/OTPInput.tsx b/src/ReturnUserExperience/OTPInput/OTPInput.tsx new file mode 100644 index 0000000000..dccdc5f053 --- /dev/null +++ b/src/ReturnUserExperience/OTPInput/OTPInput.tsx @@ -0,0 +1,202 @@ +import React, { useEffect } from 'react' + +import { Text } from '@mxenabled/mxui' +import Button from '@mui/material/Button' +import { Stack, styled } from '@mui/system' +import { TextField } from 'src/privacy/input' + +import { __ } from 'src/utilities/Intl' +import styles from './otpInput.module.css' + +const RESEND_OTP_INTERVAL = 10 +const OTP_LENGTH = 6 + +const getOtpDigits = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, OTP_LENGTH).split('') + return [...digits, ...new Array(OTP_LENGTH - digits.length).fill('')] +} + +export const OTPInput = ({ + onChange, + value, +}: { + onChange: React.Dispatch> + value: string +}) => { + const inputRefs = React.useRef>( + Array.from({ length: OTP_LENGTH }, () => null), + ) + const [seconds, setSeconds] = React.useState(RESEND_OTP_INTERVAL) + const otpDigits = React.useMemo(() => getOtpDigits(value), [value]) + + useEffect(() => { + // This timer is a delay before the user can request a new OTP. + setSeconds(RESEND_OTP_INTERVAL) + const timer = window.setInterval(() => { + setSeconds((s) => { + if (s <= 1) { + clearInterval(timer) + return 0 + } + return s - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, []) + + const focusInput = (targetIndex: number) => { + inputRefs.current[targetIndex]?.focus() + } + + const selectInput = (targetIndex: number) => { + inputRefs.current[targetIndex]?.select() + } + + const updateOtp = (updater: (digits: string[]) => void) => { + onChange((prev) => { + const digits = getOtpDigits(prev) + updater(digits) + return digits.join('') + }) + } + + const handleKeyDown = (event: React.KeyboardEvent, currentIndex: number) => { + switch (event.key) { + case 'ArrowUp': + case 'ArrowDown': + case ' ': + event.preventDefault() + break + case 'ArrowLeft': + event.preventDefault() + if (currentIndex > 0) { + focusInput(currentIndex - 1) + selectInput(currentIndex - 1) + } + break + case 'ArrowRight': + event.preventDefault() + if (currentIndex < OTP_LENGTH - 1) { + focusInput(currentIndex + 1) + selectInput(currentIndex + 1) + } + break + case 'Delete': + event.preventDefault() + updateOtp((digits) => { + digits[currentIndex] = '' + }) + break + case 'Backspace': { + event.preventDefault() + const isEmpty = event.currentTarget.value === '' + + updateOtp((digits) => { + if (isEmpty && currentIndex > 0) { + digits[currentIndex - 1] = '' + } else { + digits[currentIndex] = '' + } + }) + + if (isEmpty && currentIndex > 0) { + focusInput(currentIndex - 1) + selectInput(currentIndex - 1) + } + break + } + default: + break + } + } + + const handleChange = (event: React.ChangeEvent, currentIndex: number) => { + const nextValue = event.target.value.replace(/\D/g, '').slice(-1) + + updateOtp((digits) => { + digits[currentIndex] = nextValue + }) + + if (nextValue && currentIndex < OTP_LENGTH - 1) { + focusInput(currentIndex + 1) + } + } + + const handlePaste = (event: React.ClipboardEvent, currentIndex: number) => { + event.preventDefault() + + const pastedText = event.clipboardData + .getData('text/plain') + .replace(/\D/g, '') + .slice(0, OTP_LENGTH - currentIndex) + + if (!pastedText) { + return + } + + updateOtp((digits) => { + for (let index = 0; index < pastedText.length; index += 1) { + digits[currentIndex + index] = pastedText[index] + } + }) + + setTimeout(() => { + const focusIndex = Math.min(currentIndex + pastedText.length, OTP_LENGTH - 1) + focusInput(focusIndex) + selectInput(focusIndex) + }, 0) + } + + return ( + <> + + {Array.from({ length: OTP_LENGTH }, (_, index) => ( + { + inputRefs.current[index] = ele + }} + key={index} + onChange={(event: React.ChangeEvent) => handleChange(event, index)} + onFocus={() => selectInput(index)} + onKeyDown={(event: React.KeyboardEvent) => + handleKeyDown(event, index) + } + onPaste={(event: React.ClipboardEvent) => handlePaste(event, index)} + size="small" + value={otpDigits[index]} + variant="outlined" + /> + ))} + + + {seconds > 0 ? ( + + {__('Resend code in (%1 seconds)', seconds)} + + ) : ( + + )} + + ) +} + +const OTPTextField = styled(TextField)(() => ({ + height: '60px', + '& .MuiInputBase-input': { + textAlign: 'center', + fontSize: '23px', + }, + '& .MuiOutlinedInput-root': { + height: '100%', + }, +})) diff --git a/src/ReturnUserExperience/OTPInput/otpInput.module.css b/src/ReturnUserExperience/OTPInput/otpInput.module.css new file mode 100644 index 0000000000..045bbfa0f6 --- /dev/null +++ b/src/ReturnUserExperience/OTPInput/otpInput.module.css @@ -0,0 +1,3 @@ +.container { + margin: 40px 0 0; +} diff --git a/src/ReturnUserExperience/ReturnUserExperience-test.tsx b/src/ReturnUserExperience/ReturnUserExperience-test.tsx new file mode 100644 index 0000000000..32565f23ea --- /dev/null +++ b/src/ReturnUserExperience/ReturnUserExperience-test.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { ReturnUserExperience } from './ReturnUserExperience' + +describe('ReturnUserExperience', () => { + const mockAppName = 'Test Financial App' + + const preloadedState = { + profiles: { + client: { + oauth_app_name: mockAppName, + }, + }, + } + + describe('rendering', () => { + it('should render the component without crashing', () => { + render(, { preloadedState }) + expect(screen.getByText('Connect your accounts')).toBeInTheDocument() + }) + + it('should render the development warning alert', () => { + render(, { preloadedState }) + const alert = screen.getByText('This feature is currently in development.') + expect(alert).toBeInTheDocument() + }) + + it('should render the main heading', () => { + render(, { preloadedState }) + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Connect your accounts') + }) + + it('should render the subtitle with app name interpolation', () => { + render(, { preloadedState }) + const subtitle = screen.getByText(new RegExp(mockAppName)) + expect(subtitle).toBeInTheDocument() + expect(subtitle).toHaveTextContent(`${mockAppName} uses MX to connect your accounts.`) + }) + + it('should render the learn more link', () => { + render(, { preloadedState }) + const link = screen.getByRole('link', { name: /learn more about mx/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://mx.com/learn-more') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render the MX sign in button', () => { + render(, { preloadedState }) + const button = screen.getByRole('button', { name: /connect faster by signing into mx/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('MuiButton-contained') + }) + + it('should render the guest sign in button', () => { + render(, { preloadedState }) + const button = screen.getByRole('button', { name: /continue as guest/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('MuiButton-outlined') + }) + + it('should render both buttons as full width', () => { + render(, { preloadedState }) + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + if ( + button.textContent?.includes('Connect faster') || + button.textContent?.includes('Continue as guest') + ) { + expect(button).toHaveClass('MuiButton-fullWidth') + } + }) + }) + }) + + describe('Redux state integration', () => { + it('should use the oauth_app_name from Redux state', () => { + render(, { preloadedState }) + expect(screen.getByText(new RegExp(mockAppName))).toBeInTheDocument() + }) + + it('should display default app name when oauth_app_name is not provided', () => { + const emptyState = { + profiles: { + client: {}, + }, + } + render(, { preloadedState: emptyState }) + expect(screen.getByText(/This app uses MX to connect your accounts/)).toBeInTheDocument() + }) + + it('should display default app name when oauth_app_name is null', () => { + const nullState = { + profiles: { + client: { + oauth_app_name: null, + }, + }, + } + render(, { preloadedState: nullState }) + expect(screen.getByText(/This app uses MX to connect your accounts/)).toBeInTheDocument() + }) + }) + + describe('button interactions', () => { + it('should render the MX sign in button as clickable', async () => { + const { user } = render(, { preloadedState }) + const button = screen.getByRole('button', { name: /connect faster by signing into mx/i }) + expect(button).not.toBeDisabled() + await user.click(button) + }) + + it('should render the guest continue button as clickable', async () => { + const { user } = render(, { preloadedState }) + const button = screen.getByRole('button', { name: /continue as guest/i }) + expect(button).not.toBeDisabled() + await user.click(button) + }) + }) + + describe('accessibility', () => { + it('should have proper heading hierarchy', () => { + render(, { preloadedState }) + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Connect your accounts') + }) + + it('should have accessible buttons', () => { + render(, { preloadedState }) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + buttons.forEach((button) => { + expect(button).toHaveAccessibleName() + }) + }) + + it('should have an accessible learn more link', () => { + render(, { preloadedState }) + const link = screen.getByRole('link', { name: /learn more about mx/i }) + expect(link).toHaveAccessibleName() + }) + }) +}) diff --git a/src/ReturnUserExperience/ReturnUserExperience.test.tsx b/src/ReturnUserExperience/ReturnUserExperience.test.tsx new file mode 100644 index 0000000000..c3030aa8cf --- /dev/null +++ b/src/ReturnUserExperience/ReturnUserExperience.test.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { render, screen } from 'src/utilities/testingLibrary' +import { ReturnUserExperience } from './ReturnUserExperience' +import { initialState } from 'src/services/mockedData' + +describe('ReturnUserExperience', () => { + const mockAppName = 'Test Financial App' + + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + client: { + ...initialState.profiles.client, + oauth_app_name: mockAppName, + }, + }, + } + + describe('rendering', () => { + it('should render the component without crashing', () => { + render(, { preloadedState }) + expect(screen.getByText('Connect your accounts')).toBeInTheDocument() + }) + + it('should render the main heading', () => { + render(, { preloadedState }) + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Connect your accounts') + }) + + it('should render the subtitle with app name interpolation', () => { + render(, { preloadedState }) + const subtitle = screen.getByText(new RegExp(mockAppName)) + expect(subtitle).toBeInTheDocument() + expect(subtitle).toHaveTextContent(`${mockAppName} uses MX to connect your accounts.`) + }) + + it('should render the learn more link', () => { + render(, { preloadedState }) + const link = screen.getByRole('link', { name: /learn more about mx/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://mx.com/learn-more') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render the MX sign in button', () => { + render(, { preloadedState }) + const button = screen.getByRole('button', { name: /connect faster by signing into mx/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('MuiButton-contained') + }) + + it('should render the guest sign in button', () => { + render(, { preloadedState }) + const button = screen.getByRole('button', { name: /continue as guest/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('MuiButton-outlined') + }) + + it('should render both buttons as full width', () => { + render(, { preloadedState }) + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + if ( + button.textContent?.includes('Connect faster') || + button.textContent?.includes('Continue as guest') + ) { + expect(button).toHaveClass('MuiButton-fullWidth') + } + }) + }) + }) + + describe('Redux state integration', () => { + const undefinedState = { + ...initialState, + profiles: { + ...initialState.profiles, + client: { + ...initialState.profiles.client, + oauth_app_name: undefined, + }, + }, + } + const nullState = { + ...initialState, + profiles: { + ...initialState.profiles, + client: { + ...initialState.profiles.client, + oauth_app_name: null, + }, + }, + } + + it('should use the oauth_app_name from Redux state', () => { + render(, { preloadedState }) + expect(screen.getByText(new RegExp(mockAppName))).toBeInTheDocument() + }) + + it('should display default app name when oauth_app_name is not provided', () => { + render(, { preloadedState: undefinedState }) + expect(screen.getByText(/This app uses MX to connect your accounts/)).toBeInTheDocument() + }) + + it('should display default app name when oauth_app_name is null', () => { + render(, { preloadedState: nullState }) + expect(screen.getByText(/This app uses MX to connect your accounts/)).toBeInTheDocument() + }) + }) + + describe('button interactions', () => { + it('should render the MX sign in button as clickable', async () => { + const { user } = render(, { preloadedState }) + const button = screen.getByRole('button', { name: /connect faster by signing into mx/i }) + expect(button).not.toBeDisabled() + await user.click(button) + }) + + it('should render the guest continue button as clickable', async () => { + const { user } = render(, { preloadedState }) + const button = screen.getByRole('button', { name: /continue as guest/i }) + expect(button).not.toBeDisabled() + await user.click(button) + }) + }) + + describe('accessibility', () => { + it('should have proper heading hierarchy', () => { + render(, { preloadedState }) + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Connect your accounts') + }) + + it('should have accessible buttons', () => { + render(, { preloadedState }) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + buttons.forEach((button) => { + expect(button).toHaveAccessibleName() + }) + }) + + it('should have an accessible learn more link', () => { + render(, { preloadedState }) + const link = screen.getByRole('link', { name: /learn more about mx/i }) + expect(link).toHaveAccessibleName() + }) + }) +}) diff --git a/src/ReturnUserExperience/ReturnUserExperience.tsx b/src/ReturnUserExperience/ReturnUserExperience.tsx index 7e594ef3d7..4714568c6f 100644 --- a/src/ReturnUserExperience/ReturnUserExperience.tsx +++ b/src/ReturnUserExperience/ReturnUserExperience.tsx @@ -1,8 +1,11 @@ import React from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import styles from './returnUserExperience.module.css' import RuxInfo from 'src/ReturnUserExperience/RuxInfo' +import RuxPhoneNumber from 'src/ReturnUserExperience/RuxPhoneNumber' +import RuxOtp from 'src/ReturnUserExperience/RuxOtp' +import RuxList from 'src/ReturnUserExperience/RuxList' import { Stack } from '@mui/material' import { Icon } from '@mxenabled/mxui' @@ -12,6 +15,8 @@ import useAnalyticsEvent from 'src/hooks/useAnalyticsEvent' import { __ } from 'src/utilities/Intl' import { AnalyticEvents } from 'src/const/Analytics' import { RootState } from 'src/redux/Store' +import { ActionTypes } from 'src/redux/actions/Connect' +import { selectInitialConfig } from 'src/redux/reducers/configSlice' import { ClientLogo } from 'src/components/ClientLogo' export const RUXViews = { @@ -23,7 +28,10 @@ export const RUXViews = { export const ReturnUserExperience = React.forwardRef(() => { const [view, setView] = React.useState<(typeof RUXViews)[keyof typeof RUXViews]>(RUXViews.INFO) + const [userEnteredPhone, setUserEnteredPhone] = React.useState('') const clientGuid = useSelector((state: RootState) => state.profiles.client.guid) + const connectConfig = useSelector(selectInitialConfig) + const dispatch = useDispatch() const sendAnalyticsEvent = useAnalyticsEvent() const handleRuxInfoContinue = () => { @@ -31,15 +39,21 @@ export const ReturnUserExperience = React.forwardRef(() => { sendAnalyticsEvent(AnalyticEvents.RUX_INFO_CONTINUE_CLICKED) setView(RUXViews.PHONE_NUMBER) } + const handleContinueWithoutPhone = () => + dispatch({ type: ActionTypes.RESET_WIDGET_MFA_STEP, payload: connectConfig }) return (
{view !== RUXViews.LIST && ( -
- -
- + {view === RUXViews.INFO && ( + <> +
+ +
+ + + )}
@@ -47,6 +61,22 @@ export const ReturnUserExperience = React.forwardRef(() => { )} {view === RUXViews.INFO && } + + {view === RUXViews.PHONE_NUMBER && ( + { + // sendAnalyticsEvent(AnalyticEvents.RUX_PHONE_NUMBER_CONTINUE_CLICKED) + setView(RUXViews.OTP) + }} + setUserEnteredPhone={setUserEnteredPhone} + userEnteredPhone={userEnteredPhone} + /> + )} + + {view === RUXViews.OTP && } + + {view === RUXViews.LIST && }
) }) diff --git a/src/ReturnUserExperience/RuxList.tsx b/src/ReturnUserExperience/RuxList.tsx new file mode 100644 index 0000000000..fc20ccb25e --- /dev/null +++ b/src/ReturnUserExperience/RuxList.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import Stack from '@mui/material/Stack' +import { Text } from '@mxenabled/mxui' +import { Icon } from '@mxenabled/mxui' +import Button from '@mui/material/Button' + +import { __ } from 'src/utilities/Intl' +import styles from './returnUserExperience.module.css' + +export const RuxList = () => { + return ( + <> + {__('Select your institution')} + + {__('Choose a previously connected institution or add a new one.')} + + + {/* Connections list goes here */} +

Connections list goes here

+ + + + + {__('Disconnect accounts from MX anytime by contacting support.')} + + + + ) +} + +export default RuxList diff --git a/src/ReturnUserExperience/RuxOtp.tsx b/src/ReturnUserExperience/RuxOtp.tsx new file mode 100644 index 0000000000..19b32cf6a7 --- /dev/null +++ b/src/ReturnUserExperience/RuxOtp.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import Stack from '@mui/material/Stack' +import { Text } from '@mxenabled/mxui' +import Button from '@mui/material/Button' + +import { __ } from 'src/utilities/Intl' +import styles from './returnUserExperience.module.css' +import { OTPInput } from 'src/ReturnUserExperience/OTPInput/OTPInput' + +export const RuxOtp = () => { + const [otp, setOtp] = React.useState('') + + return ( + <> + + + {__('Verify your phone number')} + + + {__('Enter the code sent to ••• ••• 1234.')} + + + + + + + + + + ) +} + +export default RuxOtp diff --git a/src/ReturnUserExperience/RuxPhoneNumber.tsx b/src/ReturnUserExperience/RuxPhoneNumber.tsx new file mode 100644 index 0000000000..f81428a75d --- /dev/null +++ b/src/ReturnUserExperience/RuxPhoneNumber.tsx @@ -0,0 +1,128 @@ +import React from 'react' + +import { useTheme } from '@mui/material' +import InputAdornment from '@mui/material/InputAdornment' +import Stack from '@mui/material/Stack' +import Button from '@mui/material/Button' +import { Text } from '@mxenabled/mxui' +import { Link } from '@mui/material' + +import { __ } from 'src/utilities/Intl' +import useAnalyticsPath from 'src/hooks/useAnalyticsPath' +import { TextField } from 'src/privacy/input' +import { PageviewInfo } from 'src/const/Analytics' +import styles from './returnUserExperience.module.css' + +export const RuxPhoneNumber = ({ + handleContinueWithoutPhone, + handleRuxContinue, + userEnteredPhone, + setUserEnteredPhone, +}: { + handleContinueWithoutPhone: () => void + handleRuxContinue: () => void + userEnteredPhone: string + setUserEnteredPhone: (phone: string) => void +}) => { + useAnalyticsPath(...PageviewInfo.CONNECT_RUX_PHONE_NUMBER) + const { palette } = useTheme() + + return ( + <> + + + {__('Connect faster with your phone number')} + + + {__('Login or sign up with MX to securely access your saved accounts. ')} + + {__('Learn more about MX.')} + + + + + +
+ + Phone + + + +1 + +
+ + ), + style: { + paddingRight: '14px', + margin: '40px 0', + fontSize: '23px', + fontWeight: '400', + height: 'auto', + maxHeight: '60px', + }, + }} + fullWidth={true} + name="phoneNumber" + onChange={(e: React.ChangeEvent) => + setUserEnteredPhone(e.target.value.replace(/\D/g, '').slice(0, 10)) + } + required={true} + value={formatPhone(userEnteredPhone)} + /> + + + + {/* --TR: Full string 'By selecting "Get code", you agree to MX's Terms & Conditions' */} + {__('By selecting "Continue", you agree to ')} + + {/* TODO: Do we translate this below? */} + {__("MX's Terms & Conditions")} + + + + + + + ) +} + +export default RuxPhoneNumber + +const formatPhone = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 10) + + if (digits.length === 0) return digits + if (digits.length <= 3) return `(${digits}` + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}` + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)} - ${digits.slice(6)}` +} diff --git a/src/ReturnUserExperience/returnUserExperience.module.css b/src/ReturnUserExperience/returnUserExperience.module.css index fc76276baf..0e462743a7 100644 --- a/src/ReturnUserExperience/returnUserExperience.module.css +++ b/src/ReturnUserExperience/returnUserExperience.module.css @@ -71,6 +71,6 @@ .titleContainer { padding-top: 16px; - padding-right: 16px; - padding-left: 16px; + margin-right: -8px; + margin-left: -8px; } diff --git a/src/const/Analytics.js b/src/const/Analytics.js index 03405a3d45..f73a55b8ce 100644 --- a/src/const/Analytics.js +++ b/src/const/Analytics.js @@ -117,6 +117,7 @@ export const PageviewInfo = { CONNECT_OAUTH_ERROR: ['Connect Oauth Error', '/oauth_error'], CONNECT_NO_ELIGIBLE_ACCOUNTS: ['Connect No Eligible Accounts', '/no_eligible_accounts'], CONNECT_RUX_INFO: ['Connect RUX Info', '/rux_info'], + CONNECT_RUX_PHONE_NUMBER: ['Connect RUX Phone Number', '/rux_phone_number'], CONNECT_SEARCH: ['Connect Search', '/search'], CONNECT_SEARCH_FAILED: ['Connect Search Failed', '/search_failed'], CONNECT_SEARCH_NO_RESULTS: ['Connect Search No Results', '/no_results'],