diff --git a/package-lock.json b/package-lock.json index 72e5924c7b18..63d08c854fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@deriv-com/quill-ui": "1.16.9", "@deriv-com/translations": "1.3.5", "@deriv-com/ui": "1.35.0", - "@deriv-com/utils": "^0.0.25", + "@deriv-com/utils": "0.0.33", "@deriv/api-types": "1.0.172", "@deriv/deriv-api": "^1.0.15", "@deriv/deriv-charts": "^2.5.1", @@ -94,6 +94,7 @@ "css-hot-loader": "^1.4.4", "css-loader": "^5.0.1", "css-minimizer-webpack-plugin": "^3.0.1", + "dayjs": "^1.11.11", "deep-diff": "^1.0.2", "dompurify": "^3.1.0", "dotenv": "^8.2.0", @@ -3183,9 +3184,9 @@ } }, "node_modules/@deriv-com/utils": { - "version": "0.0.25", - "resolved": "https://registry.npmjs.org/@deriv-com/utils/-/utils-0.0.25.tgz", - "integrity": "sha512-zIJLDHgc8Aja+u8YDZ0FVNbMX8DQB3T+ioRzwIj8SevEo/VDEke1IUMxJe5PXCHYNs7ml2mB/mXSfLOr7KGqdA==" + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@deriv-com/utils/-/utils-0.0.33.tgz", + "integrity": "sha512-LzIpzMvfWhK9y06Qpe/HOB4pFCizk2wAyhv9I0s48Romq+d5MM1mmsuh5CvS4SnzzdLyuBy4rgXrOO3394HB7w==" }, "node_modules/@deriv/api-types": { "version": "1.0.172", diff --git a/packages/account/jest.config.js b/packages/account/jest.config.js index ee7834a92264..1e3d737cc6df 100644 --- a/packages/account/jest.config.js +++ b/packages/account/jest.config.js @@ -4,8 +4,7 @@ module.exports = { ...baseConfigForPackages, preset: 'ts-jest', moduleNameMapper: { - '\\.css$': '/../../__mocks__/styleMock.js', - '\\.s(c|a)ss$': '/../../__mocks__/styleMock.js', + '\\.(s(c|a)ss|css|less)$': '/../../__mocks__/styleMock.js', '^.+\\.svg$': '/../../__mocks__/styleMock.js', '@deriv-com/translations': '/../../__mocks__/translation.mock.js', '^Assets/(.*)$': '/src/Assets/$1', diff --git a/packages/account/package.json b/packages/account/package.json index e435d609f2de..ecc8c0dc04a6 100644 --- a/packages/account/package.json +++ b/packages/account/package.json @@ -32,7 +32,7 @@ "@binary-com/binary-document-uploader": "^2.4.8", "@deriv-com/analytics": "1.14.0", "@deriv-com/translations": "1.3.5", - "@deriv-com/utils": "^0.0.25", + "@deriv-com/utils": "0.0.33", "@deriv-com/ui": "1.35.0", "@deriv/api": "^1.0.0", "@deriv-com/quill-ui": "1.16.9", diff --git a/packages/account/src/Configs/personal-details-config.ts b/packages/account/src/Configs/personal-details-config.ts index bf7c3c548bff..b6889e8df665 100644 --- a/packages/account/src/Configs/personal-details-config.ts +++ b/packages/account/src/Configs/personal-details-config.ts @@ -101,12 +101,9 @@ export const personal_details_config = ({ ['phone', localize('Phone is not in a proper format.')], [ (value: string) => { - // phone_trim uses regex that trims non-digits - const phone_trim = value.replace(/\D/g, ''); - // minimum characters required is 9 numbers (excluding +- signs or space) - return validLength(phone_trim, { min: PHONE_NUMBER_LENGTH.MIN, max: PHONE_NUMBER_LENGTH.MAX }); + return validLength(value, { min: PHONE_NUMBER_LENGTH.MIN, max: PHONE_NUMBER_LENGTH.MAX }); }, - localize('You should enter {{min}}-{{max}} numbers.', { + localize('You should enter {{min}}-{{max}} characters.', { min: PHONE_NUMBER_LENGTH.MIN, max: PHONE_NUMBER_LENGTH.MAX, }), diff --git a/packages/account/src/Constants/personal-details.ts b/packages/account/src/Constants/personal-details.ts index 936d07ecec71..465fdeb533cd 100644 --- a/packages/account/src/Constants/personal-details.ts +++ b/packages/account/src/Constants/personal-details.ts @@ -1,4 +1,4 @@ export const PHONE_NUMBER_LENGTH = { MIN: 9, - MAX: 35, + MAX: 20, }; diff --git a/packages/account/src/Constants/routes-config.tsx b/packages/account/src/Constants/routes-config.tsx index cae933c6c535..4f739697a4a8 100644 --- a/packages/account/src/Constants/routes-config.tsx +++ b/packages/account/src/Constants/routes-config.tsx @@ -26,6 +26,11 @@ const Passwords = makeLazyLoader( () => )(); +const PhoneVerificationPage = makeLazyLoader( + () => moduleLoader(() => import('../Sections/Profile/PhoneVerification')), + () => +)(); + const Passkeys = makeLazyLoader( () => moduleLoader(() => import('../Sections/Security/Passkeys')), () => @@ -109,6 +114,12 @@ const initRoutesConfig = () => [ getTitle: () => localize('Profile'), icon: 'IcUserOutline', subroutes: [ + { + path: routes.phone_verification, + component: PhoneVerificationPage, + getTitle: () => localize('Phone number verification'), + is_hidden: true, + }, { path: routes.personal_details, component: PersonalDetails, diff --git a/packages/account/src/Containers/__tests__/routes.spec.tsx b/packages/account/src/Containers/__tests__/routes.spec.tsx index 5d2ad2c67afe..076bd2c3a205 100644 --- a/packages/account/src/Containers/__tests__/routes.spec.tsx +++ b/packages/account/src/Containers/__tests__/routes.spec.tsx @@ -16,6 +16,19 @@ jest.mock('@deriv/components', () => ({ })); describe('', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); const history = createBrowserHistory(); const mock_root_store = mockStore({ diff --git a/packages/account/src/Containers/routes.tsx b/packages/account/src/Containers/routes.tsx index 63566793c9db..3dbb9079f1b2 100644 --- a/packages/account/src/Containers/routes.tsx +++ b/packages/account/src/Containers/routes.tsx @@ -1,18 +1,23 @@ -import React from 'react'; import { withRouter } from 'react-router'; import { observer, useStore } from '@deriv/stores'; import { BinaryRoutes } from '../Components/Routes'; import ErrorComponent from '../Components/error-component'; +import { ThemeProvider } from '@deriv-com/quill-ui'; const Routes = observer(() => { - const { client, common } = useStore(); + const { client, common, ui } = useStore(); const { is_logged_in, is_logging_in } = client; const { error, has_error } = common; + const { is_dark_mode_on } = ui; if (has_error) { return ; } - return ; + return ( + + + + ); }); // need to wrap withRouter diff --git a/packages/account/src/Helpers/utils.tsx b/packages/account/src/Helpers/utils.tsx index 4e41947ea623..1fea91cf93e5 100644 --- a/packages/account/src/Helpers/utils.tsx +++ b/packages/account/src/Helpers/utils.tsx @@ -10,6 +10,7 @@ import { getIDVNotApplicableOption, IDV_ERROR_STATUS, AUTH_STATUS_CODES, + VERIFICATION_SERVICES, } from '@deriv/shared'; import { localize } from '@deriv-com/translations'; import { getIDVDocuments } from '../Configs/idv-document-config'; @@ -277,3 +278,9 @@ export const verifyFields = (status: TIDVErrorStatus) => { export const isSpecialPaymentMethod = (payment_method_icon: string) => ['IcOnlineNaira', 'IcAstroPayLight', 'IcAstroPayDark'].some(icon => icon === payment_method_icon); + +export const convertPhoneTypeDisplay = (phone_verification_type: string) => { + if (phone_verification_type === VERIFICATION_SERVICES.SMS) return phone_verification_type.toUpperCase(); + + return 'WhatsApp'; +}; diff --git a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx index 7f4652e8014c..8aa4b8541e11 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx @@ -6,7 +6,7 @@ import { APIProvider } from '@deriv/api'; import userEvent from '@testing-library/user-event'; import { StoreProvider, mockStore } from '@deriv/stores'; import PersonalDetailsForm from '../personal-details-form'; -import { useResidenceList } from '@deriv/hooks'; +import { useGrowthbookGetFeatureValue, useResidenceList } from '@deriv/hooks'; afterAll(cleanup); jest.mock('@deriv/components', () => ({ @@ -16,6 +16,7 @@ jest.mock('@deriv/components', () => ({ jest.mock('@deriv/shared/src/services/ws-methods', () => ({ WS: { + send: jest.fn().mockResolvedValue({ time: 1620000000 }), wait: (...payload: []) => Promise.resolve([...payload]), }, useWS: () => undefined, @@ -32,6 +33,7 @@ jest.mock('@deriv/hooks', () => ({ ...jest.requireActual('@deriv/hooks'), useStatesList: jest.fn(() => ({ data: residence_list, isLoading: false })), useResidenceList: jest.fn(() => ({ data: residence_list, isLoading: false })), + useGrowthbookGetFeatureValue: jest.fn(), })); describe('', () => { @@ -44,6 +46,9 @@ describe('', () => { place_of_birth: 'Thailand', citizen: 'Thailand', email_consent: 1, + phone_number_verification: { + verified: 0, + }, }, }, }); @@ -60,15 +65,15 @@ describe('', () => { ); }; + beforeEach(() => { + (useGrowthbookGetFeatureValue as jest.Mock).mockReturnValue([true]); + }); + it('should render successfully', async () => { renderComponent(); expect(screen.queryByText(/Loading/)).not.toBeInTheDocument(); await waitFor(() => { - expect( - screen.getByText( - /Please make sure your information is correct or it may affect your trading experience./i - ) - ).toBeInTheDocument(); + expect(screen.getByText(/Ensure your information is correct./i)).toBeInTheDocument(); }); }); @@ -176,14 +181,14 @@ describe('', () => { ).toBeInTheDocument(); }); - it('should update user profile after clicking on submit', () => { + it('should update user profile after clicking on Save changes', () => { renderComponent(); const first_name = screen.getByTestId('dt_first_name') as HTMLInputElement; expect(first_name.value).toBe('John'); userEvent.clear(first_name); userEvent.type(first_name, 'James'); - const submit_button = screen.getByRole('button', { name: /Submit/ }); - userEvent.click(submit_button); + const save_changes_button = screen.getByRole('button', { name: /Save changes/ }); + userEvent.click(save_changes_button); expect(first_name.value).toBe('James'); }); diff --git a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/verify-button.spec.tsx b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/verify-button.spec.tsx new file mode 100644 index 000000000000..7d8ad68c5736 --- /dev/null +++ b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/verify-button.spec.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Router } from 'react-router'; +import { createBrowserHistory } from 'history'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { usePhoneNumberVerificationSetTimer, useVerifyEmail } from '@deriv/hooks'; +import { routes } from '@deriv/shared'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { VerifyButton } from '../verify-button'; + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + usePhoneNumberVerificationSetTimer: jest.fn(), + useVerifyEmail: jest.fn(() => ({ + sendPhoneNumberVerifyEmail: jest.fn(), + WS: {}, + error: null, + })), + useSettings: jest.fn(() => ({ + refetch: jest.fn(), + })), +})); + +describe('VerifyButton', () => { + beforeEach(() => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_otp_request: '' }); + }); + const history = createBrowserHistory(); + const mock_store = mockStore({ + client: { + account_settings: { + phone_number_verification: { + verified: 0, + }, + }, + }, + }); + let mock_next_email_otp_request_timer = 0; + const mock_set_status = jest.fn(); + + const renderWithRouter = () => { + return render( + + + + + + ); + }; + + beforeEach(() => { + mock_next_email_otp_request_timer = 0; + }); + + it('should render Verify Button', () => { + renderWithRouter(); + expect(screen.getByText('Verify')).toBeInTheDocument(); + }); + + it('should redirect user to phone-verification page when clicked on Verify Button', () => { + (useVerifyEmail as jest.Mock).mockReturnValue({ + sendPhoneNumberVerifyEmail: jest.fn(), + WS: { + isSuccess: true, + }, + }); + renderWithRouter(); + const verifyButton = screen.getByText('Verify'); + userEvent.click(verifyButton); + expect(history.location.pathname).toBe(routes.phone_verification); + }); + + it('should setStatus with error returned by WS', () => { + (useVerifyEmail as jest.Mock).mockReturnValue({ + sendPhoneNumberVerifyEmail: jest.fn(), + WS: { + isSuccess: false, + }, + error: { + message: 'Phone Taken', + code: 'PhoneNumberTaken', + }, + }); + renderWithRouter(); + const verifyButton = screen.getByText('Verify'); + userEvent.click(verifyButton); + expect(mock_set_status).toBeCalledWith({ msg: 'Phone Taken', code: 'PhoneNumberTaken' }); + }); + + it('should render Verify Button with timer if next_otp_request has value', () => { + mock_next_email_otp_request_timer = 2; + renderWithRouter(); + expect(screen.getByText('Verify in 2s')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Verify in 2s' })).toBeDisabled(); + }); + + it('should render Verified text', () => { + if (mock_store.client.account_settings.phone_number_verification) + mock_store.client.account_settings.phone_number_verification.verified = 1; + renderWithRouter(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx index 8ac9321ec2f2..bdb8f45a4df2 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, Fragment } from 'react'; +import { useState, useRef, useEffect, Fragment, ChangeEvent } from 'react'; import clsx from 'clsx'; import { Formik, Form, FormikHelpers } from 'formik'; import { useHistory } from 'react-router'; @@ -11,6 +11,7 @@ import { HintBox, Input, Loading, + OpenLiveChatLink, SelectNative, Text, } from '@deriv/components'; @@ -29,14 +30,25 @@ import { getEmploymentStatusList } from 'Sections/Assessment/FinancialAssessment import InputGroup from './input-group'; import { getPersonalDetailsInitialValues, getPersonalDetailsValidationSchema, makeSettingsRequest } from './validation'; import FormSelectField from 'Components/forms/form-select-field'; +import { VerifyButton } from './verify-button'; import { useInvalidateQuery } from '@deriv/api'; -import { useStatesList, useResidenceList } from '@deriv/hooks'; +import { + useStatesList, + useResidenceList, + useGrowthbookGetFeatureValue, + usePhoneNumberVerificationSetTimer, + useIsPhoneNumberVerified, +} from '@deriv/hooks'; type TRestState = { show_form: boolean; api_error?: string; }; +type THintMessage = { + is_phone_number_editted: boolean; +}; + const PersonalDetailsForm = observer(() => { const { isDesktop } = useDevice(); const [is_loading, setIsLoading] = useState(false); @@ -44,12 +56,17 @@ const PersonalDetailsForm = observer(() => { const [is_submit_success, setIsSubmitSuccess] = useState(false); const invalidate = useInvalidateQuery(); const history = useHistory(); + const [isPhoneNumberVerificationEnabled] = useGrowthbookGetFeatureValue({ + featureFlag: 'phone_number_verification', + }); + const { next_email_otp_request_timer, is_email_otp_timer_loading } = usePhoneNumberVerificationSetTimer(); const { client, notifications, common: { is_language_changing }, } = useStore(); + const { is_phone_number_verified } = useIsPhoneNumberVerified(); const { account_settings, @@ -104,14 +121,37 @@ const PersonalDetailsForm = observer(() => { } }, [invalidate, is_language_changing]); + const hintMessage = ({ is_phone_number_editted }: THintMessage) => { + if (isPhoneNumberVerificationEnabled) { + if (is_phone_number_verified) { + return ( + , + ]} + /> + ); + } else if (is_phone_number_editted) { + return ; + } + } else { + return null; + } + }; + const onSubmit = async (values: GetSettings, { setStatus, setSubmitting }: FormikHelpers) => { - setStatus({ msg: '' }); + setStatus({ msg: '', code: '' }); const request = makeSettingsRequest({ ...values }, residence_list, states_list, is_virtual); setIsBtnLoading(true); const data = await WS.authorized.setSettings(request); if (data.error) { - setStatus({ msg: data.error.message }); + setStatus({ msg: data.error.message, code: data.error.code }); setIsBtnLoading(false); setSubmitting(false); } else { @@ -199,6 +239,24 @@ const PersonalDetailsForm = observer(() => { return undefined; }; + const displayErrorMessage = (status: any) => { + if (status?.code === 'PhoneNumberTaken') { + return ( + ]} + /> + } + text_color='loss-danger' + weight='none' + /> + ); + } + return ; + }; + const PersonalDetailSchema = getPersonalDetailsValidationSchema(is_eu, is_virtual); const initialValues = getPersonalDetailsInitialValues(account_settings, residence_list, states_list, is_virtual); @@ -213,6 +271,7 @@ const PersonalDetailsForm = observer(() => { {({ values, errors, + setStatus, status, touched, handleChange, @@ -362,13 +421,35 @@ const PersonalDetailsForm = observer(() => { label={localize('Phone number*')} //@ts-expect-error type of residence should not be null: needs to be updated in GetSettings type value={values.phone} - onChange={handleChange} + hint={hintMessage({ + is_phone_number_editted: account_settings.phone !== values.phone, + })} + onChange={(e: ChangeEvent) => { + handleChange(e); + setStatus(''); + }} onBlur={handleBlur} required error={errors.phone} - disabled={isFieldDisabled('phone')} + disabled={ + isFieldDisabled('phone') || + !!next_email_otp_request_timer || + is_email_otp_timer_loading + } data-testid='dt_phone' /> + {isPhoneNumberVerificationEnabled && ( + + )} )} @@ -642,7 +723,7 @@ const PersonalDetailsForm = observer(() => { - {status?.msg && } + {status?.msg && displayErrorMessage(status)} {!is_virtual && !(isSubmitting || is_submit_success || status?.msg) && ( { color='prominent' align={isDesktop ? 'right' : 'center'} > - {localize( - 'Please make sure your information is correct or it may affect your trading experience.' - )} + )} + + ); + } +); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/cancel-phone-verification-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/cancel-phone-verification-modal.spec.tsx new file mode 100644 index 000000000000..a3fef1cfdcac --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/cancel-phone-verification-modal.spec.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CancelPhoneVerificationModal from '../cancel-phone-verification-modal'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { routes } from '@deriv/shared'; + +const mock_push = jest.fn(); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useHistory: () => ({ + push: mock_push, + block: jest.fn(callback => { + callback({ pathname: routes.personal_details }); + return jest.fn(); + }), + }), + useLocation: () => ({ + pathname: '/phone-verification', + }), +})); +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + usePhoneNumberVerificationSessionTimer: jest.fn(() => ({ + should_show_session_timeout_modal: false, + })), +})); + +describe('CancelPhoneVerificationModal', () => { + let modal_root_el: HTMLElement; + + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'modal_root'); + document.body.appendChild(modal_root_el); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + + const mock_store = mockStore({}); + + const buttons = [/Continue verification/, /Cancel/]; + + const renderComponent = () => { + render( + + + + ); + }; + + it('it should render CancelPhoneVerificationModal', () => { + renderComponent(); + buttons.forEach(value => { + expect(screen.getByRole('button', { name: value })).toBeInTheDocument(); + }); + expect(screen.getByText(/Cancel phone number verification?/)).toBeInTheDocument(); + expect(screen.getByText(/If you cancel, you'll lose all progress./)).toBeInTheDocument(); + }); + + it('it should render only mockSetShowCancelModal when Continue verification is clicked', () => { + renderComponent(); + const cancelButton = screen.getByRole('button', { name: buttons[0] }); + userEvent.click(cancelButton); + expect(mock_push).not.toBeCalled(); + }); + + it('it should render mockSetShowCancelModal and mock_back_router when Cancel is clicked', () => { + renderComponent(); + const cancelButton = screen.getByRole('button', { name: buttons[1] }); + userEvent.click(cancelButton); + expect(mock_push).toBeCalledWith(routes.personal_details); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/confirm-phone-number.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/confirm-phone-number.spec.tsx new file mode 100644 index 000000000000..23432c53a599 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/confirm-phone-number.spec.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { usePhoneNumberVerificationSetTimer, useRequestPhoneNumberOTP, useSettings } from '@deriv/hooks'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import ConfirmPhoneNumber from '../confirm-phone-number'; + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + useRequestPhoneNumberOTP: jest.fn(() => ({ + error_message: '', + requestOnWhatsApp: jest.fn(), + requestOnSMS: jest.fn(), + setErrorMessage: jest.fn(), + setUsersPhoneNumber: jest.fn(), + setIsDisabledRequestButton: jest.fn(), + })), + useSettings: jest.fn(() => ({ + data: {}, + invalidate: jest.fn(), + })), + usePhoneNumberVerificationSetTimer: jest.fn(() => ({ + next_phone_otp_request_timer: undefined, + is_phone_otp_timer_loading: false, + })), +})); + +describe('ConfirmPhoneNumber', () => { + const store = mockStore({ + ui: { + setShouldShowPhoneNumberOTP: jest.fn(), + }, + }); + + const mockSetOtp = jest.fn(); + const whatsapp_button_text = 'Get code via WhatsApp'; + const sms_button_text = 'Get code via SMS'; + + it('should render ConfirmPhoneNumber', () => { + (useSettings as jest.Mock).mockReturnValue({ + data: { phone: '+0123456789' }, + }); + render( + + + + ); + const phone_number_textfield = screen.getByRole('textbox', { name: 'Phone number' }); + expect(screen.getByText('Confirm your phone number')).toBeInTheDocument(); + expect(phone_number_textfield).toBeInTheDocument(); + expect(phone_number_textfield).toHaveValue('0123456789'); + expect(screen.getByRole('button', { name: sms_button_text })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: whatsapp_button_text })).toBeInTheDocument(); + }); + + it('should call setErrorMessage when the user presses a key', async () => { + const mock_set_error_message = jest.fn(); + const mock_set_is_disabled_request_button = jest.fn(); + (useRequestPhoneNumberOTP as jest.Mock).mockReturnValue({ + setErrorMessage: mock_set_error_message, + setIsDisabledRequestButton: mock_set_is_disabled_request_button, + }); + render( + + + + ); + const phone_number_textfield = screen.getByRole('textbox', { name: 'Phone number' }); + expect(screen.getByText('Confirm your phone number')).toBeInTheDocument(); + expect(phone_number_textfield).toBeInTheDocument(); + userEvent.clear(phone_number_textfield); + userEvent.type(phone_number_textfield, '+01293291291'); + await waitFor(() => { + expect(mock_set_error_message).toHaveBeenCalled(); + expect(mock_set_is_disabled_request_button).toHaveBeenCalled(); + }); + }); + + it('should display given error message', () => { + (useRequestPhoneNumberOTP as jest.Mock).mockReturnValue({ + error_message: 'This is an error message', + }); + render( + + + + ); + expect(screen.getByText(/This is an error message/)).toBeInTheDocument(); + }); + + it('should render handleError function when WS returns error promises', async () => { + const mock_handle_error = jest.fn().mockResolvedValue({ error: null }); + (useRequestPhoneNumberOTP as jest.Mock).mockReturnValue({ + requestOnWhatsApp: jest.fn(), + setUsersPhoneNumber: mock_handle_error, + }); + + render( + + + + ); + const whatsapp_btn = screen.getByRole('button', { name: whatsapp_button_text }); + userEvent.click(whatsapp_btn); + await waitFor(() => { + expect(mock_handle_error).toBeCalledTimes(1); + }); + }); + + it('should call requestOnWhatsApp when Whatsapp button is clicked', async () => { + const mockWhatsappButtonClick = jest.fn(); + + (useRequestPhoneNumberOTP as jest.Mock).mockReturnValue({ + requestOnWhatsApp: mockWhatsappButtonClick, + setUsersPhoneNumber: jest.fn().mockResolvedValue({ error: null }), + }); + render( + + + + ); + const whatsapp_btn = screen.getByRole('button', { name: whatsapp_button_text }); + userEvent.click(whatsapp_btn); + await waitFor(() => { + expect(mockWhatsappButtonClick).toHaveBeenCalled(); + }); + }); + + it('should call requestOnSMS when SMS button is clicked', async () => { + const mockSmsButtonClick = jest.fn(); + + (useRequestPhoneNumberOTP as jest.Mock).mockReturnValue({ + requestOnSMS: mockSmsButtonClick, + setUsersPhoneNumber: jest.fn().mockResolvedValue({ error: null }), + }); + render( + + + + ); + const sms_btn = screen.getByRole('button', { name: sms_button_text }); + userEvent.click(sms_btn); + await waitFor(() => { + expect(mockSmsButtonClick).toHaveBeenCalled(); + }); + }); + + it('should make both buttons disabled if next_otp_request text is provided', async () => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_phone_otp_request_timer: 60 }); + render( + + + + ); + const sms_btn = screen.getByRole('button', { name: sms_button_text }); + const whatsapp_btn = screen.getByRole('button', { name: whatsapp_button_text }); + expect(sms_btn).toBeDisabled(); + expect(whatsapp_btn).toBeDisabled(); + }); + + it('should get snackbar text when next_otp_request text is provided', async () => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_phone_otp_request_timer: 60 }); + render( + + + + ); + expect(screen.getByText(/An error occurred. Request a new OTP in 1 minutes./)); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/cool-down-period-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/cool-down-period-modal.spec.tsx new file mode 100644 index 000000000000..b3ebaa96daf6 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/cool-down-period-modal.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { mockStore, StoreProvider } from '@deriv/stores'; +import CoolDownPeriodModal from '../cool-down-period-modal'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { routes } from '@deriv/shared'; + +const mock_push_function = jest.fn(); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useHistory: () => ({ + push: mock_push_function, + }), +})); + +describe('CooldownPeriodModal', () => { + const mock_store = mockStore({}); + const show_cool_down_period_modal = true; + const mockSetShowCoolDownPeriodModal = jest.fn(); + + const renderComponent = () => { + render( + + + + ); + }; + it('should show CooldownPeriodModal when show_cool_down_period_modal is true', () => { + renderComponent(); + expect(screen.getByText(/OTP limit reached/)).toBeInTheDocument(); + expect(screen.getByText(/Request a new OTP after 10 minutes./)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /OK/ })).toBeInTheDocument(); + }); + + it('should call history.push, setIsForcedToExitPnv, mockSetShowCoolDownPeriodModal with value of personal details', () => { + renderComponent(); + const ok_button = screen.getByRole('button', { name: /OK/ }); + userEvent.click(ok_button); + expect(mock_push_function).toBeCalledWith(routes.personal_details); + expect(mockSetShowCoolDownPeriodModal).toBeCalledWith(false); + expect(mock_store.ui.setIsForcedToExitPnv).toBeCalledWith(false); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/didnt-get-the-code-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/didnt-get-the-code-modal.spec.tsx new file mode 100644 index 000000000000..e00213ede6dd --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/didnt-get-the-code-modal.spec.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DidntGetTheCodeModal from '../didnt-get-the-code-modal'; +import userEvent from '@testing-library/user-event'; +import { VERIFICATION_SERVICES } from '@deriv/shared'; + +describe('DidntGetTheCodeModal', () => { + const mockClearOtpValue = jest.fn(); + const mockSetShouldShowDidntGetTheCodeModal = jest.fn(); + const mockSetOtpVerification = jest.fn(); + const mockReInitializeGetSettings = jest.fn(); + const mockSetIsButtonDisabled = jest.fn(); + const mockRequestOnWhatsApp = jest.fn(); + const mockRequestOnSms = jest.fn(); + const resend_code_text = /Resend code/; + + const renderComponent = (phone_verification_type: string) => { + render( + + ); + }; + + beforeEach(() => { + mockSetShouldShowDidntGetTheCodeModal.mockClear(); + mockSetOtpVerification.mockClear(); + mockRequestOnSms.mockClear(); + mockRequestOnWhatsApp.mockClear(); + }); + + it('should render DidntGetTheCodeModal', () => { + renderComponent(VERIFICATION_SERVICES.SMS); + expect(screen.getByText(/Didn't receive a code/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: resend_code_text })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Send code via WhatsApp/ })).toBeInTheDocument(); + }); + + it('should show Send code via SMS if phone_verification_type is whatsapp', () => { + renderComponent(VERIFICATION_SERVICES.WHATSAPP); + expect(screen.getByRole('button', { name: /Send code via SMS/ })).toBeInTheDocument(); + }); + + it('should render setShouldShowDidintGetTheCodeModal when Resend code is clicked', () => { + renderComponent(VERIFICATION_SERVICES.SMS); + const resend_code_button = screen.getByRole('button', { name: resend_code_text }); + userEvent.click(resend_code_button); + expect(mockSetShouldShowDidntGetTheCodeModal).toHaveBeenCalledTimes(1); + }); + + it('should render mockRequestOnSMS and setOtpVerification with phone_verification_type: sms when Resend code is clicked, phone_verification_type is sms', () => { + renderComponent(VERIFICATION_SERVICES.SMS); + const resend_code_button = screen.getByRole('button', { name: resend_code_text }); + userEvent.click(resend_code_button); + expect(mockRequestOnSms).toHaveBeenCalledTimes(1); + expect(mockSetOtpVerification).toBeCalledWith({ + show_otp_verification: true, + phone_verification_type: VERIFICATION_SERVICES.SMS, + }); + }); + + it('should render mockRequestOnWhatsapp and setOtpVerification with phone_verification_type: whatsapp when Resend code is clicked, phone_verification_type is whatsapp', () => { + renderComponent(VERIFICATION_SERVICES.WHATSAPP); + const resend_code_button = screen.getByRole('button', { name: resend_code_text }); + userEvent.click(resend_code_button); + expect(mockRequestOnWhatsApp).toHaveBeenCalledTimes(1); + expect(mockSetOtpVerification).toBeCalledWith({ + show_otp_verification: true, + phone_verification_type: VERIFICATION_SERVICES.WHATSAPP, + }); + }); + + it('should render mockRequestOnSMS and setOtpVerification with phone_verification_type: sms when Send code via SMS is clicked, phone_verification_type is whatsapp', () => { + renderComponent(VERIFICATION_SERVICES.WHATSAPP); + const resend_code_button = screen.getByRole('button', { name: /Send code via SMS/ }); + userEvent.click(resend_code_button); + expect(mockRequestOnSms).toHaveBeenCalledTimes(1); + expect(mockSetOtpVerification).toBeCalledWith({ + show_otp_verification: true, + phone_verification_type: VERIFICATION_SERVICES.SMS, + }); + }); + + it('should render mockRequestOnWhatsApp and setOtpVerification with phone_verification_type: whatsapp when Send code via WhatsApp is clicked, phone_verification_type is whatsapp', () => { + renderComponent(VERIFICATION_SERVICES.SMS); + const resend_code_button = screen.getByRole('button', { name: /Send code via WhatsApp/ }); + userEvent.click(resend_code_button); + expect(mockRequestOnWhatsApp).toHaveBeenCalledTimes(1); + expect(mockSetOtpVerification).toBeCalledWith({ + show_otp_verification: true, + phone_verification_type: VERIFICATION_SERVICES.WHATSAPP, + }); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/otp-verification.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/otp-verification.spec.tsx new file mode 100644 index 000000000000..9d6a9d800e7b --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/otp-verification.spec.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import OTPVerification from '../otp-verification'; +import { useSendOTPVerificationCode, useSettings } from '@deriv/hooks'; +import userEvent from '@testing-library/user-event'; + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + useSettings: jest.fn(), + useSendOTPVerificationCode: jest.fn(), + usePhoneNumberVerificationSetTimer: jest.fn(() => ({ + setNextEmailOtpRequestTimer: jest.fn(), + setNextPhoneOtpRequestTimer: jest.fn(), + is_email_otp_timer_loading: false, + is_phone_otp_timer_loading: false, + })), +})); + +jest.mock('../phone-number-verified-modal', () => jest.fn(() =>
Phone Number Verified Modal
)); +jest.mock('../resend-code-timer', () => jest.fn(() =>
Resend Code Timer
)); +jest.mock('../cool-down-period-modal', () => jest.fn(() =>
Cooldown Period Modal
)); + +describe('OTPVerification', () => { + const store = mockStore({ + client: { + email: 'johndoe@regentmarkets.com', + }, + ui: { + should_show_phone_number_otp: false, + }, + }); + let phone_verification_type = 'sms'; + const mockSetOtpVerification = jest.fn(); + const mockSendPhoneOTPVerification = jest.fn(); + const mockSetPhoneOtpErrorMessage = jest.fn(); + const renderComponent = () => { + render( + + + + ); + }; + + beforeEach(() => { + (useSettings as jest.Mock).mockReturnValue({ + data: { + email: 'johndoe@regentmarkets.com', + }, + invalidate: jest.fn(() => Promise.resolve()), + }); + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: jest.fn(), + }); + }); + + it('should render ConfirmYourEmail in OTP Verification', () => { + renderComponent(); + expect(screen.getByText(/Verify access/)).toBeInTheDocument(); + expect(screen.getByText(/We've sent a verification code to/)).toBeInTheDocument(); + expect(screen.getByText('johndoe@regentmarkets.com')).toBeInTheDocument(); + expect(screen.getByText(/Enter the code below so we know the request has come from you./)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /Verification code/ })).toBeInTheDocument(); + expect(screen.getByText(/Resend Code Timer/)).toBeInTheDocument(); + }); + + it('should render Verify your number in OTP Verification', () => { + store.ui.should_show_phone_number_otp = true; + renderComponent(); + expect(screen.getByText(/Verify your number/)).toBeInTheDocument(); + expect(screen.getByText(/Enter the 6-digit code sent to you via SMS at ./)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Change/ })).toBeInTheDocument(); + }); + + it('should render whatsapp when phone_verification_type is whatsapp', () => { + store.ui.should_show_phone_number_otp = true; + phone_verification_type = 'whatsapp'; + renderComponent(); + expect(screen.getByText(/WhatsApp/)).toBeInTheDocument(); + }); + + it('should not enabled Verify button when otp does not have 6 characters', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: jest.fn(), + setPhoneOtpErrorMessage: jest.fn(), + }); + renderComponent(); + const otp_textfield = screen.getByRole('textbox'); + const verify_button = screen.getByRole('button', { name: 'Verify' }); + userEvent.type(otp_textfield, '12345'); + expect(verify_button).toBeDisabled(); + }); + + it('should contain value of 123456 for otp textfield component', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: jest.fn(), + setPhoneOtpErrorMessage: jest.fn(), + }); + renderComponent(); + const otp_textfield = screen.getByRole('textbox'); + userEvent.type(otp_textfield, '123456'); + expect(otp_textfield).toHaveValue('123456'); + }); + + it('should render mockSendPhoneOTPVerification when Verify button is clicked', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: mockSendPhoneOTPVerification, + setPhoneOtpErrorMessage: jest.fn(), + }); + renderComponent(); + const otp_textfield = screen.getByRole('textbox'); + const verify_button = screen.getByRole('button', { name: 'Verify' }); + userEvent.type(otp_textfield, '123456'); + expect(verify_button).toBeEnabled(); + userEvent.click(verify_button); + expect(mockSendPhoneOTPVerification).toBeCalledTimes(1); + }); + + it('should show error message when API returns error', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: mockSendPhoneOTPVerification, + phone_otp_error_message: 'Error Message', + }); + renderComponent(); + expect(screen.getByText(/Error Message/)).toBeInTheDocument(); + }); + + it('should render mockSetPhoneOtpErrorMessage to be empty when users retype inside textfield', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: mockSendPhoneOTPVerification, + phone_otp_error_message: 'Error Message', + setPhoneOtpErrorMessage: mockSetPhoneOtpErrorMessage, + }); + renderComponent(); + expect(screen.getByText(/Error Message/)).toBeInTheDocument(); + const otp_textfield = screen.getByRole('textbox'); + userEvent.type(otp_textfield, '123456'); + expect(mockSetPhoneOtpErrorMessage).toBeCalled(); + }); + + it('should display Phone Number Verified Modal when API returns phone_number_verified is true', () => { + store.ui.should_show_phone_number_otp = true; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendPhoneOTPVerification: mockSendPhoneOTPVerification, + is_phone_number_verified: true, + }); + renderComponent(); + expect(screen.getByText(/Phone Number Verified Modal/)).toBeInTheDocument(); + }); + + it('should render sendEmailOTPVerification when should_show_phone_number_otp is false', () => { + const mockSendEmailOTPVerification = jest.fn(); + store.ui.should_show_phone_number_otp = false; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendEmailOTPVerification: mockSendEmailOTPVerification, + setPhoneOtpErrorMessage: jest.fn(), + }); + renderComponent(); + const otp_textfield = screen.getByRole('textbox'); + const verify_button = screen.getByRole('button', { name: 'Verify' }); + userEvent.type(otp_textfield, '123456'); + expect(verify_button).toBeEnabled(); + userEvent.click(verify_button); + expect(mockSendEmailOTPVerification).toBeCalledTimes(1); + }); + + it('should render setOtpVerification and setVerificationCode when is_email_verified is true', () => { + store.ui.should_show_phone_number_otp = false; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + is_email_verified: true, + sendEmailOTPVerification: jest.fn(), + setPhoneOtpErrorMessage: jest.fn(), + }); + renderComponent(); + const otp_textfield = screen.getByRole('textbox'); + const verify_button = screen.getByRole('button', { name: 'Verify' }); + userEvent.type(otp_textfield, '123456'); + expect(verify_button).toBeEnabled(); + userEvent.click(verify_button); + expect(store.client.setVerificationCode).toBeCalled(); + expect(mockSetOtpVerification).toBeCalledWith({ phone_verification_type: '', show_otp_verification: false }); + }); + + it('should display cooldown period modal when show_cool_down_period_modal is true', () => { + store.ui.should_show_phone_number_otp = false; + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + show_cool_down_period_modal: true, + }); + renderComponent(); + expect(screen.getByText(/Cooldown Period Modal/)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-number-verified-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-number-verified-modal.spec.tsx new file mode 100644 index 000000000000..f3737540a01e --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-number-verified-modal.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { render, screen } from '@testing-library/react'; +import PhoneNumberVerifiedModal from '../phone-number-verified-modal'; +import userEvent from '@testing-library/user-event'; +import { mockStore, StoreProvider } from '@deriv/stores'; +import { routes } from '@deriv/shared'; + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), +})); + +const mockPush = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockPush, + }), +})); + +describe('PhoneNumberVerifiedModal', () => { + let modal_root_el: HTMLElement; + const mock_store = mockStore({}); + + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'modal_root'); + document.body.appendChild(modal_root_el); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + + const renderModal = () => { + render( + + + + + + ); + }; + + it('it should render PhoneNumberVerifiedModal', () => { + renderModal(); + expect(screen.getByText(/Success/)).toBeInTheDocument(); + expect(screen.getByText(/Your phone number is verified./)).toBeInTheDocument(); + }); + + it('it should refetch GetSettings when done is clicked', async () => { + renderModal(); + const doneButton = screen.getByRole('button', { name: /OK/ }); + await userEvent.click(doneButton); + expect(mockPush).toHaveBeenCalledTimes(1); + expect(mockPush).toBeCalledWith(routes.personal_details); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-card.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-card.spec.tsx new file mode 100644 index 000000000000..f4bf19b3615d --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-card.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import PhoneVerificationCard from '../phone-verification-card'; + +describe('ConfirmPhoneNumber', () => { + it('should render ConfirmPhoneNumber', () => { + render(Card Content); + expect(screen.getByText(/Card Content/)).toBeInTheDocument(); + expect(screen.getByText(/Card Content/)).not.toHaveClass( + 'phone-verification__card phone-verification__card--small-card' + ); + }); + + it('should include --small-card className if props is being passed in', () => { + render(Card Content); + const card_content = screen.getByText(/Card Content/); + expect(card_content).toHaveClass('phone-verification__card phone-verification__card--small-card'); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-page.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-page.spec.tsx new file mode 100644 index 000000000000..4824d9ba6ff6 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/phone-verification-page.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PhoneVerificationPage from '../phone-verification-page'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { useGrowthbookGetFeatureValue, useSendOTPVerificationCode } from '@deriv/hooks'; + +jest.mock('../otp-verification.tsx', () => jest.fn(() =>
Confirm Your Email
)); +jest.mock('../confirm-phone-number.tsx', () => jest.fn(() =>
Confirm Phone Number
)); +jest.mock('../cancel-phone-verification-modal', () => jest.fn(() =>
Cancel Phone Verification Modal
)); +jest.mock('../verification-link-expired-modal', () => jest.fn(() =>
Verification Link Expired Modal
)); +jest.mock('../session-timeout-modal.tsx', () => jest.fn(() =>
Session Timeout Modal
)); +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + useSendOTPVerificationCode: jest.fn(() => ({ + email_otp_error: undefined, + is_email_verified: false, + sendEmailOTPVerification: jest.fn(), + })), + useGrowthbookGetFeatureValue: jest.fn(), +})); +jest.mock('@deriv/components', () => ({ + ...jest.requireActual('@deriv/components'), + Loading: jest.fn(() => 'mockedLoading'), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn(), + }), +})); + +describe('ConfirmPhoneNumber', () => { + let mock_store_data = mockStore({}); + const renderComponent = () => { + render( + + + + ); + }; + beforeEach(() => { + (useGrowthbookGetFeatureValue as jest.Mock).mockReturnValue([true, true]); + mock_store_data = mockStore({ + client: { + verification_code: { + phone_number_verification: '', + }, + }, + ui: { + is_redirected_from_email: false, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render ConfirmPhoneNumber', () => { + renderComponent(); + expect(screen.getByText(/Back to personal details/)).toBeInTheDocument(); + expect(screen.getByText(/Confirm Your Email/)).toBeInTheDocument(); + }); + + it('should display cancel phone verification modal when back button is clicked', () => { + renderComponent(); + const backButton = screen.getByTestId('dt_phone_verification_back_btn'); + userEvent.click(backButton); + expect(screen.getByText(/Cancel Phone Verification Modal/)).toBeInTheDocument(); + }); + + it('should display mockedLoading and render sendEmailOTPVerification when phone_number_verification has value', () => { + const mockSendEmailOTPVerification = jest.fn(); + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + sendEmailOTPVerification: mockSendEmailOTPVerification, + }); + mock_store_data.client.verification_code.phone_number_verification = '123456'; + mock_store_data.client.is_authorize = true; + mock_store_data.ui.is_redirected_from_email = true; + renderComponent(); + expect(screen.getByText(/mockedLoading/)).toBeInTheDocument(); + expect(mockSendEmailOTPVerification).toBeCalledTimes(1); + }); + + it('should display Verification Link Expired Modal when hook returns error', async () => { + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + email_otp_error: { code: 'InvalidToken', message: '' }, + sendEmailOTPVerification: jest.fn(), + }); + renderComponent(); + expect(screen.getByText(/Verification Link Expired Modal/)).toBeInTheDocument(); + }); + + it('should display Confirm Phone Number when is_email_verified is true', async () => { + (useSendOTPVerificationCode as jest.Mock).mockReturnValue({ + is_email_verified: true, + }); + mock_store_data.ui.is_redirected_from_email = true; + renderComponent(); + expect(screen.getByText(/Confirm Phone Number/)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/resend-code-timer.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/resend-code-timer.spec.tsx new file mode 100644 index 000000000000..50de56ab2c04 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/resend-code-timer.spec.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { usePhoneNumberVerificationSetTimer, useVerifyEmail } from '@deriv/hooks'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import ResendCodeTimer from '../resend-code-timer'; + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + useVerifyEmail: jest.fn(() => ({ + sendPhoneNumberVerifyEmail: jest.fn(), + WS: { + isSuccess: false, + }, + })), + useSettings: jest.fn(() => ({ + data: { + phone_number_verification: { + next_email_attempt: null, + next_attempt: null, + }, + }, + })), + usePhoneNumberVerificationSetTimer: jest.fn(() => ({ + next_phone_otp_request_timer: '', + next_email_otp_request_timer: '', + })), +})); + +describe('ConfirmPhoneNumber', () => { + beforeEach(() => { + (useVerifyEmail as jest.Mock).mockReturnValue({ + sendPhoneNumberVerifyEmail: jest.fn(), + WS: { isSuccess: false }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ + next_phone_otp_request_timer: '', + next_email_otp_request_timer: '', + }); + }); + + const mockSetShouldShowDidntGetTheCodeModal = jest.fn(); + const mockSetIsButtonDisabled = jest.fn(); + const mockReInitializeGetSettings = jest.fn(); + const mockClearOtpValue = jest.fn(); + + const renderComponent = (is_button_disabled = false, should_show_resend_code_button = true) => { + render( + + + + ); + }; + + const mock_store = mockStore({}); + it('should enable button if usePhoneNumberVerificationSetTimer did not return next_otp_request', async () => { + renderComponent(); + + expect(screen.queryByRole('button', { name: 'Resend code' })).toBeEnabled; + }); + + it('should disable button if usePhoneNumberVerificationSetTimer returns next_otp_request', async () => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_email_otp_request_timer: 59 }); + renderComponent(); + + expect(screen.queryByRole('button', { name: 'Resend code in 59s' })).toBeDisabled; + }); + + it('should trigger mockSend when send button is clicked', () => { + const mockSend = jest.fn(); + (useVerifyEmail as jest.Mock).mockReturnValue({ + sendPhoneNumberVerifyEmail: mockSend, + WS: { isSuccess: false }, + }); + renderComponent(); + const resend_button = screen.getByRole('button', { name: 'Resend code' }); + expect(resend_button).toBeEnabled(); + + userEvent.click(resend_button); + expect(mockSend).toBeCalled(); + }); + + it('should display Didn’t get the code? should_show_resend_code_button is false', () => { + renderComponent(false, false); + const resend_button = screen.getByRole('button', { name: "Didn't get the code?" }); + expect(resend_button).toBeInTheDocument(); + }); + + it('should display Didn’t get the code? (60s) when usePhoneNumberSetTimer returns (60s)', () => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_phone_otp_request_timer: 60 }); + renderComponent(false, false); + const resend_button = screen.getByRole('button', { name: "Didn't get the code? (1m)" }); + expect(resend_button).toBeInTheDocument(); + }); + + it('should trigger setShouldShowDidntGetTheCodeModal when Didn`t get the code is clicked', () => { + renderComponent(false, false); + const resend_button_after = screen.getByRole('button', { name: "Didn't get the code?" }); + userEvent.click(resend_button_after); + expect(mockSetShouldShowDidntGetTheCodeModal).toHaveBeenCalled(); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/session-timeout-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/session-timeout-modal.spec.tsx new file mode 100644 index 000000000000..e03c83957722 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/session-timeout-modal.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import SessionTimeoutModal from '../session-timeout-modal'; +import { mockStore, StoreProvider } from '@deriv/stores'; +import userEvent from '@testing-library/user-event'; +import { routes } from '@deriv/shared'; + +const mock_push = jest.fn(); + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + usePhoneNumberVerificationSessionTimer: jest.fn(() => ({ + should_show_session_timeout_modal: true, + })), +})); + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mock_push, + }), +})); + +describe('SessionTimeoutModal', () => { + const mock_store = mockStore({}); + const renderComponent = () => { + render( + + + + ); + }; + + it('should show SessionTimeoutModal content', () => { + renderComponent(); + expect(screen.getByText(/Session expired/)).toBeInTheDocument(); + expect(screen.getByText(/Restart your phone number verification./)).toBeInTheDocument(); + const ok_button = screen.getByRole('button', { name: 'OK' }); + expect(ok_button).toBeInTheDocument(); + }); + + it('should redirect user back to Personal Details Settings on OK button', () => { + renderComponent(); + const ok_button = screen.getByRole('button', { name: 'OK' }); + userEvent.click(ok_button); + expect(mock_push).lastCalledWith(routes.personal_details); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/validation.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/validation.spec.tsx new file mode 100644 index 000000000000..0b51dc7b682d --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/validation.spec.tsx @@ -0,0 +1,57 @@ +import { act } from '@testing-library/react'; +import { validatePhoneNumber } from '../validation'; + +describe('validatePhoneNumber', () => { + const setErrorMessage = jest.fn(); + const setIsDisabledRequestButton = jest.fn(); + + const error_message = 'Enter a valid phone number.'; + + it('should set an empty error message for a valid phone number', async () => { + const validPhoneNumber = '+1234567890'; + await act(async () => { + validatePhoneNumber(validPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith(''); + }); + + it('should set an error message for an invalid phone number', async () => { + const invalidPhoneNumber = 'invalid'; + await act(async () => { + validatePhoneNumber(invalidPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith([error_message]); + }); + + it('should set an error message for an empty phone number', async () => { + const invalidPhoneNumber = ''; + await act(async () => { + validatePhoneNumber(invalidPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith([error_message]); + }); + + it('should set an error message for an phone number more than 36 characters', async () => { + const invalidPhoneNumber = '+123123123123123123123123232333333333'; + await act(async () => { + validatePhoneNumber(invalidPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith([error_message]); + }); + + it('should set an error message for an phone number less than 8 characters', async () => { + const invalidPhoneNumber = '+1234567'; + await act(async () => { + validatePhoneNumber(invalidPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith([error_message]); + }); + + it('should set an error message for phone number without including + sign', async () => { + const invalidPhoneNumber = '0123456789'; + await act(async () => { + validatePhoneNumber(invalidPhoneNumber, setErrorMessage, setIsDisabledRequestButton); + }); + expect(setErrorMessage).toHaveBeenCalledWith([error_message]); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/__test__/verification-link-expired-modal.spec.tsx b/packages/account/src/Sections/Profile/PhoneVerification/__test__/verification-link-expired-modal.spec.tsx new file mode 100644 index 000000000000..92d903921eff --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/__test__/verification-link-expired-modal.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { APIProvider } from '@deriv/api'; +import { usePhoneNumberVerificationSetTimer } from '@deriv/hooks'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { routes } from '@deriv/shared'; +import VerificationLinkExpiredModal from '../verification-link-expired-modal'; + +const mock_push_function = jest.fn(); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useHistory: () => ({ + push: mock_push_function, + }), +})); + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + usePhoneNumberVerificationSetTimer: jest.fn(() => ({ + next_email_otp_request_timer: '', + })), +})); + +describe('VerificationLinkExpiredModal', () => { + let modal_root_el: HTMLElement; + const mockSetShowVerificationLinkExpiredModal = jest.fn(); + + beforeEach(() => { + mockSetShowVerificationLinkExpiredModal.mockClear(); + mock_push_function.mockClear(); + }); + + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'modal_root'); + document.body.appendChild(modal_root_el); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + + const mock_store = mockStore({}); + + const buttons = [/Send new link/, /Cancel/]; + + const renderComponent = () => { + render( + + + + + + ); + }; + + it('should render VerificationLinkExpiredModal', () => { + renderComponent(); + buttons.forEach(value => { + expect(screen.getByRole('button', { name: value })).toBeInTheDocument(); + }); + expect(screen.getByText(/Link expired/)).toBeInTheDocument(); + expect(screen.getByText(/Request a new verification link via email./)).toBeInTheDocument(); + }); + + it('should render mockSetShowVerificationLinkExpiredModal and mock_back_router when Cancel is clicked', () => { + renderComponent(); + const cancelButton = screen.getByRole('button', { name: buttons[1] }); + userEvent.click(cancelButton); + expect(mockSetShowVerificationLinkExpiredModal).toBeCalledTimes(1); + expect(mock_push_function).toBeCalledWith(routes.personal_details); + }); + + it('should show in 60s which is coming from usePhoneNumberVerificationSetTimer', () => { + (usePhoneNumberVerificationSetTimer as jest.Mock).mockReturnValue({ next_email_otp_request_timer: 60 }); + renderComponent(); + expect(screen.getByText(/in 1m/)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Profile/PhoneVerification/cancel-phone-verification-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/cancel-phone-verification-modal.tsx new file mode 100644 index 000000000000..88f64fb98a4c --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/cancel-phone-verification-modal.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router'; +import { observer, useStore } from '@deriv/stores'; +import { useIsPhoneNumberVerified, usePhoneVerificationAnalytics } from '@deriv/hooks'; +import { Modal, Text } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; + +const CancelPhoneVerificationModal = observer(() => { + const history = useHistory(); + const location = useLocation(); + const [show_modal, setShowModal] = useState(false); + const [next_location, setNextLocation] = useState(location.pathname); + const { ui, client } = useStore(); + const { setShouldShowPhoneNumberOTP, is_forced_to_exit_pnv } = ui; + const { setVerificationCode, is_virtual } = client; + const { isMobile } = useDevice(); + const { trackPhoneVerificationEvents } = usePhoneVerificationAnalytics(); + const { is_phone_number_verified } = useIsPhoneNumberVerified(); + + useEffect(() => { + const unblock = history.block((location: Location) => { + if (!show_modal && !is_virtual && !is_forced_to_exit_pnv && !is_phone_number_verified) { + setShowModal(true); + setNextLocation(location.pathname); + return false; + } + return true; + }); + + return () => unblock(); + }, [history, show_modal, is_virtual, is_forced_to_exit_pnv, is_phone_number_verified]); + + const handleStayAtPhoneVerificationPage = () => { + setShowModal(false); + setNextLocation(location.pathname); + }; + + const handleLeavePhoneVerificationPage = () => { + if (next_location) { + setVerificationCode('', 'phone_number_verification'); + setShouldShowPhoneNumberOTP(false); + setShowModal(false); + trackPhoneVerificationEvents({ + action: 'back', + }); + history.push(next_location); + } + }; + + return ( + } + buttonColor='coral' + showSecondaryButton + showCrossIcon + toggleModal={handleStayAtPhoneVerificationPage} + secondaryButtonLabel={} + secondaryButtonCallback={handleLeavePhoneVerificationPage} + > + } /> + +
+ + + +
+
+
+ ); +}); + +export default CancelPhoneVerificationModal; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/confirm-phone-number.tsx b/packages/account/src/Sections/Profile/PhoneVerification/confirm-phone-number.tsx new file mode 100644 index 000000000000..8c97b2574d8b --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/confirm-phone-number.tsx @@ -0,0 +1,169 @@ +import { useState, useEffect, ChangeEvent } from 'react'; +import { + usePhoneNumberVerificationSetTimer, + usePhoneVerificationAnalytics, + useRequestPhoneNumberOTP, + useSettings, +} from '@deriv/hooks'; +import { VERIFICATION_SERVICES } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import { Button, Snackbar, Text, TextFieldAddon } from '@deriv-com/quill-ui'; +import { Localize, useTranslations } from '@deriv-com/translations'; +import PhoneVerificationCard from './phone-verification-card'; +import { validatePhoneNumber } from './validation'; + +type TConfirmPhoneNumber = { + show_confirm_phone_number?: boolean; + setOtpVerification: (value: { show_otp_verification: boolean; phone_verification_type: string }) => void; +}; + +const ConfirmPhoneNumber = observer(({ show_confirm_phone_number, setOtpVerification }: TConfirmPhoneNumber) => { + const [phone_number, setPhoneNumber] = useState(''); + const [phone_verification_type, setPhoneVerificationType] = useState(''); + const [is_button_loading, setIsButtonLoading] = useState(false); + const { + requestOnSMS, + requestOnWhatsApp, + error_message, + setErrorMessage, + setUsersPhoneNumber, + is_email_verified, + email_otp_error, + is_disabled_request_button, + setIsDisabledRequestButton, + } = useRequestPhoneNumberOTP(); + const { data: account_settings, invalidate } = useSettings(); + const { ui } = useStore(); + const { setShouldShowPhoneNumberOTP } = ui; + const { next_phone_otp_request_timer, is_phone_otp_timer_loading } = usePhoneNumberVerificationSetTimer(true); + const { trackPhoneVerificationEvents } = usePhoneVerificationAnalytics(); + const { localize } = useTranslations(); + + useEffect(() => { + if (show_confirm_phone_number) { + trackPhoneVerificationEvents({ + action: 'open', + subform_name: 'verify_phone_screen', + }); + } + }, [show_confirm_phone_number, trackPhoneVerificationEvents]); + + useEffect(() => { + setPhoneNumber(account_settings?.phone?.replace('+', '') || ''); + }, [account_settings?.phone]); + + useEffect(() => { + if (email_otp_error) { + invalidate('get_settings').then(() => setIsButtonLoading(false)); + } + if (is_email_verified) { + setIsButtonLoading(false); + setOtpVerification({ show_otp_verification: true, phone_verification_type }); + setShouldShowPhoneNumberOTP(true); + } + }, [is_email_verified, email_otp_error, invalidate]); + + const handleOnChangePhoneNumber = (e: ChangeEvent) => { + setPhoneNumber(e.target.value); + validatePhoneNumber(`+${e.target.value}`, setErrorMessage, setIsDisabledRequestButton); + }; + + const handleSubmit = async (phone_verification_type: string) => { + setIsButtonLoading(true); + setPhoneVerificationType(phone_verification_type); + const { error } = await setUsersPhoneNumber({ phone: `+${phone_number}` }); + + if (!error) { + trackPhoneVerificationEvents({ + action: 'click_cta', + cta_name: + phone_verification_type === VERIFICATION_SERVICES.SMS + ? 'Get code via SMS' + : 'Get code via WhatsApp', + subform_name: 'verify_phone_screen', + }); + phone_verification_type === VERIFICATION_SERVICES.SMS ? requestOnSMS() : requestOnWhatsApp(); + } else { + setIsButtonLoading(false); + } + }; + + const resendPhoneOtpTimer = () => { + let resendPhoneOtpTimer = ''; + if (next_phone_otp_request_timer) { + next_phone_otp_request_timer < 60 + ? (resendPhoneOtpTimer = next_phone_otp_request_timer + localize(' seconds')) + : (resendPhoneOtpTimer = Math.round(next_phone_otp_request_timer / 60) + localize(' minutes')); + } else { + resendPhoneOtpTimer = ''; + } + + return resendPhoneOtpTimer; + }; + + return ( + + + + +
+ +
+
+ + +
+ + } + isVisible={!!next_phone_otp_request_timer} + /> +
+ ); +}); + +export default ConfirmPhoneNumber; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/cool-down-period-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/cool-down-period-modal.tsx new file mode 100644 index 000000000000..a0405afa8a2e --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/cool-down-period-modal.tsx @@ -0,0 +1,48 @@ +import { Modal, Text } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import { useHistory } from 'react-router'; + +type TCoolDownPeriodModal = { + show_cool_down_period_modal: boolean; + setShowCoolDownPeriodModal: (value: boolean) => void; +}; + +const CoolDownPeriodModal = observer( + ({ show_cool_down_period_modal, setShowCoolDownPeriodModal }: TCoolDownPeriodModal) => { + const { ui } = useStore(); + const history = useHistory(); + const { isMobile } = useDevice(); + const { setIsForcedToExitPnv, setShouldShowPhoneNumberOTP } = ui; + const handleCloseCoolDownPeriodModal = () => { + setShouldShowPhoneNumberOTP(false); + setIsForcedToExitPnv(false); + setShowCoolDownPeriodModal(false); + history.push(routes.personal_details); + }; + return ( + } + primaryButtonCallback={handleCloseCoolDownPeriodModal} + disableCloseOnOverlay + buttonColor='coral' + > + } /> + + + + + + + ); + } +); + +export default CoolDownPeriodModal; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/didnt-get-the-code-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/didnt-get-the-code-modal.tsx new file mode 100644 index 000000000000..75eafa7dbe32 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/didnt-get-the-code-modal.tsx @@ -0,0 +1,124 @@ +import { useEffect } from 'react'; +import { TSocketError } from '@deriv/api/types'; +import { VERIFICATION_SERVICES } from '@deriv/shared'; +import { useTranslations, Localize } from '@deriv-com/translations'; +import { Modal, Text } from '@deriv-com/quill-ui'; +import { useDevice } from '@deriv-com/ui'; +import { convertPhoneTypeDisplay } from '../../../Helpers/utils'; + +type TDidntGetTheCodeModal = { + phone_verification_type: string; + should_show_didnt_get_the_code_modal: boolean; + setIsButtonDisabled: (value: boolean) => void; + setShouldShowDidntGetTheCodeModal: (value: boolean) => void; + requestOnSMS: () => void; + requestOnWhatsApp: () => void; + clearOtpValue: () => void; + is_email_verified: boolean; + email_otp_error: TSocketError<'phone_number_challenge'> | null; + setOtpVerification: (value: { show_otp_verification: boolean; phone_verification_type: string }) => void; + reInitializeGetSettings: () => void; +}; + +const DidntGetTheCodeModal = ({ + should_show_didnt_get_the_code_modal, + setShouldShowDidntGetTheCodeModal, + setIsButtonDisabled, + reInitializeGetSettings, + requestOnSMS, + requestOnWhatsApp, + clearOtpValue, + phone_verification_type, + is_email_verified, + email_otp_error, + setOtpVerification, +}: TDidntGetTheCodeModal) => { + const { isMobile } = useDevice(); + const { localize } = useTranslations(); + + useEffect(() => { + if (is_email_verified || email_otp_error) reInitializeGetSettings(); + }, [is_email_verified, email_otp_error, reInitializeGetSettings]); + + const setDidntGetACodeButtonDisabled = () => { + setIsButtonDisabled(true); + }; + + const handleResendCode = () => { + clearOtpValue(); + setDidntGetACodeButtonDisabled(); + phone_verification_type === VERIFICATION_SERVICES.SMS ? requestOnSMS() : requestOnWhatsApp(); + setOtpVerification({ show_otp_verification: true, phone_verification_type }); + setShouldShowDidntGetTheCodeModal(false); + }; + + const handleChangeOTPVerification = () => { + clearOtpValue(); + setDidntGetACodeButtonDisabled(); + const changed_phone_verification_type = + phone_verification_type === VERIFICATION_SERVICES.SMS + ? VERIFICATION_SERVICES.WHATSAPP + : VERIFICATION_SERVICES.SMS; + + phone_verification_type === VERIFICATION_SERVICES.SMS ? requestOnWhatsApp() : requestOnSMS(); + + setOtpVerification({ + show_otp_verification: true, + phone_verification_type: changed_phone_verification_type, + }); + setShouldShowDidntGetTheCodeModal(false); + }; + + return ( + } + buttonColor='coral' + secondaryButtonLabel={ + + } + showCrossIcon + toggleModal={() => setShouldShowDidntGetTheCodeModal(false)} + > + } /> + +
+ + + +
+
+
+ ); +}; + +export default DidntGetTheCodeModal; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/index.ts b/packages/account/src/Sections/Profile/PhoneVerification/index.ts new file mode 100644 index 000000000000..8a1c82b65a3d --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/index.ts @@ -0,0 +1,3 @@ +import PhoneVerificationPage from './phone-verification-page'; + +export default PhoneVerificationPage; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/otp-verification.tsx b/packages/account/src/Sections/Profile/PhoneVerification/otp-verification.tsx new file mode 100644 index 000000000000..59593c8b319c --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/otp-verification.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState, useCallback, Fragment } from 'react'; +import { + usePhoneNumberVerificationSetTimer, + usePhoneVerificationAnalytics, + useSendOTPVerificationCode, + useSettings, +} from '@deriv/hooks'; +import { Text, InputGroupButton, Button } from '@deriv-com/quill-ui'; +import { observer, useStore } from '@deriv/stores'; +import { Localize, useTranslations } from '@deriv-com/translations'; +import PhoneVerificationCard from './phone-verification-card'; +import { convertPhoneTypeDisplay } from '../../../Helpers/utils'; +import ResendCodeTimer from './resend-code-timer'; +import DidntGetTheCodeModal from './didnt-get-the-code-modal'; +import PhoneNumberVerifiedModal from './phone-number-verified-modal'; +import CoolDownPeriodModal from './cool-down-period-modal'; + +type TOTPVerification = { + phone_verification_type: string; + setOtpVerification: (value: { show_otp_verification: boolean; phone_verification_type: string }) => void; +}; + +const OTPVerification = observer(({ phone_verification_type, setOtpVerification }: TOTPVerification) => { + const { client, ui } = useStore(); + const { setVerificationCode, is_authorize } = client; + const { data: account_settings, invalidate } = useSettings(); + const [should_show_didnt_get_the_code_modal, setShouldShowDidntGetTheCodeModal] = useState(false); + const [otp, setOtp] = useState(''); + const [is_button_disabled, setIsButtonDisabled] = useState(false); + const { trackPhoneVerificationEvents } = usePhoneVerificationAnalytics(); + const { localize } = useTranslations(); + + const { + sendPhoneOTPVerification, + phone_otp_error_message, + setPhoneOtpErrorMessage, + is_phone_number_verified, + is_email_verified, + show_cool_down_period_modal, + setShowCoolDownPeriodModal, + sendEmailOTPVerification, + requestOnSMS, + requestOnWhatsApp, + email_otp_error, + reset, + } = useSendOTPVerificationCode(); + const { + setNextEmailOtpRequestTimer, + is_email_otp_timer_loading, + setNextPhoneOtpRequestTimer, + is_phone_otp_timer_loading, + } = usePhoneNumberVerificationSetTimer(); + const { should_show_phone_number_otp, setIsForcedToExitPnv } = ui; + + const reInitializeGetSettings = useCallback(() => { + invalidate('get_settings').then(() => { + setIsButtonDisabled(false); + }); + }, [invalidate]); + + useEffect(() => { + if (should_show_phone_number_otp) { + trackPhoneVerificationEvents({ + action: 'open', + subform_name: 'verify_phone_otp_screen', + }); + } else { + trackPhoneVerificationEvents({ + action: 'open', + subform_name: 'verify_email_screen', + }); + } + }, [should_show_phone_number_otp, trackPhoneVerificationEvents]); + + useEffect(() => { + if (is_authorize) { + setIsButtonDisabled(true); + reInitializeGetSettings(); + } + }, [reInitializeGetSettings, is_authorize]); + + useEffect(() => { + if (is_phone_number_verified) { + setIsForcedToExitPnv(true); + } else if (is_email_verified && !should_show_phone_number_otp) { + setVerificationCode(otp, 'phone_number_verification'); + setOtpVerification({ show_otp_verification: false, phone_verification_type: '' }); + } + }, [is_phone_number_verified, is_email_verified, setOtpVerification, should_show_phone_number_otp]); + + const clearOtpValue = () => { + setOtp(''); + setPhoneOtpErrorMessage(''); + should_show_phone_number_otp ? setNextPhoneOtpRequestTimer(undefined) : setNextEmailOtpRequestTimer(undefined); + reset(); + }; + + const handleGetOtpValue = (e: React.ChangeEvent) => { + setOtp(e.target.value); + setPhoneOtpErrorMessage(''); + }; + + const handleVerifyOTP = () => { + if (should_show_phone_number_otp) { + trackPhoneVerificationEvents({ + action: 'click_cta', + subform_name: 'verify_phone_otp_screen', + }); + sendPhoneOTPVerification(otp); + } else { + trackPhoneVerificationEvents({ + action: 'click_cta', + subform_name: 'verify_email_screen', + }); + sendEmailOTPVerification(otp); + } + }; + + const isTimerLoading = () => { + return should_show_phone_number_otp ? is_phone_otp_timer_loading : is_email_otp_timer_loading; + }; + + return ( + + + + {should_show_phone_number_otp && ( + + )} + + {should_show_phone_number_otp ? ( + + ) : ( + + )} + +
+ {should_show_phone_number_otp ? ( + + + setOtpVerification({ show_otp_verification: false, phone_verification_type }) + } + />, + ]} + /> + + ) : ( + + + ]} + /> + + + + + + )} +
+
+ ) => { + if (e.key === 'Enter') { + handleVerifyOTP(); + } + }} + inputMode='numeric' + buttonColor='coral' + onChange={handleGetOtpValue} + message={phone_otp_error_message} + value={otp} + type='number' + maxLength={6} + buttonDisabled={otp.length < 6} + /> + +
+
+ ); +}); + +export default OTPVerification; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/phone-number-verified-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/phone-number-verified-modal.tsx new file mode 100644 index 000000000000..e2f40fce281c --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/phone-number-verified-modal.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import { usePhoneVerificationAnalytics } from '@deriv/hooks'; +import { Modal, Text } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { observer, useStore } from '@deriv/stores'; +import { useHistory } from 'react-router-dom'; +import { routes } from '@deriv/shared'; + +type TPhoneNumberVerifiedModal = { + should_show_phone_number_verified_modal: boolean; +}; + +const PhoneNumberVerifiedModal = observer(({ should_show_phone_number_verified_modal }: TPhoneNumberVerifiedModal) => { + const history = useHistory(); + + const handleDoneButton = () => { + history.push(routes.personal_details); + }; + const { isMobile } = useDevice(); + const { ui } = useStore(); + const { setIsPhoneVerificationCompleted } = ui; + const { trackPhoneVerificationEvents } = usePhoneVerificationAnalytics(); + + useEffect(() => { + if (should_show_phone_number_verified_modal) { + trackPhoneVerificationEvents({ + action: 'open', + subform_name: 'verification_successful', + }); + setIsPhoneVerificationCompleted(true); + } + }, [should_show_phone_number_verified_modal, trackPhoneVerificationEvents]); + + return ( + } + disableCloseOnOverlay + > + } /> + +
+ + + +
+
+
+ ); +}); + +export default PhoneNumberVerifiedModal; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-card.tsx b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-card.tsx new file mode 100644 index 000000000000..d8b162ae3acd --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-card.tsx @@ -0,0 +1,14 @@ +import clsx from 'clsx'; +import React from 'react'; + +type TPhoneVerificationCard = { + is_small_card?: boolean; +}; + +const PhoneVerificationCard = ({ children, is_small_card }: React.PropsWithChildren) => ( +
+ {children} +
+); + +export default PhoneVerificationCard; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-page.tsx b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-page.tsx new file mode 100644 index 000000000000..6251ff9408ee --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification-page.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Loading } from '@deriv/components'; +import { useGrowthbookGetFeatureValue, useIsPhoneNumberVerified, useSendOTPVerificationCode } from '@deriv/hooks'; +import { LabelPairedArrowLeftCaptionFillIcon } from '@deriv/quill-icons'; +import { routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import { Text, IconButton } from '@deriv-com/quill-ui'; +import { useDevice } from '@deriv-com/ui'; +import { Localize } from '@deriv-com/translations'; +import ConfirmPhoneNumber from './confirm-phone-number'; +import CancelPhoneVerificationModal from './cancel-phone-verification-modal'; +import OTPVerification from './otp-verification'; +import SessionTimeoutModal from './session-timeout-modal'; +import VerificationLinkExpiredModal from './verification-link-expired-modal'; +import './phone-verification.scss'; + +const PhoneVerificationPage = observer(() => { + const history = useHistory(); + const [otp_verification, setOtpVerification] = useState({ + show_otp_verification: true, + phone_verification_type: '', + }); + const phone_verification_code = sessionStorage.getItem('phone_number_verification_code'); + const [is_loading, setIsLoading] = useState(false); + const [should_show_verification_link_expired_modal, setShouldShowVerificationLinkExpiredModal] = useState(false); + const handleBackButton = () => { + history.push(routes.personal_details); + }; + const { sendEmailOTPVerification, email_otp_error, is_email_verified } = useSendOTPVerificationCode(); + const { is_phone_number_verified } = useIsPhoneNumberVerified(); + const [isPhoneNumberVerificationEnabled, isPhoneNumberVerificationGBLoaded] = useGrowthbookGetFeatureValue({ + featureFlag: 'phone_number_verification', + }); + const { isDesktop } = useDevice(); + const { client, ui } = useStore(); + const { is_redirected_from_email, setRedirectFromEmail, setIsForcedToExitPnv } = ui; + const { + verification_code: { phone_number_verification: phone_number_verification_code }, + is_authorize, + is_virtual, + setVerificationCode, + } = client; + + useEffect(() => { + if ( + (isPhoneNumberVerificationGBLoaded && !isPhoneNumberVerificationEnabled) || + is_virtual || + is_phone_number_verified + ) { + setIsForcedToExitPnv(true); + history.push(routes.personal_details); + } + }, [ + isPhoneNumberVerificationGBLoaded, + isPhoneNumberVerificationEnabled, + is_virtual, + history, + is_phone_number_verified, + ]); + + useEffect(() => { + if (is_redirected_from_email || phone_verification_code) { + setIsLoading(true); + if (email_otp_error) { + setIsLoading(false); + setIsForcedToExitPnv(true); + setShouldShowVerificationLinkExpiredModal(true); + setRedirectFromEmail(false); + sessionStorage.removeItem('phone_number_verification_code'); + } else if (is_email_verified) { + setIsLoading(false); + setOtpVerification({ + show_otp_verification: false, + phone_verification_type: '', + }); + setRedirectFromEmail(false); + sessionStorage.removeItem('phone_number_verification_code'); + } else if ((phone_number_verification_code || phone_verification_code) && is_authorize) { + if (phone_verification_code) setVerificationCode(phone_verification_code, 'phone_number_verification'); + sendEmailOTPVerification(phone_verification_code || phone_number_verification_code); + } + } + }, [ + email_otp_error, + is_email_verified, + phone_number_verification_code, + is_authorize, + is_redirected_from_email, + phone_verification_code, + ]); + + if (is_loading || !isPhoneNumberVerificationGBLoaded) { + return ; + } + + return ( +
+ + {!should_show_verification_link_expired_modal && } + + {isDesktop && ( +
+ + } + /> + + + +
+ )} + {otp_verification.show_otp_verification ? ( + + ) : ( + + )} +
+ ); +}); + +export default PhoneVerificationPage; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/phone-verification.scss b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification.scss new file mode 100644 index 000000000000..58b2585b5293 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/phone-verification.scss @@ -0,0 +1,142 @@ +.phone-verification { + &__get-code-modal, + &__verified-modal, + &__cancel-modal { + &--contents { + display: flex; + flex-direction: column; + gap: 3.2rem; + &__buttons { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: 0.8rem; + margin-top: 2.4rem; + } + } + } + + &__cancel-modal--header { + background-color: var(--core-color-solid-red-100); + } + &__verified-modal--header { + background-color: var(--core-color-solid-green-100); + } + + &__redirect_button { + display: flex; + align-items: center; + + &--text { + padding-inline-start: 1.6rem; + } + + &--icon { + cursor: pointer; + } + } + + &__card { + width: 60rem; + height: 60rem; + border: 1px solid var(--core-color-solid-slate-100, #ebecef); + border-radius: 2 * $BORDER_RADIUS; + display: flex; + flex-direction: column; + align-items: center; + padding: 1.6rem; + margin-top: 2.4rem; + + &--small-card { + height: 40rem; + } + + &--inputfield { + width: 60%; + margin-top: 2.4rem; + margin-bottom: 4.4rem; + @include mobile-or-tablet-screen { + margin-top: 3.2rem; + } + @include tablet-screen { + width: 80%; + } + @include mobile-screen { + width: 100%; + } + + &__livechat { + color: var(--core-color-solid-red-900, $color-red-12); + font-weight: bold; + text-decoration: underline; + &:hover { + cursor: pointer; + } + } + } + + &--buttons_container { + width: 100%; + display: flex; + gap: 1.6rem; + margin-top: 0.8rem; + + @include mobile { + flex-direction: column-reverse; + position: fixed; + padding: 0 1.6rem; + bottom: 1.6rem; + } + } + + &--email-verification { + &-content { + width: 100%; + margin-top: 2.4rem; + display: flex; + flex-direction: column; + text-align: center; + } + + &-otp-container { + display: flex; + flex-direction: column; + gap: 1.6rem; + width: 60%; + margin-top: 1.6rem; + align-items: flex-start; + @include mobile-or-tablet-screen { + gap: 3.2rem; + } + @include tablet-screen { + width: 80%; + } + @include mobile-screen { + width: 100%; + } + } + } + + .quill-snackbar { + z-index: 1; + } + + @include mobile { + width: 100vw; + border: none; + margin-top: 0.8rem; + + .dc-input { + width: 100%; + } + } + + @include desktop { + .quill-snackbar { + margin-bottom: 3.2rem; + width: fit-content; + } + } + } +} diff --git a/packages/account/src/Sections/Profile/PhoneVerification/resend-code-timer.tsx b/packages/account/src/Sections/Profile/PhoneVerification/resend-code-timer.tsx new file mode 100644 index 000000000000..0771f78f4ee9 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/resend-code-timer.tsx @@ -0,0 +1,107 @@ +import React, { useCallback } from 'react'; +import { usePhoneNumberVerificationSetTimer, useVerifyEmail } from '@deriv/hooks'; +import { Button, CaptionText } from '@deriv-com/quill-ui'; +import { Localize, useTranslations } from '@deriv-com/translations'; + +type TResendCodeTimer = { + is_button_disabled: boolean; + should_show_resend_code_button: boolean; + setIsButtonDisabled: (value: boolean) => void; + setShouldShowDidntGetTheCodeModal: (value: boolean) => void; + clearOtpValue: () => void; + reInitializeGetSettings: () => void; +}; +const ResendCodeTimer = ({ + is_button_disabled, + should_show_resend_code_button, + clearOtpValue, + setIsButtonDisabled, + setShouldShowDidntGetTheCodeModal, + reInitializeGetSettings, +}: TResendCodeTimer) => { + // @ts-expect-error this for now + const { sendPhoneNumberVerifyEmail, WS, error } = useVerifyEmail('phone_number_verification'); + const { is_request_button_disabled, next_email_otp_request_timer, next_phone_otp_request_timer } = + usePhoneNumberVerificationSetTimer(); + const { localize } = useTranslations(); + + React.useEffect(() => { + if (WS.isSuccess || error) reInitializeGetSettings(); + }, [WS.isSuccess, reInitializeGetSettings, error]); + + const resendCode = () => { + if (should_show_resend_code_button) { + clearOtpValue(); + setIsButtonDisabled(true); + sendPhoneNumberVerifyEmail(); + } else { + setShouldShowDidntGetTheCodeModal(true); + } + }; + + const resendCodeTimer = () => { + let resendCodeTimer = ''; + if (next_email_otp_request_timer) { + next_email_otp_request_timer < 60 + ? (resendCodeTimer = `${localize(' in ')}${next_email_otp_request_timer}s`) + : (resendCodeTimer = `${localize(' in ')}${Math.round(next_email_otp_request_timer / 60)}m`); + } else { + resendCodeTimer = ''; + } + + return resendCodeTimer; + }; + + const didntGetACodeTimer = () => { + let didntGetACodeTimer = ''; + if (next_phone_otp_request_timer) { + next_phone_otp_request_timer < 60 + ? (didntGetACodeTimer = ` (${next_phone_otp_request_timer}s)`) + : (didntGetACodeTimer = ` (${ + next_phone_otp_request_timer && Math.round(next_phone_otp_request_timer / 60) + }m)`); + } else { + didntGetACodeTimer = ''; + } + + return didntGetACodeTimer; + }; + + const isButtonDisabled = useCallback(() => { + const disable_resend_code_button = !!next_email_otp_request_timer || is_button_disabled; + const disable_didnt_get_a_code_button = + !!next_phone_otp_request_timer || is_button_disabled || is_request_button_disabled; + + return should_show_resend_code_button ? disable_resend_code_button : disable_didnt_get_a_code_button; + }, [ + should_show_resend_code_button, + next_email_otp_request_timer, + next_phone_otp_request_timer, + is_button_disabled, + is_request_button_disabled, + ]); + + return ( + + ); +}; + +export default ResendCodeTimer; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/session-timeout-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/session-timeout-modal.tsx new file mode 100644 index 000000000000..37040b4c56dd --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/session-timeout-modal.tsx @@ -0,0 +1,53 @@ +import { useHistory } from 'react-router'; +import { usePhoneNumberVerificationSessionTimer } from '@deriv/hooks'; +import { routes } from '@deriv/shared'; +import { Modal, Text } from '@deriv-com/quill-ui'; +import { Localize, useTranslations } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { observer, useStore } from '@deriv/stores'; +import { useEffect } from 'react'; + +const SessionTimeoutModal = observer(() => { + const { isMobile } = useDevice(); + const history = useHistory(); + const { localize } = useTranslations(); + const { should_show_session_timeout_modal } = usePhoneNumberVerificationSessionTimer(); + const { ui } = useStore(); + const { is_phone_verification_completed, setShouldShowPhoneNumberOTP, setIsForcedToExitPnv } = ui; + + useEffect(() => { + if (should_show_session_timeout_modal) { + setIsForcedToExitPnv(true); + } + }, [should_show_session_timeout_modal]); + + const redirectBackToPersonalDetails = () => { + setIsForcedToExitPnv(false); + setShouldShowPhoneNumberOTP(false); + history.push(routes.personal_details); + }; + + return ( + } + buttonColor='coral' + title={localize('Session Expired')} + disableCloseOnOverlay + > + } /> + + + + + + + ); +}); + +export default SessionTimeoutModal; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/validation.ts b/packages/account/src/Sections/Profile/PhoneVerification/validation.ts new file mode 100644 index 000000000000..50ceb411fe50 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/validation.ts @@ -0,0 +1,25 @@ +import * as Yup from 'yup'; +import { ValidationConstants } from '@deriv-com/utils'; +import { localize } from '@deriv-com/translations'; + +const phoneNumberSchema = Yup.string().matches( + ValidationConstants.patterns.phoneNumber, + localize('Enter a valid phone number.') +); + +export const validatePhoneNumber = ( + phone_number: string, + setErrorMessage: (value: string) => void, + setIsDisabledRequestButton: (value: boolean) => void +) => { + phoneNumberSchema + .validate(phone_number) + .then(() => { + setErrorMessage(''); + setIsDisabledRequestButton(false); + }) + .catch(({ errors }: any) => { + setErrorMessage(errors); + setIsDisabledRequestButton(true); + }); +}; diff --git a/packages/account/src/Sections/Profile/PhoneVerification/verification-link-expired-modal.tsx b/packages/account/src/Sections/Profile/PhoneVerification/verification-link-expired-modal.tsx new file mode 100644 index 000000000000..1006af277cf8 --- /dev/null +++ b/packages/account/src/Sections/Profile/PhoneVerification/verification-link-expired-modal.tsx @@ -0,0 +1,93 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { usePhoneNumberVerificationSetTimer, useSettings, useVerifyEmail } from '@deriv/hooks'; +import { Modal, Text } from '@deriv-com/quill-ui'; +import { routes } from '@deriv/shared'; +import { Localize, useTranslations } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { observer, useStore } from '@deriv/stores'; + +type TVerificationLinkExpiredModal = { + should_show_verification_link_expired_modal: boolean; + setShouldShowVerificationLinkExpiredModal: (value: boolean) => void; +}; + +const VerificationLinkExpiredModal = observer( + ({ + should_show_verification_link_expired_modal, + setShouldShowVerificationLinkExpiredModal, + }: TVerificationLinkExpiredModal) => { + const history = useHistory(); + const { ui } = useStore(); + const { setIsForcedToExitPnv } = ui; + //@ts-expect-error ignore this until we add it in GetSettings api types + const { sendPhoneNumberVerifyEmail, WS } = useVerifyEmail('phone_number_verification'); + const { next_email_otp_request_timer, is_email_otp_timer_loading, setNextEmailOtpRequestTimer } = + usePhoneNumberVerificationSetTimer(); + const { invalidate } = useSettings(); + const { isMobile } = useDevice(); + const { localize } = useTranslations(); + + const handleCancelButton = () => { + setIsForcedToExitPnv(false); + setShouldShowVerificationLinkExpiredModal(false); + history.push(routes.personal_details); + }; + + const handleSendNewLinkButton = () => { + sendPhoneNumberVerifyEmail(); + setNextEmailOtpRequestTimer(undefined); + setIsForcedToExitPnv(false); + }; + + const sendNewLinkTimer = () => { + let sendNewLinkTimer = ''; + if (next_email_otp_request_timer) { + next_email_otp_request_timer < 60 + ? (sendNewLinkTimer = `${localize(' in ')}${next_email_otp_request_timer}s`) + : (sendNewLinkTimer = `${localize(' in ')}${Math.round(next_email_otp_request_timer / 60)}m`); + } else { + sendNewLinkTimer = ''; + } + + return sendNewLinkTimer; + }; + + useEffect(() => { + if (WS.isSuccess) invalidate('get_settings').then(() => setShouldShowVerificationLinkExpiredModal(false)); + }, [WS.isSuccess, invalidate]); + + return ( + + } + toggleModal={() => setShouldShowVerificationLinkExpiredModal(false)} + showSecondaryButton + secondaryButtonLabel={} + secondaryButtonCallback={handleCancelButton} + > + } /> + +
+ + + +
+
+
+ ); + } +); + +export default VerificationLinkExpiredModal; diff --git a/packages/account/src/Styles/account.scss b/packages/account/src/Styles/account.scss index 3a593971ccef..6eacad76b027 100644 --- a/packages/account/src/Styles/account.scss +++ b/packages/account/src/Styles/account.scss @@ -330,6 +330,10 @@ $MIN_HEIGHT_FLOATING: calc( max-width: unset; } + &--phone-verification-livechat-link { + color: var(--text-general); + } + &--2-cols { display: grid; grid-template-columns: 1fr 1fr; diff --git a/packages/api-v2/package.json b/packages/api-v2/package.json index e01c144976e5..642394601786 100644 --- a/packages/api-v2/package.json +++ b/packages/api-v2/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "main": "src/index.ts", "dependencies": { - "@deriv-com/utils": "^0.0.25", + "@deriv-com/utils": "0.0.33", "@deriv/deriv-api": "^1.0.15", "@deriv/shared": "^1.0.0", "@deriv/utils": "^1.0.0", diff --git a/packages/api/package.json b/packages/api/package.json index dffeba2f80b5..35417ce450ac 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "sideEffects": false, "dependencies": { - "@deriv-com/utils": "^0.0.25", + "@deriv-com/utils": "0.0.33", "@deriv/deriv-api": "^1.0.15", "@deriv/shared": "^1.0.0", "@deriv/utils": "^1.0.0", diff --git a/packages/api/types.ts b/packages/api/types.ts index 2f309c23d180..221f5c5af62f 100644 --- a/packages/api/types.ts +++ b/packages/api/types.ts @@ -2300,6 +2300,89 @@ type PasskeysRenameResponse = { [k: string]: unknown; }; +// TODO: remove these mock phone number challenge types after implementing them inside api-types +type PhoneNumberChallengeRequest = { + /** + * Must be `1` + */ + phone_number_challenge: 1; + /** + * The carrier sending the email code. + */ + email_code: string; + /** + * The carrier sending the OTP. + */ + carrier?: 'whatsapp' | 'sms'; + /** + * [Optional] The login id of the user. If left unspecified, it defaults to the initial authorized token's login id. + */ + loginid?: string; + /** + * [Optional] Used to pass data through the websocket, which may be retrieved via the `echo_req` output field. + */ + passthrough?: { + [k: string]: unknown; + }; + /** + * [Optional] Used to map request to response. + */ + req_id?: number; +}; + +type PhoneNumberChallengeResponse = { + phone_number_challenge?: number; + /** + * Echo of the request made. + */ + echo_req: { + [k: string]: unknown; + }; + /** + * Action name of the request made. + */ + msg_type: 'phone_number_challenge'; + /** + * Optional field sent in request to map to response, present only when request contains `req_id`. + */ + req_id?: number; + [k: string]: unknown; +}; + +// TODO: remove these mock phone number challenge types after implementing them inside api-types +type PhoneNumberVerifyRequest = { + /** + * Must be `1` + */ + phone_number_verify: 1; + /** + * The carrier sending the OTP. + */ + otp: string; + /** + * [Optional] Used to map request to response. + */ + req_id?: number; +}; + +type PhoneNumberVerifyResponse = { + /** + * Echo of the request made. + */ + echo_req: { + [k: string]: unknown; + }; + /** + * Action name of the request made. + */ + msg_type: 'phone_number_verify'; + /** + * Optional field sent in request to map to response, present only when request contains `req_id`. + */ + req_id?: number; + [k: string]: unknown; +}; + type ChangeEmailRequest = { change_email: 'verify' | 'update'; new_email: string; @@ -2696,6 +2779,14 @@ type TSocketEndpoints = { request: PayoutCurrenciesRequest; response: PayoutCurrenciesResponse; }; + phone_number_challenge: { + request: PhoneNumberChallengeRequest; + response: PhoneNumberChallengeResponse; + }; + phone_number_verify: { + request: PhoneNumberVerifyRequest; + response: PhoneNumberVerifyResponse; + }; ping: { request: PingRequest; response: PingResponse; diff --git a/packages/cfd/package.json b/packages/cfd/package.json index 965de0bb927d..0dde6a37d4cc 100644 --- a/packages/cfd/package.json +++ b/packages/cfd/package.json @@ -88,7 +88,7 @@ "@deriv-com/ui": "1.35.0", "@deriv-com/analytics": "1.14.0", "@deriv-com/translations": "1.3.5", - "@deriv-com/utils": "^0.0.25", + "@deriv-com/utils": "0.0.33", "@deriv/account": "^1.0.0", "@deriv/api": "^1.0.0", "@deriv/api-types": "1.0.172", diff --git a/packages/components/src/components/form-submit-error-message/form-submit-error-message.tsx b/packages/components/src/components/form-submit-error-message/form-submit-error-message.tsx index 1e787ef729fa..e455f3b8ae25 100644 --- a/packages/components/src/components/form-submit-error-message/form-submit-error-message.tsx +++ b/packages/components/src/components/form-submit-error-message/form-submit-error-message.tsx @@ -5,16 +5,27 @@ import Text from '../text'; type TFormSubmitErrorMessage = { className?: string; - message: string; + message: React.ReactNode; + weight?: string; + text_color?: string; }; -const FormSubmitErrorMessage = ({ className, message }: TFormSubmitErrorMessage) => ( -
- - - {message} - -
-); +const FormSubmitErrorMessage = ({ + className, + message, + text_color = 'prominent', + weight = 'bold', +}: TFormSubmitErrorMessage) => { + return ( +
+ + { + + {message} + + } +
+ ); +}; export default FormSubmitErrorMessage; diff --git a/packages/components/src/components/icon/icons.js b/packages/components/src/components/icon/icons.js index b76d9fa74b1f..e5a4304a3131 100644 --- a/packages/components/src/components/icon/icons.js +++ b/packages/components/src/components/icon/icons.js @@ -792,7 +792,6 @@ import './option/ic-option-over-under.svg'; import './option/ic-option-raise-fall.svg'; import './option/ic-option-touch-notouch.svg'; import './option/ic-option-up-down-asian.svg'; -import './rebranding/ic-rebranding-binary-bot.svg'; import './rebranding/ic-rebranding-ctrader-dashboard.svg'; import './rebranding/ic-rebranding-deriv-bot-dashboard.svg'; import './rebranding/ic-rebranding-deriv-bot.svg'; diff --git a/packages/components/src/components/open-livechat-link/open-livechat-link.tsx b/packages/components/src/components/open-livechat-link/open-livechat-link.tsx index e845b32247c7..22fa92aca117 100644 --- a/packages/components/src/components/open-livechat-link/open-livechat-link.tsx +++ b/packages/components/src/components/open-livechat-link/open-livechat-link.tsx @@ -2,13 +2,19 @@ import React from 'react'; import { Localize } from '@deriv/translations'; import Text from '../text'; import './open-livechat-link.scss'; +import clsx from 'clsx'; type TOpenLiveChatLink = { text_size?: React.ComponentProps['size']; + className?: string; }; -const OpenLiveChatLink = ({ children, text_size }: React.PropsWithChildren) => ( -