feat: login and register, otp verify api calls added

This commit is contained in:
مهرزاد قدرتی
2025-08-09 15:46:51 +03:30
parent 284e60fab3
commit cd86254ce1
12 changed files with 403 additions and 59 deletions

View File

@@ -1,6 +1,7 @@
import type { ApiResponse } from '@/types/apiResponse';
import type { FetchPromise } from '@/types/fetchPromise';
import type {
CompleteUserInformationRequest,
ConfirmEmailOtpRequest,
ConfirmForgetPassCodeRequest,
ConfirmOtpResponse,
@@ -18,7 +19,7 @@ import type {
SendSmsOtpRequest,
} from '../types/userTypes';
const API_URL = 'https://account.business-harmony.com/api/';
const API_URL = 'https://account.business-harmony.com/api';
export const fetchRequest = <T = ApiResponse>(
url: string,
@@ -91,6 +92,12 @@ export const loginOrSignUpWithGoogle = async (
);
};
export const completeUserInformation = async (
body: CompleteUserInformationRequest,
) => {
return fetchRequest<ApiResponse>('User/CompleteUserInformation', body);
};
export const logOut = async () => {
return fetchRequest<ApiResponse>('User/LogOut', {});
};

View File

@@ -5,61 +5,79 @@ import { OtpVerifyForm } from './OtpVerifyForm';
import { isNumeric } from '@/utils/regexes/isNumeric';
import { CompleteSignUp } from './CompleteSignUp';
import { EnterPasswordForm } from './EnterPasswordForm';
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
import { UserStatus } from '../../types/userTypes';
import type { CountryCode } from '@/types/commonTypes';
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
export const AuthenticationSteps = (): JSX.Element => {
const [authMode, setAuthMode] = useState<AuthMode>('register');
const [authType, setAuthType] = useState<AuthType>('phone');
const [currentStep, setCurrentStep] = useState<
| 'emailOrPassword'
| 'emailOrPhone'
| 'verify'
| 'enterPassword'
| 'addPhoneNumber'
| 'addedPhoneNumberVerify'
>('emailOrPassword');
>('emailOrPhone');
const [loginRegisterValue, setLoginRegisterValue] = useState<string>('');
const [countryCode, setCountryCode] = useState<CountryCode>('+98');
const [addedPhoneNumberValue, setAddedPhoneNumberValue] =
useState<string>('');
const handleLoginRegister = (value: string) => {
setLoginRegisterValue(value);
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
setAuthType(isNumeric(value) ? 'phone' : 'email');
// TODO: after api: send to password if it has account and has password
if (true) {
setCurrentStep('enterPassword');
} else {
setCurrentStep('verify');
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 handleOTPVerfied = (otpCode: string) => {
if (authMode === 'register' && authType === 'email') {
setCurrentStep('addPhoneNumber');
}
const handleOTPVerfied = (registeredWithoutPhoneNumber: boolean = false) => {
// if (registeredWithoutPhoneNumber) {
// setCurrentStep('addPhoneNumber');
// }
};
const handleEditValue = () => {
setCurrentStep('emailOrPassword');
setCurrentStep('emailOrPhone');
};
const handleCompleteSignUp = (countryCode: string, value: string) => {
setCurrentStep('addedPhoneNumberVerify');
};
const handleCompleteSignUpOTPVerified = (otpCode: string) => {
console.log(otpCode);
const handleCompleteSignUpOTPVerified = () => {
console.log('phoneNumberVerified');
};
const handleCompleteSignUpEditValue = () => {
setCurrentStep('emailOrPassword');
setCurrentStep('emailOrPhone');
};
const handleLoggedInWithPassowrd = () => {};
return (
<>
{currentStep === 'emailOrPassword' && (
{currentStep === 'emailOrPhone' && (
<LoginRegisterForm
countryCode={countryCode}
setCountryCode={setCountryCode}
loginRegisterValue={loginRegisterValue}
setLoginRegisterValue={setLoginRegisterValue}
authType={authType}
@@ -70,6 +88,7 @@ export const AuthenticationSteps = (): JSX.Element => {
{currentStep === 'verify' && (
<OtpVerifyForm
countryCode={countryCode}
onOTPVerified={handleOTPVerfied}
onEditValue={handleEditValue}
authMode={authMode}
@@ -97,12 +116,11 @@ export const AuthenticationSteps = (): JSX.Element => {
)}
{currentStep === 'addedPhoneNumberVerify' && (
<OtpVerifyForm
onOTPVerified={handleCompleteSignUpOTPVerified}
<VerifyPhoneNumber
countryCode={countryCode}
onEditValue={handleCompleteSignUpEditValue}
authMode="login"
authType="phone"
value={addedPhoneNumberValue}
onPhoneNumberVerified={handleCompleteSignUpOTPVerified}
/>
)}
</>

View File

@@ -15,29 +15,38 @@ import { isEmail } from '@/utils/regexes/isEmail';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../CountryCodeSelector';
import type { UserStatus } from '../../types/userTypes';
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
import { Toast } from '@/components/Toast';
import type { CountryCode } from '@/types/commonTypes';
export interface LoginRegisterFormProps {
loginRegisterValue: string;
setLoginRegisterValue: Dispatch<string>;
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
authType: AuthType;
setAuthType: Dispatch<AuthType>;
onLoginRegisterSubmit: (value: string) => void;
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
}
export function LoginRegisterForm({
loginRegisterValue,
setLoginRegisterValue,
countryCode,
setCountryCode,
authType,
setAuthType,
onLoginRegisterSubmit,
}: LoginRegisterFormProps) {
const [checkStatusLoading, setCheckStatusLoading] = useState<boolean>(false);
const { t, i18n } = useTranslation('authentication');
const [countryCode, setCountryCode] = useState('+98');
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dir = i18n.dir();
const [error, setError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>();
const inputError: boolean = touched && !!error;
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -91,9 +100,22 @@ export function LoginRegisterForm({
return true;
};
const handleSubmit = () => {
const handleSubmit = async () => {
if (isInputValid(loginRegisterValue, authType)) {
onLoginRegisterSubmit(loginRegisterValue);
setCheckStatusLoading(true);
const result = await getUserStatusByPhoneNumberOrEmail({
phoneNumber:
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
email: authType === 'email' ? loginRegisterValue : undefined,
});
const jsonResult = await result.json();
if (jsonResult.success) {
onLoginRegisterSubmit(loginRegisterValue, jsonResult.userStatus);
} else {
setErrorMessage(jsonResult.message);
}
setCheckStatusLoading(false);
} else {
inputRef.current?.focus();
validateInput(loginRegisterValue, authType);
@@ -104,6 +126,14 @@ export function LoginRegisterForm({
return (
<AuthenticationCard>
<Toast
color="error"
onClose={() => setErrorMessage(undefined)}
open={!!errorMessage}
>
{errorMessage}
</Toast>
<Stack spacing={1}>
<Typography variant="h5">{t('loginForm.title')}</Typography>
<Typography variant="body2" color="text.secondary">
@@ -139,8 +169,14 @@ export function LoginRegisterForm({
/>
<Stack spacing={2}>
<Button onClick={handleSubmit}>{t('loginForm.submitButton')}</Button>
<Button variant="outlined" startIcon={<Google variant="Bold" />}>
<Button loading={checkStatusLoading} onClick={handleSubmit}>
{t('loginForm.submitButton')}
</Button>
<Button
disabled={checkStatusLoading}
variant="outlined"
startIcon={<Google variant="Bold" />}
>
{t('loginForm.loginWithGoogle')}
</Button>
</Stack>

View File

@@ -6,25 +6,37 @@ import type { AuthMode, AuthType } from '../../types/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { LoginRequest } from '../../types/userTypes';
import { useSearchParams } from 'react-router';
import {
loginOrSignUpWithOtp,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
interface OtpVerifyFormProps {
value: string;
countryCode: CountryCode;
authType: AuthType;
authMode: AuthMode;
onEditValue: () => void;
onOTPVerified: (otpCode: string) => void;
onOTPVerified: (registeredWithoutPhoneNumber: boolean) => void;
}
export function OtpVerifyForm({
value,
countryCode,
authType,
authMode,
onEditValue,
onOTPVerified,
}: OtpVerifyFormProps) {
const [searchParams] = useSearchParams();
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
@@ -46,18 +58,18 @@ export function OtpVerifyForm({
return () => clearInterval(interval);
}, [resendTimer]);
const handleResendOTPCode = () => {
const handleResendOTPCode = async () => {
setResendLoading(true);
// TODO: Call API here instead of settimeout
if (authType === 'phone') {
await sendSmsOtp({ phoneNumber: countryCode + value });
} else {
await sendEmailOtp({ email: value });
}
setTimeout(() => {
console.log('resended');
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
}, 1000);
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
const formatTime = (seconds: number) => {
@@ -72,7 +84,7 @@ export function OtpVerifyForm({
setOtpCode(formattedValue);
};
const handleVerifyOTP = () => {
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
@@ -80,12 +92,26 @@ export function OtpVerifyForm({
setVerifyStatusLoading(true);
// Change setTimeout to api call
setTimeout(() => {
setVerifyAlertOpen(true);
const loginRequest: LoginRequest = {
otpCode: otpCode,
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
email: authType === 'email' ? value : undefined,
returnUrl: searchParams.get('returnUrl') ?? '/',
};
const result = await loginOrSignUpWithOtp(loginRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setVerifyStatus('success');
onOTPVerified(otpCode);
setVerifyStatusLoading(false);
}, 1000);
onOTPVerified(jsonRes.registeredWithOutPhoneNumber);
} else {
setVerifyStatus('failed');
setErrorMessage(jsonRes.message);
}
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
}
};
@@ -113,7 +139,7 @@ export function OtpVerifyForm({
const verifyAlertMessage = (): string => {
if (verifyStatus === 'failed') {
return t('verify.theVerificationCodeIsIncorrect');
return errorMessage ?? t('verify.theVerificationCodeIsIncorrect');
} else if (verifyStatus === 'success' && authMode === 'register') {
return t('verify.youHaveSuccessfullySignedIn');
} else if (verifyStatus === 'success' && authMode === 'login') {
@@ -153,7 +179,7 @@ export function OtpVerifyForm({
endIcon={<Edit2 />}
onClick={onEditValue}
>
{value}
{authType === 'phone' ? countryCode + value : value}
</Button>
</Box>

View File

@@ -0,0 +1,191 @@
import { useTranslation } from 'react-i18next';
import { Alert, Box, Button, Snackbar, Stack, Typography } from '@mui/material';
import { Edit2 } from 'iconsax-reactjs';
import DigitInput from '@/components/components/DigitsInput';
import type { AuthMode, AuthType } from '../../types/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { LoginRequest } from '../../types/userTypes';
import { useSearchParams } from 'react-router';
import {
loginOrSignUpWithOtp,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
interface VerifyPhoneNumberProps {
value: string;
countryCode: CountryCode;
onEditValue: () => void;
onPhoneNumberVerified: () => void;
}
export function VerifyPhoneNumber({
value,
countryCode,
onEditValue,
onPhoneNumberVerified,
}: VerifyPhoneNumberProps) {
const [searchParams] = useSearchParams();
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [canResend, setCanResend] = useState(false);
const [resendLoading, setResendLoading] = useState<boolean>(false);
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 () => {
setResendLoading(true);
await sendSmsOtp({ phoneNumber: countryCode + value });
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
const formatTime = (seconds: number) => {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
};
const handleDigitInputChange = (value: string[]) => {
const formattedValue = value.filter((char) => char !== '').join('');
setOtpCode(formattedValue);
};
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
setOtpDigitInvalid(false);
setVerifyStatusLoading(true);
// Change setTimeout to api call
// const loginRequest: LoginRequest = {
// otpCode: otpCode,
// phoneNumber: authType === 'phone' ? countryCode + value : undefined,
// email: authType === 'email' ? value : undefined,
// returnUrl: searchParams.get('returnUrl') ?? '/',
// };
// const result = await loginOrSignUpWithOtp(loginRequest);
// const jsonRes = await result.json();
// if (jsonRes.success) {
// setVerifyStatus('success');
// onOTPVerified(jsonRes.registeredWithOutPhoneNumber);
// } else {
// setVerifyStatus('failed');
// setErrorMessage(jsonRes.message);
// }
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
}
};
const verifyAlertMessage = (): string => {
if (verifyStatus === 'failed') {
return errorMessage ?? t('verify.theVerificationCodeIsIncorrect');
} else {
return t('verify.youHaveSuccessfullyLoggedIn');
}
};
return (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={verifyAlertOpen}
onClose={() => setVerifyAlertOpen(false)}
color={verifyStatus === 'failed' ? 'error' : 'success'}
>
{verifyAlertMessage()}
</Toast>
<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={<Edit2 />}
onClick={onEditValue}
>
{countryCode + value}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{t(
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
)}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => handleDigitInputChange(value as string[])}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{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={resendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -15,10 +15,11 @@ import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import { countries, type Country } from '../data/countries';
import type { CountryCode } from '@/types/commonTypes';
interface CountryCodeSelectorProps {
show: boolean;
value: string;
onChange: (newValue: string) => void;
value: CountryCode;
onChange: (newValue: CountryCode) => void;
menuAnchor: HTMLElement | null;
onCloseFocusRef: RefObject<HTMLInputElement | null>;
}

View File

@@ -1,13 +1,9 @@
export interface Country {
code: string;
label: string;
phone: string;
}
import type { CountryCode } from '@/types/commonTypes';
export interface Country {
code: string;
label: string;
phone: string;
phone: CountryCode;
}
export const countries: readonly Country[] = [

View File

@@ -14,9 +14,9 @@ export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
export enum UserStatus {
None = 0,
Value1 = 1,
Value2 = 2,
Value3 = 3,
RegisteredWithPassword = 1,
RegisteredWithoutPassword = 2,
NotRegistered = 3,
}
// LoginOrSignUpWithOtp
@@ -104,3 +104,24 @@ export interface LoginOrSignUpWithGoogleResponse extends ApiResponse {
completedUserInformation: boolean;
returnUrl: string;
}
// CompleteUserInformation
export interface CompleteUserInformationRequest {
firstName?: string;
lastName?: string;
gender?: Gender;
nationalCode?: string;
savePassword?: boolean;
password?: string;
saveEmail?: boolean;
email?: string;
birthDate?: string;
countryCode?: string;
userId?: GUID;
}
export enum Gender {
Male = 1,
Female = 2,
}