Merge branch 'develop' into fix/completion-page

This commit is contained in:
Sajad Mirjalili
2025-08-21 01:59:25 +03:30
56 changed files with 602 additions and 393 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
)}
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
};

View File

@@ -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}
/>
)}
</>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}