-
{t('login:login-title')}
- {loginType === LoginType.Email ? (
-
- ) : (
- <>
-
}
- text={
-
,
- }}
- />
- }
- />
-
} text={t('login:feature-1')} />
-
} text={t('login:feature-2')} />
-
} text={t('login:feature-3')} />
-
} text={t('login:feature-4')} />
-
} text={t('login:feature-5')} />
-
{t('login:login-cta')}
- >
- )}
-
- {loginType === LoginType.Social && (
- <>
-
- {process.env.NEXT_PUBLIC_ENABLE_MAGIC_LINK_LOGIN === 'true' && (
-
- )}
- >
- )}
-
- ,
- link1: ,
- }}
- i18nKey="login:privacy-policy"
- />
-
+
);
diff --git a/src/components/Login/ResendEmailSection.tsx b/src/components/Login/ResendEmailSection.tsx
deleted file mode 100644
index 35cba0af6a..0000000000
--- a/src/components/Login/ResendEmailSection.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import useTranslation from 'next-translate/useTranslation';
-
-import Button, { ButtonVariant } from '../dls/Button/Button';
-
-import styles from './login.module.scss';
-
-type ResendEmailSectionProps = {
- onResendButtonClicked: () => void;
- initialRemainingTimeInSeconds?: number;
-};
-
-const ResendEmailSection = ({
- onResendButtonClicked,
- initialRemainingTimeInSeconds = 60,
-}: ResendEmailSectionProps) => {
- const [remainingTimeInSeconds, setRemainingTimeInSeconds] = useState(
- initialRemainingTimeInSeconds,
- );
- const { t } = useTranslation('common');
-
- const disabled = remainingTimeInSeconds > 0;
-
- useEffect(() => {
- const interval = setInterval(() => {
- setRemainingTimeInSeconds((prevRemainingTime) => {
- if (prevRemainingTime > 0) {
- return prevRemainingTime - 1;
- }
- return prevRemainingTime;
- });
- }, 1000);
-
- return () => clearInterval(interval);
- }, []);
-
- return (
-
-
{t('email-verification.check-spam')}
-
-
- );
-};
-
-export default ResendEmailSection;
diff --git a/src/components/Login/ResetPassword/ResetPasswordForm.tsx b/src/components/Login/ResetPassword/ResetPasswordForm.tsx
new file mode 100644
index 0000000000..efef601009
--- /dev/null
+++ b/src/components/Login/ResetPassword/ResetPasswordForm.tsx
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+
+import { useRouter } from 'next/router';
+import useTranslation from 'next-translate/useTranslation';
+
+import AuthHeader from '../AuthHeader';
+import styles from '../login.module.scss';
+import getFormErrors, { ErrorType } from '../SignUpForm/errors';
+import getPasswordFields from '../SignUpForm/PasswordFields';
+
+import Button, { ButtonShape, ButtonType, ButtonVariant } from '@/components/dls/Button/Button';
+import FormBuilder from '@/components/FormBuilder/FormBuilder';
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import { useToast, ToastStatus } from '@/dls/Toast/Toast';
+import ArrowLeft from '@/icons/west.svg';
+import QueryParam from '@/types/QueryParam';
+import { resetPassword } from '@/utils/auth/authRequests';
+import { logButtonClick, logFormSubmission } from '@/utils/eventLogger';
+import { getLoginNavigationUrl } from '@/utils/navigation';
+
+const ResetPasswordForm: React.FC = () => {
+ const { t } = useTranslation('login');
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const toast = useToast();
+ const token = router.query[QueryParam.TOKEN] as string;
+
+ const formFields: FormBuilderFormField[] = getPasswordFields(
+ t,
+ 'new-password-placeholder',
+ 'confirm-new-password-placeholder',
+ );
+
+ const handleSubmit = async (values: { password: string; confirmPassword: string }) => {
+ logFormSubmission('reset_password');
+ try {
+ if (values.password !== values.confirmPassword) {
+ return getFormErrors(t, ErrorType.MISMATCH);
+ }
+
+ setIsSubmitting(true);
+ const { data: response, errors } = await resetPassword(values.password, token);
+
+ if (!response.success) {
+ setIsSubmitting(false);
+ if (errors.token) {
+ toast(t('errors.expiredToken'), { status: ToastStatus.Error });
+ }
+ return getFormErrors(t, ErrorType.API, errors);
+ }
+
+ toast(t('reset-password-success'), { status: ToastStatus.Success });
+ router.push(getLoginNavigationUrl());
+ return undefined;
+ } catch (error) {
+ setIsSubmitting(false);
+ return getFormErrors(t, ErrorType.RESET_PASSWORD);
+ }
+ };
+
+ const handleBack = () => {
+ logButtonClick('reset_password_back');
+ router.push(getLoginNavigationUrl());
+ };
+
+ const renderAction = (props) => (
+
+ );
+
+ return (
+
+
+
+
{t('set-new-password')}
+
+
+
+ );
+};
+
+export default ResetPasswordForm;
diff --git a/src/components/Login/ServiceCard.tsx b/src/components/Login/ServiceCard.tsx
new file mode 100644
index 0000000000..dda0541ba6
--- /dev/null
+++ b/src/components/Login/ServiceCard.tsx
@@ -0,0 +1,109 @@
+import { FC } from 'react';
+
+import Link from 'next/link';
+import Trans from 'next-translate/Trans';
+import useTranslation from 'next-translate/useTranslation';
+
+import Feature from './Feature';
+import styles from './login.module.scss';
+import SocialButtons from './SocialButtons';
+
+import { SubmissionResult } from '@/components/FormBuilder/FormBuilder';
+import EmailLogin, { EmailLoginData } from '@/components/Login/EmailLogin';
+import QuranLogo from '@/icons/logo_main.svg';
+import QRColoredLogo from '@/icons/qr-colored.svg';
+import QRLogo from '@/icons/qr-logo.svg';
+import ArrowLeft from '@/icons/west.svg';
+
+interface Benefit {
+ id: string;
+ label: string;
+}
+
+interface Props {
+ benefits: {
+ quran: Benefit[];
+ reflect: Benefit[];
+ };
+ isEmailLogin?: boolean;
+ onEmailLoginSubmit?: (data: { email: string }) => SubmissionResult
;
+ onOtherOptionsClicked?: () => void;
+ onBackClick?: () => void;
+ redirect?: string;
+}
+
+const ServiceCard: FC = ({
+ benefits,
+ isEmailLogin,
+ onEmailLoginSubmit,
+ onOtherOptionsClicked,
+ onBackClick,
+ redirect,
+}) => {
+ const { t } = useTranslation('login');
+
+ const renderServiceSection = (isQuranReflect: boolean) => (
+
+
+ {isQuranReflect ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {(isQuranReflect ? benefits.reflect : benefits.quran).map(({ id, label }) => (
+
+ ))}
+
+
+ );
+
+ return (
+
+ {!isEmailLogin && (
+ <>
+
{t('welcome-title')}
+
+ {t('welcome-description-1')} {t('quran-text')}
+ {t('welcome-description-2')}
+
+ {t('welcome-description-3')}
+
+ >
+ )}
+ {isEmailLogin ? (
+
+ ) : (
+ <>
+
+ {renderServiceSection(false)}
+
+ {renderServiceSection(true)}
+
+
{t('login-cta')}
+
+
+ ,
+ link1: ,
+ }}
+ />
+
+ {onBackClick && (
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default ServiceCard;
diff --git a/src/components/Login/SignInForm.tsx b/src/components/Login/SignInForm.tsx
new file mode 100644
index 0000000000..5d3f55e08d
--- /dev/null
+++ b/src/components/Login/SignInForm.tsx
@@ -0,0 +1,104 @@
+import { FC, useState } from 'react';
+
+import { useRouter } from 'next/router';
+import useTranslation from 'next-translate/useTranslation';
+
+import styles from './login.module.scss';
+import SignInPasswordField from './SignInForm/SignInPasswordField';
+import getFormErrors, { ErrorType } from './SignUpForm/errors';
+import { getEmailField } from './SignUpFormFields/credentialFields';
+
+import FormBuilder from '@/components/FormBuilder/FormBuilder';
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import Button, { ButtonShape, ButtonType } from '@/dls/Button/Button';
+import Link, { LinkVariant } from '@/dls/Link/Link';
+import { RuleType } from '@/types/FieldRule';
+import { FormFieldType } from '@/types/FormField';
+import { signIn } from '@/utils/auth/authRequests';
+import { logFormSubmission } from '@/utils/eventLogger';
+import { getForgotPasswordNavigationUrl } from '@/utils/navigation';
+
+interface FormData {
+ email: string;
+ password: string;
+}
+
+interface Props {
+ redirect?: string;
+}
+
+const SignInForm: FC = ({ redirect }) => {
+ const { t } = useTranslation('login');
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const formFields: FormBuilderFormField[] = [
+ getEmailField(t),
+ {
+ field: 'password',
+ type: FormFieldType.Password,
+ placeholder: t('password-placeholder'),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('error.password-required'),
+ },
+ ],
+ customRender: SignInPasswordField,
+ },
+ ];
+
+ const handleSubmit = async (data: FormData) => {
+ setIsSubmitting(true);
+ logFormSubmission('sign_in');
+ try {
+ const { data: response, errors } = await signIn(data.email, data.password);
+
+ if (!response.success) {
+ setIsSubmitting(false);
+ return getFormErrors(t, ErrorType.API, errors);
+ }
+
+ router.push(redirect || '/');
+ return undefined;
+ } catch (error) {
+ setIsSubmitting(false);
+ return getFormErrors(t, ErrorType.SIGN_IN);
+ }
+ };
+
+ const renderAction = (props) => (
+
+ );
+
+ return (
+
+
+
+
+ {t('forgot-password')}?
+
+
+
+ );
+};
+
+export default SignInForm;
diff --git a/src/components/Login/SignInForm/SignInPasswordField.tsx b/src/components/Login/SignInForm/SignInPasswordField.tsx
new file mode 100644
index 0000000000..52c2c6e831
--- /dev/null
+++ b/src/components/Login/SignInForm/SignInPasswordField.tsx
@@ -0,0 +1,15 @@
+import { FC } from 'react';
+
+import PasswordInput from '../SignUpForm/PasswordInput';
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+const SignInPasswordField: FC = ({ value = '', onChange, placeholder }) => (
+
+);
+
+export default SignInPasswordField;
diff --git a/src/components/Login/SignUpForm.tsx b/src/components/Login/SignUpForm.tsx
new file mode 100644
index 0000000000..12d0087420
--- /dev/null
+++ b/src/components/Login/SignUpForm.tsx
@@ -0,0 +1,64 @@
+import useTranslation from 'next-translate/useTranslation';
+
+import styles from './login.module.scss';
+import getFormErrors, { ErrorType } from './SignUpForm/errors';
+
+import FormBuilder from '@/components/FormBuilder/FormBuilder';
+import getSignUpFormFields from '@/components/Login/SignUpFormFields';
+import Button, { ButtonShape, ButtonType } from '@/dls/Button/Button';
+import { signUp } from '@/utils/auth/authRequests';
+import { logFormSubmission } from '@/utils/eventLogger';
+import SignUpRequest from 'types/auth/SignUpRequest';
+
+interface Props {
+ onSuccess: (data: SignUpRequest) => void;
+}
+
+const SignUpForm = ({ onSuccess }: Props) => {
+ const { t } = useTranslation('login');
+
+ const handleSubmit = async (data: SignUpRequest) => {
+ logFormSubmission('sign_up');
+
+ if (data.password !== data.confirmPassword) {
+ return getFormErrors(t, ErrorType.MISMATCH);
+ }
+
+ try {
+ const { data: response, errors } = await signUp(data);
+
+ if (!response.success) {
+ return getFormErrors(t, ErrorType.API, errors);
+ }
+
+ onSuccess(data);
+ return undefined;
+ } catch (error) {
+ return getFormErrors(t, ErrorType.SIGNUP);
+ }
+ };
+
+ const renderAction = (props) => (
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default SignUpForm;
diff --git a/src/components/Login/SignUpForm/ConfirmPasswordField.tsx b/src/components/Login/SignUpForm/ConfirmPasswordField.tsx
new file mode 100644
index 0000000000..354cad4b4c
--- /dev/null
+++ b/src/components/Login/SignUpForm/ConfirmPasswordField.tsx
@@ -0,0 +1,15 @@
+import { FC } from 'react';
+
+import PasswordInput from './PasswordInput';
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+const ConfirmPasswordField: FC = ({ value, onChange, placeholder }) => (
+
+);
+
+export default ConfirmPasswordField;
diff --git a/src/components/Login/SignUpForm/PasswordField.tsx b/src/components/Login/SignUpForm/PasswordField.tsx
new file mode 100644
index 0000000000..8dd69c0226
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordField.tsx
@@ -0,0 +1,19 @@
+import { FC } from 'react';
+
+import PasswordInput from './PasswordInput';
+import PasswordValidation from './PasswordValidation';
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+const PasswordField: FC = ({ value = '', onChange, placeholder }) => (
+ <>
+
+
+ >
+);
+
+export default PasswordField;
diff --git a/src/components/Login/SignUpForm/PasswordFields.tsx b/src/components/Login/SignUpForm/PasswordFields.tsx
new file mode 100644
index 0000000000..0521d0f46f
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordFields.tsx
@@ -0,0 +1,71 @@
+import { FC } from 'react';
+
+import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../SignUpFormFields/consts';
+
+import ConfirmPasswordField from './ConfirmPasswordField';
+import PasswordField from './PasswordField';
+
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import { RuleType } from '@/types/FieldRule';
+import { FormFieldType } from '@/types/FormField';
+
+const getPasswordFields = (
+ t: any,
+ passwordPlaceholderKey = 'password-placeholder',
+ confirmPasswordPlaceholderKey = 'confirm-password-placeholder',
+): FormBuilderFormField[] => {
+ const PasswordInput: FC<{
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ }> = ({ value, onChange, placeholder }) => (
+
+ );
+
+ return [
+ {
+ field: 'password',
+ type: FormFieldType.Password,
+ placeholder: t(passwordPlaceholderKey),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.password') }),
+ },
+ {
+ type: RuleType.MinimumLength,
+ value: PASSWORD_MIN_LENGTH,
+ errorMessage: t('errors.min', {
+ fieldName: t('common:form.password'),
+ min: PASSWORD_MIN_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.MaximumLength,
+ value: PASSWORD_MAX_LENGTH,
+ errorMessage: t('errors.max', {
+ fieldName: t('common:form.password'),
+ max: PASSWORD_MAX_LENGTH,
+ }),
+ },
+ ],
+ customRender: PasswordInput,
+ },
+ {
+ field: 'confirmPassword',
+ type: FormFieldType.Password,
+ placeholder: t(confirmPasswordPlaceholderKey),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.confirm-password') }),
+ },
+ ],
+ customRender: ConfirmPasswordField,
+ },
+ ];
+};
+
+export default getPasswordFields;
diff --git a/src/components/Login/SignUpForm/PasswordInput.module.scss b/src/components/Login/SignUpForm/PasswordInput.module.scss
new file mode 100644
index 0000000000..941e5aa862
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordInput.module.scss
@@ -0,0 +1,38 @@
+.passwordInputContainer {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ border: 1px solid var(--color-borders-hairline);
+ border-radius: var(--border-radius-rounded);
+ padding-inline: var(--spacing-small);
+ height: calc(2 * var(--spacing-large));
+}
+
+.toggleButton {
+ position: relative;
+ background: none;
+ border: none;
+ padding: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-faded);
+ padding-inline: var(--spacing-xxsmall);
+
+ &:hover {
+ color: var(--color-text-default);
+ }
+
+ &:focus {
+ outline: none;
+ color: var(--color-text-default);
+ }
+}
+
+.icon {
+ width: var(--spacing-large);
+ height: var(--spacing-large);
+}
diff --git a/src/components/Login/SignUpForm/PasswordInput.tsx b/src/components/Login/SignUpForm/PasswordInput.tsx
new file mode 100644
index 0000000000..969b4e10de
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordInput.tsx
@@ -0,0 +1,37 @@
+import { FC, useState } from 'react';
+
+import styles from './PasswordInput.module.scss';
+
+import HideIcon from '@/icons/hide.svg';
+import ShowIcon from '@/icons/show.svg';
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+const PasswordInput: FC = ({ value = '', onChange, placeholder }) => {
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ />
+
+
+ );
+};
+
+export default PasswordInput;
diff --git a/src/components/Login/SignUpForm/PasswordValidation.module.scss b/src/components/Login/SignUpForm/PasswordValidation.module.scss
new file mode 100644
index 0000000000..26f3e60036
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordValidation.module.scss
@@ -0,0 +1,16 @@
+.passwordValidation {
+ margin-top: var(--spacing-medium2-px);
+}
+
+.validationRule {
+ display: flex;
+ align-items: center;
+ margin-block: var(--spacing-xxsmall);
+ color: var(--color-text-gray);
+ font-size: var(--font-size-medium-px);
+}
+
+.ruleIcon {
+ margin-inline-end: var(--spacing-xxsmall);
+ font-weight: bold;
+}
diff --git a/src/components/Login/SignUpForm/PasswordValidation.tsx b/src/components/Login/SignUpForm/PasswordValidation.tsx
new file mode 100644
index 0000000000..da7126431f
--- /dev/null
+++ b/src/components/Login/SignUpForm/PasswordValidation.tsx
@@ -0,0 +1,75 @@
+import { FC } from 'react';
+
+import classNames from 'classnames';
+import useTranslation from 'next-translate/useTranslation';
+
+import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../SignUpFormFields/consts';
+
+import styles from './PasswordValidation.module.scss';
+
+interface PasswordRule {
+ test: (value: string) => boolean;
+ messageKey: string;
+}
+
+interface Props {
+ value: string;
+}
+
+const PasswordValidation: FC = ({ value = '' }) => {
+ const { t } = useTranslation('login');
+
+ const rules: PasswordRule[] = [
+ {
+ test: (password) => password.length >= PASSWORD_MIN_LENGTH,
+ messageKey: 'password-rules.min-length',
+ },
+ {
+ test: (password) => password.length <= PASSWORD_MAX_LENGTH,
+ messageKey: 'password-rules.max-length',
+ },
+ {
+ test: (password) => /[A-Z]/.test(password),
+ messageKey: 'password-rules.uppercase',
+ },
+ {
+ test: (password) => /[a-z]/.test(password),
+ messageKey: 'password-rules.lowercase',
+ },
+ {
+ test: (password) => /\d/.test(password),
+ messageKey: 'password-rules.number',
+ },
+ {
+ test: (password) => /[!@#$%^&*_-]/.test(password),
+ messageKey: 'password-rules.special',
+ },
+ ];
+
+ // Only show validation rules when there's a value
+ if (!value) {
+ return null;
+ }
+
+ return (
+
+ {rules.map((rule) => {
+ const isValid = rule.test(value);
+ return (
+
+ {isValid ? '✓' : '×'}
+ {t(rule.messageKey)}
+
+ );
+ })}
+
+ );
+};
+
+export default PasswordValidation;
diff --git a/src/components/Login/SignUpForm/errors.ts b/src/components/Login/SignUpForm/errors.ts
new file mode 100644
index 0000000000..58a101aa3c
--- /dev/null
+++ b/src/components/Login/SignUpForm/errors.ts
@@ -0,0 +1,117 @@
+/* eslint-disable react-func/max-lines-per-function */
+import {
+ NAME_MAX_LENGTH,
+ NAME_MIN_LENGTH,
+ PASSWORD_MAX_LENGTH,
+ PASSWORD_MIN_LENGTH,
+ USERNAME_MAX_LENGTH,
+ USERNAME_MIN_LENGTH,
+} from '../SignUpFormFields/consts';
+
+import SignUpRequest from '@/types/auth/SignUpRequest';
+
+type AuthErrors = Record;
+
+export enum ErrorType {
+ MISMATCH = 'mismatch',
+ SIGNUP = 'signup',
+ API = 'api',
+ FORGOT_PASSWORD = 'forgot-password',
+ SIGN_IN = 'sign-in',
+ RESET_PASSWORD = 'reset-password',
+}
+
+const getFormErrors = (t: any, type: ErrorType, apiErrors?: any): { errors: AuthErrors } => {
+ const baseErrors = {};
+
+ switch (type) {
+ case ErrorType.MISMATCH:
+ return {
+ errors: {
+ ...baseErrors,
+ confirmPassword: t('errors.confirm'),
+ } as AuthErrors,
+ };
+ case ErrorType.API: {
+ const errors: Partial = { ...baseErrors };
+
+ if (apiErrors?.email) {
+ errors.email = t(apiErrors.email, {
+ fieldName: t('common:form.email'),
+ });
+ }
+
+ if (apiErrors?.username) {
+ errors.username = t(apiErrors.username, {
+ fieldName: t('common:form.username'),
+ min: USERNAME_MIN_LENGTH,
+ max: USERNAME_MAX_LENGTH,
+ });
+ }
+
+ if (apiErrors?.password) {
+ errors.password = t(apiErrors.password, {
+ fieldName: t('common:form.password'),
+ min: PASSWORD_MIN_LENGTH,
+ max: PASSWORD_MAX_LENGTH,
+ });
+ }
+
+ if (apiErrors?.confirmPassword) {
+ errors.confirmPassword = t(apiErrors.confirmPassword, {
+ fieldName: t('common:form.confirm-password'),
+ });
+ }
+
+ if (apiErrors?.firstName) {
+ errors.firstName = t(apiErrors.firstName, {
+ fieldName: t('common:form.firstName'),
+ min: NAME_MIN_LENGTH,
+ max: NAME_MAX_LENGTH,
+ });
+ }
+
+ if (apiErrors?.lastName) {
+ errors.lastName = t(apiErrors.lastName, {
+ fieldName: t('common:form.lastName'),
+ min: NAME_MIN_LENGTH,
+ max: NAME_MAX_LENGTH,
+ });
+ }
+
+ return { errors: errors as AuthErrors };
+ }
+ case ErrorType.SIGNUP:
+ return {
+ errors: {
+ ...baseErrors,
+ password: t('errors.signup-failed'),
+ } as AuthErrors,
+ };
+ case ErrorType.FORGOT_PASSWORD:
+ return {
+ errors: {
+ ...baseErrors,
+ email: t('errors.forgot-password-failed'),
+ } as AuthErrors,
+ };
+ case ErrorType.SIGN_IN:
+ return {
+ errors: {
+ ...baseErrors,
+ password: t('errors.signin-failed'),
+ } as AuthErrors,
+ };
+ case ErrorType.RESET_PASSWORD:
+ return {
+ errors: {
+ ...baseErrors,
+ password: t('errors.reset-password-failed'),
+ } as AuthErrors,
+ };
+ default:
+ return { errors: baseErrors as AuthErrors };
+ }
+};
+
+export default getFormErrors;
diff --git a/src/components/Login/SignUpFormFields/consts.ts b/src/components/Login/SignUpFormFields/consts.ts
new file mode 100644
index 0000000000..51cf08c56f
--- /dev/null
+++ b/src/components/Login/SignUpFormFields/consts.ts
@@ -0,0 +1,10 @@
+export const PASSWORD_MIN_LENGTH = 8;
+export const PASSWORD_MAX_LENGTH = 20;
+export const USERNAME_MIN_LENGTH = 3;
+export const USERNAME_MAX_LENGTH = 25;
+export const NAME_MIN_LENGTH = 3;
+export const NAME_MAX_LENGTH = 25;
+
+export const VERIFICATION_CODE_LENGTH = 6;
+export const RESEND_COOLDOWN_SECONDS = 60;
+export const CODE_EXPIRY_MINUTES = 5;
diff --git a/src/components/Login/SignUpFormFields/credentialFields.ts b/src/components/Login/SignUpFormFields/credentialFields.ts
new file mode 100644
index 0000000000..d5f6a31048
--- /dev/null
+++ b/src/components/Login/SignUpFormFields/credentialFields.ts
@@ -0,0 +1,63 @@
+/* eslint-disable react-func/max-lines-per-function */
+import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH } from './consts';
+
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import { RuleType } from '@/types/FieldRule';
+import { FormFieldType } from '@/types/FormField';
+
+const EMAIL_REGEX =
+ '^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\\.)+[a-zA-Z]{2,}))$';
+
+export const REGEX_USERNAME = '^[a-zA-Z0-9_]+$';
+
+export const getEmailField = (t: any): FormBuilderFormField => ({
+ field: 'email',
+ type: FormFieldType.Text,
+ placeholder: t('email-placeholder'),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.email') }),
+ },
+ {
+ type: RuleType.Regex,
+ value: EMAIL_REGEX,
+ errorMessage: t('errors.email'),
+ },
+ ],
+});
+
+export const getUsernameField = (t: any): FormBuilderFormField => ({
+ field: 'username',
+ type: FormFieldType.Text,
+ placeholder: t('username-placeholder'),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.username') }),
+ },
+ {
+ type: RuleType.MinimumLength,
+ value: USERNAME_MIN_LENGTH,
+ errorMessage: t('errors.min', {
+ fieldName: t('common:form.username'),
+ min: USERNAME_MIN_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.MaximumLength,
+ value: USERNAME_MAX_LENGTH,
+ errorMessage: t('errors.max', {
+ fieldName: t('common:form.username'),
+ max: USERNAME_MAX_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.Regex,
+ value: REGEX_USERNAME,
+ errorMessage: t('errors.username', { fieldName: t('common:form.username') }),
+ },
+ ],
+});
diff --git a/src/components/Login/SignUpFormFields/index.ts b/src/components/Login/SignUpFormFields/index.ts
new file mode 100644
index 0000000000..ea72024ead
--- /dev/null
+++ b/src/components/Login/SignUpFormFields/index.ts
@@ -0,0 +1,14 @@
+import { getEmailField, getUsernameField } from './credentialFields';
+import { getNameFields } from './nameFields';
+
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import getPasswordFields from '@/components/Login/SignUpForm/PasswordFields';
+
+const getSignUpFormFields = (t: any): FormBuilderFormField[] => [
+ ...getNameFields(t),
+ getEmailField(t),
+ getUsernameField(t),
+ ...getPasswordFields(t),
+];
+
+export default getSignUpFormFields;
diff --git a/src/components/Login/SignUpFormFields/nameFields.ts b/src/components/Login/SignUpFormFields/nameFields.ts
new file mode 100644
index 0000000000..cab473d46a
--- /dev/null
+++ b/src/components/Login/SignUpFormFields/nameFields.ts
@@ -0,0 +1,78 @@
+/* eslint-disable import/prefer-default-export */
+/* eslint-disable react-func/max-lines-per-function */
+import { NAME_MAX_LENGTH, NAME_MIN_LENGTH } from './consts';
+
+import { FormBuilderFormField } from '@/components/FormBuilder/FormBuilderTypes';
+import { RuleType } from '@/types/FieldRule';
+import { FormFieldType } from '@/types/FormField';
+
+export const REGEX_NAME = '^[^!"#$%&\'\\*\\+,\\-\\./:;<=>?@\\[\\\\\\]\\^_`{|}~§±×÷]+$';
+
+export const getNameFields = (t: any): FormBuilderFormField[] => [
+ {
+ field: 'firstName',
+ type: FormFieldType.Text,
+ placeholder: t('first-name-placeholder'),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.firstName') }),
+ },
+ {
+ type: RuleType.MinimumLength,
+ value: NAME_MIN_LENGTH,
+ errorMessage: t('errors.min', {
+ fieldName: t('common:form.firstName'),
+ min: NAME_MIN_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.MaximumLength,
+ value: NAME_MAX_LENGTH,
+ errorMessage: t('errors.max', {
+ fieldName: t('common:form.firstName'),
+ max: NAME_MAX_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.Regex,
+ value: REGEX_NAME,
+ errorMessage: t('errors.name', { fieldName: t('common:form.firstName') }),
+ },
+ ],
+ },
+ {
+ field: 'lastName',
+ type: FormFieldType.Text,
+ placeholder: t('last-name-placeholder'),
+ rules: [
+ {
+ type: RuleType.Required,
+ value: true,
+ errorMessage: t('errors.required', { fieldName: t('common:form.lastName') }),
+ },
+ {
+ type: RuleType.MinimumLength,
+ value: NAME_MIN_LENGTH,
+ errorMessage: t('errors.min', {
+ fieldName: t('common:form.lastName'),
+ min: NAME_MIN_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.MaximumLength,
+ value: NAME_MAX_LENGTH,
+ errorMessage: t('errors.max', {
+ fieldName: t('common:form.lastName'),
+ max: NAME_MAX_LENGTH,
+ }),
+ },
+ {
+ type: RuleType.Regex,
+ value: REGEX_NAME,
+ errorMessage: t('errors.name', { fieldName: t('common:form.lastName') }),
+ },
+ ],
+ },
+];
diff --git a/src/components/Login/SocialButtons.tsx b/src/components/Login/SocialButtons.tsx
new file mode 100644
index 0000000000..60724512de
--- /dev/null
+++ b/src/components/Login/SocialButtons.tsx
@@ -0,0 +1,71 @@
+import { FC } from 'react';
+
+import useTranslation from 'next-translate/useTranslation';
+
+import styles from './login.module.scss';
+
+import Button, { ButtonShape, ButtonVariant } from '@/dls/Button/Button';
+import AppleIcon from '@/icons/apple.svg';
+import FacebookIcon from '@/icons/facebook.svg';
+import GoogleIcon from '@/icons/google.svg';
+import { makeAppleLoginUrl, makeFacebookLoginUrl, makeGoogleLoginUrl } from '@/utils/auth/apiPaths';
+import { logButtonClick } from '@/utils/eventLogger';
+import AuthType from 'types/auth/AuthType';
+
+interface Props {
+ redirect?: string;
+ onEmailLoginClick: () => void;
+}
+
+const SocialButtons: FC = ({ redirect, onEmailLoginClick }) => {
+ const { t } = useTranslation('login');
+
+ const onSocialButtonClick = (type: AuthType) => {
+ logButtonClick(type);
+ };
+
+ return (
+
+ }
+ className={styles.loginButton}
+ onClick={() => onSocialButtonClick(AuthType.Google)}
+ shape={ButtonShape.Pill}
+ >
+ {t('continue-google')}
+
+ }
+ className={styles.loginButton}
+ onClick={() => onSocialButtonClick(AuthType.Facebook)}
+ shape={ButtonShape.Pill}
+ >
+ {t('continue-facebook')}
+
+ }
+ className={styles.loginButton}
+ onClick={() => onSocialButtonClick(AuthType.Apple)}
+ shape={ButtonShape.Pill}
+ >
+ {t('continue-apple')}
+
+
+
+ );
+};
+
+export default SocialButtons;
diff --git a/src/components/Login/VerificationCode/VerificationCode.module.scss b/src/components/Login/VerificationCode/VerificationCode.module.scss
new file mode 100644
index 0000000000..abf218eed0
--- /dev/null
+++ b/src/components/Login/VerificationCode/VerificationCode.module.scss
@@ -0,0 +1,115 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: 400px;
+ margin: 0 auto;
+ padding: var(--spacing-medium);
+}
+
+.title {
+ font-size: var(--font-size-xlarge);
+ font-weight: var(--font-weight-bold);
+ margin-bottom: var(--spacing-mega);
+ text-align: center;
+}
+
+.emailContainer {
+ text-align: center;
+ margin-bottom: var(--spacing-large);
+}
+
+.description {
+ font-size: var(--font-size-large);
+ color: var(--color-text-default);
+ margin-bottom: var(--spacing-xsmall);
+}
+
+.email {
+ font-size: var(--font-size-large);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+}
+
+.instruction {
+ font-size: var(--font-size-large);
+ color: var(--color-text-default);
+ text-align: center;
+ margin-bottom: var(--spacing-large);
+}
+
+.inputContainer {
+ margin-bottom: var(--spacing-medium);
+}
+
+.verificationContainer {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--spacing-xsmall);
+ margin-bottom: var(--spacing-medium);
+}
+
+.character {
+ width: 48px;
+ height: 48px;
+ border: 1px solid var(--color-borders-hairline);
+ border-radius: var(--border-radius-default);
+ font-size: var(--font-size-jumbo);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ background-color: var(--color-background-default);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: text;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-primary-medium);
+ box-shadow: 0 0 0 1px var(--color-primary-medium);
+ }
+}
+
+.characterInactive {
+ background-color: var(--color-background-elevated);
+}
+
+.characterSelected {
+ border-color: var(--color-primary-medium);
+ box-shadow: 0 0 0 1px var(--color-primary-medium);
+}
+
+.error {
+ color: var(--color-error-medium);
+ font-size: var(--font-size-small);
+ margin-top: var(--spacing-xxsmall);
+ text-align: center;
+}
+
+.spamNote {
+ font-size: var(--font-size-medium);
+ color: var(--color-text-default);
+ text-align: center;
+ margin-bottom: var(--spacing-large);
+}
+
+.actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-small);
+ width: 100%;
+}
+
+.resendButton {
+ color: var(--color-text-faded);
+ font-size: var(--font-size-medium);
+
+ &:disabled {
+ opacity: 1;
+ cursor: not-allowed;
+ }
+}
+
+.backButton {
+ margin-top: var(--spacing-xsmall);
+}
diff --git a/src/components/Login/VerificationCode/VerificationCodeBase.tsx b/src/components/Login/VerificationCode/VerificationCodeBase.tsx
new file mode 100644
index 0000000000..95a43d0d89
--- /dev/null
+++ b/src/components/Login/VerificationCode/VerificationCodeBase.tsx
@@ -0,0 +1,138 @@
+import { FC, useEffect, useState } from 'react';
+
+import useTranslation from 'next-translate/useTranslation';
+import VerificationInput from 'react-verification-input';
+
+import styles from './VerificationCode.module.scss';
+
+import {
+ VERIFICATION_CODE_LENGTH,
+ RESEND_COOLDOWN_SECONDS,
+} from '@/components/Login/SignUpFormFields/consts';
+import Button, { ButtonType, ButtonVariant } from '@/dls/Button/Button';
+import { useToast } from '@/dls/Toast/Toast';
+import ArrowLeft from '@/icons/west.svg';
+import { logButtonClick, logFormSubmission } from '@/utils/eventLogger';
+
+interface Props {
+ email: string;
+ onBack: () => void;
+ onResendCode: () => Promise;
+ onSubmitCode: (code: string) => Promise;
+ titleTranslationKey?: string;
+ descriptionTranslationKey?: string;
+ errorTranslationKey?: string;
+ formSubmissionKey?: string;
+ resendClickKey?: string;
+}
+
+const VerificationCodeBase: FC = ({
+ email,
+ onBack,
+ onResendCode,
+ onSubmitCode,
+ titleTranslationKey = 'check-email-title',
+ descriptionTranslationKey = 'verification-code-sent-to',
+ errorTranslationKey = 'errors.verification-code-invalid',
+ formSubmissionKey = 'verification_code_submit',
+ resendClickKey = 'verification_code_resend',
+}) => {
+ const { t } = useTranslation('login');
+ const showToast = useToast();
+ const [resendTimer, setResendTimer] = useState(0);
+ const [verificationCode, setVerificationCode] = useState('');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ let interval;
+ if (resendTimer > 0) {
+ interval = setInterval(() => {
+ setResendTimer((prev) => prev - 1);
+ }, 1000);
+ }
+ return () => clearInterval(interval);
+ }, [resendTimer]);
+
+ const handleSubmit = async (code: string) => {
+ logFormSubmission(formSubmissionKey);
+ setError('');
+
+ try {
+ await onSubmitCode(code);
+ } catch (err) {
+ setError(t(errorTranslationKey));
+ }
+ };
+
+ const handleChange = (code: string) => {
+ setVerificationCode(code);
+ if (code.length === VERIFICATION_CODE_LENGTH) {
+ handleSubmit(code);
+ }
+ };
+
+ const handleResendCode = async () => {
+ logButtonClick(resendClickKey);
+ if (resendTimer > 0) return;
+
+ try {
+ await onResendCode();
+ setResendTimer(RESEND_COOLDOWN_SECONDS);
+ setVerificationCode(''); // Clear the input on resend
+ showToast(t('verification-code-sent'));
+ } catch (err) {
+ showToast(t('errors.verification-resend-failed'));
+ }
+ };
+
+ return (
+
+
{t(titleTranslationKey)}
+
+
{t(descriptionTranslationKey)}
+
{email}
+
+
{t('verification-code-instruction')}
+
+
+
+
{t('verification-code-spam-note')}
+
+
+
+
+
+
+
+ );
+};
+
+export default VerificationCodeBase;
diff --git a/src/components/Login/VerificationCode/VerificationCodeForm.tsx b/src/components/Login/VerificationCode/VerificationCodeForm.tsx
new file mode 100644
index 0000000000..8726c3e043
--- /dev/null
+++ b/src/components/Login/VerificationCode/VerificationCodeForm.tsx
@@ -0,0 +1,53 @@
+/* eslint-disable react-func/max-lines-per-function */
+import { FC } from 'react';
+
+import { useRouter } from 'next/router';
+
+import VerificationCodeBase from './VerificationCodeBase';
+
+import AuthHeader from '@/components/Login/AuthHeader';
+import styles from '@/components/Login/login.module.scss';
+import SignUpRequest from '@/types/auth/SignUpRequest';
+import { signUp } from '@/utils/auth/authRequests';
+import { logFormSubmission } from '@/utils/eventLogger';
+
+interface Props {
+ email: string;
+ onBack: () => void;
+ onResendCode: () => Promise;
+ signUpData: SignUpRequest;
+}
+
+const VerificationCodeForm: FC = ({ email, onBack, onResendCode, signUpData }) => {
+ const router = useRouter();
+
+ const handleSubmitCode = async (code: string) => {
+ logFormSubmission('verification_code_submit');
+ const { data: response } = await signUp({
+ ...signUpData,
+ verificationCode: code,
+ });
+
+ if (!response.success) {
+ throw new Error('Invalid verification code');
+ }
+
+ // If successful, redirect back or to home
+ const redirectPath = (router.query.redirect as string) || '/';
+ router.push(redirectPath);
+ };
+
+ return (
+
+ );
+};
+
+export default VerificationCodeForm;
diff --git a/src/components/Login/login.module.scss b/src/components/Login/login.module.scss
index 6588ed5bad..7be2e6910c 100644
--- a/src/components/Login/login.module.scss
+++ b/src/components/Login/login.module.scss
@@ -1,43 +1,129 @@
@use "src/styles/theme";
@use "src/styles/breakpoints";
+$container-width: 370px;
+
.outerContainer {
min-height: 80vh;
display: flex;
justify-content: center;
align-items: center;
- padding-block-end: calc(5 * var(--spacing-mega));
- padding-block-start: var(--spacing-mega);
- padding-inline: var(--spacing-large);
+ padding: var(--spacing-mega);
flex-direction: column;
+ background-color: var(--color-background-default);
+ max-width: 400px;
+ margin-inline: auto;
}
.innerContainer {
+ width: 100%;
display: flex;
flex-direction: column;
- @include breakpoints.desktop {
- width: calc(10 * var(--spacing-mega));
- }
+ align-items: center;
+ padding: var(--spacing-large);
+ background-color: var(--color-background-elevated);
+ border-radius: var(--border-radius-large);
+ box-shadow: var(--shadow-small);
}
-.cta {
- font-weight: var(--font-weight-bold);
- font-size: var(--font-size-large);
- padding-block: var(--spacing-large);
+.title {
+ font-size: var(--font-size-xlarge);
+ color: var(--color-text-default);
+ margin-bottom: var(--spacing-large);
text-align: center;
}
-.title {
- font-size: var(--font-size-jumbo);
+.subtitle {
+ text-align: center;
+ margin-block-end: var(--spacing-xlarge);
+ color: var(--color-text-default);
+ max-width: 400px;
+}
+
+.inlineText {
+ display: inline;
+}
+
+.boldText {
font-weight: var(--font-weight-bold);
- margin-block-end: var(--spacing-large);
+}
+
+.serviceCard {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ inline-size: 100%;
+ padding: var(--spacing-large);
+ background: var(--color-background-default);
+}
+
+.serviceLogo {
text-align: center;
+
+ svg {
+ height: 30px;
+ width: auto;
+ }
}
-.loginButton {
- margin-block-start: var(--spacing-small);
- font-weight: var(--font-weight-semibold);
+.reflectLogo {
+ height: 40px;
+ width: auto;
+}
+
+.reflectLogos {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-medium);
+}
+
+.benefits {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--spacing-medium);
+ inline-size: 100%;
+ margin-block-start: var(--spacing-large);
+}
+
+.benefit {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-small);
+ font-size: var(--font-size-medium-px);
+ color: var(--color-text-default);
+}
+
+.benefitText {
+ margin: 0;
+}
+
+.authButtons {
display: flex;
+ flex-direction: column;
+ gap: var(--spacing-medium);
+ width: 100%;
+}
+
+.loginButton {
+ width: 100%;
+ justify-content: center;
+ padding: var(--spacing-medium);
+ font-size: var(--font-size-normal);
+ font-weight: var(--font-weight-bold);
+ border-radius: var(--border-radius-rounded);
+ border: 1px solid var(--color-text-default);
+ background-color: var(--color-background-default);
+ color: var(--color-text-default);
+ transition: all 0.2s ease-in-out;
+
+ &:hover {
+ background-color: var(--color-background-elevated);
+ }
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
}
.googleButton {
@@ -72,9 +158,11 @@
.emailSentContainer {
text-align: center;
}
+
.paragraphContainer {
margin-block-end: var(--spacing-medium);
}
+
.emailContainer {
font-weight: var(--font-weight-bold);
}
@@ -100,11 +188,249 @@
}
.privacyText {
- margin-block-start: var(--spacing-medium);
- font-size: var(--font-size-xsmall);
+ text-align: center;
+ font-size: var(--font-size-small);
color: var(--color-text-faded);
+ margin-block-start: var(--spacing-xlarge-px);
}
.bold {
font-weight: var(--font-weight-bold);
}
+
+.authContainer {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.authLogos {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-large);
+ padding: var(--spacing-large);
+
+ svg {
+ height: 32px;
+ width: auto;
+ @include breakpoints.smallerThanTablet {
+ height: 20px;
+ }
+ }
+}
+
+.qrLogos {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-small-px);
+ svg {
+ height: 29px;
+ width: auto;
+ @include breakpoints.smallerThanTablet {
+ height: 20px;
+ }
+ }
+}
+
+.authTabs {
+ width: 100%;
+ margin-top: var(--spacing-large);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-large);
+}
+
+.authSwitchContainer {
+ width: 95%;
+ margin-inline: auto;
+}
+
+.authTitle {
+ font-size: var(--font-size-xlarge);
+ font-weight: var(--font-weight-bold);
+ margin: 0;
+ text-align: center;
+ color: var(--color-text-default);
+ margin-block: var(--spacing-large);
+}
+
+.description {
+ text-align: center;
+ color: var(--color-text-default);
+ margin-block: var(--spacing-large);
+ font-size: var(--font-size-large);
+}
+
+.formContainer {
+ width: 100%;
+ margin-inline: auto;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ gap: var(--spacing-large);
+ margin-top: var(--spacing-large);
+
+ form {
+ width: 95%;
+ }
+
+ input {
+ border: none;
+ width: 100%;
+ background: transparent;
+ color: var(--color-text-default);
+
+ &::placeholder {
+ color: var(--color-text-faded);
+ }
+
+ &:focus {
+ outline: none;
+ }
+ }
+}
+
+.submitButton {
+ width: 100%;
+ padding: var(--spacing-medium);
+ font-size: var(--font-size-normal);
+ font-weight: var(--font-weight-bold);
+ border-radius: var(--border-radius-pill);
+ background-color: var(--color-success-medium);
+ color: var(--color-text-inverse);
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ margin-top: var(--spacing-medium);
+
+ &:hover {
+ background-color: var(--color-success-dark);
+ }
+}
+
+.formActions {
+ width: 95%;
+}
+
+.forgotPassword {
+ color: var(--color-success-medium);
+ text-decoration: none;
+ font-size: var(--font-size-medium);
+ text-align: start;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.backButton {
+ align-self: flex-start;
+ margin-bottom: var(--spacing-medium);
+ color: var(--color-text-default);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xsmall);
+ font-size: var(--font-size-normal);
+ background: none;
+ border: none;
+ padding: 0;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.logos {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-large);
+ margin-block-end: var(--spacing-xlarge);
+ border-block-end: 1px solid var(--color-borders-hairline);
+ padding-block-end: var(--spacing-large);
+ width: 100%;
+ justify-content: center;
+
+ svg {
+ height: 32px;
+ width: auto;
+ }
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-mega);
+ gap: var(--spacing-medium);
+ width: 400px;
+ margin: 0 auto;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-medium);
+ width: 100%;
+}
+
+.formField {
+ width: 100%;
+ padding: var(--spacing-medium);
+ font-size: var(--font-size-normal);
+ border: 1px solid var(--color-borders-hairline);
+ border-radius: var(--border-radius-default);
+ background-color: var(--color-background-elevated);
+ color: var(--color-text-default);
+
+ &::placeholder {
+ color: var(--color-text-faded);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-primary-medium);
+ }
+}
+
+.comingSoon {
+ text-align: center;
+ color: var(--color-text-default);
+ font-size: var(--font-size-medium);
+ margin-top: var(--spacing-large);
+}
+
+.servicesContainer {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-block-start: var(--spacing-large);
+ border: 1px solid var(--color-text-default);
+ border-radius: var(--border-radius-rounded);
+ padding: 0;
+}
+
+.serviceSection {
+ padding: var(--spacing-large);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.serviceDivider {
+ width: 95%;
+ border: none;
+ border-top: 1px solid var(--color-borders-hairline);
+ margin: auto;
+}
+
+.loginCta {
+ padding-block: var(--spacing-large);
+}
diff --git a/src/components/dls/Forms/Input/Input.module.scss b/src/components/dls/Forms/Input/Input.module.scss
index c0cf5e8028..9d78d8528e 100644
--- a/src/components/dls/Forms/Input/Input.module.scss
+++ b/src/components/dls/Forms/Input/Input.module.scss
@@ -89,6 +89,16 @@
&.warning {
color: var(--color-warning-medium);
}
+
+ &:-webkit-autofill,
+ &:-webkit-autofill:hover,
+ &:-webkit-autofill:focus,
+ &:-webkit-autofill:active {
+ -webkit-box-shadow: 0 0 0 30px transparent inset !important;
+ -webkit-text-fill-color: var(--color-text-default) !important;
+ transition: background-color 5000s ease-in-out 0s;
+ background-color: transparent !important;
+ }
}
@mixin prefixSuffixContainer {
diff --git a/src/components/dls/Tabs/Tabs.tsx b/src/components/dls/Tabs/Tabs.tsx
index 13a33c2070..8312bf77e0 100644
--- a/src/components/dls/Tabs/Tabs.tsx
+++ b/src/components/dls/Tabs/Tabs.tsx
@@ -2,7 +2,7 @@ import classNames from 'classnames';
import styles from './Tabs.module.scss';
-type Tab = {
+export type Tab = {
title: string;
value: string;
id?: string;
diff --git a/src/pages/forgot-password.tsx b/src/pages/forgot-password.tsx
new file mode 100644
index 0000000000..1b900d2324
--- /dev/null
+++ b/src/pages/forgot-password.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+import { GetStaticProps, NextPage } from 'next';
+import { useRouter } from 'next/router';
+import useTranslation from 'next-translate/useTranslation';
+
+import ForgotPasswordForm from '@/components/Login/ForgotPassword/ForgotPasswordForm';
+import NextSeoWrapper from '@/components/NextSeoWrapper';
+import PageContainer from '@/components/PageContainer';
+import { getAllChaptersData } from '@/utils/chapter';
+import { getLanguageAlternates } from '@/utils/locale';
+import { getCanonicalUrl, getForgotPasswordNavigationUrl } from '@/utils/navigation';
+
+const ForgotPasswordPage: NextPage = () => {
+ const { t } = useTranslation('login');
+ const router = useRouter();
+ const lang = router.locale;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+ const allChaptersData = await getAllChaptersData(locale);
+
+ return {
+ props: {
+ chaptersData: allChaptersData,
+ },
+ };
+};
+
+export default ForgotPasswordPage;
diff --git a/src/pages/reset-password.tsx b/src/pages/reset-password.tsx
new file mode 100644
index 0000000000..20f178fa2d
--- /dev/null
+++ b/src/pages/reset-password.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+import { GetStaticProps, NextPage } from 'next';
+import { useRouter } from 'next/router';
+import useTranslation from 'next-translate/useTranslation';
+
+import ResetPasswordForm from '@/components/Login/ResetPassword/ResetPasswordForm';
+import NextSeoWrapper from '@/components/NextSeoWrapper';
+import PageContainer from '@/components/PageContainer';
+import { getAllChaptersData } from '@/utils/chapter';
+import { getLanguageAlternates } from '@/utils/locale';
+import { getCanonicalUrl, getResetPasswordNavigationUrl } from '@/utils/navigation';
+
+const ResetPasswordPage: NextPage = () => {
+ const { t } = useTranslation('login');
+ const router = useRouter();
+ const lang = router.locale;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+ const allChaptersData = await getAllChaptersData(locale);
+
+ return {
+ props: {
+ chaptersData: allChaptersData,
+ },
+ };
+};
+
+export default ResetPasswordPage;
diff --git a/src/styles/auth/auth.module.scss b/src/styles/auth/auth.module.scss
new file mode 100644
index 0000000000..730a184d38
--- /dev/null
+++ b/src/styles/auth/auth.module.scss
@@ -0,0 +1,84 @@
+@use "src/styles/theme";
+@use "src/styles/breakpoints";
+
+.outerContainer {
+ min-height: 80vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: var(--spacing-mega);
+ flex-direction: column;
+ max-width: 400px;
+ margin-inline: auto;
+}
+
+.innerContainer {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.title {
+ font-size: var(--font-size-xlarge);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ margin-bottom: var(--spacing-large);
+ text-align: center;
+}
+
+.description {
+ font-size: var(--font-size-large);
+ color: var(--color-text-default);
+ text-align: center;
+ margin: 0;
+ margin-bottom: var(--spacing-xlarge);
+ line-height: 1.5;
+}
+
+.formContainer {
+ width: 90%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-large);
+ margin-top: var(--spacing-large);
+
+ input {
+ border: none;
+ width: 90%;
+ background: transparent;
+ color: var(--color-text-default);
+
+ &::placeholder {
+ color: var(--color-text-faded);
+ }
+
+ &:focus {
+ outline: none;
+ }
+ }
+}
+
+.submitButton {
+ width: 90%;
+ padding: var(--spacing-medium);
+ font-size: var(--font-size-normal);
+ font-weight: var(--font-weight-bold);
+ border-radius: var(--border-radius-pill);
+ background-color: var(--color-success-medium);
+ color: var(--color-text-inverse);
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ margin-top: var(--spacing-medium);
+
+ &:hover {
+ background-color: var(--color-success-dark);
+ }
+}
+
+.backButton {
+ width: 50%;
+ margin-block: var(--spacing-medium);
+ border-radius: var(--border-radius-pill);
+}
diff --git a/src/types/auth/SignUpRequest.ts b/src/types/auth/SignUpRequest.ts
new file mode 100644
index 0000000000..33b890adb2
--- /dev/null
+++ b/src/types/auth/SignUpRequest.ts
@@ -0,0 +1,11 @@
+interface SignUpRequest {
+ firstName: string;
+ lastName: string;
+ username: string;
+ password: string;
+ confirmPassword: string;
+ email: string;
+ verificationCode?: string;
+}
+
+export default SignUpRequest;
diff --git a/src/utils/auth/api.ts b/src/utils/auth/api.ts
index 2dd7974e4b..b3157165ca 100644
--- a/src/utils/auth/api.ts
+++ b/src/utils/auth/api.ts
@@ -7,6 +7,7 @@ import { getTimezone } from '../datetime';
import { prepareGenerateMediaFileRequestData } from '../media/utils';
import { BANNED_USER_ERROR_ID } from './constants';
+import { AuthErrorCodes } from './errors';
import generateSignature from './signature';
import BookmarkByCollectionIdQueryParams from './types/BookmarkByCollectionIdQueryParams';
import GetAllNotesQueryParams from './types/Note/GetAllNotesQueryParams';
@@ -101,6 +102,18 @@ type RequestData = Record;
const IGNORE_ERRORS = [
MediaRenderError.MediaVersesRangeLimitExceeded,
MediaRenderError.MediaFilesPerUserLimitExceeded,
+ AuthErrorCodes.InvalidCredentials,
+ AuthErrorCodes.NotFound,
+ AuthErrorCodes.BadRequest,
+ AuthErrorCodes.Invalid,
+ AuthErrorCodes.Mismatch,
+ AuthErrorCodes.Missing,
+ AuthErrorCodes.Duplicate,
+ AuthErrorCodes.Banned,
+ AuthErrorCodes.Expired,
+ AuthErrorCodes.Used,
+ AuthErrorCodes.Immutable,
+ AuthErrorCodes.ValidationError,
];
const handleErrors = async (res) => {
diff --git a/src/utils/auth/apiPaths.ts b/src/utils/auth/apiPaths.ts
index 3312b1364e..a63e2b9e9a 100644
--- a/src/utils/auth/apiPaths.ts
+++ b/src/utils/auth/apiPaths.ts
@@ -37,6 +37,10 @@ export const makeSyncLocalDataUrl = (): string => makeUrl('users/syncLocalData')
export const makeVerificationCodeUrl = (): string => makeUrl('users/verificationCode');
+export const makeForgotPasswordUrl = (): string => makeUrl('users/forgetPassword');
+
+export const makeResetPasswordUrl = (): string => makeUrl('users/resetPassword');
+
export const makeSendMagicLinkUrl = (redirect?: string): string =>
makeUrl('auth/magiclogin', redirect ? { redirect } : undefined);
@@ -49,6 +53,10 @@ export const makeFacebookLoginUrl = (redirect?: string): string =>
export const makeAppleLoginUrl = (redirect?: string): string =>
makeUrl('auth/apple', redirect ? { redirect } : undefined);
+export const makeSignInUrl = (): string => makeUrl('users/login');
+
+export const makeSignUpUrl = (): string => makeUrl('users/signup');
+
export const makeBookmarksUrl = (mushafId: number, limit?: number): string =>
makeUrl('bookmarks', { mushafId, limit });
diff --git a/src/utils/auth/authRequests.ts b/src/utils/auth/authRequests.ts
new file mode 100644
index 0000000000..97d094dd17
--- /dev/null
+++ b/src/utils/auth/authRequests.ts
@@ -0,0 +1,101 @@
+import { privateFetcher } from './api';
+import {
+ makeForgotPasswordUrl,
+ makeResetPasswordUrl,
+ makeSignInUrl,
+ makeSignUpUrl,
+} from './apiPaths';
+import mapAPIErrorToFormFields, { AuthEndpoint } from './errors';
+
+import SignUpRequest from '@/types/auth/SignUpRequest';
+import BaseAuthResponse from '@/types/BaseAuthResponse';
+
+const CONTENT_TYPE = 'Content-Type';
+
+interface APIResponse {
+ data: T;
+ errors: Record;
+}
+
+interface AuthFieldMap {
+ [key: string]: string;
+}
+
+/**
+ * Generic function to handle auth requests
+ * @returns {Promise>} Promise containing the API response and mapped error fields
+ */
+const handleAuthRequest = async (
+ url: string,
+ data: T,
+ endpoint: AuthEndpoint,
+ fieldMap: AuthFieldMap,
+): Promise> => {
+ const response = await privateFetcher(url, {
+ method: 'POST',
+ headers: {
+ [CONTENT_TYPE]: 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ return {
+ data: response,
+ errors: await mapAPIErrorToFormFields(response, {
+ endpoint,
+ fieldMap,
+ }),
+ };
+};
+
+/**
+ * Sign in request handler
+ * @returns {Promise>} Promise containing the authentication response and any validation errors
+ */
+export const signIn = async (
+ email: string,
+ password: string,
+): Promise> => {
+ return handleAuthRequest(makeSignInUrl(), { email, password }, AuthEndpoint.SignIn, {
+ credentials: 'password',
+ email: 'email',
+ });
+};
+
+/**
+ * Sign up request handler
+ * @returns {Promise>} Promise containing the authentication response and any validation errors
+ */
+export const signUp = async (data: SignUpRequest): Promise> => {
+ return handleAuthRequest(makeSignUpUrl(), data, AuthEndpoint.SignUp, {
+ email: 'email',
+ password: 'password',
+ confirmPassword: 'confirmPassword',
+ username: 'username',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ });
+};
+
+export const requestPasswordReset = async (
+ email: string,
+): Promise> => {
+ return handleAuthRequest(makeForgotPasswordUrl(), { email }, AuthEndpoint.ForgotPassword, {
+ email: 'email',
+ });
+};
+
+export const resetPassword = async (
+ password: string,
+ token: string,
+): Promise> => {
+ return handleAuthRequest(
+ makeResetPasswordUrl(),
+ { password, token },
+ AuthEndpoint.ResetPassword,
+ {
+ password: 'password',
+ token: 'token',
+ },
+ );
+};
diff --git a/src/utils/auth/errors.ts b/src/utils/auth/errors.ts
new file mode 100644
index 0000000000..d55c6b8ff1
--- /dev/null
+++ b/src/utils/auth/errors.ts
@@ -0,0 +1,182 @@
+/* eslint-disable max-lines */
+import { ServerErrorCodes, BASE_SERVER_ERRORS_MAP } from '@/types/auth/error';
+
+export enum AuthErrorCodes {
+ Invalid = 'INVALID',
+ InvalidCredentials = 'INVALID_CREDENTIALS',
+ MinLength = 'MIN_LENGTH',
+ MaxLength = 'MAX_LENGTH',
+ ExactLength = 'EXACT_LENGTH',
+ Mismatch = 'MISMATCH',
+ Missing = 'MISSING',
+ Duplicate = 'DUPLICATE',
+ Banned = 'BANNED',
+ Expired = 'EXPIRED',
+ Used = 'USED',
+ Immutable = 'IMMUTABLE',
+ BadRequest = 'BAD_REQUEST',
+ NotFound = 'NOT_FOUND',
+ ValidationError = 'ValidationError',
+}
+
+interface APIErrorResponse {
+ details?: {
+ success: boolean;
+ error?: {
+ code: string;
+ message: string;
+ details: Record;
+ };
+ };
+ message?: string;
+ type?: string;
+ success: boolean;
+ error?: {
+ code: string;
+ message: string;
+ details: Record;
+ };
+ status?: number;
+}
+
+type ErrorFieldMap = Record;
+
+export enum AuthEndpoint {
+ SignIn = 'signIn',
+ SignUp = 'signUp',
+ ForgotPassword = 'forgotPassword',
+ ResetPassword = 'resetPassword',
+}
+
+// Map of API endpoint to their error field keys
+const API_ERROR_FIELD_MAP: Record = {
+ [AuthEndpoint.SignIn]: {
+ credentials: 'password',
+ email: 'email',
+ },
+ [AuthEndpoint.SignUp]: {
+ email: 'email',
+ password: 'password',
+ confirmPassword: 'confirmPassword',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ },
+ [AuthEndpoint.ForgotPassword]: {
+ email: 'email',
+ },
+ [AuthEndpoint.ResetPassword]: {
+ password: 'password',
+ token: 'token',
+ },
+};
+
+interface ErrorMappingConfig {
+ endpoint: keyof typeof API_ERROR_FIELD_MAP;
+ fieldMap?: ErrorFieldMap;
+ defaultErrorKey?: string;
+ errorMessageMap?: Record; // Map HTTP status codes to error messages
+}
+
+const handleResponseError = async (response: Response): Promise => {
+ const errorData = await response.json();
+ errorData.status = response.status;
+ return errorData;
+};
+
+const handleStatusBasedError = (
+ errorData: APIErrorResponse,
+ config: ErrorMappingConfig,
+): { [key: string]: string } | null => {
+ if (errorData.status && config.errorMessageMap?.[errorData.status]) {
+ return {
+ form: config.errorMessageMap[errorData.status],
+ };
+ }
+ return null;
+};
+
+const mapErrorDetailsToFields = (
+ error: APIErrorResponse['error'],
+ endpointErrorMap: ErrorFieldMap,
+ config: ErrorMappingConfig,
+): { [key: string]: string } => {
+ const result: Record = {};
+
+ Object.entries(error.details || {}).forEach(([errorField, errorCode]) => {
+ const field = config.fieldMap?.[errorField] || endpointErrorMap[errorField];
+ if (field) {
+ result[field] =
+ BASE_SERVER_ERRORS_MAP[errorCode as ServerErrorCodes] ||
+ BASE_SERVER_ERRORS_MAP[error.code as ServerErrorCodes] ||
+ config.defaultErrorKey ||
+ 'errors.badRequest';
+ }
+ });
+
+ return result;
+};
+
+const handleErrorResponse = (
+ errorData: APIErrorResponse,
+ error: APIErrorResponse['error'],
+ config: ErrorMappingConfig,
+): { [key: string]: string } => {
+ const endpointErrorMap = API_ERROR_FIELD_MAP[config.endpoint];
+ if (!endpointErrorMap) {
+ return {
+ form:
+ config.defaultErrorKey ||
+ BASE_SERVER_ERRORS_MAP[error.code as ServerErrorCodes] ||
+ 'errors.badRequest',
+ };
+ }
+
+ const result = mapErrorDetailsToFields(error, endpointErrorMap, config);
+ return Object.keys(result).length === 0
+ ? {
+ form:
+ config.defaultErrorKey ||
+ BASE_SERVER_ERRORS_MAP[error.code as ServerErrorCodes] ||
+ 'errors.badRequest',
+ }
+ : result;
+};
+
+/**
+ * Maps API error responses to form field errors
+ * @param {APIErrorResponse | Response} response - The API error response or fetch Response
+ * @param {ErrorMappingConfig} config - Configuration for error mapping
+ * @returns {{ [key: string]: string }} An object with field keys and their corresponding error translation keys
+ */
+const mapAPIErrorToFormFields = async (
+ response: APIErrorResponse | Response,
+ config: ErrorMappingConfig,
+): Promise<{ [key: string]: string }> => {
+ let errorData: APIErrorResponse;
+
+ if (response instanceof Response) {
+ try {
+ errorData = await handleResponseError(response);
+ } catch (e) {
+ return {
+ form: config.errorMessageMap?.[response.status] || 'errors.badRequest',
+ };
+ }
+ } else {
+ errorData = response;
+ }
+
+ const statusError = handleStatusBasedError(errorData, config);
+ if (statusError) return statusError;
+
+ const error = errorData.details?.error || errorData.error;
+ if (!error) {
+ return {
+ form: config.defaultErrorKey || 'errors.badRequest',
+ };
+ }
+
+ return handleErrorResponse(errorData, error, config);
+};
+
+export default mapAPIErrorToFormFields;
diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts
index 7e0f9e3d35..2386e731fe 100644
--- a/src/utils/navigation.ts
+++ b/src/utils/navigation.ts
@@ -369,6 +369,13 @@ export const getFirstTimeReadingGuideNavigationUrl = () => '/first-time-reading-
export const getNotesNavigationUrl = () => '/notes-and-reflections';
+export const getForgotPasswordNavigationUrl = () => `/forgot-password`;
+
+export const getResetPasswordNavigationUrl = () => `/reset-password`;
+
+export const getVerifyEmailNavigationUrl = (email?: string) =>
+ `/verify-email${email ? `?${QueryParam.EMAIL}=${email}` : ''}`;
+
export const getNotificationSettingsNavigationUrl = () => '/notification-settings';
export const getQuranicCalendarNavigationUrl = () => '/calendar';
export const getQuranMediaMakerNavigationUrl = (params?: ParsedUrlQuery) => {
diff --git a/types/BaseAuthResponse.ts b/types/BaseAuthResponse.ts
new file mode 100644
index 0000000000..fc5b398ff2
--- /dev/null
+++ b/types/BaseAuthResponse.ts
@@ -0,0 +1,10 @@
+interface BaseAuthResponse {
+ success: boolean;
+ error?: {
+ code: string;
+ message: string;
+ details: Record;
+ };
+}
+
+export default BaseAuthResponse;
diff --git a/types/QueryParam.ts b/types/QueryParam.ts
index f93fbe49f1..84955a82b7 100644
--- a/types/QueryParam.ts
+++ b/types/QueryParam.ts
@@ -25,6 +25,8 @@ enum QueryParam {
VIDEO_ID = 'videoId',
SURAH = 'surah',
PAGE = 'page',
+ EMAIL = 'email',
+ TOKEN = 'token',
}
export default QueryParam;
diff --git a/types/auth/SignUpRequest.ts b/types/auth/SignUpRequest.ts
new file mode 100644
index 0000000000..b0b13834b0
--- /dev/null
+++ b/types/auth/SignUpRequest.ts
@@ -0,0 +1,11 @@
+interface SignUpRequest {
+ firstName: string;
+ lastName: string;
+ email: string;
+ username: string;
+ password: string;
+ confirmPassword: string;
+ verificationCode?: string;
+}
+
+export default SignUpRequest;
diff --git a/types/auth/error.ts b/types/auth/error.ts
new file mode 100644
index 0000000000..253d950188
--- /dev/null
+++ b/types/auth/error.ts
@@ -0,0 +1,33 @@
+export enum ServerErrorCodes {
+ INVALID = 'INVALID',
+ INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
+ MIN_LENGTH = 'MIN_LENGTH',
+ MAX_LENGTH = 'MAX_LENGTH',
+ MISSING = 'MISSING',
+ DUPLICATE = 'DUPLICATE',
+ EXACT_LENGTH = 'EXACT_LENGTH',
+ MISMATCH = 'MISMATCH',
+ USED = 'USED',
+ EXPIRED = 'EXPIRED',
+ BANNED = 'BANNED',
+ IMMUTABLE = 'IMMUTABLE',
+ BAD_REQUEST = 'BAD_REQUEST',
+ NOT_FOUND = 'NOT_FOUND',
+}
+
+export const BASE_SERVER_ERRORS_MAP: Record = {
+ [ServerErrorCodes.DUPLICATE]: 'errors.taken',
+ [ServerErrorCodes.MIN_LENGTH]: 'errors.min',
+ [ServerErrorCodes.MAX_LENGTH]: 'errors.max',
+ [ServerErrorCodes.MISSING]: 'errors.required',
+ [ServerErrorCodes.INVALID]: 'errors.invalid',
+ [ServerErrorCodes.INVALID_CREDENTIALS]: 'errors.invalidEmailOrPassword',
+ [ServerErrorCodes.BANNED]: 'errors.banned',
+ [ServerErrorCodes.MISMATCH]: 'errors.confirm',
+ [ServerErrorCodes.USED]: 'errors.usedToken',
+ [ServerErrorCodes.EXPIRED]: 'errors.expiredToken',
+ [ServerErrorCodes.EXACT_LENGTH]: 'errors.exactLength',
+ [ServerErrorCodes.IMMUTABLE]: 'errors.immutable',
+ [ServerErrorCodes.BAD_REQUEST]: 'errors.badRequest',
+ [ServerErrorCodes.NOT_FOUND]: 'errors.notFound',
+};
diff --git a/yarn.lock b/yarn.lock
index 528a08d4ef..a877aa173b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14628,6 +14628,11 @@ react-toastify@^9.0.8:
dependencies:
clsx "^1.1.1"
+react-verification-input@^4.2.2:
+ version "4.2.2"
+ resolved "https://registry.yarnpkg.com/react-verification-input/-/react-verification-input-4.2.2.tgz#8d0e54705ed38b23adced59bd41a06ee6aa52a75"
+ integrity sha512-GqcsiZ5iwcXD4V0cKbZv1pbknqAZRszPoXzSKc5LLlfSdo3iOnxLxEbmDi+BWN1LfhCx2iXhEDGke9Bdk3q+4A==
+
react-virtuoso@^2.19.0:
version "2.19.1"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-2.19.1.tgz#a660a5c3cafcc7a84b59dfc356e1916e632c1e3a"