feat: layout padding and scroll added

This commit is contained in:
2025-08-20 23:28:18 +03:30
parent c738c58618
commit 882498637b
37 changed files with 19 additions and 6 deletions

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,233 @@
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: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={smsResendLoading || emailResendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,160 @@
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: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={smsResendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}