From cd86254ce148de241f97fe2f50d137d750ee872a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=85=D9=87=D8=B1=D8=B2=D8=A7=D8=AF=20=D9=82=D8=AF=D8=B1?= =?UTF-8?q?=D8=AA=DB=8C?= Date: Sat, 9 Aug 2025 15:46:51 +0330 Subject: [PATCH] feat: login and register, otp verify api calls added --- package-lock.json | 38 ++++ package.json | 1 + src/App.tsx | 9 +- .../authorization/api/authorizationAPI.ts | 9 +- .../AuthenticationSteps.tsx | 62 ++++-- .../AuthenticationSteps/LoginRegiserForm.tsx | 48 ++++- .../AuthenticationSteps/OtpVerifyForm.tsx | 62 ++++-- .../AuthenticationSteps/VerifyPhoneNumber.tsx | 191 ++++++++++++++++++ .../components/CountryCodeSelector.tsx | 5 +- src/features/authorization/data/countries.ts | 8 +- src/features/authorization/types/userTypes.ts | 27 ++- src/types/commonTypes.ts | 2 + 12 files changed, 403 insertions(+), 59 deletions(-) create mode 100644 src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx diff --git a/package-lock.json b/package-lock.json index 9e9436f..d528328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-country-flag": "^3.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.6.0", + "react-router": "^7.8.0", "react-virtuoso": "^4.13.0", "stylis": "^4.3.6", "stylis-plugin-rtl": "^2.1.1" @@ -2404,6 +2405,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -3794,6 +3804,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz", + "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -3940,6 +3972,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 91e1810..a876532 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-country-flag": "^3.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.6.0", + "react-router": "^7.8.0", "react-virtuoso": "^4.13.0", "stylis": "^4.3.6", "stylis-plugin-rtl": "^2.1.1" diff --git a/src/App.tsx b/src/App.tsx index 700db76..f1f5aa2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,13 +2,20 @@ import { CssBaseline } from '@mui/material'; import './App.css'; import { LanguageManager } from './components/LanguageManager'; import { AuthenticationPage } from './features/authorization/routes/AuthenticationPage'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router'; function App() { return ( <> - + + + } /> + } /> + + + , ); } diff --git a/src/features/authorization/api/authorizationAPI.ts b/src/features/authorization/api/authorizationAPI.ts index 990924b..290a736 100644 --- a/src/features/authorization/api/authorizationAPI.ts +++ b/src/features/authorization/api/authorizationAPI.ts @@ -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 = ( url: string, @@ -91,6 +92,12 @@ export const loginOrSignUpWithGoogle = async ( ); }; +export const completeUserInformation = async ( + body: CompleteUserInformationRequest, +) => { + return fetchRequest('User/CompleteUserInformation', body); +}; + export const logOut = async () => { return fetchRequest('User/LogOut', {}); }; diff --git a/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx index c9c951a..ccfc6c1 100644 --- a/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx +++ b/src/features/authorization/components/AuthenticationSteps/AuthenticationSteps.tsx @@ -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('register'); const [authType, setAuthType] = useState('phone'); const [currentStep, setCurrentStep] = useState< - | 'emailOrPassword' + | 'emailOrPhone' | 'verify' | 'enterPassword' | 'addPhoneNumber' | 'addedPhoneNumberVerify' - >('emailOrPassword'); + >('emailOrPhone'); const [loginRegisterValue, setLoginRegisterValue] = useState(''); + const [countryCode, setCountryCode] = useState('+98'); const [addedPhoneNumberValue, setAddedPhoneNumberValue] = useState(''); - 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' && ( { {currentStep === 'verify' && ( { )} {currentStep === 'addedPhoneNumberVerify' && ( - )} diff --git a/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx b/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx index 66e7cbd..3d64449 100644 --- a/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx +++ b/src/features/authorization/components/AuthenticationSteps/LoginRegiserForm.tsx @@ -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; + countryCode: CountryCode; + setCountryCode: Dispatch; authType: AuthType; setAuthType: Dispatch; - 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(false); const { t, i18n } = useTranslation('authentication'); - const [countryCode, setCountryCode] = useState('+98'); const textFieldRef = useRef(null); const inputRef = useRef(null); const dir = i18n.dir(); const [error, setError] = useState(); const [touched, setTouched] = useState(false); + const [errorMessage, setErrorMessage] = useState(); const inputError: boolean = touched && !!error; const handleInputChange = (event: React.ChangeEvent) => { @@ -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 ( + setErrorMessage(undefined)} + open={!!errorMessage} + > + {errorMessage} + + {t('loginForm.title')} @@ -139,8 +169,14 @@ export function LoginRegisterForm({ /> - - + diff --git a/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx b/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx index 54de8ba..748f7e9 100644 --- a/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx +++ b/src/features/authorization/components/AuthenticationSteps/OtpVerifyForm.tsx @@ -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(''); const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>(); + const [errorMessage, setErrorMessage] = useState(); const [verifyStatusLoading, setVerifyStatusLoading] = useState(false); const [verifyAlertOpen, setVerifyAlertOpen] = useState(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={} onClick={onEditValue} > - {value} + {authType === 'phone' ? countryCode + value : value} diff --git a/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx b/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx new file mode 100644 index 0000000..b047bdc --- /dev/null +++ b/src/features/authorization/components/AuthenticationSteps/VerifyPhoneNumber.tsx @@ -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(''); + const [otpDigitInvalid, setOtpDigitInvalid] = useState(false); + const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>(); + const [errorMessage, setErrorMessage] = useState(); + const [verifyStatusLoading, setVerifyStatusLoading] = + useState(false); + const [verifyAlertOpen, setVerifyAlertOpen] = useState(false); + const { t } = useTranslation('authentication'); + const [resendTimer, setResendTimer] = useState(120); + const [canResend, setCanResend] = useState(false); + const [resendLoading, setResendLoading] = useState(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 ( + + + setVerifyAlertOpen(false)} + color={verifyStatus === 'failed' ? 'error' : 'success'} + > + {verifyAlertMessage()} + + + + {t('verify.verify')} + + + + + + {t( + 'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt', + )} + + + handleDigitInputChange(value as string[])} + /> + + + + + + {t('verify.resendCodeIn')} + + + + + ); +} diff --git a/src/features/authorization/components/CountryCodeSelector.tsx b/src/features/authorization/components/CountryCodeSelector.tsx index 427bc8a..04ad265 100644 --- a/src/features/authorization/components/CountryCodeSelector.tsx +++ b/src/features/authorization/components/CountryCodeSelector.tsx @@ -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; } diff --git a/src/features/authorization/data/countries.ts b/src/features/authorization/data/countries.ts index 2b05f9e..a2a0166 100644 --- a/src/features/authorization/data/countries.ts +++ b/src/features/authorization/data/countries.ts @@ -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[] = [ diff --git a/src/features/authorization/types/userTypes.ts b/src/features/authorization/types/userTypes.ts index 54070a0..74b5e1d 100644 --- a/src/features/authorization/types/userTypes.ts +++ b/src/features/authorization/types/userTypes.ts @@ -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, +} diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index a380ad0..b6dee6b 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -1 +1,3 @@ export type GUID = `${string}-${string}-${string}-${string}-${string}`; + +export type CountryCode = `+${number}`;