diff --git a/i18n.json b/i18n.json index 7a0a338d93..29ebf7d463 100644 --- a/i18n.json +++ b/i18n.json @@ -57,6 +57,8 @@ "/reciters": ["reciter", "home"], "/profile": ["home", "profile", "collection", "quran-reader"], "/login": ["login"], + "/forgot-password": ["login"], + "/reset-password": ["login"], "/about-the-quran": ["about-quran"], "/notes-and-reflections": ["notes"], "/ramadan": ["ramadan-activities"], diff --git a/locales/en/common.json b/locales/en/common.json index 2564039100..af01406764 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -190,7 +190,11 @@ "email": "Email", "firstName": "First Name", "lastName": "Last Name", - "title": "Title" + "title": "Title", + "username": "Username", + "password": "Password", + "confirm-password": "Confirm Password", + "verification-code": "Verification Code" }, "from": "From", "fundraising-sticky-banner": { diff --git a/locales/en/login.json b/locales/en/login.json index 3527fde7ba..d36ce61d3b 100644 --- a/locales/en/login.json +++ b/locales/en/login.json @@ -1,17 +1,68 @@ { "awaiting-confirmation": "Awaiting Confirmation", + "back": "Back", + "continue": "Continue", "continue-apple": "Continue with Apple", "continue-email": "Continue with Email", "continue-facebook": "Continue with Facebook", "continue-google": "Continue with Google", - "email-placeholder": "Email Address", + "email-placeholder": "Email address", + "first-name-placeholder": "First Name", + "last-name-placeholder": "Last Name", + "username-placeholder": "Username", + "confirm-password-placeholder": "Confirm password", + "confirm-new-password-placeholder": "Confirm new password", + "confirm": "Confirm", + "error": { + "email-required": "Email is missing!", + "invalid-email": "Invalid Email Format!", + "invalid-credentials": "Invalid Email or password", + "login-failed": "Login failed. Please try again.", + "password-required": "Password is missing!" + }, + "errors": { + "min": "*{{fieldName}} must be more than or equal to {{min}} digits", + "max": "*{{fieldName}} must be less than or equal to {{max}} digits", + "name": "*{{fieldName}} should be letters and numbers only", + "username": "*{{fieldName}} accept underscore and letters only", + "email": "*Invalid Email Format!", + "confirm": "*Confirm password doesn't match the password", + "required": "*{{fieldName}} is missing!", + "taken": "*{{fieldName}} already exists!", + "invalid": "*This {{fieldName}} is invalid", + "invalidEmailOrPassword": "*Invalid email or password", + "usedToken": "*This token already used", + "expiredToken": "*This token is expired", + "banned": "*Sorry, Your account is banned. Contact Quran.Foundation", + "exactLength": "*Value must be exact length", + "immutable": "*This value cannot be changed", + "badRequest": "*Invalid request", + "notFound": "*Not found", + "verification-code-invalid": "This verification code is invalid", + "verification-code-length": "Verification code must be {{length}} digits", + "verification-resend-failed": "Failed to resend verification code", + "account-banned": "Sorry, Your account is banned. Contact Quran.Foundation", + "verification-failed": "Verification failed. Please try again.", + "signup-failed": "Signup failed. Please try again.", + "signin-failed": "Signin failed. Please try again.", + "forgot-password-failed": "Failed to send password reset email. Please try again.", + "reset-password-failed": "Failed to reset password. Please try again." + }, "feature-1": "Track your goals", "feature-2": "Maintain reading streaks", "feature-3": "Create collections", "feature-4": "Sync your data across browsers", "feature-5": "And more!", - "feature-6": "New! Notes & Reflections", - "login-cta": "Log-in or Sign up now:", + "feature-6": "New! Notes & Reflections", + "forgot-password": "Forgot password", + "forgot-password-title": "Forgot password?", + "forgot-password-description": "Enter your email address and we'll send you instructions to reset your password.", + "reset-password": "Reset Password", + "set-new-password": "Set a new password", + "back-to-login": "Back to Login", + "forgot-password-success": "Password reset email sent! Please check your inbox.", + "password-reset-success": "Password reset successfully!", + "login-cta": "Sign in or Sign up now:", "login-error": { "AuthenticationError": "Authentication failed. Please try again later", "TokenExpiredError": "You have been logged out, please login again.", @@ -19,6 +70,41 @@ }, "login-title": "Login to Quran.com", "other-options": "Other Login Options", + "password-placeholder": "Password", + "new-password-placeholder": "New password", "privacy-policy": "Protecting your privacy is our priority – By signing up, you consent to our Privacy Policy and Terms and conditions.", - "verify-code": "Verify that the provided security code matches the following text:" + "quran-title": "Quran.com", + "sign-in": "Sign in", + "sign-up": "Sign up", + "sign-in-or-sign-up": "Sign in or Sign up", + "verify-code": "Verify that the provided security code matches the following text:", + "welcome-title": "Welcome to", + "welcome-description-1": "The unified registration of", + "welcome-description-2": ".Foundation", + "welcome-description-3": "You will have access to the following websites through your sign in details.", + "quran-text": "Quran", + "unified-registration-1": "The unified registration of ", + "unified-registration-2": "Quran.Foundation", + "unified-registration-3": ". You will have access to the following websites ", + "unified-registration-4": "through your sign in details.", + "reflect-feature-1": "Reflect on the Quran", + "reflect-feature-2": "Join Groups", + "reflect-feature-3": "Interact with others", + "reflect-feature-4": "And more!", + "password-rules": { + "min-length": "Min 8 characters", + "max-length": "Max 20 characters", + "uppercase": "At least one uppercase letter", + "lowercase": "At least one lowercase letter", + "number": "At least one number", + "special": "At least one special character (!@#$%^&*_-)" + }, + "check-email-title": "Check email to complete sign up", + "verification-code-sent-to": "We just sent an email to", + "verification-code-instruction": "Please enter the code provided in the email to complete sign up", + "verification-code-spam-note": "Didn't receive an email? Check your spam folder", + "verification-code-resend": "Resend email", + "verification-code-resend-countdown": "Resend verification email in {{seconds}} sec...", + "verification-code-sent": "Verification code sent!", + "reset-password-success": "Password reset successfully!" } diff --git a/package.json b/package.json index 0a4bde91dc..7dbafa760c 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "react-redux": "^9.1.2", "react-share": "^5.2.0", "react-toastify": "^9.0.8", + "react-verification-input": "^4.2.2", "react-virtuoso": "^2.19.0", "redux": "^5.0.1", "redux-persist": "^6.0.0", diff --git a/public/icons/hide.svg b/public/icons/hide.svg new file mode 100644 index 0000000000..ef37d04c23 --- /dev/null +++ b/public/icons/hide.svg @@ -0,0 +1,5 @@ + + + diff --git a/public/icons/show.svg b/public/icons/show.svg new file mode 100644 index 0000000000..17c93c3122 --- /dev/null +++ b/public/icons/show.svg @@ -0,0 +1,5 @@ + + + diff --git a/public/icons/sun-login.svg b/public/icons/sun-login.svg new file mode 100644 index 0000000000..cbf23a446b --- /dev/null +++ b/public/icons/sun-login.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/components/FormBuilder/FormBuilder.tsx b/src/components/FormBuilder/FormBuilder.tsx index 71742e2cf5..1084504343 100644 --- a/src/components/FormBuilder/FormBuilder.tsx +++ b/src/components/FormBuilder/FormBuilder.tsx @@ -67,6 +67,14 @@ const FormBuilder = ({ } }; + const renderExtraSection = (formField: FormBuilderFormField, value: string) => { + if (!formField.extraSection) return null; + if (typeof formField.extraSection === 'function') { + return formField.extraSection(value); + } + return formField.extraSection; + }; + return (
{formFields?.map((formField) => { @@ -78,6 +86,20 @@ const FormBuilder = ({ rules={buildReactHookFormRules(formField)} name={formField.field} render={({ field, fieldState: { error } }) => { + if (formField.customRender) { + return ( +
+ {formField.customRender({ + value: field.value, + onChange: field.onChange, + placeholder: formField.placeholder, + })} + {error && {error.message}} + {renderExtraSection(formField, field.value)} +
+ ); + } + const inputFieldProps = { key: formField.field, value: field.value, @@ -85,7 +107,7 @@ const FormBuilder = ({ name: formField.field, containerClassName: formField.containerClassName, fieldSetLegend: formField.fieldSetLegend, - label: formField.label, + label: formField.label as string, placeholder: formField.placeholder, onChange: (val) => { field.onChange(val); @@ -107,7 +129,7 @@ const FormBuilder = ({
{error && {error.message}} - {formField.extraSection &&
{formField.extraSection}
} + {renderExtraSection(formField, field.value)}
); }} diff --git a/src/components/FormBuilder/FormBuilderTypes.ts b/src/components/FormBuilder/FormBuilderTypes.ts index 9c9215c056..b8252d0f66 100644 --- a/src/components/FormBuilder/FormBuilderTypes.ts +++ b/src/components/FormBuilder/FormBuilderTypes.ts @@ -1,15 +1,22 @@ +import { ReactNode } from 'react'; + import FieldRule from 'types/FieldRule'; import FormField from 'types/FormField'; export type FormBuilderFieldRule = Pick & { errorMessage: string }; export type FormBuilderFormField = Pick & { defaultValue?: any; - label?: string | JSX.Element; + label?: string | ReactNode; placeholder?: string; rules?: FormBuilderFieldRule[]; containerClassName?: string; checked?: boolean; fieldSetLegend?: string; onChange?: (value: unknown) => void; - extraSection?: JSX.Element; + extraSection?: ReactNode | ((value: string) => ReactNode); + customRender?: (props: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }) => ReactNode; }; diff --git a/src/components/Login/AuthHeader.tsx b/src/components/Login/AuthHeader.tsx new file mode 100644 index 0000000000..ec58a9dc98 --- /dev/null +++ b/src/components/Login/AuthHeader.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; + +import styles from './login.module.scss'; + +import QuranLogo from '@/icons/logo_main.svg'; +import QRColoredLogo from '@/icons/qr-colored.svg'; +import QuranReflectLogo from '@/icons/qr-logo.svg'; + +const AuthHeader: FC = () => { + return ( + <> +
+ +
+ + +
+
+
+ + ); +}; + +export default AuthHeader; diff --git a/src/components/Login/AuthTabs.tsx b/src/components/Login/AuthTabs.tsx new file mode 100644 index 0000000000..2c4a6ce554 --- /dev/null +++ b/src/components/Login/AuthTabs.tsx @@ -0,0 +1,62 @@ +import { FC } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import AuthHeader from './AuthHeader'; +import styles from './login.module.scss'; +import SignInForm from './SignInForm'; +import SignUpForm from './SignUpForm'; + +import Switch, { SwitchSize } from '@/dls/Switch/Switch'; +import SignUpRequest from 'types/auth/SignUpRequest'; + +export enum AuthTab { + SignIn = 'signin', + SignUp = 'signup', +} + +interface Props { + activeTab: AuthTab; + onTabChange: (tab: AuthTab) => void; + redirect?: string; + onSignUpSuccess: (data: SignUpRequest) => void; +} + +const AuthTabs: FC = ({ activeTab, onTabChange, redirect, onSignUpSuccess }) => { + const { t } = useTranslation('login'); + + const items = [ + { + name: t('sign-in'), + value: AuthTab.SignIn, + }, + { + name: t('sign-up'), + value: AuthTab.SignUp, + }, + ]; + + return ( +
+ +
+

{t('sign-in-or-sign-up')}

+
+ +
+ {activeTab === AuthTab.SignIn ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default AuthTabs; diff --git a/src/components/Login/CompleteSignupForm.tsx b/src/components/Login/CompleteSignupForm.tsx index 8f2e891153..2829e1acee 100644 --- a/src/components/Login/CompleteSignupForm.tsx +++ b/src/components/Login/CompleteSignupForm.tsx @@ -2,11 +2,11 @@ import useTranslation from 'next-translate/useTranslation'; import { useSWRConfig } from 'swr'; import buildFormBuilderFormField from '../FormBuilder/buildFormBuilderFormField'; -import FormBuilder from '../FormBuilder/FormBuilder'; import styles from './CompleteSignupForm.module.scss'; import EmailVerificationForm from './EmailVerificationForm'; +import FormBuilder from '@/components/FormBuilder/FormBuilder'; import { completeSignup } from '@/utils/auth/api'; import { makeUserProfileUrl } from '@/utils/auth/apiPaths'; import { logFormSubmission } from '@/utils/eventLogger'; diff --git a/src/components/Login/CompleteSignupForm/EmailVerificationSection.tsx b/src/components/Login/CompleteSignupForm/EmailVerificationSection.tsx new file mode 100644 index 0000000000..17e2820a65 --- /dev/null +++ b/src/components/Login/CompleteSignupForm/EmailVerificationSection.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; + +import { useSWRConfig } from 'swr'; + +import VerificationCodeBase from '../VerificationCode/VerificationCodeBase'; + +import SignUpRequest from '@/types/auth/SignUpRequest'; +import { makeUserProfileUrl } from '@/utils/auth/apiPaths'; +import { signUp } from '@/utils/auth/authRequests'; + +interface Props { + email: string; + formData: SignUpRequest; + onBack: () => void; + onVerified: () => void; +} + +const EmailVerificationSection: FC = ({ email, formData, onBack, onVerified }) => { + const { mutate } = useSWRConfig(); + + const handleSubmitCode = async (code: string) => { + const { data: response } = await signUp({ + ...formData, + verificationCode: code, + }); + + if (!response.success) { + throw new Error('Invalid verification code'); + } + + mutate(makeUserProfileUrl()); + onVerified(); + }; + + const handleResendCode = async () => { + const { data: response } = await signUp(formData); + + if (!response.success) { + throw new Error('Failed to resend verification code'); + } + }; + + return ( + + ); +}; + +export default EmailVerificationSection; diff --git a/src/components/Login/CompleteSignupForm/index.tsx b/src/components/Login/CompleteSignupForm/index.tsx new file mode 100644 index 0000000000..edaec8332f --- /dev/null +++ b/src/components/Login/CompleteSignupForm/index.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useSWRConfig } from 'swr'; + +import styles from '../CompleteSignupForm.module.scss'; + +import EmailVerificationSection from './EmailVerificationSection'; + +import buildFormBuilderFormField from '@/components/FormBuilder/buildFormBuilderFormField'; +import FormBuilder from '@/components/FormBuilder/FormBuilder'; +import { makeUserProfileUrl } from '@/utils/auth/apiPaths'; +import { signUp } from '@/utils/auth/authRequests'; +import { logFormSubmission } from '@/utils/eventLogger'; +import SignUpRequest from 'types/auth/SignUpRequest'; +import FormField from 'types/FormField'; + +type CompleteSignupFormProps = { + requiredFields: FormField[]; +}; + +const CompleteSignupForm: React.FC = ({ requiredFields }) => { + const { mutate } = useSWRConfig(); + const { t } = useTranslation('common'); + const [isEmailSubmitted, setIsEmailSubmitted] = useState(false); + const [isEmailVerified, setIsEmailVerified] = useState(false); + const [email, setEmail] = useState(); + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + username: '', + password: '', + confirmPassword: '', + verificationCode: '', + }); + + const emailFormField = requiredFields.find((field) => field.field === 'email'); + const isEmailRequired = !!emailFormField; + + const handleEmailVerification = async (signUpData: SignUpRequest) => { + try { + const { data: response } = await signUp(signUpData); + + if (!response.success) { + throw new Error('Failed to send verification code'); + } + + setFormData(signUpData); + setIsEmailSubmitted(true); + setEmail(signUpData.email); + return undefined; + } catch (error) { + return { + errors: { + email: error?.message, + }, + }; + } + }; + + const handleDirectSignup = async (signUpData: SignUpRequest) => { + try { + const { data: response } = await signUp(signUpData); + + if (!response.success) { + throw new Error('Signup failed'); + } + + mutate(makeUserProfileUrl()); + return undefined; + } catch (error) { + return { + errors: { + firstName: error?.message, + lastName: error?.message, + email: error?.message, + username: error?.message, + password: error?.message, + confirmPassword: error?.message, + }, + }; + } + }; + + const onFormSubmit = async (data: Partial) => { + logFormSubmission('complete_signUp'); + const signUpData: SignUpRequest = { ...formData, ...data }; + + if (isEmailRequired) { + return handleEmailVerification(signUpData); + } + + return handleDirectSignup(signUpData); + }; + + const onBackFromVerification = () => { + setIsEmailSubmitted(false); + }; + + const onVerified = () => { + setIsEmailVerified(true); + }; + + if (isEmailRequired && isEmailSubmitted && !isEmailVerified) { + return ( + + ); + } + + return ( +
+

{t('complete-sign-up')}

+ + buildFormBuilderFormField({ ...field, placeholder: t(`form.${field.field}`) }, t), + )} + onSubmit={onFormSubmit} + actionText={t('submit')} + /> +
+ ); +}; + +export default CompleteSignupForm; diff --git a/src/components/Login/EmailVerificationForm.tsx b/src/components/Login/EmailVerificationForm.tsx index 3cb639240d..7bce4152a3 100644 --- a/src/components/Login/EmailVerificationForm.tsx +++ b/src/components/Login/EmailVerificationForm.tsx @@ -1,22 +1,17 @@ import { useState } from 'react'; -import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; import { useSWRConfig } from 'swr'; -import buildTranslatedErrorMessageByErrorId from '../FormBuilder/buildTranslatedErrorMessageByErrorId'; import FormBuilder from '../FormBuilder/FormBuilder'; import { FormBuilderFormField } from '../FormBuilder/FormBuilderTypes'; import styles from './CompleteSignupForm.module.scss'; -import ResendEmailSection from './ResendEmailSection'; +import VerificationCodeBase from './VerificationCode/VerificationCodeBase'; import { completeSignup, requestVerificationCode } from '@/utils/auth/api'; import { makeUserProfileUrl } from '@/utils/auth/apiPaths'; import { logFormSubmission } from '@/utils/eventLogger'; -import ErrorMessageId from 'types/ErrorMessageId'; -import { RuleType } from 'types/FieldRule'; -import { FormFieldType } from 'types/FormField'; type EmailVerificationFormProps = { emailFormField: FormBuilderFormField; @@ -25,16 +20,11 @@ type EmailFormData = { email: string; }; -type VerificationCodeFormData = { - code: string; -}; - const EmailVerificationForm = ({ emailFormField }: EmailVerificationFormProps) => { const [isSubmitted, setIsSubmitted] = useState(false); const { mutate } = useSWRConfig(); const [email, setEmail] = useState(); const { t } = useTranslation('common'); - const [forceRerenderKey, setForceRerenderKey] = useState(0); const onEmailSubmitted = (data: EmailFormData) => { logFormSubmission('email_verification'); @@ -43,62 +33,46 @@ const EmailVerificationForm = ({ emailFormField }: EmailVerificationFormProps) = setEmail(data.email); }; - const verificationCodeFormField: FormBuilderFormField = { - field: 'code', - type: FormFieldType.Number, - placeholder: t('form.code'), - rules: [ - { - type: RuleType.Required, - value: true, - errorMessage: buildTranslatedErrorMessageByErrorId(ErrorMessageId.RequiredField, 'code', t), - }, - ], + const onBack = () => { + setEmail(undefined); }; - const onVerificationCodeSubmitted = (data: VerificationCodeFormData) => { - logFormSubmission('verification_code'); - return completeSignup({ email, verificationCode: data.code.toString() }) - .then(() => { - // mutate the cache version of users/profile - mutate(makeUserProfileUrl()); - }) - .catch(async (err) => { - const result = { errors: { code: err?.message } }; - return result; - }); + const handleResendCode = async () => { + if (!email) { + throw new Error('Email is required'); + } + + await requestVerificationCode(email); }; - const onResendEmailRequested = () => { - requestVerificationCode(email); - setForceRerenderKey((preVal) => preVal + 1); + const handleSubmitCode = async (code: string) => { + if (!email) { + throw new Error('Email is required'); + } + + const response = await completeSignup({ email, verificationCode: code }); + if (!response) { + throw new Error('Invalid verification code'); + } + + mutate(makeUserProfileUrl()); }; return (

{t('email-verification.email-verification')}

{isSubmitted ? ( - <> -

- , - }} - /> -

- - - - + ) : ( void; +} + +const EmailVerificationForm: FC = ({ onBack }) => { + const { mutate } = useSWRConfig(); + const { t } = useTranslation('common'); + const [email, setEmail] = useState(''); + const [isEmailSubmitted, setIsEmailSubmitted] = useState(false); + const [error, setError] = useState(); + + const handleSubmitCode = async (code: string) => { + if (!email) { + throw new Error('Email is required'); + } + + const response = await completeSignup({ email, verificationCode: code }); + if (!response) { + throw new Error('Invalid verification code'); + } + + mutate(makeUserProfileUrl()); + }; + + const handleResendCode = async () => { + if (!email) { + throw new Error('Email is required'); + } + + await requestVerificationCode(email); + }; + + const handleBack = () => { + setIsEmailSubmitted(false); + setError(undefined); + if (onBack) { + onBack(); + } + }; + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(undefined); + + if (!email) { + setError(t('form.email-required')); + return; + } + + try { + logFormSubmission('email_verification'); + await requestVerificationCode(email); + setIsEmailSubmitted(true); + } catch (err) { + setError(err?.message || t('errors.something-went-wrong')); + } + }; + + if (!isEmailSubmitted) { + return ( +
+

{t('email-verification.email-verification')}

+ + + {error &&
{error}
} + + +
+ ); + } + + return ( + + ); +}; + +export default EmailVerificationForm; diff --git a/src/components/Login/Feature.tsx b/src/components/Login/Feature.tsx new file mode 100644 index 0000000000..ace8735a2a --- /dev/null +++ b/src/components/Login/Feature.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react'; + +import styles from './login.module.scss'; + +import useDirection from '@/hooks/useDirection'; +import SunIcon from '@/icons/sun-login.svg'; + +interface Props { + label: string; +} + +const Feature: FC = ({ label }) => { + const direction = useDirection(); + + return ( +
+ +

{label}

+
+ ); +}; + +export default Feature; diff --git a/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx b/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx new file mode 100644 index 0000000000..fbf89e86fa --- /dev/null +++ b/src/components/Login/ForgotPassword/ForgotPasswordForm.tsx @@ -0,0 +1,87 @@ +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 { getEmailField } from '../SignUpFormFields/credentialFields'; + +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 { requestPasswordReset } from '@/utils/auth/authRequests'; +import { logButtonClick, logFormSubmission } from '@/utils/eventLogger'; +import { getLoginNavigationUrl } from '@/utils/navigation'; + +const ForgotPasswordForm: React.FC = () => { + const { t } = useTranslation('login'); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const toast = useToast(); + + const formFields: FormBuilderFormField[] = [getEmailField(t)]; + + const handleSubmit = async (values: { email: string }) => { + logFormSubmission('forgot_password'); + try { + setIsSubmitting(true); + const { data: response, errors } = await requestPasswordReset(values.email); + + if (!response.success) { + setIsSubmitting(false); + return getFormErrors(t, ErrorType.API, errors); + } + + toast(t('forgot-password-success'), { status: ToastStatus.Success }); + router.push(getLoginNavigationUrl()); + return undefined; + } catch (error) { + setIsSubmitting(false); + return getFormErrors(t, ErrorType.FORGOT_PASSWORD); + } + }; + + const handleBack = () => { + logButtonClick('forgot_password_back'); + router.push(getLoginNavigationUrl()); + }; + + const renderAction = (props) => ( + + ); + + return ( +
+
+ +

{t('forgot-password-title')}

+

{t('forgot-password-description')}

+
+ + +
+
+
+ ); +}; + +export default ForgotPasswordForm; diff --git a/src/components/Login/LoginContainer.tsx b/src/components/Login/LoginContainer.tsx index 3ad69de125..8d7b2c0094 100644 --- a/src/components/Login/LoginContainer.tsx +++ b/src/components/Login/LoginContainer.tsx @@ -1,140 +1,145 @@ -import { useState } from 'react'; +import { FC, useState } from 'react'; import { useRouter } from 'next/router'; -import Trans from 'next-translate/Trans'; import useTranslation from 'next-translate/useTranslation'; -import EmailSent from './EmailSent'; -import Feature from './Feature'; -import styles from './login.module.scss'; -import ResendEmailSection from './ResendEmailSection'; - -import { SubmissionResult } from '@/components/FormBuilder/FormBuilder'; -import EmailLogin, { EmailLoginData, sendMagicLink } from '@/components/Login/EmailLogin'; -import SocialLogin from '@/components/Login/SocialLogin'; -import Button, { ButtonType, ButtonVariant } from '@/dls/Button/Button'; -import Link, { LinkVariant } from '@/dls/Link/Link'; -import CalendarIcon from '@/icons/calendar-1.svg'; -import GoalIcon from '@/icons/goal-1.svg'; -import HeartIcon from '@/icons/love.svg'; -import MobileIcon from '@/icons/mobile-1.svg'; -import NotesIcon from '@/icons/notes-empty.svg'; -import MoreIcon from '@/icons/sun-outline.svg'; -import { logButtonClick, logFormSubmission } from '@/utils/eventLogger'; -import AuthType from 'types/auth/AuthType'; - -enum LoginType { - Social = 'social', - Email = 'email', +import AuthTabs, { AuthTab } from './AuthTabs'; +import ServiceCard from './ServiceCard'; +import VerificationCodeForm from './VerificationCode/VerificationCodeForm'; + +import Button, { ButtonVariant } from '@/dls/Button/Button'; +import ArrowLeft from '@/icons/west.svg'; +import authStyles from '@/styles/auth/auth.module.scss'; +import { signUp } from '@/utils/auth/authRequests'; +import { logButtonClick, logEvent } from '@/utils/eventLogger'; +import SignUpRequest from 'types/auth/SignUpRequest'; + +enum LoginView { + SOCIAL = 'social', + EMAIL = 'email', + VERIFICATION = 'verification', +} + +interface Props { + redirect?: string; } -const LoginContainer = () => { - const [loginType, setLoginType] = useState(LoginType.Social); - const { t } = useTranslation(); - const { query } = useRouter(); - const redirect = query.r ? decodeURIComponent(query.r.toString()) : undefined; - - const [magicLinkVerificationCode, setMagicLinkVerificationCode] = useState(''); - const [email, setEmail] = useState(''); - - const onEmailLoginSubmit = ({ email: emailInput }): SubmissionResult => { - setEmail(emailInput); - return sendMagicLink(emailInput, redirect) - .then(setMagicLinkVerificationCode) - .catch(() => { - return { - errors: { - email: t('common:error.email-login-fail'), - }, - }; - }); +const LoginContainer: FC = ({ redirect }) => { + const { t } = useTranslation('login'); + const [loginView, setLoginView] = useState(LoginView.SOCIAL); + const [activeTab, setActiveTab] = useState(AuthTab.SignIn); + const [signUpData, setSignUpData] = useState | null>(null); + const router = useRouter(); + + const onBack = () => { + logButtonClick('login_back'); + if (loginView === LoginView.VERIFICATION) { + setLoginView(LoginView.EMAIL); + } else if (loginView === LoginView.EMAIL) { + setLoginView(LoginView.SOCIAL); + } else { + router.back(); + } }; - const onMagicLinkClicked = () => { - setLoginType(LoginType.Email); - // eslint-disable-next-line i18next/no-literal-string - logButtonClick(`${AuthType.Email}_login`); + const onEmailLoginClick = () => { + logEvent('login_email_click'); + setLoginView(LoginView.EMAIL); }; - const onOtherOptionsClicked = () => { - setLoginType(LoginType.Social); - logButtonClick('other_auth_options'); + const onTabChange = (tab: AuthTab) => { + logEvent('login_tab_change', { tab }); + setActiveTab(tab); }; - const onResendEmailButtonClicked = () => { - onEmailLoginSubmit({ email }); - logButtonClick('resend_email'); + const handleEmailLoginSubmit = async (data: { + email: string; + }): Promise => { + setSignUpData(data); + setLoginView(LoginView.VERIFICATION); }; - const onLoginWithEmailSubmit = (data) => { - logFormSubmission('email_login'); - return onEmailLoginSubmit(data); + const handleResendCode = async () => { + if (!signUpData?.email) return; + + try { + const { data: response } = await signUp(signUpData as SignUpRequest); + + if (!response.success) { + throw new Error('Failed to resend verification code'); + } + } catch (error) { + throw new Error('Failed to resend verification code'); + } }; - if (magicLinkVerificationCode) { + const renderContent = () => { + if (loginView === LoginView.VERIFICATION) { + return ( +
+ +
+ ); + } + + if (loginView === LoginView.EMAIL) { + return ( + <> + + + + ); + } + + const benefits = { + quran: [ + { id: 'feature-6', label: t('feature-6') }, + { id: 'feature-1', label: t('feature-1') }, + { id: 'feature-2', label: t('feature-2') }, + { id: 'feature-3', label: t('feature-3') }, + { id: 'feature-4', label: t('feature-4') }, + { id: 'feature-5', label: t('feature-5') }, + ], + reflect: [ + { id: 'reflect-1', label: t('reflect-feature-1') }, + { id: 'reflect-2', label: t('reflect-feature-2') }, + { id: 'reflect-3', label: t('reflect-feature-3') }, + { id: 'reflect-4', label: t('reflect-feature-4') }, + ], + }; + return ( -
- - -
+ ); - } + }; return ( -
-
-
{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" - /> - +
+
+ {renderContent()}
); 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 ( +
+ + + + +
+ ); +}; + +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')}

+ +
+ + {error &&
{error}
} +
+ +
{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"