diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index c9fca1a8c..75a610d81 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -9,13 +9,17 @@ const translation = { cancel: 'Cancel', done: 'Done', search: 'Search', + continue: 'Continue', }, sign_in: { + sign_in: 'Sign In', action: 'Sign In', loading: 'Signing in...', error: 'Username or password is invalid.', username: 'Username', password: 'Password', + email: 'Email', + phone_number: 'Phone Number', terms_of_use: 'Terms of Use', terms_agreement_prefix: 'I agree with ', continue_with: 'Continue With', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index b439df992..94b19a29d 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -11,13 +11,17 @@ const translation = { cancel: '取消', done: '完成', search: '搜索', + continue: '继续', }, sign_in: { + sign_in: '登录', action: '登录', loading: '登录中...', error: '用户名或密码错误。', username: '用户名', password: '密码', + email: '邮箱', + phone_number: '手机', terms_of_use: '用户协议', terms_agreement_prefix: '登录即表明您已经同意', continue_with: '更多', diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4b88c1007..84643163f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,6 +6,7 @@ import useTheme from './hooks/use-theme'; import initI18n from './i18n/init'; import Consent from './pages/Consent'; import Register from './pages/Register'; +import SecondarySignIn from './pages/SecondarySignIn'; import SignIn from './pages/SignIn'; import './scss/normalized.scss'; @@ -21,6 +22,7 @@ const App = () => { + diff --git a/packages/ui/src/components/Input/PhoneInput.tsx b/packages/ui/src/components/Input/PhoneInput.tsx index 874d0ee4a..049c54ae6 100644 --- a/packages/ui/src/components/Input/PhoneInput.tsx +++ b/packages/ui/src/components/Input/PhoneInput.tsx @@ -3,6 +3,7 @@ import React, { useState, useMemo, useRef } from 'react'; import { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number'; +import ErrorMessage, { ErrorType } from '../ErrorMessage'; import { ClearIcon, DownArrowIcon } from '../Icons'; import * as styles from './index.module.scss'; import * as phoneInputStyles from './phoneInput.module.scss'; @@ -12,26 +13,24 @@ type Value = { countryCallingCode?: CountryCallingCode; nationalNumber?: string export type Props = { name: string; autoComplete?: AutoCompleteType; - isDisabled?: boolean; className?: string; placeholder?: string; countryCallingCode?: CountryCallingCode; nationalNumber: string; countryList?: CountryMetaData[]; - hasError?: boolean; + error?: ErrorType; onChange: (value: Value) => void; }; const PhoneInput = ({ name, autoComplete, - isDisabled, className, placeholder, countryCallingCode, nationalNumber, countryList, - hasError = false, + error, onChange, }: Props) => { const [onFocus, setOnFocus] = useState(false); @@ -69,43 +68,38 @@ const PhoneInput = ({ }, [countryCallingCode, countryList, onChange]); return ( -
- {countrySelector} - { - setOnFocus(true); - }} - onBlur={() => { - setOnFocus(false); - }} - onChange={({ target: { value } }) => { - onChange({ nationalNumber: value }); - }} - /> - {nationalNumber && onFocus && ( - { - event.preventDefault(); - onChange({ nationalNumber: '' }); +
+
+ {countrySelector} + { + setOnFocus(true); + }} + onBlur={() => { + setOnFocus(false); + }} + onChange={({ target: { value } }) => { + onChange({ nationalNumber: value.replaceAll(/\D/g, '') }); }} /> - )} + {nationalNumber && onFocus && ( + { + event.preventDefault(); + onChange({ nationalNumber: '' }); + }} + /> + )} +
+ {error && }
); }; diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx new file mode 100644 index 000000000..a07057844 --- /dev/null +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { sendEmailPasscode as sendRegisterEmailPasscode } from '@/apis/register'; +import { sendEmailPasscode as sendSignInEmailPasscode } from '@/apis/sign-in'; + +import EmailPasswordless from './EmailPasswordless'; + +jest.mock('@/apis/sign-in', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) })); +jest.mock('@/apis/register', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) })); + +describe('', () => { + test('render', () => { + const { queryByText, container } = render(); + expect(container.querySelector('input[name="email"]')).not.toBeNull(); + expect(queryByText('general.continue')).not.toBeNull(); + expect(queryByText('sign_in.terms_of_use')).not.toBeNull(); + }); + + test('required email with error message', () => { + const { queryByText, container, getByText } = render(); + const submitButton = getByText('general.continue'); + + fireEvent.click(submitButton); + expect(queryByText('errors:user.invalid_email')).not.toBeNull(); + expect(sendSignInEmailPasscode).not.toBeCalled(); + + const emailInput = container.querySelector('input[name="email"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'foo' } }); + expect(queryByText('errors:user.invalid_email')).not.toBeNull(); + + fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); + expect(queryByText('errors:user.invalid_email')).toBeNull(); + } + }); + + test('required terms of agreement with error message', () => { + const { queryByText, container, getByText } = render(); + const submitButton = getByText('general.continue'); + const emailInput = container.querySelector('input[name="email"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); + } + + fireEvent.click(submitButton); + expect(queryByText('errors:form.terms_required')).not.toBeNull(); + }); + + test('signin method properly', async () => { + const { container, getByText } = render(); + const emailInput = container.querySelector('input[name="email"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); + } + const termsButton = getByText('sign_in.terms_agreement_prefix'); + fireEvent.click(termsButton); + + const submitButton = getByText('general.continue'); + + await waitFor(() => { + fireEvent.click(submitButton); + }); + + expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io'); + }); + + test('register method properly', async () => { + const { container, getByText } = render(); + const emailInput = container.querySelector('input[name="email"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); + } + const termsButton = getByText('sign_in.terms_agreement_prefix'); + fireEvent.click(termsButton); + + const submitButton = getByText('general.continue'); + + await waitFor(() => { + fireEvent.click(submitButton); + }); + + expect(sendRegisterEmailPasscode).toBeCalledWith('foo@logto.io'); + }); +}); diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx new file mode 100644 index 000000000..d41aced7c --- /dev/null +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -0,0 +1,157 @@ +/** + * TODO: + * 1. API redesign handle api error and loading status globally in PageContext + * 2. Input field validation, should move the validation rule to the input field scope + * 4. Read terms of use settings from SignInExperience Settings + */ +import { LogtoErrorI18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { sendEmailPasscode as sendRegisterEmailPasscode } from '@/apis/register'; +import { sendEmailPasscode as sendSignInEmailPasscode } from '@/apis/sign-in'; +import Button from '@/components/Button'; +import { ErrorType } from '@/components/ErrorMessage'; +import Input from '@/components/Input'; +import TermsOfUse from '@/components/TermsOfUse'; +import PageContext from '@/hooks/page-context'; +import useApi from '@/hooks/use-api'; + +import * as styles from './index.module.scss'; + +type Props = { + type: 'sign-in' | 'register'; +}; + +type FieldState = { + email: string; + termsAgreement: boolean; +}; + +type ErrorState = { + [key in keyof FieldState]?: ErrorType; +}; + +type FieldValidations = { + [key in keyof FieldState]: (state: FieldState) => ErrorType | undefined; +}; + +const defaultState: FieldState = { email: '', termsAgreement: false }; + +const emailRegEx = /^\S+@\S+\.\S+$/; + +const EmailPasswordless = ({ type }: Props) => { + const { t, i18n } = useTranslation(); + const [fieldState, setFieldState] = useState(defaultState); + const [fieldErrors, setFieldErrors] = useState({}); + const { setToast } = useContext(PageContext); + + const sendPasscode = type === 'sign-in' ? sendSignInEmailPasscode : sendRegisterEmailPasscode; + + const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode); + + const validations = useMemo( + () => ({ + email: ({ email }) => { + if (!emailRegEx.test(email)) { + return 'user.invalid_email'; + } + }, + termsAgreement: ({ termsAgreement }) => { + if (!termsAgreement) { + return 'form.terms_required'; + } + }, + }), + [] + ); + + const onSubmitHandler = useCallback(() => { + // Should be removed after api redesign + if (loading) { + return; + } + + const emailError = validations.email(fieldState); + + if (emailError) { + setFieldErrors((previous) => ({ ...previous, email: emailError })); + + return; + } + + const termsAgreementError = validations.termsAgreement(fieldState); + + if (termsAgreementError) { + setFieldErrors((previous) => ({ ...previous, termsAgreement: termsAgreementError })); + + return; + } + + void asyncSendPasscode(fieldState.email); + }, [loading, validations, fieldState, asyncSendPasscode]); + + useEffect(() => { + // TODO: navigate to the passcode page + console.log(result); + }, [result]); + + useEffect(() => { + // Clear errors + for (const key of Object.keys(fieldState) as [keyof FieldState]) { + if (fieldState[key]) { + setFieldErrors((previous) => { + if (!previous[key]) { + return previous; + } + + return { ...previous, [key]: validations[key](fieldState) }; + }); + } + } + }, [fieldState, validations]); + + useEffect(() => { + if (error) { + setToast(i18n.t(`errors:${error.code}`)); + } + }, [error, i18n, setToast]); + + return ( +
+ { + if (target instanceof HTMLInputElement) { + const { value } = target; + setFieldState((state) => ({ ...state, email: value })); + } + }} + onClear={() => { + setFieldState((state) => ({ ...state, email: '' })); + }} + /> + + { + setFieldState((state) => ({ ...state, termsAgreement: checked })); + }} + /> + + + + ); +}; + +export default EmailPasswordless; diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx new file mode 100644 index 000000000..7377f4a7d --- /dev/null +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { sendPhonePasscode as sendRegisterPhonePasscode } from '@/apis/register'; +import { sendPhonePasscode as sendSignInPhonePasscode } from '@/apis/sign-in'; +import { defaultCountryCallingCode } from '@/hooks/use-phone-number'; + +import PhonePasswordless from './PhonePasswordless'; + +jest.mock('@/apis/sign-in', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) })); +jest.mock('@/apis/register', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) })); + +describe('', () => { + const phoneNumber = '18888888888'; + + test('render', () => { + const { queryByText, container } = render(); + expect(container.querySelector('input[name="phone"]')).not.toBeNull(); + expect(queryByText('general.continue')).not.toBeNull(); + expect(queryByText('sign_in.terms_of_use')).not.toBeNull(); + }); + + test('required phone with error message', () => { + const { queryByText, container, getByText } = render(); + const submitButton = getByText('general.continue'); + + fireEvent.click(submitButton); + expect(queryByText('errors:user.invalid_phone')).not.toBeNull(); + expect(sendSignInPhonePasscode).not.toBeCalled(); + + const phoneInput = container.querySelector('input[name="phone"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: '1113' } }); + expect(queryByText('errors:user.invalid_phone')).not.toBeNull(); + + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + expect(queryByText('errors:user.invalid_phone')).toBeNull(); + } + }); + + test('required terms of agreement with error message', () => { + const { queryByText, container, getByText } = render(); + const submitButton = getByText('general.continue'); + const phoneInput = container.querySelector('input[name="phone"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + + fireEvent.click(submitButton); + expect(queryByText('errors:form.terms_required')).not.toBeNull(); + }); + + test('signin method properly', async () => { + const { container, getByText } = render(); + const phoneInput = container.querySelector('input[name="phone"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + const termsButton = getByText('sign_in.terms_agreement_prefix'); + fireEvent.click(termsButton); + + const submitButton = getByText('general.continue'); + + await waitFor(() => { + fireEvent.click(submitButton); + }); + + expect(sendSignInPhonePasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`); + }); + + test('register method properly', async () => { + const { container, getByText } = render(); + const phoneInput = container.querySelector('input[name="phone"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + const termsButton = getByText('sign_in.terms_agreement_prefix'); + fireEvent.click(termsButton); + + const submitButton = getByText('general.continue'); + + await waitFor(() => { + fireEvent.click(submitButton); + }); + + expect(sendRegisterPhonePasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`); + }); +}); diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx new file mode 100644 index 000000000..28b94509a --- /dev/null +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -0,0 +1,159 @@ +/** + * TODO: + * 1. API redesign handle api error and loading status globally in PageContext + * 2. Input field validation, should move the validation rule to the input field scope + * 4. Read terms of use settings from SignInExperience Settings + */ +import { LogtoErrorI18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { sendPhonePasscode as sendRegisterPhonePasscode } from '@/apis/register'; +import { sendPhonePasscode as sendSignInPhonePasscode } from '@/apis/sign-in'; +import Button from '@/components/Button'; +import { ErrorType } from '@/components/ErrorMessage'; +import PhoneInput from '@/components/Input/PhoneInput'; +import TermsOfUse from '@/components/TermsOfUse'; +import PageContext from '@/hooks/page-context'; +import useApi from '@/hooks/use-api'; +import usePhoneNumber, { countryList } from '@/hooks/use-phone-number'; + +import * as styles from './index.module.scss'; + +type Props = { + type: 'sign-in' | 'register'; +}; + +type FieldState = { + phone: string; + termsAgreement: boolean; +}; + +type ErrorState = { + [key in keyof FieldState]?: ErrorType; +}; + +type FieldValidations = { + [key in keyof FieldState]: (state: FieldState) => ErrorType | undefined; +}; + +const defaultState: FieldState = { phone: '', termsAgreement: false }; + +const PhonePasswordless = ({ type }: Props) => { + const { t, i18n } = useTranslation(); + const [fieldState, setFieldState] = useState(defaultState); + const [fieldErrors, setFieldErrors] = useState({}); + const { setToast } = useContext(PageContext); + + const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); + + const sendPasscode = type === 'sign-in' ? sendSignInPhonePasscode : sendRegisterPhonePasscode; + const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode); + + const validations = useMemo( + () => ({ + phone: ({ phone }) => { + if (!isValidPhoneNumber(phone)) { + return 'user.invalid_phone'; + } + }, + termsAgreement: ({ termsAgreement }) => { + if (!termsAgreement) { + return 'form.terms_required'; + } + }, + }), + [isValidPhoneNumber] + ); + + const onSubmitHandler = useCallback(() => { + // Should be removed after api redesign + if (loading) { + return; + } + + const phoneError = validations.phone(fieldState); + + if (phoneError) { + setFieldErrors((previous) => ({ ...previous, phone: phoneError })); + + return; + } + + const termsAgreementError = validations.termsAgreement(fieldState); + + if (termsAgreementError) { + setFieldErrors((previous) => ({ ...previous, termsAgreement: termsAgreementError })); + + return; + } + + void asyncSendPasscode(fieldState.phone); + }, [loading, validations, fieldState, asyncSendPasscode]); + + useEffect(() => { + setFieldState((previous) => ({ + ...previous, + phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`, + })); + }, [phoneNumber]); + + useEffect(() => { + // TODO: navigate to the passcode page + console.log(result); + }, [result]); + + useEffect(() => { + // Clear errors + for (const key of Object.keys(fieldState) as [keyof FieldState]) { + if (fieldState[key]) { + setFieldErrors((previous) => { + if (!previous[key]) { + return previous; + } + + return { ...previous, [key]: validations[key](fieldState) }; + }); + } + } + }, [fieldState, validations]); + + useEffect(() => { + if (error) { + setToast(i18n.t(`errors:${error.code}`)); + } + }, [error, i18n, setToast]); + + return ( +
+ { + setPhoneNumber((previous) => ({ ...previous, ...data })); + }} + /> + { + setFieldState((state) => ({ ...state, termsAgreement: checked })); + }} + /> + + + + ); +}; + +export default PhonePasswordless; diff --git a/packages/ui/src/containers/Passwordless/index.module.scss b/packages/ui/src/containers/Passwordless/index.module.scss new file mode 100644 index 000000000..3a5811973 --- /dev/null +++ b/packages/ui/src/containers/Passwordless/index.module.scss @@ -0,0 +1,27 @@ +@use '@/scss/underscore' as _; + +.form { + width: 100%; + max-width: 320px; + @include _.flex-column; + + > * { + width: 100%; + } + + .inputField { + margin-bottom: _.unit(11); + + &.withError { + margin-bottom: _.unit(9); + } + } + + .terms { + margin-bottom: _.unit(6); + + &.withError { + margin-bottom: _.unit(5); + } + } +} diff --git a/packages/ui/src/containers/Passwordless/index.tsx b/packages/ui/src/containers/Passwordless/index.tsx new file mode 100644 index 000000000..877bd6119 --- /dev/null +++ b/packages/ui/src/containers/Passwordless/index.tsx @@ -0,0 +1,2 @@ +export { default as EmailPasswordless } from './EmailPasswordless'; +export { default as PhonePasswordless } from './PhonePasswordless'; diff --git a/packages/ui/src/containers/PhoneInputProvider/index.test.tsx b/packages/ui/src/containers/PhoneInputProvider/index.test.tsx deleted file mode 100644 index f6d77b589..000000000 --- a/packages/ui/src/containers/PhoneInputProvider/index.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { render, fireEvent } from '@testing-library/react'; -import React from 'react'; - -import { defaultCountryCallingCode } from '@/hooks/use-phone-number'; - -import PhoneInputProvider from '.'; - -describe('Phone Input Provider', () => { - const onChange = jest.fn(); - - beforeEach(() => { - onChange.mockClear(); - }); - - it('render with empty input', () => { - const { queryByText } = render( - - ); - - expect(queryByText(`+${defaultCountryCallingCode}`)).not.toBeNull(); - }); - - it('render with input', () => { - const { queryByText, container } = render( - - ); - - expect(queryByText('+1')).not.toBeNull(); - expect(container.querySelector('input')?.value).toEqual('911'); - }); - - it('update country code', () => { - const { container } = render( - - ); - - const selector = container.querySelector('select'); - - if (selector) { - fireEvent.change(selector, { target: { value: '86' } }); - expect(onChange).toBeCalledWith('+86911'); - } - }); - - it('update national code', () => { - const { container } = render( - - ); - - const input = container.querySelector('input'); - - if (input) { - fireEvent.change(input, { target: { value: '119' } }); - expect(onChange).toBeCalledWith('+1119'); - } - }); - - it('clear national code', () => { - const { container } = render( - - ); - - const input = container.querySelector('input'); - - if (!input) { - return; - } - - fireEvent.focus(input); - - const clearButton = container.querySelectorAll('svg'); - expect(clearButton).toHaveLength(2); - - if (clearButton[1]) { - fireEvent.mouseDown(clearButton[1]); - expect(onChange).toBeCalledWith('+1'); - } - }); -}); diff --git a/packages/ui/src/containers/PhoneInputProvider/index.tsx b/packages/ui/src/containers/PhoneInputProvider/index.tsx deleted file mode 100644 index 4869a43b7..000000000 --- a/packages/ui/src/containers/PhoneInputProvider/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import PhoneInput from '@/components/Input/PhoneInput'; -import usePhoneNumber, { countryList } from '@/hooks/use-phone-number'; - -export type Props = { - name: string; - autoComplete?: AutoCompleteType; - isDisabled?: boolean; - className?: string; - placeholder?: string; - value: string; - onChange: (value: string) => void; -}; - -const PhoneInputProvider = ({ value, onChange, ...inputProps }: Props) => { - // TODO: error message - const { - error, - phoneNumber: { countryCallingCode, nationalNumber, interacted }, - setPhoneNumber, - } = usePhoneNumber(value, onChange); - - return ( - { - setPhoneNumber((phoneNumber) => ({ ...phoneNumber, ...data, interacted: true })); - }} - /> - ); -}; - -export default PhoneInputProvider; diff --git a/packages/ui/src/hooks/use-phone-number.ts b/packages/ui/src/hooks/use-phone-number.ts index 7a9a09729..d1cc3b3a1 100644 --- a/packages/ui/src/hooks/use-phone-number.ts +++ b/packages/ui/src/hooks/use-phone-number.ts @@ -4,7 +4,7 @@ */ import { - parsePhoneNumber as _parsePhoneNumber, + parsePhoneNumberWithError, getCountries, getCountryCallingCode, CountryCallingCode, @@ -12,20 +12,12 @@ import { E164Number, ParseError, } from 'libphonenumber-js'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; // Should not need the react-phone-number-input package, but we use its locale country name for now import en from 'react-phone-number-input/locale/en.json'; export type { CountryCallingCode } from 'libphonenumber-js'; -/** - * TODO: Get Default Country Code - */ -const defaultCountryCode: CountryCode = 'CN'; - -export const defaultCountryCallingCode: CountryCallingCode = - getCountryCallingCode(defaultCountryCode); - /** * Provide Country Code Options * TODO: Country Name i18n @@ -52,9 +44,6 @@ type PhoneNumberData = { nationalNumber: string; }; -// Add interact status to prevent the initial onUpdate useEffect call -type PhoneNumberState = PhoneNumberData & { interacted: boolean }; - const parseE164Number = (value: string): E164Number | '' => { if (!value || value.startsWith('+')) { return value; @@ -63,65 +52,33 @@ const parseE164Number = (value: string): E164Number | '' => { return `+${value}`; }; -export const parsePhoneNumber = (value: string): [ParseError?, PhoneNumberData?] => { +const isValidPhoneNumber = (value: string): boolean => { try { - const phoneNumber = _parsePhoneNumber(parseE164Number(value)); - const { countryCallingCode, nationalNumber } = phoneNumber; + const phoneNumber = parsePhoneNumberWithError(parseE164Number(value)); - return [undefined, { countryCallingCode, nationalNumber }]; + return phoneNumber.isValid(); } catch (error: unknown) { if (error instanceof ParseError) { - return [error]; + return false; } throw error; } }; -const usePhoneNumber = (value: string, onChangeCallback: (value: string) => void) => { - // TODO: phoneNumber format based on country +export const defaultCountryCode: CountryCode = 'CN'; +export const defaultCountryCallingCode = getCountryCallingCode(defaultCountryCode); - const [phoneNumber, setPhoneNumber] = useState({ +const usePhoneNumber = () => { + // TODO: Get Default Country Code + const [phoneNumber, setPhoneNumber] = useState({ countryCallingCode: defaultCountryCallingCode, nationalNumber: '', - interacted: false, }); - const [error, setError] = useState(); - - useEffect(() => { - // Only run on data initialization - if (phoneNumber.interacted) { - return; - } - - const [parseError, result] = parsePhoneNumber(value); - setError(parseError); - - if (result) { - const { countryCallingCode, nationalNumber } = result; - setPhoneNumber((previous) => ({ - ...previous, - countryCallingCode, - nationalNumber, - })); - } - }, [phoneNumber.interacted, value]); - - useEffect(() => { - // Only run after data initialization - if (!phoneNumber.interacted) { - return; - } - - const { countryCallingCode, nationalNumber } = phoneNumber; - const [parseError] = parsePhoneNumber(`${countryCallingCode}${nationalNumber}`); - setError(parseError); - onChangeCallback(`+${countryCallingCode}${nationalNumber}`); - }, [onChangeCallback, phoneNumber]); return { - error, phoneNumber, setPhoneNumber, + isValidPhoneNumber, }; }; diff --git a/packages/ui/src/pages/SecondarySignIn/index.module.scss b/packages/ui/src/pages/SecondarySignIn/index.module.scss new file mode 100644 index 000000000..4364e12e6 --- /dev/null +++ b/packages/ui/src/pages/SecondarySignIn/index.module.scss @@ -0,0 +1,22 @@ +@use '@/scss/underscore' as _; + +.wrapper { + position: relative; + padding: _.unit(8); + @include _.flex-column; +} + +.navBar { + width: 100%; + margin-bottom: _.unit(6); + + svg { + margin-left: _.unit(-2); + } +} + +.title { + width: 100%; + @include _.title; + margin-bottom: _.unit(9); +} diff --git a/packages/ui/src/pages/SecondarySignIn/index.tsx b/packages/ui/src/pages/SecondarySignIn/index.tsx new file mode 100644 index 000000000..1d5b82b53 --- /dev/null +++ b/packages/ui/src/pages/SecondarySignIn/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +import NavArrowIcon from '@/components/Icons/NavArrowIcon'; +import { PhonePasswordless } from '@/containers/Passwordless'; + +import * as styles from './index.module.scss'; + +const SecondarySignIn = () => { + const { t } = useTranslation(); + const history = useHistory(); + + return ( +
+
+ { + history.goBack(); + }} + /> +
+
{t('sign_in.sign_in')}
+ +
+ ); +}; + +export default SecondarySignIn;