fix: ui bugs

This commit is contained in:
Sajad Mirjalili
2025-11-28 16:15:43 +03:30
parent 3ef73a0360
commit 2a6dd67cb4
19 changed files with 165 additions and 182 deletions

View File

@@ -57,10 +57,20 @@ const DigitInput: React.FC<DigitInputProps> = ({
event: KeyboardEvent<HTMLDivElement>,
index: number,
) => {
if (event.key === 'Backspace' && code[index]) {
if (event.key === 'Backspace') {
event.preventDefault();
handleChange('', index);
if (index > 0) inputRefs.current[index - 1]?.focus();
if (code[index]) {
// We clear the current value.
handleChange('', index);
} else if (index > 0) {
// We move focus to the previous input and SELECT its content.
const prevInput = inputRefs.current[index - 1];
if (prevInput) {
prevInput.focus();
prevInput.select();
}
}
}
};

View File

@@ -16,6 +16,7 @@ import type {
SendEmailOtpRequest,
SendForgetPassCodeRequest,
SendSmsOtpRequest,
SendSmsOtpResponse,
} from '../types/userTypes';
import apiClient from '@/lib/apiClient';
@@ -39,15 +40,29 @@ export const loginWithPassword = async (body: PasswordLoginRequest) => {
};
export const sendSmsOtp = async (body: SendSmsOtpRequest) => {
return apiClient.post<ApiResponse>('User/SendSmsOtp', body);
return apiClient.post<SendSmsOtpResponse>('User/SendSmsOtp', body);
};
export const sendEmailOtp = async (body: SendEmailOtpRequest) => {
return apiClient.post<ApiResponse>('User/SendEmailOtp', body);
};
export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => {
return apiClient.post<ConfirmOtpResponse>('User/ConfirmSmsOtp', body);
export const sendSmsCodeCompleteUserInforamation = async (
body: SendSmsOtpRequest,
) => {
return apiClient.post<SendSmsOtpResponse>(
'User/SendSmsCodeCompleteUserInforamation',
body,
);
};
export const confirmSmsCodeCompleteUserInforamation = async (
body: ConfirmSmsOtpRequest,
) => {
return apiClient.post<ConfirmOtpResponse>(
'User/ConfirmSmsCodeCompleteUserInforamation',
body,
);
};
export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => {

View File

@@ -6,7 +6,6 @@ export interface AuthenticationCardProps extends PropsWithChildren {
sx?: SxProps<Theme>;
}
// Beacuse in the otp verify there is a element outside of the authentication card
export const AuthenticationCard = ({
children,
maxWidth,

View File

@@ -31,6 +31,7 @@ export const AuthenticationSteps = (): JSX.Element => {
useState<string>('');
const [memoryTokenRes, setMemoryTokenRes] = useState<GenerateTokenResponse>();
const [hasPassword, setHasPassword] = useState(false);
const [timerValue, setTimerValue] = useState(120);
const { login } = useAuth();
const authFactory: AuthFactory = useMemo(() => {
@@ -66,8 +67,13 @@ export const AuthenticationSteps = (): JSX.Element => {
return resFactory;
}, [searchParams]);
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
const handleLoginRegister = (
value: string,
userStatus: UserStatus,
timerValue: number,
) => {
setAuthType(isNumeric(value) ? 'phone' : 'email');
setTimerValue(timerValue);
switch (userStatus) {
case UserStatus.NotRegistered:
@@ -131,7 +137,7 @@ export const AuthenticationSteps = (): JSX.Element => {
const redirectToReturnUrl = (refreshToken: string) => {
if (authFactory.isCurrentApplication()) {
navigate(import.meta.env.VITE_DEFUALT_AUTH_RETURN_URL);
navigate(import.meta.env.VITE_DEFAULT_AUTH_RETURN_URL);
} else {
if (authMode === 'register') {
navigate(
@@ -169,6 +175,7 @@ export const AuthenticationSteps = (): JSX.Element => {
authType={authType}
value={loginRegisterValue}
hasPassword={hasPassword}
initialTimerValue={timerValue}
onLoginWithPassword={() => setCurrentStep('enterPassword')}
/>
)}
@@ -194,6 +201,7 @@ export const AuthenticationSteps = (): JSX.Element => {
setValue={setAddedPhoneNumberValue}
email={loginRegisterValue}
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
setTimerValue={setTimerValue}
/>
)}
@@ -203,6 +211,7 @@ export const AuthenticationSteps = (): JSX.Element => {
onEditValue={() => setCurrentStep('addPhoneNumber')}
value={addedPhoneNumberValue}
onPhoneNumberVerified={handlePhoneNumberVerified}
initialTimerValue={timerValue}
/>
)}
</>

View File

@@ -4,10 +4,14 @@ import { useRef, useState, type ChangeEvent, type Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../../../../components/CountryCodeSelector';
import { sendSmsOtp } from '../../api/authorizationAPI';
import {
sendSmsCodeCompleteUserInforamation,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
import { useApi } from '@/hooks/useApi';
import { replacePersianWithRealNumbers } from '@/utils/replacePersianWithRealNumbers';
import { useToast } from '@rkheftan/harmony-ui';
export interface CompleteSignUpProps {
email: string;
@@ -16,6 +20,7 @@ export interface CompleteSignUpProps {
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
onCompleteSignUp: (countryCode: string, value: string) => void;
setTimerValue: Dispatch<number>;
}
export const CompleteSignUp = ({
@@ -25,6 +30,7 @@ export const CompleteSignUp = ({
countryCode,
setCountryCode,
onCompleteSignUp,
setTimerValue,
}: CompleteSignUpProps) => {
const { t, i18n } = useTranslation('authentication');
const [error, setError] = useState<string>();
@@ -32,7 +38,10 @@ export const CompleteSignUp = ({
const inputRef = useRef<HTMLInputElement>(null);
const [touched, setTouched] = useState<boolean>(false);
const inputError: boolean = touched && !!error;
const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(sendSmsOtp);
const { loading: sendSmsLoading, execute: sendSmsCall } = useApi(
sendSmsCodeCompleteUserInforamation,
);
const toast = useToast();
const isPhoneValid = (code: string, phone: string) => {
const phoneNumber = parsePhoneNumberFromString(code + phone);
@@ -74,8 +83,20 @@ export const CompleteSignUp = ({
newValue = newValue.substring(1);
setValue(newValue);
}
await sendSmsCall({ phoneNumber: countryCode + newValue });
onCompleteSignUp(countryCode, newValue);
const res = await sendSmsCall({ phoneNumber: countryCode + newValue });
if (!res) return;
if (res.success) {
onCompleteSignUp(countryCode, newValue);
setTimerValue(res.totalSecondForCodeToExpire);
} else {
toast({
message: res.message,
severity: 'error',
});
setError(res.message);
}
}
};

View File

@@ -133,7 +133,7 @@ export const EnterPasswordForm = ({
<Button
variant="outlined"
size="large"
sx={{ width: 'auto' }}
sx={{ width: 'auto', textTransform: 'none' }}
endIcon={<Icon Component={Edit2} />}
onClick={onEditValue}
>

View File

@@ -1,101 +0,0 @@
import { Button } from '@mui/material';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type {
GoogleCodeClientResponse,
LoginOrSignUpWithGoogleRequest,
LoginResult,
} from '../../types/userTypes';
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
import { Google } from 'iconsax-react';
import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi';
import {
generateTokenWithGoogle,
type GenerateTokenResponse,
} from '../../api/identityAPI';
import type { AuthFactory } from '../../types/authTypes';
export interface GoogleAuthenticationProps {
disabled: boolean;
authFactory: AuthFactory;
onGoogleAuthenticated: (
loginResult: LoginResult,
tokenResponse: GenerateTokenResponse,
) => void;
}
/**
* @deprecated use V2 instead
*/
export const GoogleAuthentication = ({
disabled,
authFactory,
onGoogleAuthenticated,
}: GoogleAuthenticationProps) => {
const { t } = useTranslation('authentication');
const { loading: loginWithGoogleLoading, execute: loginWithGoogleCall } =
useApi(loginOrSignUpWithGoogle);
const toast = useToast();
const clientRef = useRef<any>(null);
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.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: async (resp: GoogleCodeClientResponse) => {
const apiRequest: LoginOrSignUpWithGoogleRequest = {
idToken: resp.id_token,
returnUrl: authFactory.redirectUrl,
};
const res = await loginWithGoogleCall(apiRequest);
if (!res) return;
if (res.success) {
const tokenRes = await generateTokenWithGoogle({
...apiRequest,
client_id: authFactory.clientId,
});
onGoogleAuthenticated(res, tokenRes.data);
} else {
toast({
message: t('loginForm.googleAuthenticationFailed'),
severity: 'error',
});
}
},
});
};
return () => {
document.body.removeChild(script);
};
}, []);
const handleGoogleLogin = () => {
if (clientRef.current) {
clientRef.current.requestCode();
}
};
return (
<Button
type="button"
onClick={handleGoogleLogin}
disabled={disabled}
loading={loginWithGoogleLoading}
variant="outlined"
startIcon={<Icon Component={Google} variant="Bold" />}
>
{t('loginForm.loginWithGoogle')}
</Button>
);
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import type {
LoginOrSignUpWithGoogleRequest,
LoginResult,
@@ -12,7 +12,7 @@ import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
import { useApi } from '@/hooks/useApi';
import { useToast } from '@rkheftan/harmony-ui';
import { useTranslation } from 'react-i18next';
import { Box } from '@mui/material';
import { Box, Button } from '@mui/material';
interface GoogleAuthenticationV2Props {
authFactory: AuthFactory;
@@ -29,6 +29,7 @@ export const GoogleAuthenticationV2 = ({
const toast = useToast();
const { t } = useTranslation('authentication');
const { execute: loginWithGoogleCall } = useApi(loginOrSignUpWithGoogle);
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
const googleBtnRef = useRef<HTMLDivElement>(null);
const renderedRef = useRef(false);
@@ -74,6 +75,8 @@ export const GoogleAuthenticationV2 = ({
const initializeGoogle = () => {
if (!window.google) return;
setIsGoogleLoaded(true);
google.accounts.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: handleCredentialResponse,
@@ -85,8 +88,9 @@ export const GoogleAuthenticationV2 = ({
type: 'standard', // The standard "Sign in with Google" button
text: 'signin_with', // Text: "Sign in with Google"
shape: 'rectangular', // Matches your MUI button shape
width: '456', // Set a width (Google caps this at 400px)
width: '400', // Set a width (Google caps this at 400px)
logo_alignment: 'left',
height: '44',
});
@@ -116,6 +120,7 @@ export const GoogleAuthenticationV2 = ({
minHeight: 44,
}}
>
{!isGoogleLoaded && <Button variant="outlined" loading />}
<div ref={googleBtnRef} id="google-signin-button"></div>
</Box>
</>

View File

@@ -1,4 +1,5 @@
import { Box, Typography, MenuItem, Select, Stack } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { Global } from 'iconsax-react';
import { useTranslation } from 'react-i18next';
@@ -15,32 +16,45 @@ export default function LanguageAccountBar() {
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 2,
py: 1,
py: 1.5,
position: 'absolute',
bottom: 0,
direction: i18n.language === 'fa' ? 'rtl' : 'ltr',
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography sx={{ pr: 4, color: 'secondary.light' }} variant="body2">
{t('loginForm.accountInfo')}
</Typography>
<Icon Component={Global} color="action.active" size="small" />
<Select
size="small"
value={i18n.language}
onChange={handleChange}
variant="standard"
disableUnderline
sx={{
'& .MuiSelect-select': {
paddingTop: 0,
paddingBottom: 0,
paddingRight: 2,
fontSize: '0.875rem',
fontWeight: 600,
color: 'action.active',
display: 'flex',
alignItems: 'center',
},
}}
>
<MenuItem value="fa">{t('loginForm.persian')}</MenuItem>
<MenuItem value="en">{t('loginForm.english')}</MenuItem>
</Select>
<Global size={20} style={{ color: '#666' }} />
</Stack>
<Typography sx={{ color: 'text.primary' }} variant="body2">
{t('loginForm.accountInfo')}
</Typography>
</Box>
);
}

View File

@@ -24,7 +24,11 @@ export interface LoginRegisterFormProps {
setCountryCode: Dispatch<CountryCode>;
authType: AuthType;
setAuthType: Dispatch<AuthType>;
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
onLoginRegisterSubmit: (
value: string,
userStatus: UserStatus,
timerValue: number,
) => void;
onGoogleAuthenticated: (
loginResult: LoginResult,
tokenResponse: GenerateTokenResponse,
@@ -90,17 +94,17 @@ export function LoginRegisterForm({
setErrors: boolean = true,
): boolean => {
if (!value) {
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
if (setErrors) setError('loginForm.thisFieldIsRequired');
return false;
}
if (authType === 'email' && !isEmail(value)) {
if (setErrors) setError(t('loginForm.emailIsInvalid'));
if (setErrors) setError('loginForm.emailIsInvalid');
return false;
}
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
if (setErrors) setError('loginForm.phoneNumberIsInvalid');
return false;
}
@@ -131,7 +135,11 @@ export function LoginRegisterForm({
}
if (res.success) {
onLoginRegisterSubmit(newValue, res.userStatus);
onLoginRegisterSubmit(
newValue,
res.userStatus,
res.totalSecondForOtpToExpire,
);
} else {
toast({ message: res.message, severity: 'error' });
}
@@ -171,7 +179,7 @@ export function LoginRegisterForm({
onChange={handleInputChange}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
helperText={inputError && t(error || '')}
autoFocus
slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5 } },

View File

@@ -32,6 +32,7 @@ interface OtpVerifyFormProps {
) => void;
authFactory: AuthFactory;
hasPassword: boolean;
initialTimerValue: number;
onLoginWithPassword: () => void;
}
@@ -44,13 +45,14 @@ export function OtpVerifyForm({
onOTPVerified,
authFactory,
hasPassword,
initialTimerValue,
onLoginWithPassword,
}: OtpVerifyFormProps) {
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [isStatusSuccess, setIsStatusSuccess] = useState<boolean>();
const { t, i18n } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
const [canResend, setCanResend] = useState(false);
const toast = useToast();
const { loading: smsResendLoading, execute: smsResendCall } =
@@ -200,7 +202,7 @@ export function OtpVerifyForm({
variant="outlined"
size="large"
sx={{
textTransform: 'lowercase',
textTransform: 'none',
width: 'auto',
}}
endIcon={<Icon Component={Edit2} />}

View File

@@ -5,16 +5,21 @@ 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 {
confirmSmsCodeCompleteUserInforamation,
sendSmsCodeCompleteUserInforamation,
} from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
import { Icon, useToast } from '@rkheftan/harmony-ui';
import { useApi } from '@/hooks/useApi';
import { LTRTypography } from '@/components/common/LTRTypography';
interface VerifyPhoneNumberProps {
value: string;
countryCode: CountryCode;
onEditValue: () => void;
onPhoneNumberVerified: () => void;
initialTimerValue: number;
}
export function VerifyPhoneNumber({
@@ -22,18 +27,21 @@ export function VerifyPhoneNumber({
countryCode,
onEditValue,
onPhoneNumberVerified,
initialTimerValue,
}: 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 [resendTimer, setResendTimer] = useState<number>(initialTimerValue);
const [canResend, setCanResend] = useState(false);
const toast = useToast();
const { loading: smsResendLoading, execute: smsResendCall } =
useApi(sendSmsOtp);
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } =
useApi(confirmSmsOtp);
const { loading: smsResendLoading, execute: smsResendCall } = useApi(
sendSmsCodeCompleteUserInforamation,
);
const { loading: confirmSmsOtpLoading, execute: confirmSmsOtpCall } = useApi(
confirmSmsCodeCompleteUserInforamation,
);
useEffect(() => {
if (otpCode.length === 4) {
@@ -55,9 +63,10 @@ export function VerifyPhoneNumber({
}, [resendTimer]);
const handleResendOTPCode = async () => {
await smsResendCall({ phoneNumber: countryCode + value });
const res = await smsResendCall({ phoneNumber: countryCode + value });
setResendTimer(120);
if (!res) return;
setResendTimer(res.totalSecondForCodeToExpire);
setCanResend(false);
};
@@ -130,7 +139,7 @@ export function VerifyPhoneNumber({
endIcon={<Icon Component={Edit2} />}
onClick={onEditValue}
>
{countryCode + value}
<LTRTypography>{countryCode + value}</LTRTypography>
</Button>
</Box>

View File

@@ -110,22 +110,14 @@ export function EmailSection(props: EmailSectionProps) {
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start" sx={{ width: ADORN_W }}>
<Box
sx={{
width: ADORN_W,
display: 'flex',
justifyContent: 'center',
}}
>
startAdornment:
!isVerifyingCode && emailVerified ? (
<InputAdornment position="start" sx={{ width: ADORN_W }}>
<Box
sx={{
alignItems: 'center',
visibility:
!isVerifyingCode && emailVerified
? 'visible'
: 'hidden',
width: ADORN_W,
display: 'flex',
justifyContent: 'center',
}}
>
<Icon
@@ -135,10 +127,9 @@ export function EmailSection(props: EmailSectionProps) {
color="success.main"
/>
</Box>
</Box>
</InputAdornment>
),
endAdornment: (
</InputAdornment>
) : undefined,
endAdornment: codeSent ? (
<InputAdornment position="end" sx={{ width: ADORN_W }}>
<Box
sx={{
@@ -147,23 +138,19 @@ export function EmailSection(props: EmailSectionProps) {
justifyContent: 'center',
}}
>
<Box
sx={{ visibility: codeSent ? 'visible' : 'hidden' }}
<IconButton
onClick={handleEditEmail}
disabled={!codeSent}
>
<IconButton
onClick={handleEditEmail}
disabled={!codeSent}
>
<Icon
Component={Edit2}
color="primary.main"
size="medium"
/>
</IconButton>
</Box>
<Icon
Component={Edit2}
color="primary.main"
size="medium"
/>
</IconButton>
</Box>
</InputAdornment>
),
) : undefined,
},
}}
/>

View File

@@ -9,7 +9,7 @@ export function AuthenticationPage() {
align="center"
justify="center"
sx={{
minHeight: '100vh',
minHeight: '100dvh',
gap: 3,
}}
>

View File

@@ -10,6 +10,7 @@ export interface GetUserStatusByPhoneNumberOrEmailRequest {
export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
userStatus: UserStatus;
totalSecondForOtpToExpire: number;
}
export enum UserStatus {
@@ -50,6 +51,10 @@ export interface SendSmsOtpRequest {
phoneNumber: string;
}
export interface SendSmsOtpResponse extends ApiResponse {
totalSecondForCodeToExpire: number;
}
// SendEmailOtp
export interface SendEmailOtpRequest {