Merge branch 'develop' into fix/completion-page
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { Box, Divider, Stack, Typography } from '@mui/material';
|
||||
import AccountCreatedIcon from '@/assets/account-created.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import Link from '@mui/material/Link';
|
||||
import { AccountCreatedClubBanner } from './AccountCreatedClubBanner';
|
||||
import { AccountCreatedRedirectButton } from './AccountCreatedRedirectButton';
|
||||
|
||||
export type RequestedApplication = 'default' | 'unknown' | 'club';
|
||||
|
||||
export const AccountCreated = () => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [params] = useSearchParams();
|
||||
|
||||
// Checking if there is a requested application and if its club or any other url
|
||||
const requestedApplication = useMemo<RequestedApplication>(() => {
|
||||
const returnUrl = params.get('returnUrl');
|
||||
|
||||
if (!returnUrl) return 'default';
|
||||
|
||||
if (returnUrl.includes('club.business-harmony.com')) {
|
||||
return 'club';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}, [params]);
|
||||
|
||||
const handleRedirectUser = () => {
|
||||
const returnUrl = params.get('returnUrl');
|
||||
|
||||
if (returnUrl) {
|
||||
location.href = returnUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard maxWidth="636px">
|
||||
<Stack sx={{ alignItems: 'center' }}>
|
||||
<img src={AccountCreatedIcon} />
|
||||
|
||||
<Typography variant="h5" sx={{ mt: 2 }}>
|
||||
{t('accountCreated.yourHarmonyAccountHasBeenSuccessfullyCreated')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ mt: 1, mb: 2 }}>
|
||||
{t('accountCreated.yourAccountInformationCanBeChangedThrough')}
|
||||
|
||||
<Link component={RouterLink} to="/setting/profile" underline="always">
|
||||
{t('accountCreated.accountSettings')}
|
||||
</Link>
|
||||
|
||||
{t('accountCreated.yourAccountInformationCanBeChangedThroughEnd')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{requestedApplication !== 'default' && (
|
||||
<Box sx={{ mx: 7 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ py: 2.5 }}>
|
||||
{requestedApplication === 'club' && <AccountCreatedClubBanner />}
|
||||
|
||||
<AccountCreatedRedirectButton
|
||||
onRedirectUser={handleRedirectUser}
|
||||
requestedApplication={requestedApplication}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Stack, Typography, useTheme } from '@mui/material';
|
||||
import { Profile2User } from 'iconsax-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const AccountCreatedClubBanner = () => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ pb: 1 }} alignItems="start">
|
||||
{/* FIXME: Replace with Club Logo (this icon is placeholder) */}
|
||||
<Profile2User size={80} color={theme.palette.club.main} />
|
||||
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h6">{t('accountCreated.harmonyClub')}</Typography>
|
||||
<Typography variant="body2">
|
||||
{t(
|
||||
'accountCreated.encourageYourBusinessCustomersToMakeRepeatPurchasesBySendingThemDiscountCodesAndPoints',
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { RequestedApplication } from './AccountCreated';
|
||||
import { Box, Button } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CountDownTimer } from '@/components/CountDownTimer';
|
||||
import type { PalleteColor } from '@/theme/palette';
|
||||
|
||||
export interface AccountCreatedRedirectButtonProps {
|
||||
requestedApplication: RequestedApplication;
|
||||
onRedirectUser: () => void;
|
||||
}
|
||||
|
||||
export const AccountCreatedRedirectButton = ({
|
||||
requestedApplication,
|
||||
onRedirectUser,
|
||||
}: AccountCreatedRedirectButtonProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
|
||||
const buttonText = useMemo<string>(() => {
|
||||
switch (requestedApplication) {
|
||||
case 'club':
|
||||
return (
|
||||
t('accountCreated.redirectingTo') +
|
||||
' ' +
|
||||
t('accountCreated.harmonyClub')
|
||||
);
|
||||
|
||||
default:
|
||||
return t('accountCreated.redirecting');
|
||||
}
|
||||
}, [requestedApplication, t]);
|
||||
|
||||
const buttonColor = useMemo<PalleteColor>(() => {
|
||||
switch (requestedApplication) {
|
||||
case 'club':
|
||||
return 'club';
|
||||
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
}, [requestedApplication]);
|
||||
|
||||
return (
|
||||
<Box sx={{ m: 1 }}>
|
||||
<Button color={buttonColor} variant="outlined" onClick={onRedirectUser}>
|
||||
{buttonText + ' '} (
|
||||
<CountDownTimer initialSeconds={5} onComplete={onRedirectUser} />)
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Paper, type SxProps, type Theme } from '@mui/material';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export interface AuthenticationCardProps extends PropsWithChildren {
|
||||
maxWidth?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// Beacuse in the otp verify there is a element outside of the authentication card
|
||||
export const AuthenticationCard = ({
|
||||
children,
|
||||
maxWidth,
|
||||
sx,
|
||||
}: AuthenticationCardProps) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
p: {
|
||||
xs: 3,
|
||||
md: 6,
|
||||
},
|
||||
marginInline: 2,
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
maxWidth: maxWidth ?? '552px',
|
||||
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, type JSX } from 'react';
|
||||
import { LoginRegisterForm } from './LoginRegiserForm';
|
||||
import type { AuthMode, AuthStep, AuthType } from '../../types/authTypes';
|
||||
import { OtpVerifyForm } from './OtpVerifyForm';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import { CompleteSignUp } from './CompleteSignUp';
|
||||
import { EnterPasswordForm } from './EnterPasswordForm';
|
||||
import { UserStatus } from '../../types/userTypes';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export const AuthenticationSteps = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const authReturnUrl: string | null = searchParams.get('returnUrl');
|
||||
const authReturnUrlOrDefault: string =
|
||||
authReturnUrl ?? import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL;
|
||||
const [authMode, setAuthMode] = useState<AuthMode>('register');
|
||||
const [authType, setAuthType] = useState<AuthType>('phone');
|
||||
const [currentStep, setCurrentStep] = useState<AuthStep>('emailOrPhone');
|
||||
const [loginRegisterValue, setLoginRegisterValue] = useState<string>('');
|
||||
const [countryCode, setCountryCode] = useState<CountryCode>('+98');
|
||||
const [addPhoneCountryCode, setAddPhoneCountryCode] =
|
||||
useState<CountryCode>('+98');
|
||||
const [addedPhoneNumberValue, setAddedPhoneNumberValue] =
|
||||
useState<string>('');
|
||||
|
||||
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
|
||||
setAuthType(isNumeric(value) ? 'phone' : 'email');
|
||||
|
||||
switch (userStatus) {
|
||||
case UserStatus.NotRegistered:
|
||||
setAuthMode('register');
|
||||
setCurrentStep('verify');
|
||||
break;
|
||||
|
||||
case UserStatus.RegisteredWithoutPassword:
|
||||
setAuthMode('login');
|
||||
setCurrentStep('verify');
|
||||
|
||||
break;
|
||||
|
||||
case UserStatus.RegisteredWithPassword:
|
||||
setAuthMode('login');
|
||||
setCurrentStep('enterPassword');
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserLoggedIn = () => {
|
||||
redirectToReturnUrl();
|
||||
};
|
||||
|
||||
const handleConfrimPhoneNumber = () => {
|
||||
setCurrentStep('addPhoneNumber');
|
||||
};
|
||||
|
||||
const handlePhoneNumberVerified = () => {
|
||||
if (authReturnUrl) {
|
||||
navigate(`/signup?returnUrl=${authReturnUrl}`);
|
||||
} else {
|
||||
navigate(`/signup`);
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToReturnUrl = () => {
|
||||
if (!authReturnUrl) {
|
||||
navigate(import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL);
|
||||
} else {
|
||||
if (authMode === 'register') {
|
||||
navigate(`/account-created?returnUrl=${authReturnUrl}`);
|
||||
} else {
|
||||
location.href = authReturnUrl;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentStep === 'emailOrPhone' && (
|
||||
<LoginRegisterForm
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
onGoogleAuthenticated={handleUserLoggedIn}
|
||||
countryCode={countryCode}
|
||||
setCountryCode={setCountryCode}
|
||||
loginRegisterValue={loginRegisterValue}
|
||||
setLoginRegisterValue={setLoginRegisterValue}
|
||||
authType={authType}
|
||||
setAuthType={setAuthType}
|
||||
onLoginRegisterSubmit={handleLoginRegister}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'verify' && (
|
||||
<OtpVerifyForm
|
||||
onVerifyPhoneNumber={handleConfrimPhoneNumber}
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
countryCode={countryCode}
|
||||
onOTPVerified={handleUserLoggedIn}
|
||||
onEditValue={() => setCurrentStep('emailOrPhone')}
|
||||
authMode={authMode}
|
||||
authType={authType}
|
||||
value={loginRegisterValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'enterPassword' && (
|
||||
<EnterPasswordForm
|
||||
authReturnUrl={authReturnUrlOrDefault}
|
||||
loginRegisterValue={loginRegisterValue}
|
||||
countryCode={countryCode}
|
||||
authType={authType}
|
||||
onLoggedIn={handleUserLoggedIn}
|
||||
onEditValue={() => setCurrentStep('emailOrPhone')}
|
||||
onLoginWithOTP={() => setCurrentStep('verify')}
|
||||
emailOrPhone={loginRegisterValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'addPhoneNumber' && (
|
||||
<CompleteSignUp
|
||||
countryCode={addPhoneCountryCode}
|
||||
setCountryCode={setAddPhoneCountryCode}
|
||||
value={addedPhoneNumberValue}
|
||||
setValue={setAddedPhoneNumberValue}
|
||||
email={loginRegisterValue}
|
||||
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'addedPhoneNumberVerify' && (
|
||||
<VerifyPhoneNumber
|
||||
countryCode={countryCode}
|
||||
onEditValue={() => setCurrentStep('addPhoneNumber')}
|
||||
value={addedPhoneNumberValue}
|
||||
onPhoneNumberVerified={handlePhoneNumberVerified}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
import parsePhoneNumberFromString from 'libphonenumber-js';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../CountryCodeSelector';
|
||||
import { sendSmsOtp } from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface CompleteSignUpProps {
|
||||
email: string;
|
||||
value: string;
|
||||
setValue: Dispatch<string>;
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
onCompleteSignUp: (countryCode: string, value: string) => void;
|
||||
}
|
||||
|
||||
export const CompleteSignUp = ({
|
||||
email,
|
||||
value,
|
||||
setValue,
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
onCompleteSignUp,
|
||||
}: CompleteSignUpProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [error, setError] = useState<string>();
|
||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(sendSmsOtp);
|
||||
|
||||
const isPhoneValid = (code: string, phone: string) => {
|
||||
const phoneNumber = parsePhoneNumberFromString(code + phone);
|
||||
|
||||
return phoneNumber && phoneNumber.isValid();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setTouched(true);
|
||||
|
||||
handleValueError();
|
||||
};
|
||||
|
||||
const handleValueError = () => {
|
||||
if (!value) {
|
||||
setError(t('loginForm.thisFieldIsRequired'));
|
||||
}
|
||||
if (!isPhoneValid(countryCode, value)) {
|
||||
setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteSignUp = async () => {
|
||||
handleValueError();
|
||||
|
||||
if (!value || !isPhoneValid(countryCode, value)) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
await sendSmsCall({ phoneNumber: countryCode + value });
|
||||
onCompleteSignUp(countryCode, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
||||
{t('completeSignUp.completeSignUp')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
|
||||
{t(
|
||||
'completeSignUp.emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue',
|
||||
{ email },
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('completeSignUp.phoneNumber')}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<CountryCodeSelector
|
||||
value={countryCode}
|
||||
onChange={setCountryCode}
|
||||
show={true}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Button loading={sendSmsLoading} onClick={handleCompleteSignUp}>
|
||||
{t('verify.confirmAndContinue')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { ArrowLeft, Edit2, Eye, EyeSlash } from 'iconsax-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import {
|
||||
loginWithPassword,
|
||||
sendEmailOtp,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { PasswordLoginRequest } from '../../types/userTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { generateTokenWithPassword } from '../../api/identityAPI';
|
||||
|
||||
export interface EnterPasswordFormProps {
|
||||
onEditValue: () => void;
|
||||
onLoginWithOTP: () => void;
|
||||
onLoggedIn: (userId: GUID) => void;
|
||||
emailOrPhone: string;
|
||||
authType: AuthType;
|
||||
loginRegisterValue: string;
|
||||
countryCode: CountryCode;
|
||||
authReturnUrl: string;
|
||||
}
|
||||
|
||||
export const EnterPasswordForm = ({
|
||||
onEditValue,
|
||||
onLoginWithOTP,
|
||||
onLoggedIn,
|
||||
emailOrPhone,
|
||||
authType,
|
||||
loginRegisterValue,
|
||||
countryCode,
|
||||
authReturnUrl,
|
||||
}: EnterPasswordFormProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [passValue, setPassValue] = useState<string>('');
|
||||
const [inputTouched, setInputTouched] = useState<boolean>(false);
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: emailResendLoading, execute: emailResendCall } =
|
||||
useApi(sendEmailOtp);
|
||||
const { loading: loginWithPassLoading, execute: loginWithPassCall } =
|
||||
useApi(loginWithPassword);
|
||||
const auth = useAuth();
|
||||
|
||||
const handleBlur = () => {
|
||||
setInputTouched(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!passValue) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
const apiRequest: PasswordLoginRequest = {
|
||||
phoneNumber:
|
||||
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
|
||||
email: authType === 'email' ? loginRegisterValue : undefined,
|
||||
password: passValue,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginWithPassCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
const tokenRes = await generateTokenWithPassword({
|
||||
username: apiRequest.email ?? (apiRequest.phoneNumber as string),
|
||||
password: apiRequest.password,
|
||||
});
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
onLoggedIn(res.userId);
|
||||
toast({
|
||||
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithOtp = async () => {
|
||||
if (authType === 'phone') {
|
||||
await smsResendCall({ phoneNumber: countryCode + loginRegisterValue });
|
||||
} else {
|
||||
await emailResendCall({ email: loginRegisterValue });
|
||||
}
|
||||
|
||||
onLoginWithOTP();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">
|
||||
{t('enterPassword.loginWithPassword')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{emailOrPhone}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
|
||||
{t('enterPassword.enterThePasswordYouSetForYourAccount')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('enterPassword.loginPassword')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passValue}
|
||||
inputRef={inputRef}
|
||||
onChange={(e) => setPassValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
error={!passValue && inputTouched}
|
||||
helperText={
|
||||
!passValue && inputTouched ? t('loginForm.thisFieldIsRequired') : ''
|
||||
}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { sx: { lineHeight: 1.5 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<Icon Component={Eye} />
|
||||
) : (
|
||||
<Icon Component={EyeSlash} />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleLoginWithOtp}
|
||||
sx={{ width: 'auto', mb: 2 }}
|
||||
variant="text"
|
||||
loading={emailResendLoading || smsResendLoading}
|
||||
endIcon={<Icon Component={ArrowLeft} />}
|
||||
>
|
||||
{t('enterPassword.loginWithOneTimeCode')}
|
||||
</Button>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button loading={loginWithPassLoading} onClick={handleSubmit}>
|
||||
{t('verify.confirmAndLogin')}
|
||||
</Button>
|
||||
<Link to="/forget-password">
|
||||
<Button variant="text">{t('enterPassword.iForgotMyPassword')}</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button } from '@mui/material';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
GoogleCodeClientResponse,
|
||||
LoginOrSignUpWithGoogleRequest,
|
||||
} from '../../types/userTypes';
|
||||
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
|
||||
import type { GUID } from '@/types/commonTypes';
|
||||
import { Google } from 'iconsax-react';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { generateTokenWithGoogle } from '../../api/identityAPI';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export interface GoogleAuthenticationProps {
|
||||
disabled: boolean;
|
||||
authReturnUrl: string;
|
||||
onGoogleAuthenticated: (userId: GUID) => void;
|
||||
}
|
||||
|
||||
export const GoogleAuthentication = ({
|
||||
disabled,
|
||||
authReturnUrl,
|
||||
onGoogleAuthenticated,
|
||||
}: GoogleAuthenticationProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } =
|
||||
useApi(loginOrSignUpWithGoogle);
|
||||
const toast = useToast();
|
||||
const clientRef = useRef<any>(null);
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
clientRef.current = google.accounts.oauth2.initCodeClient({
|
||||
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
||||
scope: 'openid email profile',
|
||||
ux_mode: 'popup',
|
||||
response_type: 'code',
|
||||
callback: async (resp: GoogleCodeClientResponse) => {
|
||||
const apiRequest: LoginOrSignUpWithGoogleRequest = {
|
||||
idToken: resp.id_token,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginWithGoogleCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
const tokenRes = await generateTokenWithGoogle(apiRequest);
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
onGoogleAuthenticated(res.userId);
|
||||
} else {
|
||||
toast({
|
||||
message: t('loginForm.googleAuthenticationFailed'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.requestCode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={disabled}
|
||||
loading={loginWithGoogleLoading}
|
||||
variant="outlined"
|
||||
startIcon={<Icon Component={Google} variant="Bold" />}
|
||||
>
|
||||
{t('loginForm.loginWithGoogle')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { isEmail } from '@/utils/regexes/isEmail';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../CountryCodeSelector';
|
||||
import type { UserStatus } from '../../types/userTypes';
|
||||
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import { GoogleAuthentication } from './GoogleAuthentication';
|
||||
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface LoginRegisterFormProps {
|
||||
loginRegisterValue: string;
|
||||
setLoginRegisterValue: Dispatch<string>;
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
authType: AuthType;
|
||||
setAuthType: Dispatch<AuthType>;
|
||||
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
|
||||
authReturnUrl: string;
|
||||
onGoogleAuthenticated: (userId: GUID) => void;
|
||||
}
|
||||
|
||||
export function LoginRegisterForm({
|
||||
loginRegisterValue,
|
||||
setLoginRegisterValue,
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
authType,
|
||||
setAuthType,
|
||||
onLoginRegisterSubmit,
|
||||
authReturnUrl,
|
||||
onGoogleAuthenticated,
|
||||
}: LoginRegisterFormProps) {
|
||||
const { t } = useTranslation('authentication');
|
||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
const toast = useToast();
|
||||
const { loading: userStatusLoading, execute: execUserStatus } = useApi(
|
||||
getUserStatusByPhoneNumberOrEmail,
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setLoginRegisterValue(newValue);
|
||||
|
||||
// If the new value contains only digits (or is empty), it's a phone number
|
||||
if (isNumeric(newValue)) {
|
||||
setAuthType('phone');
|
||||
} else {
|
||||
setAuthType('email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setTouched(true);
|
||||
validateInput(loginRegisterValue, authType);
|
||||
};
|
||||
|
||||
const validateInput = (
|
||||
value: string,
|
||||
authType: AuthType,
|
||||
setErrors: boolean = true,
|
||||
): boolean => {
|
||||
if (!value) {
|
||||
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'email' && !isEmail(value)) {
|
||||
if (setErrors) setError(t('loginForm.emailIsInvalid'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
|
||||
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setErrors) setError(undefined);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (validateInput(loginRegisterValue, authType, false)) {
|
||||
const res = await execUserStatus({
|
||||
phoneNumber:
|
||||
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
|
||||
email: authType === 'email' ? loginRegisterValue : undefined,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
onLoginRegisterSubmit(loginRegisterValue, res.userStatus);
|
||||
} else {
|
||||
toast({ message: res.message, severity: 'error' });
|
||||
}
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
validateInput(loginRegisterValue, authType);
|
||||
}
|
||||
};
|
||||
|
||||
const showAdornment = authType === 'phone' && loginRegisterValue.length > 0;
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">{t('loginForm.title')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('loginForm.description')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('loginForm.emailOrPhoneLabel')}
|
||||
value={loginRegisterValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<CountryCodeSelector
|
||||
value={countryCode}
|
||||
onChange={setCountryCode}
|
||||
show={showAdornment}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Button loading={userStatusLoading} onClick={handleSubmit}>
|
||||
{t('loginForm.submitButton')}
|
||||
</Button>
|
||||
|
||||
<GoogleAuthentication
|
||||
authReturnUrl={authReturnUrl}
|
||||
onGoogleAuthenticated={onGoogleAuthenticated}
|
||||
disabled={userStatusLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/DigitsInput';
|
||||
import type { AuthMode, AuthType } from '../../types/authTypes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { LoginRequest } from '../../types/userTypes';
|
||||
import {
|
||||
loginOrSignUpWithOtp,
|
||||
sendEmailOtp,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import { generateTokenWithOtp } from '../../api/identityAPI';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface OtpVerifyFormProps {
|
||||
value: string;
|
||||
countryCode: CountryCode;
|
||||
authType: AuthType;
|
||||
authMode: AuthMode;
|
||||
onEditValue: () => void;
|
||||
onOTPVerified: (userId: GUID) => void;
|
||||
onVerifyPhoneNumber: (userId: GUID) => void;
|
||||
authReturnUrl: string;
|
||||
}
|
||||
|
||||
export function OtpVerifyForm({
|
||||
value,
|
||||
countryCode,
|
||||
authType,
|
||||
authMode,
|
||||
onEditValue,
|
||||
onOTPVerified,
|
||||
onVerifyPhoneNumber,
|
||||
authReturnUrl,
|
||||
}: OtpVerifyFormProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: emailResendLoading, execute: emailResendCall } =
|
||||
useApi(sendEmailOtp);
|
||||
const { loading: loginSignUpLoading, execute: loginSignUpCall } =
|
||||
useApi(loginOrSignUpWithOtp);
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (resendTimer > 0) {
|
||||
interval = setInterval(() => {
|
||||
setResendTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setCanResend(true);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [resendTimer]);
|
||||
|
||||
const handleResendOTPCode = async () => {
|
||||
if (authType === 'phone') {
|
||||
await smsResendCall({ phoneNumber: countryCode + value });
|
||||
} else {
|
||||
await emailResendCall({ email: value });
|
||||
}
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleVerifyOTP = () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
handleLoginOrSignUp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginOrSignUp = async () => {
|
||||
setOtpDigitInvalid(false);
|
||||
|
||||
const loginRequest: LoginRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
|
||||
email: authType === 'email' ? value : undefined,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const res = await loginSignUpCall(loginRequest);
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
setIsStatusSuccess(true);
|
||||
|
||||
const tokenRes = await generateTokenWithOtp({
|
||||
email: loginRequest.email,
|
||||
phonenumber: loginRequest.phoneNumber,
|
||||
otp: loginRequest.otpCode,
|
||||
});
|
||||
auth.login({
|
||||
...tokenRes.data,
|
||||
});
|
||||
|
||||
if (res.registeredWithOutPhoneNumber) {
|
||||
onVerifyPhoneNumber(res.userId);
|
||||
} else {
|
||||
onOTPVerified(res.userId);
|
||||
}
|
||||
|
||||
toast({
|
||||
message:
|
||||
authMode === 'login'
|
||||
? t('verify.youHaveSuccessfullyLoggedIn')
|
||||
: t('verify.youHaveSuccessfullySignedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
setIsStatusSuccess(false);
|
||||
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const otpMessageFn = (): string => {
|
||||
if (authType === 'phone' && authMode === 'login') {
|
||||
return t(
|
||||
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
|
||||
);
|
||||
} else if (authType === 'phone' && authMode === 'register') {
|
||||
return t(
|
||||
'verify.thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount',
|
||||
);
|
||||
} else if (authType === 'email' && authMode === 'login') {
|
||||
return t(
|
||||
'verify.a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt',
|
||||
);
|
||||
} else if (authType === 'email' && authMode === 'register') {
|
||||
return t(
|
||||
'verify.thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount',
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const otpMessage = otpMessageFn();
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">{t('verify.verify')}</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ textTransform: 'lowercase', width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{authType === 'phone' ? countryCode + value : value}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{otpMessage}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || isStatusSuccess === false}
|
||||
success={isStatusSuccess === true}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={loginSignUpLoading}>
|
||||
{authMode === 'register'
|
||||
? t('verify.confirmAndContinue')
|
||||
: t('verify.confirmAndLogin')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mt: 2.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: 'primary.main', marginInlineStart: 1 }}
|
||||
>
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Typography>
|
||||
|
||||
{canResend && (
|
||||
<Button
|
||||
variant="text"
|
||||
loading={smsResendLoading || emailResendLoading}
|
||||
sx={{ width: 'auto', p: 0 }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{t('verify.resendCode')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/DigitsInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { ConfirmSmsOtpRequest } from '../../types/userTypes';
|
||||
import { confirmSmsOtp, sendSmsOtp } from '../../api/authorizationAPI';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
interface VerifyPhoneNumberProps {
|
||||
value: string;
|
||||
countryCode: CountryCode;
|
||||
onEditValue: () => void;
|
||||
onPhoneNumberVerified: () => void;
|
||||
}
|
||||
|
||||
export function VerifyPhoneNumber({
|
||||
value,
|
||||
countryCode,
|
||||
onEditValue,
|
||||
onPhoneNumberVerified,
|
||||
}: VerifyPhoneNumberProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const { loading: smsResendLoading, execute: smsResendCall } =
|
||||
useApi(sendSmsOtp);
|
||||
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } =
|
||||
useApi(confirmSmsOtp);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (resendTimer > 0) {
|
||||
interval = setInterval(() => {
|
||||
setResendTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setCanResend(true);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [resendTimer]);
|
||||
|
||||
const handleResendOTPCode = async () => {
|
||||
await smsResendCall({ phoneNumber: countryCode + value });
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
setOtpDigitInvalid(false);
|
||||
|
||||
const confirmSmsOtpRequest: ConfirmSmsOtpRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: countryCode + value,
|
||||
};
|
||||
const res = await confirmSmsOtpCall(confirmSmsOtpRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
setIsStatusSuccess(true);
|
||||
toast({
|
||||
message: t('verify.youHaveSuccessfullyLoggedIn'),
|
||||
severity: 'success',
|
||||
});
|
||||
onPhoneNumberVerified();
|
||||
} else {
|
||||
setIsStatusSuccess(false);
|
||||
toast({
|
||||
message: res.message ?? t('verify.theVerificationCodeIsIncorrect'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">{t('verify.verify')}</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ textTransform: 'lowercase', width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditValue}
|
||||
>
|
||||
{countryCode + value}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{t(
|
||||
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || isStatusSuccess === false}
|
||||
success={isStatusSuccess === true}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={confirmSmsOtpLoading}>
|
||||
{t('verify.confirmAndLogin')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mt: 2.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: 'primary.main', marginInlineStart: 1 }}
|
||||
>
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Typography>
|
||||
|
||||
{canResend && (
|
||||
<Button
|
||||
variant="text"
|
||||
loading={smsResendLoading}
|
||||
sx={{ width: 'auto', p: 0 }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{t('verify.resendCode')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
255
src/features/authentication/components/CountryCodeSelector.tsx
Normal file
255
src/features/authentication/components/CountryCodeSelector.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Box,
|
||||
InputAdornment,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import { ArrowDown2 } from 'iconsax-react';
|
||||
import ReactCountryFlag from 'react-country-flag';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { countries, type Country } from '../../../data/countries';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
interface CountryCodeSelectorProps {
|
||||
show: boolean;
|
||||
value: CountryCode;
|
||||
onChange: (newValue: CountryCode) => void;
|
||||
menuAnchor: HTMLElement | null;
|
||||
onCloseFocusRef: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An animated country code adornment that fades and slides into view.
|
||||
* Its visibility is controlled by the `show` prop.
|
||||
*/
|
||||
export function CountryCodeSelector({
|
||||
show,
|
||||
value,
|
||||
onChange,
|
||||
menuAnchor,
|
||||
onCloseFocusRef,
|
||||
}: CountryCodeSelectorProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const open = Boolean(anchorEl);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const menuWidth = menuAnchor ? menuAnchor.clientWidth : 'auto';
|
||||
const { t, i18n } = useTranslation(['country', 'common']);
|
||||
|
||||
const selectedCountry =
|
||||
countries.find((c) => c.phone === value) || countries[0];
|
||||
|
||||
const handleClick = () => {
|
||||
setAnchorEl(menuAnchor);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTimeout(() => {
|
||||
setAnchorEl(null);
|
||||
}, 0);
|
||||
setTimeout(() => {
|
||||
onCloseFocusRef.current?.focus();
|
||||
}, 100);
|
||||
setSearchTerm(''); // Reset search on close
|
||||
};
|
||||
|
||||
const handleSelect = (country: Country) => {
|
||||
onChange(country.phone);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleMenuEntered = () => {
|
||||
// Focus the input field after the menu has finished opening
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const filteredCountries = useMemo(
|
||||
() =>
|
||||
countries.filter(
|
||||
(country) =>
|
||||
t(country.label).toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
country.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
country.phone.includes(searchTerm),
|
||||
),
|
||||
[searchTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputAdornment
|
||||
position={i18n.dir() === 'rtl' ? 'start' : 'end'}
|
||||
sx={{
|
||||
mx: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
// Animate width and opacity based on the 'show' prop
|
||||
width: show ? 'auto' : 0,
|
||||
opacity: show ? 1 : 0,
|
||||
transition: (theme) =>
|
||||
theme.transitions.create(['width', 'opacity'], {
|
||||
duration: theme.transitions.duration.standard,
|
||||
}),
|
||||
|
||||
// Prevent content from wrapping or spilling out during animation
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
// layout styles
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
pl: show ? 0.25 : 0,
|
||||
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* This inner Box prevents the content from being squeezed during the transition */}
|
||||
<Icon Component={ArrowDown2} size="medium" variant="Bold" />
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.primary"
|
||||
component="div"
|
||||
sx={{ direction: 'rtl' }} // TODO: need to fixed for both en and fa
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
|
||||
<ReactCountryFlag
|
||||
countryCode={selectedCountry.code}
|
||||
svg
|
||||
style={{
|
||||
height: '1.5rem',
|
||||
width: '1.5rem',
|
||||
// TODO: Check alignment for better styling definition
|
||||
marginTop: '-2px',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
slotProps={{
|
||||
list: {
|
||||
sx: {
|
||||
// Set the width to match the anchor element's width
|
||||
width: menuWidth,
|
||||
height: '18.75rem',
|
||||
},
|
||||
},
|
||||
transition: {
|
||||
onEntered: handleMenuEntered,
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
p: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={searchInputRef}
|
||||
size="small"
|
||||
fullWidth
|
||||
label={t('labels.search', { ns: 'common' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Can improve preformance with using virtual scrolling */}
|
||||
<Box sx={{ width: '100%', maxHeight: '14.75rem', overflow: 'auto' }}>
|
||||
{filteredCountries.length === 0 ? (
|
||||
<MenuItem disabled>
|
||||
<ListItem>
|
||||
<ListItemText>{t('messages.noResualtFound')}</ListItemText>
|
||||
</ListItem>
|
||||
</MenuItem>
|
||||
) : (
|
||||
filteredCountries.map((country) => (
|
||||
<MenuItem
|
||||
key={country.code}
|
||||
selected={country.phone === value}
|
||||
onClick={() => handleSelect(country)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ReactCountryFlag
|
||||
countryCode={country.code}
|
||||
svg
|
||||
style={{
|
||||
height: '1.125rem',
|
||||
width: '1.125rem',
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t(country.label, { ns: 'country' })} />
|
||||
<Typography color="text.secondary">
|
||||
{country.phone}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* virtual scrolling */}
|
||||
{/* <Virtuoso
|
||||
style={{ height: '14.75rem' }} // Adjust height to account for the search bar
|
||||
data={filteredCountries}
|
||||
components={{
|
||||
EmptyPlaceholder: () => (
|
||||
<MenuItem disabled>
|
||||
<ListItemText>{t('messages.noResultFound')}</ListItemText>
|
||||
</MenuItem>
|
||||
),
|
||||
}}
|
||||
initialTopMostItemIndex={countries.indexOf(selectedCountry)}
|
||||
itemContent={(_, country) => (
|
||||
<MenuItem
|
||||
key={country.code}
|
||||
selected={country.phone === value}
|
||||
onClick={() => handleSelect(country)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ReactCountryFlag
|
||||
countryCode={country.code}
|
||||
svg
|
||||
style={{ fontSize: '1.25em', lineHeight: '1.25em' }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={country.label} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{country.phone}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
/> */}
|
||||
</Menu>
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { Edit2, Eye, EyeSlash, TickCircle } from 'iconsax-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { containsNumber } from '@/utils/regexes/containsNumber';
|
||||
import { containsSymbol } from '@/utils/regexes/containsSymbol';
|
||||
import { least8Chars } from '@/utils/regexes/least8Chars';
|
||||
import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter';
|
||||
import type { ResetPasswordRequest } from '../../types/userTypes';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { resetPassword } from '../../api/authorizationAPI';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface ChangePasswordProps {
|
||||
onEditInfo: () => void;
|
||||
onPasswordChanged: () => void;
|
||||
forgettedPasswordInfo: string;
|
||||
infoType: AuthType;
|
||||
countryCode: CountryCode;
|
||||
}
|
||||
|
||||
export const ChangePassword = ({
|
||||
onEditInfo,
|
||||
onPasswordChanged,
|
||||
forgettedPasswordInfo,
|
||||
infoType,
|
||||
countryCode,
|
||||
}: ChangePasswordProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [passValue, setPassValue] = useState<string>('');
|
||||
const [confirmPassValue, setConfirmPassValue] = useState<string>('');
|
||||
const [inputTouched, setInputTouched] = useState<boolean>(false);
|
||||
const [confirmInputTouched, setConfirmInputTouched] =
|
||||
useState<boolean>(false);
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const confirmInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { loading: resetPasswordLoading, execute: resetPasswordCall } =
|
||||
useApi(resetPassword);
|
||||
|
||||
const passwordValidationRules = [
|
||||
{ title: t('forgetPassword.includingANumber'), validator: containsNumber },
|
||||
{ title: t('forgetPassword.atLeast8Characters'), validator: least8Chars },
|
||||
{
|
||||
title: t('forgetPassword.containsAnUppercaseAndLowercaseLetter'),
|
||||
validator: hasUpperAndLowerLetter,
|
||||
},
|
||||
{ title: t('forgetPassword.ContainsASymbol'), validator: containsSymbol },
|
||||
];
|
||||
|
||||
const handleBlur = () => {
|
||||
setInputTouched(true);
|
||||
};
|
||||
|
||||
const handleConfirmPassBlur = () => {
|
||||
setConfirmInputTouched(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!passValue || !isValidPassword(passValue)) {
|
||||
setInputTouched(true);
|
||||
inputRef.current?.focus();
|
||||
} else if (passValue !== confirmPassValue) {
|
||||
setConfirmInputTouched(true);
|
||||
confirmInputRef.current?.focus();
|
||||
} else {
|
||||
const apiRequest: ResetPasswordRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
newPassword: passValue,
|
||||
confirmNewPassword: confirmPassValue,
|
||||
};
|
||||
|
||||
const res = await resetPasswordCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
onPasswordChanged();
|
||||
toast({
|
||||
message: t('forgetPassword.passwordChangedSuccessfully'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPassword = (value: string) => {
|
||||
return (
|
||||
containsNumber(value) &&
|
||||
containsSymbol(value) &&
|
||||
least8Chars(value) &&
|
||||
hasUpperAndLowerLetter(value)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">
|
||||
{t('forgetPassword.changePassword')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ textTransform: 'lowercase', width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditInfo}
|
||||
>
|
||||
{forgettedPasswordInfo}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
|
||||
{t('forgetPassword.createANewPassword')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('forgetPassword.newPassword')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passValue}
|
||||
inputRef={inputRef}
|
||||
onChange={(e) => setPassValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
error={inputTouched && !isValidPassword(passValue)}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } },
|
||||
input: {
|
||||
startAdornment: confirmPassValue &&
|
||||
isValidPassword(passValue) &&
|
||||
passValue === confirmPassValue && (
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
variant="Bold"
|
||||
color="success.main"
|
||||
/>
|
||||
),
|
||||
endAdornment: passValue ? (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<Icon Component={Eye} />
|
||||
) : (
|
||||
<Icon Component={EyeSlash} />
|
||||
)}
|
||||
</IconButton>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ mt: 4 }}
|
||||
/>
|
||||
|
||||
{!isValidPassword(passValue) && (
|
||||
<Stack spacing={1} sx={{ mt: 2 }}>
|
||||
{passwordValidationRules.map((rule) => (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
variant={rule.validator(passValue) ? 'Bold' : 'Linear'}
|
||||
color={
|
||||
rule.validator(passValue) ? 'success.main' : 'primary.light'
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography variant="body2">{rule.title}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t('forgetPassword.confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassValue}
|
||||
inputRef={confirmInputRef}
|
||||
onChange={(e) => setConfirmPassValue(e.target.value)}
|
||||
onBlur={handleConfirmPassBlur}
|
||||
error={confirmInputTouched && confirmPassValue !== passValue}
|
||||
slotProps={{
|
||||
htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } },
|
||||
input: {
|
||||
startAdornment: confirmPassValue &&
|
||||
isValidPassword(passValue) &&
|
||||
passValue === confirmPassValue && (
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
variant="Bold"
|
||||
color="success.main"
|
||||
/>
|
||||
),
|
||||
endAdornment: confirmPassValue ? (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<Icon Component={Eye} />
|
||||
) : (
|
||||
<Icon Component={EyeSlash} />
|
||||
)}
|
||||
</IconButton>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button loading={resetPasswordLoading} onClick={handleSubmit}>
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { ForgettedPasswordInfo } from './ForgettedPasswordInfo';
|
||||
import { ForgetPasswordOtp } from './ForgetPasswordOtp';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const ForgetPasswordContainer = () => {
|
||||
const navigate = useNavigate();
|
||||
const [forgetPassCurrentStep, setForgetPassCurrentStep] = useState<
|
||||
'enterInfo' | 'verifyOtp' | 'setPassword'
|
||||
>('enterInfo');
|
||||
const [forgettedPasswordInfo, setForgettedPasswordInfo] =
|
||||
useState<string>('');
|
||||
const [infoCountryCode, setInfoCountryCode] = useState<CountryCode>('+98');
|
||||
const [infoType, setInfoType] = useState<AuthType>('email');
|
||||
|
||||
const handleVerifyOtp = () => {
|
||||
setForgetPassCurrentStep('verifyOtp');
|
||||
};
|
||||
|
||||
const handleEditInfo = () => {
|
||||
setForgetPassCurrentStep('enterInfo');
|
||||
};
|
||||
|
||||
const handleOtpVerified = () => {
|
||||
setForgetPassCurrentStep('setPassword');
|
||||
};
|
||||
|
||||
const handlePasswordChanged = () => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{forgetPassCurrentStep === 'enterInfo' && (
|
||||
<ForgettedPasswordInfo
|
||||
infoType={infoType}
|
||||
setInfoType={setInfoType}
|
||||
forgettedPasswordInfo={forgettedPasswordInfo}
|
||||
setForgettedPasswordInfo={setForgettedPasswordInfo}
|
||||
onVerifyOtp={handleVerifyOtp}
|
||||
countryCode={infoCountryCode}
|
||||
setCountryCode={setInfoCountryCode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forgetPassCurrentStep === 'verifyOtp' && (
|
||||
<ForgetPasswordOtp
|
||||
countryCode={infoCountryCode}
|
||||
infoType={infoType}
|
||||
onEditInfo={handleEditInfo}
|
||||
forgettedPasswordInfo={forgettedPasswordInfo}
|
||||
onOTPVerified={handleOtpVerified}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forgetPassCurrentStep === 'setPassword' && (
|
||||
<ChangePassword
|
||||
onEditInfo={handleEditInfo}
|
||||
forgettedPasswordInfo={forgettedPasswordInfo}
|
||||
onPasswordChanged={handlePasswordChanged}
|
||||
infoType={infoType}
|
||||
countryCode={infoCountryCode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/DigitsInput';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type {
|
||||
ConfirmForgetPassCodeRequest,
|
||||
SendForgetPassCodeRequest,
|
||||
} from '../../types/userTypes';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import {
|
||||
confirmForgetPassCode,
|
||||
sendForgetPassCode,
|
||||
} from '../../api/authorizationAPI';
|
||||
import { Icon, useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
interface ForgetPasswordOtpProps {
|
||||
forgettedPasswordInfo: string;
|
||||
infoType: AuthType;
|
||||
countryCode: CountryCode;
|
||||
onEditInfo: () => void;
|
||||
onOTPVerified: (otpCode: string) => void;
|
||||
}
|
||||
|
||||
export function ForgetPasswordOtp({
|
||||
forgettedPasswordInfo,
|
||||
infoType,
|
||||
countryCode,
|
||||
onEditInfo,
|
||||
onOTPVerified,
|
||||
}: ForgetPasswordOtpProps) {
|
||||
const [otpCode, setOtpCode] = useState<string>('');
|
||||
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
|
||||
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
|
||||
const { t } = useTranslation('authentication');
|
||||
const [resendTimer, setResendTimer] = useState<number>(120);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const toast = useToast();
|
||||
const {
|
||||
loading: sendForgetPassCodeLoading,
|
||||
execute: sendForgetPassCodeCall,
|
||||
} = useApi(sendForgetPassCode);
|
||||
const {
|
||||
loading: confirmForgetPassCodeLoading,
|
||||
execute: confirmForgetPassCodeCall,
|
||||
} = useApi(confirmForgetPassCode);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (resendTimer > 0) {
|
||||
interval = setInterval(() => {
|
||||
setResendTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setCanResend(true);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [resendTimer]);
|
||||
|
||||
const handleResendOTPCode = async () => {
|
||||
const sendCodeRequest: SendForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone' ? countryCode + forgettedPasswordInfo : undefined,
|
||||
};
|
||||
await sendForgetPassCodeCall(sendCodeRequest);
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
setOtpDigitInvalid(false);
|
||||
|
||||
// Change setTimeout to api call
|
||||
const apiRequest: ConfirmForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
code: otpCode,
|
||||
};
|
||||
|
||||
const res = await confirmForgetPassCodeCall(apiRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (res.success) {
|
||||
setIsStatusSuccess(true);
|
||||
onOTPVerified(otpCode);
|
||||
} else {
|
||||
setIsStatusSuccess(false);
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">
|
||||
{t('forgetPassword.forgetPassword')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{ textTransform: 'lowercase', width: 'auto' }}
|
||||
endIcon={<Icon Component={Edit2} />}
|
||||
onClick={onEditInfo}
|
||||
>
|
||||
{infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: forgettedPasswordInfo}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{infoType === 'email'
|
||||
? t(
|
||||
'forgetPassword.anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress',
|
||||
)
|
||||
: t(
|
||||
'forgetPassword.anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber',
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || isStatusSuccess === false}
|
||||
success={isStatusSuccess === true}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleVerifyOTP}
|
||||
loading={confirmForgetPassCodeLoading}
|
||||
>
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mt: 2.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: 'primary.main', marginInlineStart: 1 }}
|
||||
>
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Typography>
|
||||
|
||||
{canResend && (
|
||||
<Button
|
||||
variant="text"
|
||||
loading={sendForgetPassCodeLoading}
|
||||
sx={{ width: 'auto', p: 0 }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{t('verify.resendCode')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useRef, useState, type Dispatch } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { isEmail } from '@/utils/regexes/isEmail';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { CountryCodeSelector } from '../CountryCodeSelector';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { sendForgetPassCode } from '../../api/authorizationAPI';
|
||||
import type { SendForgetPassCodeRequest } from '../../types/userTypes';
|
||||
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
|
||||
export interface ForgettedPasswordInfoProps {
|
||||
forgettedPasswordInfo: string;
|
||||
setForgettedPasswordInfo: Dispatch<string>;
|
||||
infoType: AuthType;
|
||||
setInfoType: Dispatch<AuthType>;
|
||||
onVerifyOtp: (value: string) => void;
|
||||
countryCode: CountryCode;
|
||||
setCountryCode: Dispatch<CountryCode>;
|
||||
}
|
||||
|
||||
export function ForgettedPasswordInfo({
|
||||
forgettedPasswordInfo,
|
||||
setForgettedPasswordInfo,
|
||||
infoType,
|
||||
setInfoType,
|
||||
onVerifyOtp,
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
}: ForgettedPasswordInfoProps) {
|
||||
const { t } = useTranslation('authentication');
|
||||
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
const [touched, setTouched] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
const toast = useToast();
|
||||
const {
|
||||
loading: sendForgetPassCodeLoading,
|
||||
execute: sendForgetPassCodeCall,
|
||||
} = useApi(sendForgetPassCode);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setForgettedPasswordInfo(newValue);
|
||||
|
||||
// If the new value contains only digits (or is empty), it's a phone number
|
||||
if (isNumeric(newValue)) {
|
||||
setInfoType('phone');
|
||||
} else {
|
||||
setInfoType('email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setTouched(true);
|
||||
validateInput(forgettedPasswordInfo, infoType);
|
||||
};
|
||||
|
||||
const validateInput = (
|
||||
value: string,
|
||||
authType: AuthType,
|
||||
setErrors: boolean = true,
|
||||
) => {
|
||||
if (!value) {
|
||||
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
|
||||
return false;
|
||||
} else if (authType === 'email' && !isEmail(value)) {
|
||||
if (setErrors) setError(t('loginForm.emailIsInvalid'));
|
||||
return false;
|
||||
} else if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
|
||||
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
|
||||
return false;
|
||||
} else {
|
||||
if (setErrors) setError(undefined);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (validateInput(forgettedPasswordInfo, infoType, false)) {
|
||||
const sendCodeRequest: SendForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
};
|
||||
const res = await sendForgetPassCodeCall(sendCodeRequest);
|
||||
|
||||
if (!res) return;
|
||||
|
||||
if (!res.success) {
|
||||
toast({
|
||||
message: res.message,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyOtp(forgettedPasswordInfo);
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
validateInput(forgettedPasswordInfo, infoType);
|
||||
}
|
||||
};
|
||||
|
||||
const showAdornment =
|
||||
infoType === 'phone' && forgettedPasswordInfo.length > 0;
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">
|
||||
{t('forgetPassword.forgetPassword')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t(
|
||||
'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword',
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
ref={textFieldRef}
|
||||
inputRef={inputRef}
|
||||
label={t('loginForm.emailOrPhoneLabel')}
|
||||
value={forgettedPasswordInfo}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
error={inputError}
|
||||
helperText={inputError ? error : ''}
|
||||
autoFocus
|
||||
slotProps={{
|
||||
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
|
||||
input: {
|
||||
endAdornment: (
|
||||
<CountryCodeSelector
|
||||
value={countryCode}
|
||||
onChange={setCountryCode}
|
||||
show={showAdornment}
|
||||
menuAnchor={textFieldRef.current}
|
||||
onCloseFocusRef={inputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4, mb: 8 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Button loading={sendForgetPassCodeLoading} onClick={handleSubmit}>
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
DatePicker,
|
||||
PickersDay,
|
||||
type PickersDayProps,
|
||||
} from '@mui/x-date-pickers';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { AdapterDateFnsJalali } from '@mui/x-date-pickers/AdapterDateFnsJalali';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import { faIR as faIRJalali } from 'date-fns-jalali/locale';
|
||||
import { getDay } from 'date-fns-jalali';
|
||||
import { format as formatJalali } from 'date-fns-jalali';
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toLocaleDigits } from '@/utils/persianDigit';
|
||||
import { type DateOfBirthProps } from '../../types/settingForm';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { Calendar } from 'iconsax-react';
|
||||
|
||||
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
|
||||
const { t, i18n } = useTranslation('completionForm');
|
||||
const isFarsi = i18n.language === 'fa' || i18n.language === 'fa-IR';
|
||||
|
||||
const { Adapter, locale, formatString, dayOfWeekFormatter } = useMemo(() => {
|
||||
if (isFarsi) {
|
||||
const persianDays = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش'];
|
||||
return {
|
||||
Adapter: AdapterDateFnsJalali,
|
||||
locale: faIRJalali,
|
||||
formatString: 'yyyy/MM/dd',
|
||||
dayOfWeekFormatter: (date: Date) => persianDays[getDay(date)],
|
||||
};
|
||||
}
|
||||
return {
|
||||
Adapter: AdapterDateFns,
|
||||
locale: enUS,
|
||||
formatString: 'MM/dd/yyyy',
|
||||
dayOfWeekFormatter: undefined,
|
||||
};
|
||||
}, [isFarsi]);
|
||||
|
||||
const CustomDay = (props: PickersDayProps) => {
|
||||
const dayNumber = isFarsi
|
||||
? formatJalali(props.day, 'dd')
|
||||
: format(props.day, 'dd');
|
||||
return (
|
||||
<PickersDay {...props}>
|
||||
{toLocaleDigits(dayNumber, i18n.language)}
|
||||
</PickersDay>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomCalendarIcon = () => (
|
||||
<Icon Component={Calendar} color="primary.main" variant="Bold" />
|
||||
);
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}>
|
||||
<DatePicker
|
||||
label={toLocaleDigits(t('completion.dateOfBirth'), i18n.language)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
format={formatString}
|
||||
dayOfWeekFormatter={dayOfWeekFormatter}
|
||||
slots={{ day: CustomDay, openPickerIcon: CustomCalendarIcon }}
|
||||
disableFuture
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
},
|
||||
popper: {
|
||||
sx: {
|
||||
'& .MuiDateCalendar-root': {
|
||||
// TODO: fix this to use textfield width instead of defining hardcode
|
||||
width: '309px',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Box,
|
||||
Button,
|
||||
Switch,
|
||||
FormGroup,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TickCircle, Edit2 } from 'iconsax-react';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type EmailSectionProps } from '../../types/settingForm';
|
||||
import { toLocaleDigits } from '@/utils/persianDigit';
|
||||
import { isNumeric } from '@/utils/regexes/isNumeric';
|
||||
|
||||
export function EmailSection({
|
||||
showEmail,
|
||||
setShowEmail,
|
||||
email,
|
||||
setEmail,
|
||||
codeSent,
|
||||
verificationCode,
|
||||
setVerificationCode,
|
||||
buttonState,
|
||||
handleSendCode,
|
||||
handleVerifyCode,
|
||||
emailVerified,
|
||||
isVerifyingCode,
|
||||
isSendingCode,
|
||||
handleEditEmail,
|
||||
errors,
|
||||
touched,
|
||||
handleBlur,
|
||||
countdown,
|
||||
}: EmailSectionProps) {
|
||||
const { t, i18n } = useTranslation('completionForm');
|
||||
|
||||
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShowEmail(e.target.checked);
|
||||
};
|
||||
|
||||
const handleVerificationCodeChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = e.target.value;
|
||||
// Allow only digits and enforce the max length
|
||||
if (isNumeric(value) && value.length <= 4) {
|
||||
setVerificationCode(value);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimerValue = () => {
|
||||
const m = String(Math.floor(countdown / 60)).padStart(2, '0');
|
||||
const s = String(countdown % 60).padStart(2, '0');
|
||||
return toLocaleDigits(`${m}:${s}`, i18n.language);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Switch checked={showEmail} onChange={handleToggleEmail} />
|
||||
<Typography
|
||||
sx={{ color: showEmail ? 'primary.main' : 'text.primary' }}
|
||||
>
|
||||
{t('completion.determineEmail')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</FormGroup>
|
||||
{showEmail && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.email')}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={email}
|
||||
autoFocus
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={() => handleBlur('email')}
|
||||
disabled={isSendingCode || codeSent}
|
||||
error={touched.email && !!errors.email}
|
||||
helperText={touched.email && errors.email}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment:
|
||||
!isVerifyingCode && emailVerified ? (
|
||||
<InputAdornment position="start">
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
size="medium"
|
||||
variant="Bold"
|
||||
color="success.main"
|
||||
/>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
endAdornment: codeSent ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={handleEditEmail}>
|
||||
<Icon
|
||||
Component={Edit2}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!isVerifyingCode &&
|
||||
!emailVerified &&
|
||||
(buttonState === 'counting' ? (
|
||||
<Typography
|
||||
sx={{
|
||||
minWidth: '140px',
|
||||
width: { xs: '100%', sm: '156px' },
|
||||
alignSelf: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
color="primary"
|
||||
variant="body1"
|
||||
>
|
||||
{formatTimerValue()}
|
||||
</Typography>
|
||||
) : (
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={handleSendCode}
|
||||
loading={isSendingCode}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: '156px' },
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
{t('completion.vericationCodeButton')}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
{!emailVerified && codeSent && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.verificationCode')}
|
||||
autoFocus
|
||||
variant="outlined"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={handleVerificationCodeChange}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
disabled={isVerifyingCode}
|
||||
onBlur={() => handleBlur('verificationCode')}
|
||||
error={touched.verificationCode && !!errors.verificationCode}
|
||||
helperText={touched.verificationCode && errors.verificationCode}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleVerifyCode}
|
||||
loading={isVerifyingCode}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: '156px' },
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
{t('completion.checkCodeButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Box,
|
||||
IconButton,
|
||||
Switch,
|
||||
FormGroup,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react';
|
||||
import { PasswordValidationItem } from './PasswordValidation';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type PasswordSectionProps } from '../../types/settingForm';
|
||||
|
||||
export function PasswordSection({
|
||||
showPasswordSection,
|
||||
setShowPasswordSection,
|
||||
password,
|
||||
setPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
matchPassword,
|
||||
hasNumber,
|
||||
hasMinLength,
|
||||
hasUpperAndLower,
|
||||
hasSpecialChar,
|
||||
validPassword,
|
||||
errors,
|
||||
touched,
|
||||
handleBlur,
|
||||
}: PasswordSectionProps) {
|
||||
const { t } = useTranslation('completionForm');
|
||||
const [showPasswordText, setShowPasswordText] = useState(false);
|
||||
const [showPasswordRepetitionText, setShowPasswordRepetitionText] =
|
||||
useState(false);
|
||||
const [showValidations, setShowValidation] = useState(false);
|
||||
|
||||
const handleTogglePasswordSection = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setShowPasswordSection(e.target.checked);
|
||||
};
|
||||
|
||||
const handleTogglePasswordEye = () => setShowPasswordText((prev) => !prev);
|
||||
const handleTogglePasswordRepetitionEye = () =>
|
||||
setShowPasswordRepetitionText((prev) => !prev);
|
||||
|
||||
const handleBlurPassword = () => {
|
||||
handleBlur('password');
|
||||
setShowValidation(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={showPasswordSection}
|
||||
onChange={handleTogglePasswordSection}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
color: showPasswordSection ? 'primary.main' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{t('completion.determinePassword')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</FormGroup>
|
||||
|
||||
{showPasswordSection && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('completion.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
variant="outlined"
|
||||
type={showPasswordText ? 'text' : 'password'}
|
||||
autoFocus
|
||||
onBlur={handleBlurPassword}
|
||||
onFocus={() => setShowValidation(true)}
|
||||
error={touched.password && !!errors.password}
|
||||
helperText={touched.password && errors.password}
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
'& .MuiInputBase-input': {
|
||||
pr: 8, // Increased padding to accommodate both icons
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleTogglePasswordEye}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
{showPasswordText ? (
|
||||
<Icon
|
||||
Component={Eye}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
Component={EyeSlash}
|
||||
size="medium"
|
||||
color="primary.main"
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
startAdornment: validPassword && (
|
||||
<InputAdornment position="start">
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
size="medium"
|
||||
color="success.main"
|
||||
variant="Bold"
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('completion.passwordRepetition')}
|
||||
variant="outlined"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onBlur={() => handleBlur('confirmPassword')}
|
||||
error={touched.confirmPassword && !!errors.confirmPassword}
|
||||
helperText={touched.confirmPassword && errors.confirmPassword}
|
||||
type={showPasswordRepetitionText ? 'text' : 'password'}
|
||||
sx={{
|
||||
flex: '1 1',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleTogglePasswordRepetitionEye}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
{showPasswordRepetitionText ? (
|
||||
<Icon
|
||||
Component={Eye}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
Component={EyeSlash}
|
||||
size="medium"
|
||||
color="primary.main"
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
startAdornment: confirmPassword.length > 0 && (
|
||||
<InputAdornment position="start">
|
||||
<Icon
|
||||
Component={matchPassword ? TickCircle : CloseCircle}
|
||||
size="medium"
|
||||
color={matchPassword ? 'success.main' : 'error.main'}
|
||||
variant="Bold"
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{showValidations && (
|
||||
<>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<PasswordValidationItem
|
||||
isValid={hasNumber}
|
||||
label={t('completion.hasNumber')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<PasswordValidationItem
|
||||
isValid={hasMinLength}
|
||||
label={t('completion.hasMinLength')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<PasswordValidationItem
|
||||
isValid={hasUpperAndLower}
|
||||
label={t('completion.hasUpperAndLower')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<PasswordValidationItem
|
||||
isValid={hasSpecialChar}
|
||||
label={t('completion.hasSpecialChar')}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { TickCircle } from 'iconsax-react';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type ValidationItemProps } from '../../types/settingForm';
|
||||
|
||||
export function PasswordValidationItem({
|
||||
isValid,
|
||||
label,
|
||||
}: ValidationItemProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
size="small"
|
||||
color={isValid ? 'success.main' : 'primary.main'}
|
||||
variant={isValid ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{
|
||||
wordBreak: 'break-word',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Box,
|
||||
Autocomplete,
|
||||
Grid,
|
||||
type SelectChangeEvent,
|
||||
FormHelperText,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Woman, Man } from 'iconsax-react';
|
||||
import DateOfBirth from './DateOfBirth';
|
||||
import { countries } from '@/data/countries';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type PersonalInfoFieldsProps } from '../../types/settingForm';
|
||||
import { Gender } from '../../types/settingForm';
|
||||
import ReactCountryFlag from 'react-country-flag';
|
||||
|
||||
export function PersonalInfoFields({
|
||||
firstName,
|
||||
setFirstName,
|
||||
lastName,
|
||||
setLastName,
|
||||
nationalId,
|
||||
setNationalId,
|
||||
birthDate,
|
||||
setBirthDate,
|
||||
sex,
|
||||
setSex,
|
||||
country,
|
||||
setCountry,
|
||||
errors,
|
||||
touched,
|
||||
handleBlur,
|
||||
}: PersonalInfoFieldsProps) {
|
||||
const { t } = useTranslation(['completionForm', 'country']);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
code: c.code,
|
||||
label: t(c.label, { ns: 'country' }),
|
||||
}));
|
||||
|
||||
const currentCountry = countryOptions.find((c) => c.code === country) || null;
|
||||
|
||||
const handleChangeSex = (e: SelectChangeEvent<Gender>) => {
|
||||
setSex(e.target.value as Gender);
|
||||
};
|
||||
|
||||
const genderOptions = [
|
||||
{
|
||||
value: Gender.Female,
|
||||
icon: Woman,
|
||||
label: t('completion.woman'),
|
||||
color: '#F50057',
|
||||
},
|
||||
{
|
||||
value: Gender.Male,
|
||||
icon: Man,
|
||||
label: t('completion.man'),
|
||||
color: '#0091EA',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={t('completion.name')}
|
||||
placeholder={t('completion.name')}
|
||||
variant="outlined"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={() => handleBlur('firstName')}
|
||||
error={touched.firstName && !!errors.firstName}
|
||||
helperText={touched.firstName && errors.firstName}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('completion.familyName')}
|
||||
placeholder={t('completion.familyName')}
|
||||
variant="outlined"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onBlur={() => handleBlur('lastName')}
|
||||
error={touched.lastName && !!errors.lastName}
|
||||
helperText={touched.lastName && errors.lastName}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth error={touched.sex && !!errors.sex}>
|
||||
<InputLabel>{t('completion.gender')}</InputLabel>
|
||||
<Select
|
||||
value={sex || ''}
|
||||
label={t('completion.gender')}
|
||||
onChange={handleChangeSex}
|
||||
onBlur={() => handleBlur('sex')}
|
||||
>
|
||||
{genderOptions.map((g) => (
|
||||
<MenuItem key={g.value} value={g.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Icon Component={g.icon} size="small" color={g.color} />
|
||||
{g.label}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{touched.sex && errors.sex && (
|
||||
<FormHelperText>{errors.sex}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
options={countryOptions}
|
||||
getOptionLabel={(option) => option.label}
|
||||
noOptionsText={t('completion.noOption')}
|
||||
value={currentCountry}
|
||||
onChange={(_, newValue) => setCountry(newValue?.code || '')}
|
||||
onBlur={() => handleBlur('country')}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props} key={option.code}>
|
||||
<ReactCountryFlag
|
||||
countryCode={option.code}
|
||||
svg
|
||||
style={{
|
||||
height: '1.5rem',
|
||||
width: '1.5rem',
|
||||
marginTop: '-2px',
|
||||
marginRight: '4px',
|
||||
marginLeft: '8px',
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t('completion.country')}
|
||||
error={touched.country && !!errors.country}
|
||||
helperText={touched.country && errors.country}
|
||||
/>
|
||||
)}
|
||||
clearOnEscape
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('completion.optionalNationalCode')}
|
||||
placeholder={t('completion.optionalNationalCode')}
|
||||
value={nationalId}
|
||||
onChange={(e) => setNationalId(e.target.value)}
|
||||
variant="outlined"
|
||||
onBlur={() => handleBlur('nationalId')}
|
||||
error={touched.nationalId && !!errors.nationalId}
|
||||
helperText={touched.nationalId && errors.nationalId}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<DateOfBirth value={birthDate} onChange={setBirthDate} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Link,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type SubmitProps } from '../../types/settingForm';
|
||||
|
||||
export function SubmitSection({ onSubmit, loading }: SubmitProps) {
|
||||
const { t, i18n } = useTranslation('completionForm');
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
|
||||
const handleOpenDialog = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const agreementText = t('completion.agreement');
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
maxWidth: 'calc(100% - 263px)',
|
||||
minWidth: '260px',
|
||||
}}
|
||||
color="text.primary"
|
||||
>
|
||||
{t('completion.agreementPart1')}
|
||||
<Link href="#" onClick={handleOpenDialog}>
|
||||
{t('completion.agreementLinkText')}
|
||||
</Link>
|
||||
{t('completion.agreementPart2')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: '247px' },
|
||||
alignSelf: { xs: 'stretch', sm: 'center' },
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? t('completion.submitting')
|
||||
: t('completion.registerButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
dir={i18n.language.startsWith('fa') ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<DialogTitle>
|
||||
{t('completion.rules') || t('completion.rules')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent
|
||||
dividers
|
||||
sx={{ whiteSpace: 'pre-line', fontSize: '14px' }}
|
||||
>
|
||||
{agreementText}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user