0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-03 22:15:32 -05:00

feat(ui): add passwordless container (#469)

* feat(ui): add passwordless container

add email & phone passwordless container
add secondary sign-in page

* feat(ui): uncomment code after rebase latest code

uncomment code after rebase latest code
This commit is contained in:
simeng-li 2022-03-31 13:57:01 +08:00 committed by GitHub
parent 67939ea2bb
commit 6a61ea29f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 632 additions and 211 deletions

View file

@ -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',

View file

@ -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: '更多',

View file

@ -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 = () => {
<Route exact path="/sign-in" component={SignIn} />
<Route exact path="/sign-in/consent" component={Consent} />
<Route exact path="/register" component={Register} />
<Route exact path="/sign-in-secondary" component={SecondarySignIn} />
</Switch>
</BrowserRouter>
</AppContent>

View file

@ -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 (
<div
className={classNames(
styles.wrapper,
onFocus && styles.focus,
hasError && styles.error,
className
)}
>
{countrySelector}
<input
ref={inputReference}
name={name}
disabled={isDisabled}
placeholder={placeholder}
value={nationalNumber}
type="tel"
inputMode="numeric"
autoComplete={autoComplete}
onFocus={() => {
setOnFocus(true);
}}
onBlur={() => {
setOnFocus(false);
}}
onChange={({ target: { value } }) => {
onChange({ nationalNumber: value });
}}
/>
{nationalNumber && onFocus && (
<ClearIcon
className={styles.actionButton}
onMouseDown={(event) => {
event.preventDefault();
onChange({ nationalNumber: '' });
<div className={className}>
<div className={classNames(styles.wrapper, onFocus && styles.focus, error && styles.error)}>
{countrySelector}
<input
ref={inputReference}
name={name}
placeholder={placeholder}
value={nationalNumber}
type="tel"
inputMode="numeric"
autoComplete={autoComplete}
onFocus={() => {
setOnFocus(true);
}}
onBlur={() => {
setOnFocus(false);
}}
onChange={({ target: { value } }) => {
onChange({ nationalNumber: value.replaceAll(/\D/g, '') });
}}
/>
)}
{nationalNumber && onFocus && (
<ClearIcon
className={styles.actionButton}
onMouseDown={(event) => {
event.preventDefault();
onChange({ nationalNumber: '' });
}}
/>
)}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
</div>
);
};

View file

@ -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('<EmailPasswordless/>', () => {
test('render', () => {
const { queryByText, container } = render(<EmailPasswordless type="sign-in" />);
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(<EmailPasswordless type="sign-in" />);
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(<EmailPasswordless type="sign-in" />);
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(<EmailPasswordless type="sign-in" />);
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(<EmailPasswordless type="register" />);
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');
});
});

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const { setToast } = useContext(PageContext);
const sendPasscode = type === 'sign-in' ? sendSignInEmailPasscode : sendRegisterEmailPasscode;
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
const validations = useMemo<FieldValidations>(
() => ({
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<string, LogtoErrorI18nKey>(`errors:${error.code}`));
}
}, [error, i18n, setToast]);
return (
<form className={styles.form}>
<Input
className={classNames(styles.inputField, fieldErrors.email && styles.withError)}
name="email"
autoComplete="email"
placeholder={t('sign_in.email')}
value={fieldState.email}
error={fieldErrors.email}
onChange={({ target }) => {
if (target instanceof HTMLInputElement) {
const { value } = target;
setFieldState((state) => ({ ...state, email: value }));
}
}}
onClear={() => {
setFieldState((state) => ({ ...state, email: '' }));
}}
/>
<TermsOfUse
name="termsAgreement"
className={classNames(styles.terms, fieldErrors.termsAgreement && styles.withError)}
termsOfUse={{ enabled: true, contentUrl: '/' }}
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}
/>
<Button onClick={onSubmitHandler}>{t('general.continue')}</Button>
</form>
);
};
export default EmailPasswordless;

View file

@ -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('<PhonePasswordless/>', () => {
const phoneNumber = '18888888888';
test('render', () => {
const { queryByText, container } = render(<PhonePasswordless type="sign-in" />);
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(<PhonePasswordless type="sign-in" />);
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(<PhonePasswordless type="sign-in" />);
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(<PhonePasswordless type="sign-in" />);
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(<PhonePasswordless type="register" />);
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}`);
});
});

View file

@ -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<FieldState>(defaultState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
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<FieldValidations>(
() => ({
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<string, LogtoErrorI18nKey>(`errors:${error.code}`));
}
}, [error, i18n, setToast]);
return (
<form className={styles.form}>
<PhoneInput
name="phone"
className={classNames(styles.inputField, fieldErrors.phone && styles.withError)}
autoComplete="mobile"
placeholder={t('sign_in.phone_number')}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
countryList={countryList}
error={fieldErrors.phone}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
<TermsOfUse
name="termsAgreement"
className={classNames(styles.terms, fieldErrors.termsAgreement && styles.withError)}
termsOfUse={{ enabled: true, contentUrl: '/' }}
isChecked={fieldState.termsAgreement}
error={fieldErrors.termsAgreement}
onChange={(checked) => {
setFieldState((state) => ({ ...state, termsAgreement: checked }));
}}
/>
<Button onClick={onSubmitHandler}>{t('general.continue')}</Button>
</form>
);
};
export default PhonePasswordless;

View file

@ -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);
}
}
}

View file

@ -0,0 +1,2 @@
export { default as EmailPasswordless } from './EmailPasswordless';
export { default as PhonePasswordless } from './PhonePasswordless';

View file

@ -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(
<PhoneInputProvider name="phone" value="" onChange={onChange} />
);
expect(queryByText(`+${defaultCountryCallingCode}`)).not.toBeNull();
});
it('render with input', () => {
const { queryByText, container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
expect(queryByText('+1')).not.toBeNull();
expect(container.querySelector('input')?.value).toEqual('911');
});
it('update country code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
const selector = container.querySelector('select');
if (selector) {
fireEvent.change(selector, { target: { value: '86' } });
expect(onChange).toBeCalledWith('+86911');
}
});
it('update national code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
const input = container.querySelector('input');
if (input) {
fireEvent.change(input, { target: { value: '119' } });
expect(onChange).toBeCalledWith('+1119');
}
});
it('clear national code', () => {
const { container } = render(
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
);
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');
}
});
});

View file

@ -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 (
<PhoneInput
{...inputProps}
countryCallingCode={countryCallingCode}
nationalNumber={nationalNumber}
countryList={countryList}
hasError={Boolean(error && interacted)}
onChange={(data) => {
setPhoneNumber((phoneNumber) => ({ ...phoneNumber, ...data, interacted: true }));
}}
/>
);
};
export default PhoneInputProvider;

View file

@ -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<PhoneNumberState>({
const usePhoneNumber = () => {
// TODO: Get Default Country Code
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberData>({
countryCallingCode: defaultCountryCallingCode,
nationalNumber: '',
interacted: false,
});
const [error, setError] = useState<ParseError>();
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,
};
};

View file

@ -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);
}

View file

@ -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 (
<div className={styles.wrapper}>
<div className={styles.navBar}>
<NavArrowIcon
onClick={() => {
history.goBack();
}}
/>
</div>
<div className={styles.title}>{t('sign_in.sign_in')}</div>
<PhonePasswordless type="sign-in" />
</div>
);
};
export default SecondarySignIn;