diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index d9a0061ba..d1fa6983d 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -20,6 +20,10 @@ const translation = { password: 'Password', email: 'Email', phone_number: 'Phone Number', + enter_passcode: 'Enter Passcode', + passcode_sent: 'The Passcode has been sent to {{target}}', + resend_passcode: 'Resend Passcode', + resend_after_seconds: 'Resend after {{ seconds }} seconds', 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 5236700f1..47204b3e9 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -22,6 +22,10 @@ const translation = { password: '密码', email: '邮箱', phone_number: '手机', + enter_passcode: '请输入验证码', + passcode_sent: '验证码已发送至 {{target}}', + resend_passcode: '重新发送验证码', + resend_after_seconds: '在 {{ seconds }} 秒后重发', terms_of_use: '用户协议', terms_agreement_prefix: '登录即表明您已经同意', continue_with: '更多', diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 84643163f..6744cb741 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,6 +5,7 @@ import AppContent from './components/AppContent'; import useTheme from './hooks/use-theme'; import initI18n from './i18n/init'; import Consent from './pages/Consent'; +import Passcode from './pages/Passcode'; import Register from './pages/Register'; import SecondarySignIn from './pages/SecondarySignIn'; import SignIn from './pages/SignIn'; @@ -21,8 +22,10 @@ const App = () => { + - + + diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts new file mode 100644 index 000000000..3f84926ee --- /dev/null +++ b/packages/ui/src/apis/utils.ts @@ -0,0 +1,47 @@ +import { + verifyEmailPasscode as verifyRegisterEmailPasscode, + verifyPhonePasscode as verifyRegisterPhonePasscode, + sendEmailPasscode as sendRegisterEmailPasscode, + sendPhonePasscode as sendRegisterPhonePasscode, +} from './register'; +import { + verifyEmailPasscode as verifySignInEmailPasscode, + verifyPhonePasscode as verifySignInPhonePasscode, + sendEmailPasscode as sendSignInEmailPasscode, + sendPhonePasscode as sendSignInPhonePasscode, +} from './sign-in'; + +export type PasscodeType = 'sign-in' | 'register'; +export type PasscodeChannel = 'phone' | 'email'; + +export const getSendPasscodeApi = (type: PasscodeType, channel: PasscodeChannel) => { + if (type === 'sign-in' && channel === 'email') { + return sendSignInEmailPasscode; + } + + if (type === 'sign-in' && channel === 'phone') { + return sendSignInPhonePasscode; + } + + if (type === 'register' && channel === 'email') { + return sendRegisterEmailPasscode; + } + + return sendRegisterPhonePasscode; +}; + +export const getVerifyPasscodeApi = (type: PasscodeType, channel: PasscodeChannel) => { + if (type === 'sign-in' && channel === 'email') { + return verifySignInEmailPasscode; + } + + if (type === 'sign-in' && channel === 'phone') { + return verifySignInPhonePasscode; + } + + if (type === 'register' && channel === 'email') { + return verifyRegisterEmailPasscode; + } + + return verifyRegisterPhonePasscode; +}; diff --git a/packages/ui/src/components/Passcode/index.module.scss b/packages/ui/src/components/Passcode/index.module.scss index 5f54d074d..f271a489d 100644 --- a/packages/ui/src/components/Passcode/index.module.scss +++ b/packages/ui/src/components/Passcode/index.module.scss @@ -36,3 +36,7 @@ } } } + +.errorMessage { + margin-top: _.unit(2); +} diff --git a/packages/ui/src/components/Passcode/index.tsx b/packages/ui/src/components/Passcode/index.tsx index 27663c773..009adebb8 100644 --- a/packages/ui/src/components/Passcode/index.tsx +++ b/packages/ui/src/components/Passcode/index.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React, { useMemo, useRef, @@ -9,17 +8,17 @@ import React, { ClipboardEventHandler, } from 'react'; +import ErrorMessage, { ErrorType } from '../ErrorMessage'; import * as styles from './index.module.scss'; export const defaultLength = 6; export type Props = { name: string; - isDisabled?: boolean; className?: string; length?: number; value: string[]; - hasError?: boolean; + error?: ErrorType; onChange: (value: string[]) => void; }; @@ -46,15 +45,7 @@ const trim = (oldValue: string | undefined, newValue: string) => { return newValue; }; -const Passcode = ({ - name, - isDisabled, - className, - value, - length = defaultLength, - hasError, - onChange, -}: Props) => { +const Passcode = ({ name, className, value, length = defaultLength, error, onChange }: Props) => { /* eslint-disable @typescript-eslint/ban-types */ const inputReferences = useRef>( Array.from({ length }).fill(null) @@ -187,29 +178,32 @@ const Passcode = ({ ); return ( -
- {Array.from({ length }).map((_, index) => ( - { - // eslint-disable-next-line @silverhand/fp/no-mutation - inputReferences.current[index] = element; - }} - // eslint-disable-next-line react/no-array-index-key - key={`${name}_${index}`} - name={`${name}_${index}`} - data-id={index} - disabled={isDisabled} - value={codes[index]} - className={hasError ? styles.error : undefined} - type="text" - inputMode="numeric" - maxLength={2} // Allow overwrite input - onPaste={onPasteHandler} - onInput={onInputHandler} - onKeyDown={onKeyDownHandler} - onFocus={onFocusHandler} - /> - ))} +
+
+ {Array.from({ length }).map((_, index) => ( + { + // eslint-disable-next-line @silverhand/fp/no-mutation + inputReferences.current[index] = element; + }} + // eslint-disable-next-line react/no-array-index-key + key={`${name}_${index}`} + autoFocus={index === 0} + name={`${name}_${index}`} + data-id={index} + value={codes[index]} + className={error ? styles.error : undefined} + type="text" + inputMode="numeric" + maxLength={2} // Allow overwrite input + onPaste={onPasteHandler} + onInput={onInputHandler} + onKeyDown={onKeyDownHandler} + onFocus={onFocusHandler} + /> + ))} +
+ {error && }
); }; diff --git a/packages/ui/src/containers/CreateAccount/index.module.scss b/packages/ui/src/containers/CreateAccount/index.module.scss index 2b3b719c8..f6cd62ee8 100644 --- a/packages/ui/src/containers/CreateAccount/index.module.scss +++ b/packages/ui/src/containers/CreateAccount/index.module.scss @@ -2,7 +2,7 @@ .form { width: 100%; - max-width: 320px; + max-width: 360px; @include _.flex-column; > * { diff --git a/packages/ui/src/containers/PasscodeValidation/index.module.scss b/packages/ui/src/containers/PasscodeValidation/index.module.scss new file mode 100644 index 000000000..20507e724 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/index.module.scss @@ -0,0 +1,11 @@ +@use '@/scss/underscore' as _; + +.form { + width: 100%; + max-width: 360px; + @include _.flex-column; + + > * { + width: 100%; + } +} diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx new file mode 100644 index 000000000..c3e10ce14 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import * as styles from './index.module.scss'; + +type Props = { + type: 'sign-in' | 'register'; + channel: 'email' | 'phone'; +}; + +const PasscodeValidation = ({ type, channel }: Props) => { + console.log(type, channel); + + return
{/* TODO */}
; +}; + +export default PasscodeValidation; diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index d41aced7c..9bd3f238d 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -9,8 +9,7 @@ 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 { getSendPasscodeApi } from '@/apis/utils'; import Button from '@/components/Button'; import { ErrorType } from '@/components/ErrorMessage'; import Input from '@/components/Input'; @@ -47,7 +46,7 @@ const EmailPasswordless = ({ type }: Props) => { const [fieldErrors, setFieldErrors] = useState({}); const { setToast } = useContext(PageContext); - const sendPasscode = type === 'sign-in' ? sendSignInEmailPasscode : sendRegisterEmailPasscode; + const sendPasscode = getSendPasscodeApi(type, 'email'); const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode); diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx index 28b94509a..428a225d7 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -9,8 +9,7 @@ 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 { getSendPasscodeApi } from '@/apis/utils'; import Button from '@/components/Button'; import { ErrorType } from '@/components/ErrorMessage'; import PhoneInput from '@/components/Input/PhoneInput'; @@ -48,7 +47,7 @@ const PhonePasswordless = ({ type }: Props) => { const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); - const sendPasscode = type === 'sign-in' ? sendSignInPhonePasscode : sendRegisterPhonePasscode; + const sendPasscode = getSendPasscodeApi(type, 'phone'); const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode); const validations = useMemo( diff --git a/packages/ui/src/containers/Passwordless/index.module.scss b/packages/ui/src/containers/Passwordless/index.module.scss index 3a5811973..9bb36c69b 100644 --- a/packages/ui/src/containers/Passwordless/index.module.scss +++ b/packages/ui/src/containers/Passwordless/index.module.scss @@ -2,7 +2,7 @@ .form { width: 100%; - max-width: 320px; + max-width: 360px; @include _.flex-column; > * { diff --git a/packages/ui/src/containers/UsernameSignin/index.module.scss b/packages/ui/src/containers/UsernameSignin/index.module.scss index 9d50c4fbd..133d305ae 100644 --- a/packages/ui/src/containers/UsernameSignin/index.module.scss +++ b/packages/ui/src/containers/UsernameSignin/index.module.scss @@ -2,7 +2,7 @@ .form { width: 100%; - max-width: 320px; + max-width: 360px; @include _.flex-column; > * { diff --git a/packages/ui/src/pages/Passcode/index.module.scss b/packages/ui/src/pages/Passcode/index.module.scss new file mode 100644 index 000000000..66b0a03c5 --- /dev/null +++ b/packages/ui/src/pages/Passcode/index.module.scss @@ -0,0 +1,22 @@ +@use '@/scss/underscore' as _; + +.wrapper { + position: relative; + padding: _.unit(8) _.unit(5); + @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/Passcode/index.tsx b/packages/ui/src/pages/Passcode/index.tsx new file mode 100644 index 000000000..27eb69541 --- /dev/null +++ b/packages/ui/src/pages/Passcode/index.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useParams } from 'react-router-dom'; + +import NavArrowIcon from '@/components/Icons/NavArrowIcon'; +import PasscodeValidation from '@/containers/PasscodeValidation'; + +import * as styles from './index.module.scss'; + +type Props = { + type: string; + channel: string; +}; + +const Passcode = () => { + const { t } = useTranslation(); + const history = useHistory(); + const { type, channel } = useParams(); + + // TODO: 404 page + if (type !== 'sign-in' && type !== 'register') { + window.location.assign('/404'); + + return null; + } + + if (channel !== 'email' && channel !== 'phone') { + window.location.assign('/404'); + + return null; + } + + return ( +
+
+ { + history.goBack(); + }} + /> +
+
{t('sign_in.enter_passcode')}
+ +
+ ); +}; + +export default Passcode; diff --git a/packages/ui/src/pages/Register/index.module.scss b/packages/ui/src/pages/Register/index.module.scss index 4364e12e6..66b0a03c5 100644 --- a/packages/ui/src/pages/Register/index.module.scss +++ b/packages/ui/src/pages/Register/index.module.scss @@ -2,7 +2,7 @@ .wrapper { position: relative; - padding: _.unit(8); + padding: _.unit(8) _.unit(5); @include _.flex-column; } diff --git a/packages/ui/src/pages/Register/index.test.tsx b/packages/ui/src/pages/Register/index.test.tsx index 00cfc39b2..278495017 100644 --- a/packages/ui/src/pages/Register/index.test.tsx +++ b/packages/ui/src/pages/Register/index.test.tsx @@ -1,5 +1,6 @@ import { render } from '@testing-library/react'; import React from 'react'; +import { Route, MemoryRouter } from 'react-router-dom'; import Register from '@/pages/Register'; @@ -7,8 +8,36 @@ jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.reso describe('', () => { test('renders without exploding', async () => { - const { queryByText } = render(); + const { queryByText } = render( + + + + ); expect(queryByText('register.create_account')).not.toBeNull(); expect(queryByText('register.action')).not.toBeNull(); }); + + test('renders phone', async () => { + const { queryByText, container } = render( + + + + + + ); + expect(queryByText('register.create_account')).not.toBeNull(); + expect(container.querySelector('input[name="phone"]')).not.toBeNull(); + }); + + test('renders email', async () => { + const { queryByText, container } = render( + + + + + + ); + expect(queryByText('register.create_account')).not.toBeNull(); + expect(container.querySelector('input[name="email"]')).not.toBeNull(); + }); }); diff --git a/packages/ui/src/pages/Register/index.tsx b/packages/ui/src/pages/Register/index.tsx index a5b62cbc7..a3a2c22a4 100644 --- a/packages/ui/src/pages/Register/index.tsx +++ b/packages/ui/src/pages/Register/index.tsx @@ -1,15 +1,33 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import NavArrowIcon from '@/components/Icons/NavArrowIcon'; import CreateAccount from '@/containers/CreateAccount'; +import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless'; import * as styles from './index.module.scss'; +type Props = { + channel?: 'phone' | 'email' | 'username'; +}; + const Register = () => { const { t } = useTranslation(); const history = useHistory(); + const { channel } = useParams(); + + const registerForm = useMemo(() => { + if (channel === 'phone') { + return ; + } + + if (channel === 'email') { + return ; + } + + return ; + }, [channel]); return (
@@ -21,7 +39,7 @@ const Register = () => { />
{t('register.create_account')}
- + {registerForm}
); }; diff --git a/packages/ui/src/pages/SecondarySignIn/index.module.scss b/packages/ui/src/pages/SecondarySignIn/index.module.scss index 4364e12e6..66b0a03c5 100644 --- a/packages/ui/src/pages/SecondarySignIn/index.module.scss +++ b/packages/ui/src/pages/SecondarySignIn/index.module.scss @@ -2,7 +2,7 @@ .wrapper { position: relative; - padding: _.unit(8); + padding: _.unit(8) _.unit(5); @include _.flex-column; } diff --git a/packages/ui/src/pages/SecondarySignIn/index.test.tsx b/packages/ui/src/pages/SecondarySignIn/index.test.tsx new file mode 100644 index 000000000..26e3b7b45 --- /dev/null +++ b/packages/ui/src/pages/SecondarySignIn/index.test.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { Route, MemoryRouter } from 'react-router-dom'; + +import SecondarySignIn from '@/pages/SecondarySignIn'; + +jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) })); + +describe('', () => { + test('renders without exploding', async () => { + const { queryByText } = render( + + + + ); + expect(queryByText('sign_in.sign_in')).not.toBeNull(); + expect(queryByText('sign_in.action')).not.toBeNull(); + }); + + test('renders phone', async () => { + const { queryByText, container } = render( + + + + + + ); + expect(queryByText('sign_in.sign_in')).not.toBeNull(); + expect(container.querySelector('input[name="phone"]')).not.toBeNull(); + }); + + test('renders email', async () => { + const { queryByText, container } = render( + + + + + + ); + expect(queryByText('sign_in.sign_in')).not.toBeNull(); + expect(container.querySelector('input[name="email"]')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/pages/SecondarySignIn/index.tsx b/packages/ui/src/pages/SecondarySignIn/index.tsx index 1d5b82b53..97322ce24 100644 --- a/packages/ui/src/pages/SecondarySignIn/index.tsx +++ b/packages/ui/src/pages/SecondarySignIn/index.tsx @@ -1,15 +1,33 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import NavArrowIcon from '@/components/Icons/NavArrowIcon'; -import { PhonePasswordless } from '@/containers/Passwordless'; +import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless'; +import UsernameSignin from '@/containers/UsernameSignin'; import * as styles from './index.module.scss'; +type Props = { + channel?: 'phone' | 'email' | 'username'; +}; + const SecondarySignIn = () => { const { t } = useTranslation(); const history = useHistory(); + const { channel } = useParams(); + + const signInForm = useMemo(() => { + if (channel === 'phone') { + return ; + } + + if (channel === 'email') { + return ; + } + + return ; + }, [channel]); return (
@@ -21,7 +39,7 @@ const SecondarySignIn = () => { />
{t('sign_in.sign_in')}
- + {signInForm} ); }; diff --git a/packages/ui/src/pages/SignIn/index.module.scss b/packages/ui/src/pages/SignIn/index.module.scss index e315b2c13..338e2bf63 100644 --- a/packages/ui/src/pages/SignIn/index.module.scss +++ b/packages/ui/src/pages/SignIn/index.module.scss @@ -2,7 +2,7 @@ .wrapper { position: relative; - padding: _.unit(8); + padding: _.unit(8) _.unit(5); @include _.flex-column; .header {