diff --git a/packages/ui/src/containers/EmailForm/EmailForm.test.tsx b/packages/ui/src/containers/EmailForm/EmailForm.test.tsx
new file mode 100644
index 000000000..fa26bc460
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/EmailForm.test.tsx
@@ -0,0 +1,172 @@
+import { fireEvent, waitFor, act } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
+import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
+
+import EmailForm from './EmailForm';
+
+const onSubmit = jest.fn();
+const clearErrorMessage = jest.fn();
+
+describe('', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('render', () => {
+ const { queryByText, container } = renderWithPageContext(
+
+
+
+ );
+ expect(container.querySelector('input[name="email"]')).not.toBeNull();
+ expect(queryByText('action.continue')).not.toBeNull();
+ });
+
+ test('render with terms settings', () => {
+ const { queryByText } = renderWithPageContext(
+
+
+
+
+
+ );
+ expect(queryByText('description.terms_of_use')).not.toBeNull();
+ });
+
+ test('render with terms settings but hasTerms param set to false', () => {
+ const { queryByText } = renderWithPageContext(
+
+
+
+
+
+ );
+ expect(queryByText('description.terms_of_use')).toBeNull();
+ });
+
+ test('required email with error message', () => {
+ const { queryByText, container, getByText } = renderWithPageContext(
+
+
+
+ );
+ const submitButton = getByText('action.continue');
+
+ fireEvent.click(submitButton);
+ expect(queryByText('invalid_email')).not.toBeNull();
+ expect(onSubmit).not.toBeCalled();
+
+ const emailInput = container.querySelector('input[name="email"]');
+
+ if (emailInput) {
+ fireEvent.change(emailInput, { target: { value: 'foo' } });
+ expect(queryByText('invalid_email')).not.toBeNull();
+
+ fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
+ expect(queryByText('invalid_email')).toBeNull();
+ }
+ });
+
+ test('should display and clear the form error message as expected', () => {
+ const { queryByText, container, getByText } = renderWithPageContext(
+
+
+
+ );
+
+ expect(queryByText('form error')).not.toBeNull();
+
+ const emailInput = container.querySelector('input[name="email"]');
+
+ if (emailInput) {
+ fireEvent.change(emailInput, { target: { value: 'foo' } });
+ expect(clearErrorMessage).toBeCalled();
+ }
+ });
+
+ test('should blocked by terms validation with terms settings enabled', async () => {
+ const { container, getByText } = renderWithPageContext(
+
+
+
+
+
+ );
+
+ const emailInput = container.querySelector('input[name="email"]');
+
+ if (emailInput) {
+ fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
+ }
+
+ const submitButton = getByText('action.continue');
+
+ act(() => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(onSubmit).not.toBeCalled();
+ });
+ });
+
+ test('should call onSubmit properly with terms settings enabled but hasTerms param set to false', async () => {
+ const { container, getByText } = renderWithPageContext(
+
+
+
+
+
+ );
+
+ const emailInput = container.querySelector('input[name="email"]');
+
+ if (emailInput) {
+ fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
+ }
+
+ const submitButton = getByText('action.continue');
+
+ act(() => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(onSubmit).toBeCalledWith('foo@logto.io');
+ });
+ });
+
+ test('should call onSubmit method properly with terms settings enabled and checked', async () => {
+ const { container, getByText } = renderWithPageContext(
+
+
+
+
+
+ );
+ const emailInput = container.querySelector('input[name="email"]');
+
+ if (emailInput) {
+ fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
+ }
+
+ const termsButton = getByText('description.agree_with_terms');
+ fireEvent.click(termsButton);
+
+ const submitButton = getByText('action.continue');
+
+ act(() => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(onSubmit).toBeCalledWith('foo@logto.io');
+ });
+ });
+});
diff --git a/packages/ui/src/containers/EmailForm/EmailForm.tsx b/packages/ui/src/containers/EmailForm/EmailForm.tsx
new file mode 100644
index 000000000..be7b49ad2
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/EmailForm.tsx
@@ -0,0 +1,104 @@
+import classNames from 'classnames';
+import { useCallback, useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Button from '@/components/Button';
+import ErrorMessage from '@/components/ErrorMessage';
+import Input from '@/components/Input';
+import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
+import TermsOfUse from '@/containers/TermsOfUse';
+import useForm from '@/hooks/use-form';
+import useTerms from '@/hooks/use-terms';
+import { emailValidation } from '@/utils/field-validations';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ className?: string;
+ // eslint-disable-next-line react/boolean-prop-naming
+ autoFocus?: boolean;
+ hasTerms?: boolean;
+ hasSwitch?: boolean;
+ errorMessage?: string;
+ clearErrorMessage?: () => void;
+ onSubmit: (email: string) => Promise;
+};
+
+type FieldState = {
+ email: string;
+};
+
+const defaultState: FieldState = { email: '' };
+
+const EmailForm = ({
+ autoFocus,
+ hasTerms = true,
+ hasSwitch = false,
+ errorMessage,
+ clearErrorMessage,
+ className,
+ onSubmit,
+}: Props) => {
+ const { t } = useTranslation();
+
+ const { termsValidation } = useTerms();
+ const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
+
+ /* Clear the form error when input field is updated */
+ const errorMessageRef = useRef(errorMessage);
+
+ useEffect(() => {
+ // eslint-disable-next-line @silverhand/fp/no-mutation
+ errorMessageRef.current = errorMessage;
+ }, [errorMessage]);
+
+ useEffect(() => {
+ if (errorMessageRef.current) {
+ clearErrorMessage?.();
+ }
+ }, [clearErrorMessage, errorMessageRef, fieldValue.email]);
+
+ const onSubmitHandler = useCallback(
+ async (event?: React.FormEvent) => {
+ event?.preventDefault();
+
+ if (!validateForm()) {
+ return;
+ }
+
+ if (hasTerms && !(await termsValidation())) {
+ return;
+ }
+
+ await onSubmit(fieldValue.email);
+ },
+ [validateForm, hasTerms, termsValidation, onSubmit, fieldValue.email]
+ );
+
+ return (
+
+ );
+};
+
+export default EmailForm;
diff --git a/packages/ui/src/containers/EmailForm/EmailRegister.tsx b/packages/ui/src/containers/EmailForm/EmailRegister.tsx
new file mode 100644
index 000000000..3c9996190
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/EmailRegister.tsx
@@ -0,0 +1,23 @@
+import EmailForm from './EmailForm';
+import useEmailRegister from './use-email-register';
+
+type Props = {
+ className?: string;
+ // eslint-disable-next-line react/boolean-prop-naming
+ autoFocus?: boolean;
+};
+
+const EmailRegister = (props: Props) => {
+ const { onSubmit, errorMessage, clearErrorMessage } = useEmailRegister();
+
+ return (
+
+ );
+};
+
+export default EmailRegister;
diff --git a/packages/ui/src/containers/EmailForm/index.module.scss b/packages/ui/src/containers/EmailForm/index.module.scss
new file mode 100644
index 000000000..f73c1471a
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/index.module.scss
@@ -0,0 +1,24 @@
+@use '@/scss/underscore' as _;
+
+.form {
+ @include _.flex-column;
+
+ > * {
+ width: 100%;
+ }
+
+ .inputField,
+ .terms,
+ .switch {
+ margin-bottom: _.unit(4);
+ }
+
+ .switch {
+ display: block;
+ }
+
+ .formErrors {
+ margin-top: _.unit(-2);
+ margin-bottom: _.unit(4);
+ }
+}
diff --git a/packages/ui/src/containers/EmailForm/index.tsx b/packages/ui/src/containers/EmailForm/index.tsx
new file mode 100644
index 000000000..06a040b2b
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/index.tsx
@@ -0,0 +1 @@
+export { default as EmailRegister } from './EmailRegister';
diff --git a/packages/ui/src/containers/EmailForm/use-email-register.ts b/packages/ui/src/containers/EmailForm/use-email-register.ts
new file mode 100644
index 000000000..f056dc9d9
--- /dev/null
+++ b/packages/ui/src/containers/EmailForm/use-email-register.ts
@@ -0,0 +1,55 @@
+import { SignInIdentifier } from '@logto/schemas';
+import { useState, useMemo, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { sendRegisterEmailPasscode } from '@/apis/register';
+import type { ErrorHandlers } from '@/hooks/use-api';
+import useApi from '@/hooks/use-api';
+import { UserFlow } from '@/types';
+
+const useEmailRegister = () => {
+ const [errorMessage, setErrorMessage] = useState();
+ const navigate = useNavigate();
+
+ const errorHandlers: ErrorHandlers = useMemo(
+ () => ({
+ 'guard.invalid_input': () => {
+ setErrorMessage('invalid_email');
+ },
+ }),
+ [setErrorMessage]
+ );
+
+ const clearErrorMessage = useCallback(() => {
+ setErrorMessage('');
+ }, []);
+
+ const { run: asyncSendRegisterEmailPasscode } = useApi(sendRegisterEmailPasscode, errorHandlers);
+
+ const onSubmit = useCallback(
+ async (email: string) => {
+ const result = await asyncSendRegisterEmailPasscode(email);
+
+ if (!result) {
+ return;
+ }
+
+ navigate(
+ {
+ pathname: `/${UserFlow.register}/${SignInIdentifier.Email}/passcode-validation`,
+ search: location.search,
+ },
+ { state: { email } }
+ );
+ },
+ [asyncSendRegisterEmailPasscode, navigate]
+ );
+
+ return {
+ errorMessage,
+ clearErrorMessage,
+ onSubmit,
+ };
+};
+
+export default useEmailRegister;
diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
index dc25c1124..7fef08840 100644
--- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
+++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import Input from '@/components/Input';
+import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
@@ -14,7 +15,6 @@ import useTerms from '@/hooks/use-terms';
import type { UserFlow } from '@/types';
import { emailValidation } from '@/utils/field-validations';
-import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
type Props = {
diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx
index cf0dd9bfc..c8c1c55b9 100644
--- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx
+++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import { PhoneInput } from '@/components/Input';
+import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
@@ -14,7 +15,6 @@ import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import type { UserFlow } from '@/types';
-import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
type Props = {
diff --git a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx b/packages/ui/src/containers/PasswordlessSwitch/index.test.tsx
similarity index 94%
rename from packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx
rename to packages/ui/src/containers/PasswordlessSwitch/index.test.tsx
index 12e17bb20..3122fe8f5 100644
--- a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx
+++ b/packages/ui/src/containers/PasswordlessSwitch/index.test.tsx
@@ -2,7 +2,7 @@ import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import PasswordlessSwitch from './PasswordlessSwitch';
+import PasswordlessSwitch from '.';
describe('', () => {
test('render sms passwordless switch', () => {
diff --git a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx b/packages/ui/src/containers/PasswordlessSwitch/index.tsx
similarity index 100%
rename from packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx
rename to packages/ui/src/containers/PasswordlessSwitch/index.tsx
diff --git a/packages/ui/src/pages/Register/Main.tsx b/packages/ui/src/pages/Register/Main.tsx
index 2a983f107..b4de86328 100644
--- a/packages/ui/src/pages/Register/Main.tsx
+++ b/packages/ui/src/pages/Register/Main.tsx
@@ -1,6 +1,7 @@
import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
-import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
+import { EmailRegister } from '@/containers/EmailForm';
+import { PhonePasswordless } from '@/containers/Passwordless';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import { UserFlow } from '@/types';
@@ -15,7 +16,7 @@ type Props = {
const Main = ({ signUpMethod, socialConnectors }: Props) => {
switch (signUpMethod) {
case 'email':
- return ;
+ return ;
case 'sms':
return ;