Merge branch 'develop' into feat/user-profile
This commit is contained in:
32
src/features/authentication/api/userCompletion.ts
Normal file
32
src/features/authentication/api/userCompletion.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type SendEmailOtpPayload,
|
||||
type ConfirmEmailOtpPayload,
|
||||
type CompleteUserInfoPayload,
|
||||
type CompleteUserInfoResponse,
|
||||
type GenericApiResponse,
|
||||
} from '../types/completionFormApiTypes';
|
||||
import apiClient from '@/lib/apiClient';
|
||||
|
||||
export const sendEmailOtpApi = async (
|
||||
payload: SendEmailOtpPayload,
|
||||
): Promise<GenericApiResponse & { codeSentAnyway?: boolean }> => {
|
||||
const { data } = await apiClient.post('/User/SendEmailOtp', payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const confirmEmailOtpApi = async (
|
||||
payload: ConfirmEmailOtpPayload,
|
||||
): Promise<GenericApiResponse> => {
|
||||
const { data } = await apiClient.post('/User/ConfirmEmailOtp', payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const completeUserInformationApi = async (
|
||||
payload: CompleteUserInfoPayload,
|
||||
): Promise<CompleteUserInfoResponse> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/User/CompleteUserInformation',
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
72
src/features/authentication/components/DateOfBirth.tsx
Normal file
72
src/features/authentication/components/DateOfBirth.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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 }}
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
sx: {
|
||||
flex: '1 1 260px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
}
|
||||
184
src/features/authentication/components/EmailSection.tsx
Normal file
184
src/features/authentication/components/EmailSection.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Box,
|
||||
Button,
|
||||
Switch,
|
||||
FormGroup,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TickCircle, Edit } from 'iconsax-react';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type EmailSectionProps } from '../types/settingForm';
|
||||
|
||||
export function EmailSection({
|
||||
showEmail,
|
||||
setShowEmail,
|
||||
email,
|
||||
setEmail,
|
||||
correctEmail,
|
||||
codeSent,
|
||||
verificationCode,
|
||||
setVerificationCode,
|
||||
buttonState,
|
||||
getButtonLabel,
|
||||
handleSendCode,
|
||||
handleVerifyCode,
|
||||
emailVerified,
|
||||
loading,
|
||||
handleEditEmail,
|
||||
}: EmailSectionProps) {
|
||||
const { t } = useTranslation('completionForm');
|
||||
|
||||
const onSendCodeClick = () => {
|
||||
if (!correctEmail) return;
|
||||
handleSendCode();
|
||||
};
|
||||
|
||||
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShowEmail(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: { xs: 2, sm: 0 },
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
px: { xs: 2, sm: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.email')}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={email.length > 0 && !correctEmail}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment:
|
||||
!loading && emailVerified ? (
|
||||
<InputAdornment position="end">
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
size="medium"
|
||||
variant="Bold"
|
||||
color="success.main"
|
||||
/>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
endAdornment:
|
||||
buttonState === 'counting' ? (
|
||||
<InputAdornment position="start">
|
||||
<IconButton onClick={handleEditEmail}>
|
||||
<Icon
|
||||
Component={Edit}
|
||||
color="primary.main"
|
||||
size="medium"
|
||||
/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
sx: {
|
||||
paddingLeft: buttonState === 'counting' ? 0 : undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!loading && !emailVerified && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
onClick={onSendCodeClick}
|
||||
sx={{
|
||||
minWidth: '140px',
|
||||
width: { xs: '100%', sm: '156px' },
|
||||
alignSelf: 'center',
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{email && (
|
||||
<Typography
|
||||
sx={{ color: correctEmail ? 'success.main' : 'error.main' }}
|
||||
variant="caption"
|
||||
>
|
||||
{correctEmail ? '' : t('completion.emailCorrectForm')}
|
||||
</Typography>
|
||||
)}
|
||||
{!emailVerified && codeSent && correctEmail && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.verificationCode')}
|
||||
variant="outlined"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: '156px' },
|
||||
border: 0.5,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
t('completion.checkCodeButton')
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
src/features/authentication/components/PasswordSection.tsx
Normal file
250
src/features/authentication/components/PasswordSection.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Box,
|
||||
IconButton,
|
||||
Switch,
|
||||
FormGroup,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} 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,
|
||||
showValidations,
|
||||
}: PasswordSectionProps) {
|
||||
const { t } = useTranslation('completionForm');
|
||||
const [showPasswordText, setShowPasswordText] = useState(false);
|
||||
const [showPasswordRepetitionText, setShowPasswordRepetitionText] =
|
||||
useState(false);
|
||||
|
||||
const handleTogglePasswordSection = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setShowPasswordSection(e.target.checked);
|
||||
};
|
||||
|
||||
const handleTogglePasswordEye = () => setShowPasswordText((prev) => !prev);
|
||||
const handleTogglePasswordRepetitionEye = () =>
|
||||
setShowPasswordRepetitionText((prev) => !prev);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
px: { xs: 2, sm: -4 },
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={showPasswordSection}
|
||||
onChange={handleTogglePasswordSection}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
color: showPasswordSection ? 'primary.main' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{t('completion.determinePassword')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</FormGroup>
|
||||
|
||||
{showPasswordSection && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
px: { xs: 2, sm: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
variant="outlined"
|
||||
type={showPasswordText ? 'text' : 'password'}
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
'& .MuiInputBase-input': {
|
||||
pr: 8, // Increased padding to accommodate both icons
|
||||
},
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: '64px', // Adjusted to fit both icons
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{validPassword && (
|
||||
<Icon
|
||||
Component={TickCircle}
|
||||
size="medium"
|
||||
color="success.main"
|
||||
variant="Bold"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t('completion.passwordRepetition')}
|
||||
variant="outlined"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
error={confirmPassword.length > 0 && !matchPassword}
|
||||
helperText={
|
||||
confirmPassword.length > 0 && !matchPassword
|
||||
? t('completion.notCompatibility')
|
||||
: ' '
|
||||
}
|
||||
type={showPasswordRepetitionText ? 'text' : 'password'}
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: '64px',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{confirmPassword.length > 0 && (
|
||||
<Icon
|
||||
Component={matchPassword ? TickCircle : CloseCircle}
|
||||
size="medium"
|
||||
color={matchPassword ? 'success.main' : 'error.main'}
|
||||
variant="Bold"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{password && showValidations && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<PasswordValidationItem
|
||||
isValid={hasNumber}
|
||||
label={t('completion.hasNumber')}
|
||||
/>
|
||||
<PasswordValidationItem
|
||||
isValid={hasMinLength}
|
||||
label={t('completion.hasMinLength')}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 260px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<PasswordValidationItem
|
||||
isValid={hasUpperAndLower}
|
||||
label={t('completion.hasUpperAndLower')}
|
||||
/>
|
||||
<PasswordValidationItem
|
||||
isValid={hasSpecialChar}
|
||||
label={t('completion.hasSpecialChar')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
168
src/features/authentication/components/PersonalInfoFields.tsx
Normal file
168
src/features/authentication/components/PersonalInfoFields.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Box,
|
||||
Autocomplete,
|
||||
type SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Woman, Man } from 'iconsax-react';
|
||||
import DateOfBirth from './DateOfBirth';
|
||||
import { countries } from '../data/Countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { Icon } from '@rkheftan/harmony-ui';
|
||||
import { type PersonalInfoFieldsProps } from '../types/settingForm';
|
||||
import { Gender } from '../types/settingForm';
|
||||
|
||||
export function PersonalInfoFields({
|
||||
firstName,
|
||||
setFirstName,
|
||||
lastName,
|
||||
setLastName,
|
||||
nationalId,
|
||||
setNationalId,
|
||||
birthDate,
|
||||
setBirthDate,
|
||||
sex,
|
||||
setSex,
|
||||
country,
|
||||
setCountry,
|
||||
}: PersonalInfoFieldsProps) {
|
||||
const { t } = useTranslation('completionForm');
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
code: c.code,
|
||||
label: t(c.label, { ns: 'countries' }),
|
||||
}));
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
width: '100%',
|
||||
px: { xs: 2, sm: 4, md: 1 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t('completion.name')}
|
||||
placeholder={t('completion.name')}
|
||||
variant="outlined"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('completion.familyName')}
|
||||
placeholder={t('completion.familyName')}
|
||||
variant="outlined"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<FormControl sx={{ flex: '1 1 260px' }}>
|
||||
<InputLabel>{t('completion.gender')}</InputLabel>
|
||||
<Select
|
||||
value={sex}
|
||||
label={t('completion.gender')}
|
||||
onChange={handleChangeSex}
|
||||
>
|
||||
{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>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label={t('completion.optionalNationalCode')}
|
||||
placeholder={t('completion.optionalNationalCode')}
|
||||
value={nationalId}
|
||||
onChange={(e) => setNationalId(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Autocomplete
|
||||
sx={{ flex: '1 1 260px' }}
|
||||
options={countryOptions}
|
||||
getOptionLabel={(option) => option.label}
|
||||
value={currentCountry}
|
||||
onChange={(_, newValue) => setCountry(newValue?.code || '')}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props} key={option.code}>
|
||||
<CountryFlag code={option.code} />
|
||||
{option.label}
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={t('completion.country')} />
|
||||
)}
|
||||
clearOnEscape
|
||||
/>
|
||||
|
||||
<Box sx={{ flex: '1 1 260px' }}>
|
||||
<DateOfBirth value={birthDate} onChange={setBirthDate} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
89
src/features/authentication/components/SubmitSection.tsx
Normal file
89
src/features/authentication/components/SubmitSection.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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, error }: 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,
|
||||
px: { xs: 2, sm: 0 },
|
||||
mb: 2,
|
||||
justifyContent: { xs: 'center', sm: '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>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
329
src/features/authentication/components/UserCompletionForm.tsx
Normal file
329
src/features/authentication/components/UserCompletionForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from '@/components/Logo';
|
||||
import { PersonalInfoFields } from './PersonalInfoFields';
|
||||
import { PasswordSection } from './PasswordSection';
|
||||
import { EmailSection } from './EmailSection';
|
||||
import { SubmitSection } from './SubmitSection';
|
||||
import { useToast } from '@rkheftan/harmony-ui';
|
||||
import { regex } from '../../../utils/regex';
|
||||
import { toLocaleDigits } from '../../../utils/persianDigit';
|
||||
import i18n from '@/config/i18n';
|
||||
import { Gender } from '../types/settingForm';
|
||||
import { useApi } from '@/hooks/useApi';
|
||||
import {
|
||||
sendEmailOtpApi,
|
||||
confirmEmailOtpApi,
|
||||
completeUserInformationApi,
|
||||
} from '../api/userCompletion';
|
||||
import {
|
||||
type SendEmailOtpPayload,
|
||||
type ConfirmEmailOtpPayload,
|
||||
type CompleteUserInfoPayload,
|
||||
} from '../types/completionFormApiTypes';
|
||||
import { type ApiResponse } from '@/types/apiResponse';
|
||||
|
||||
export function UserCompletionForm() {
|
||||
const { t } = useTranslation('completionForm');
|
||||
const showToast = useToast();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [birthDate, setBirthDate] = useState<Date | null>(null);
|
||||
const [sex, setSex] = useState<Gender>(Gender.Female);
|
||||
const [country, setCountry] = useState('');
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showEmail, setShowEmail] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
|
||||
'default',
|
||||
);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [emailVerified, setEmailVerified] = useState(false);
|
||||
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
|
||||
|
||||
const {
|
||||
hasNumber,
|
||||
hasMinLength,
|
||||
hasUpperAndLower,
|
||||
hasSpecialChar,
|
||||
validPassword,
|
||||
correctEmail,
|
||||
} = regex(password, email);
|
||||
const matchPassword = password === confirmPassword;
|
||||
|
||||
const { execute: sendCode, data: sendCodeData } = useApi(
|
||||
async (payload: SendEmailOtpPayload) => {
|
||||
const result = await sendEmailOtpApi(payload);
|
||||
const conformingResult: ApiResponse = {
|
||||
...result,
|
||||
errorCode: result.errorCode || 0,
|
||||
validations: [],
|
||||
};
|
||||
return { data: conformingResult };
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
execute: verifyCode,
|
||||
loading: isVerifyingCode,
|
||||
data: verifyCodeData,
|
||||
} = useApi(async (payload: ConfirmEmailOtpPayload) => {
|
||||
const result = await confirmEmailOtpApi(payload);
|
||||
const conformingResult: ApiResponse = {
|
||||
...result,
|
||||
errorCode: result.errorCode || 0,
|
||||
validations: [],
|
||||
};
|
||||
return { data: conformingResult };
|
||||
});
|
||||
|
||||
const {
|
||||
execute: submitForm,
|
||||
loading: isSubmitting,
|
||||
error: submitError,
|
||||
data: submitData,
|
||||
} = useApi(async (payload: CompleteUserInfoPayload) => {
|
||||
const result = await completeUserInformationApi(payload);
|
||||
const conformingResult: ApiResponse = {
|
||||
...result,
|
||||
errorCode: result.errorCode || 0,
|
||||
validations: [],
|
||||
};
|
||||
return { data: conformingResult };
|
||||
});
|
||||
|
||||
const getErrorMessage = (error: unknown): string | null => {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sendCodeData) {
|
||||
if (sendCodeData.success) {
|
||||
showToast({
|
||||
message: sendCodeData.message || t('completion.successfullCodeSent'),
|
||||
severity: 'success',
|
||||
});
|
||||
setCodeSent(true);
|
||||
setButtonState('counting');
|
||||
setCountdown(120);
|
||||
} else {
|
||||
showToast({
|
||||
message: sendCodeData.message || t('completion.problem'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sendCodeData, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyCodeData) {
|
||||
if (verifyCodeData.success) {
|
||||
setEmailVerified(true);
|
||||
showToast({
|
||||
message: verifyCodeData.message || t('completion.codeVerified'),
|
||||
severity: 'success',
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: verifyCodeData.message || t('completion.invalidCode'),
|
||||
severity: 'error',
|
||||
});
|
||||
setEmailVerified(false);
|
||||
}
|
||||
}
|
||||
}, [verifyCodeData, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitData) {
|
||||
showToast({
|
||||
message:
|
||||
submitData.message ||
|
||||
t(
|
||||
submitData.success
|
||||
? 'completion.submitSuccess'
|
||||
: 'completion.submitError',
|
||||
),
|
||||
severity: submitData.success ? 'success' : 'error',
|
||||
});
|
||||
} else if (submitError) {
|
||||
showToast({
|
||||
message: getErrorMessage(submitError) || t('completion.problem'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}, [submitData, submitError, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowPasswordValidations(password ? !validPassword : false);
|
||||
}, [password, validPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (buttonState === 'counting' && countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
setButtonState('default');
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [buttonState, countdown]);
|
||||
|
||||
const handleSendCode = () => {
|
||||
sendCode({ email });
|
||||
};
|
||||
|
||||
const handleVerifyCode = () => {
|
||||
if (!verificationCode.trim()) {
|
||||
showToast({
|
||||
message: 'Please enter the verification code',
|
||||
severity: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
verifyCode({ email, otpCode: verificationCode });
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitForm({
|
||||
userId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
firstName,
|
||||
lastName,
|
||||
gender: sex,
|
||||
nationalId,
|
||||
savePassword: showPasswordSection,
|
||||
password: showPasswordSection ? password : undefined,
|
||||
saveEmail: showEmail,
|
||||
email: showEmail ? email : undefined,
|
||||
birthDate,
|
||||
country,
|
||||
});
|
||||
};
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (buttonState === 'counting') {
|
||||
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 t('completion.vericationCodeButton');
|
||||
};
|
||||
|
||||
const handleEditEmail = () => {
|
||||
setButtonState('default');
|
||||
setCodeSent(false);
|
||||
setEmailVerified(false);
|
||||
setVerificationCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'background.default',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: { xs: 1, sm: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Logo />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 730,
|
||||
backgroundColor: 'background.paper',
|
||||
border: '1px solid #ccc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
px: { xs: 2, sm: 4, md: 6 },
|
||||
py: { xs: 3, sm: 4 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="h5" color="text.primary">
|
||||
{t('completion.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('completion.description')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PersonalInfoFields
|
||||
firstName={firstName}
|
||||
setFirstName={setFirstName}
|
||||
lastName={lastName}
|
||||
setLastName={setLastName}
|
||||
nationalId={nationalId}
|
||||
setNationalId={setNationalId}
|
||||
birthDate={birthDate}
|
||||
setBirthDate={setBirthDate}
|
||||
sex={sex}
|
||||
setSex={setSex}
|
||||
country={country}
|
||||
setCountry={setCountry}
|
||||
/>
|
||||
|
||||
<PasswordSection
|
||||
showPasswordSection={showPasswordSection}
|
||||
setShowPasswordSection={setShowPasswordSection}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
matchPassword={matchPassword}
|
||||
hasNumber={hasNumber}
|
||||
hasMinLength={hasMinLength}
|
||||
hasUpperAndLower={hasUpperAndLower}
|
||||
hasSpecialChar={hasSpecialChar}
|
||||
validPassword={validPassword}
|
||||
showValidations={showPasswordValidations}
|
||||
/>
|
||||
|
||||
<EmailSection
|
||||
showEmail={showEmail}
|
||||
setShowEmail={setShowEmail}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
correctEmail={correctEmail}
|
||||
codeSent={codeSent}
|
||||
verificationCode={verificationCode}
|
||||
setVerificationCode={setVerificationCode}
|
||||
buttonState={buttonState}
|
||||
getButtonLabel={getButtonLabel}
|
||||
handleSendCode={handleSendCode}
|
||||
handleVerifyCode={handleVerifyCode}
|
||||
emailVerified={emailVerified}
|
||||
loading={isVerifyingCode}
|
||||
handleEditEmail={handleEditEmail}
|
||||
/>
|
||||
|
||||
<SubmitSection
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
error={getErrorMessage(submitError)}
|
||||
success={!!submitData?.success}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
271
src/features/authentication/data/Countries.ts
Normal file
271
src/features/authentication/data/Countries.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
export interface Country {
|
||||
code: string;
|
||||
label: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
label: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const countries: readonly Country[] = [
|
||||
{ code: 'AF', label: 'country.afghanistan', phone: '+93' },
|
||||
{ code: 'AX', label: 'country.aland_islands', phone: '+358' },
|
||||
{ code: 'AL', label: 'country.albania', phone: '+355' },
|
||||
{ code: 'DZ', label: 'country.algeria', phone: '+213' },
|
||||
{ code: 'AS', label: 'country.american_samoa', phone: '+1684' },
|
||||
{ code: 'AD', label: 'country.andorra', phone: '+376' },
|
||||
{ code: 'AO', label: 'country.angola', phone: '+244' },
|
||||
{ code: 'AI', label: 'country.anguilla', phone: '+1264' },
|
||||
{ code: 'AQ', label: 'country.antarctica', phone: '+672' },
|
||||
{ code: 'AG', label: 'country.antigua_and_barbuda', phone: '+1268' },
|
||||
{ code: 'AR', label: 'country.argentina', phone: '+54' },
|
||||
{ code: 'AM', label: 'country.armenia', phone: '+374' },
|
||||
{ code: 'AW', label: 'country.aruba', phone: '+297' },
|
||||
{ code: 'AU', label: 'country.australia', phone: '+61' },
|
||||
{ code: 'AT', label: 'country.austria', phone: '+43' },
|
||||
{ code: 'AZ', label: 'country.azerbaijan', phone: '+994' },
|
||||
{ code: 'BS', label: 'country.bahamas', phone: '+1242' },
|
||||
{ code: 'BH', label: 'country.bahrain', phone: '+973' },
|
||||
{ code: 'BD', label: 'country.bangladesh', phone: '+880' },
|
||||
{ code: 'BB', label: 'country.barbados', phone: '+1246' },
|
||||
{ code: 'BY', label: 'country.belarus', phone: '+375' },
|
||||
{ code: 'BE', label: 'country.belgium', phone: '+32' },
|
||||
{ code: 'BZ', label: 'country.belize', phone: '+501' },
|
||||
{ code: 'BJ', label: 'country.benin', phone: '+229' },
|
||||
{ code: 'BM', label: 'country.bermuda', phone: '+1441' },
|
||||
{ code: 'BT', label: 'country.bhutan', phone: '+975' },
|
||||
{ code: 'BO', label: 'country.bolivia', phone: '+591' },
|
||||
{ code: 'BA', label: 'country.bosnia_and_herzegovina', phone: '+387' },
|
||||
{ code: 'BW', label: 'country.botswana', phone: '+267' },
|
||||
{ code: 'BR', label: 'country.brazil', phone: '+55' },
|
||||
{
|
||||
code: 'IO',
|
||||
label: 'country.british_indian_ocean_territory',
|
||||
phone: '+246',
|
||||
},
|
||||
{ code: 'VG', label: 'country.british_virgin_islands', phone: '+1284' },
|
||||
{ code: 'BN', label: 'country.brunei', phone: '+673' },
|
||||
{ code: 'BG', label: 'country.bulgaria', phone: '+359' },
|
||||
{ code: 'BF', label: 'country.burkina_faso', phone: '+226' },
|
||||
{ code: 'BI', label: 'country.burundi', phone: '+257' },
|
||||
{ code: 'KH', label: 'country.cambodia', phone: '+855' },
|
||||
{ code: 'CM', label: 'country.cameroon', phone: '+237' },
|
||||
{ code: 'CA', label: 'country.canada', phone: '+1' },
|
||||
{ code: 'CV', label: 'country.cape_verde', phone: '+238' },
|
||||
{ code: 'KY', label: 'country.cayman_islands', phone: '+1345' },
|
||||
{ code: 'CF', label: 'country.central_african_republic', phone: '+236' },
|
||||
{ code: 'TD', label: 'country.chad', phone: '+235' },
|
||||
{ code: 'CL', label: 'country.chile', phone: '+56' },
|
||||
{ code: 'CN', label: 'country.china', phone: '+86' },
|
||||
{ code: 'CX', label: 'country.christmas_island', phone: '+61' },
|
||||
{ code: 'CC', label: 'country.cocos_keeling_islands', phone: '+61' },
|
||||
{ code: 'CO', label: 'country.colombia', phone: '+57' },
|
||||
{ code: 'KM', label: 'country.comoros', phone: '+269' },
|
||||
{ code: 'CG', label: 'country.congo_brazzaville', phone: '+242' },
|
||||
{ code: 'CD', label: 'country.congo_kinshasa', phone: '+243' },
|
||||
{ code: 'CK', label: 'country.cook_islands', phone: '+682' },
|
||||
{ code: 'CR', label: 'country.costa_rica', phone: '+506' },
|
||||
{ code: 'CI', label: 'country.cote_divoire', phone: '+225' },
|
||||
{ code: 'HR', label: 'country.croatia', phone: '+385' },
|
||||
{ code: 'CU', label: 'country.cuba', phone: '+53' },
|
||||
{ code: 'CW', label: 'country.curacao', phone: '+599' },
|
||||
{ code: 'CY', label: 'country.cyprus', phone: '+357' },
|
||||
{ code: 'CZ', label: 'country.czech_republic', phone: '+420' },
|
||||
{ code: 'DK', label: 'country.denmark', phone: '+45' },
|
||||
{ code: 'DJ', label: 'country.djibouti', phone: '+253' },
|
||||
{ code: 'DM', label: 'country.dominica', phone: '+1767' },
|
||||
{ code: 'DO', label: 'country.dominican_republic', phone: '+1' },
|
||||
{ code: 'EC', label: 'country.ecuador', phone: '+593' },
|
||||
{ code: 'EG', label: 'country.egypt', phone: '+20' },
|
||||
{ code: 'SV', label: 'country.el_salvador', phone: '+503' },
|
||||
{ code: 'GQ', label: 'country.equatorial_guinea', phone: '+240' },
|
||||
{ code: 'ER', label: 'country.eritrea', phone: '+291' },
|
||||
{ code: 'EE', label: 'country.estonia', phone: '+372' },
|
||||
{ code: 'SZ', label: 'country.eswatini', phone: '+268' },
|
||||
{ code: 'ET', label: 'country.ethiopia', phone: '+251' },
|
||||
{ code: 'FK', label: 'country.falkland_islands', phone: '+500' },
|
||||
{ code: 'FO', label: 'country.faroe_islands', phone: '+298' },
|
||||
{ code: 'FJ', label: 'country.fiji', phone: '+679' },
|
||||
{ code: 'FI', label: 'country.finland', phone: '+358' },
|
||||
{ code: 'FR', label: 'country.france', phone: '+33' },
|
||||
{ code: 'GF', label: 'country.french_guiana', phone: '+594' },
|
||||
{ code: 'PF', label: 'country.french_polynesia', phone: '+689' },
|
||||
{ code: 'GA', label: 'country.gabon', phone: '+241' },
|
||||
{ code: 'GM', label: 'country.gambia', phone: '+220' },
|
||||
{ code: 'GE', label: 'country.georgia', phone: '+995' },
|
||||
{ code: 'DE', label: 'country.germany', phone: '+49' },
|
||||
{ code: 'GH', label: 'country.ghana', phone: '+233' },
|
||||
{ code: 'GI', label: 'country.gibraltar', phone: '+350' },
|
||||
{ code: 'GR', label: 'country.greece', phone: '+30' },
|
||||
{ code: 'GL', label: 'country.greenland', phone: '+299' },
|
||||
{ code: 'GD', label: 'country.grenada', phone: '+1473' },
|
||||
{ code: 'GP', label: 'country.guadeloupe', phone: '+590' },
|
||||
{ code: 'GU', label: 'country.guam', phone: '+1671' },
|
||||
{ code: 'GT', label: 'country.guatemala', phone: '+502' },
|
||||
{ code: 'GG', label: 'country.guernsey', phone: '+44' },
|
||||
{ code: 'GN', label: 'country.guinea', phone: '+224' },
|
||||
{ code: 'GW', label: 'country.guinea_bissau', phone: '+245' },
|
||||
{ code: 'GY', label: 'country.guyana', phone: '+592' },
|
||||
{ code: 'HT', label: 'country.haiti', phone: '+509' },
|
||||
{ code: 'HN', label: 'country.honduras', phone: '+504' },
|
||||
{ code: 'HK', label: 'country.hong_kong', phone: '+852' },
|
||||
{ code: 'HU', label: 'country.hungary', phone: '+36' },
|
||||
{ code: 'IS', label: 'country.iceland', phone: '+354' },
|
||||
{ code: 'IN', label: 'country.india', phone: '+91' },
|
||||
{ code: 'ID', label: 'country.indonesia', phone: '+62' },
|
||||
{ code: 'IR', label: 'country.iran', phone: '+98' },
|
||||
{ code: 'IQ', label: 'country.iraq', phone: '+964' },
|
||||
{ code: 'IE', label: 'country.ireland', phone: '+353' },
|
||||
{ code: 'IM', label: 'country.isle_of_man', phone: '+44' },
|
||||
{ code: 'IL', label: 'country.israel', phone: '+972' },
|
||||
{ code: 'IT', label: 'country.italy', phone: '+39' },
|
||||
{ code: 'JM', label: 'country.jamaica', phone: '+1876' },
|
||||
{ code: 'JP', label: 'country.japan', phone: '+81' },
|
||||
{ code: 'JE', label: 'country.jersey', phone: '+44' },
|
||||
{ code: 'JO', label: 'country.jordan', phone: '+962' },
|
||||
{ code: 'KZ', label: 'country.kazakhstan', phone: '+7' },
|
||||
{ code: 'KE', label: 'country.kenya', phone: '+254' },
|
||||
{ code: 'KI', label: 'country.kiribati', phone: '+686' },
|
||||
{ code: 'XK', label: 'country.kosovo', phone: '+383' },
|
||||
{ code: 'KW', label: 'country.kuwait', phone: '+965' },
|
||||
{ code: 'KG', label: 'country.kyrgyzstan', phone: '+996' },
|
||||
{ code: 'LA', label: 'country.laos', phone: '+856' },
|
||||
{ code: 'LV', label: 'country.latvia', phone: '+371' },
|
||||
{ code: 'LB', label: 'country.lebanon', phone: '+961' },
|
||||
{ code: 'LS', label: 'country.lesotho', phone: '+266' },
|
||||
{ code: 'LR', label: 'country.liberia', phone: '+231' },
|
||||
{ code: 'LY', label: 'country.libya', phone: '+218' },
|
||||
{ code: 'LI', label: 'country.liechtenstein', phone: '+423' },
|
||||
{ code: 'LT', label: 'country.lithuania', phone: '+370' },
|
||||
{ code: 'LU', label: 'country.luxembourg', phone: '+352' },
|
||||
{ code: 'MO', label: 'country.macau', phone: '+853' },
|
||||
{ code: 'MG', label: 'country.madagascar', phone: '+261' },
|
||||
{ code: 'MW', label: 'country.malawi', phone: '+265' },
|
||||
{ code: 'MY', label: 'country.malaysia', phone: '+60' },
|
||||
{ code: 'MV', label: 'country.maldives', phone: '+960' },
|
||||
{ code: 'ML', label: 'country.mali', phone: '+223' },
|
||||
{ code: 'MT', label: 'country.malta', phone: '+356' },
|
||||
{ code: 'MH', label: 'country.marshall_islands', phone: '+692' },
|
||||
{ code: 'MQ', label: 'country.martinique', phone: '+596' },
|
||||
{ code: 'MR', label: 'country.mauritania', phone: '+222' },
|
||||
{ code: 'MU', label: 'country.mauritius', phone: '+230' },
|
||||
{ code: 'YT', label: 'country.mayotte', phone: '+262' },
|
||||
{ code: 'MX', label: 'country.mexico', phone: '+52' },
|
||||
{ code: 'FM', label: 'country.micronesia', phone: '+691' },
|
||||
{ code: 'MD', label: 'country.moldova', phone: '+373' },
|
||||
{ code: 'MC', label: 'country.monaco', phone: '+377' },
|
||||
{ code: 'MN', label: 'country.mongolia', phone: '+976' },
|
||||
{ code: 'ME', label: 'country.montenegro', phone: '+382' },
|
||||
{ code: 'MS', label: 'country.montserrat', phone: '+1664' },
|
||||
{ code: 'MA', label: 'country.morocco', phone: '+212' },
|
||||
{ code: 'MZ', label: 'country.mozambique', phone: '+258' },
|
||||
{ code: 'MM', label: 'country.myanmar', phone: '+95' },
|
||||
{ code: 'NA', label: 'country.namibia', phone: '+264' },
|
||||
{ code: 'NR', label: 'country.nauru', phone: '+674' },
|
||||
{ code: 'NP', label: 'country.nepal', phone: '+977' },
|
||||
{ code: 'NL', label: 'country.netherlands', phone: '+31' },
|
||||
{ code: 'NC', label: 'country.new_caledonia', phone: '+687' },
|
||||
{ code: 'NZ', label: 'country.new_zealand', phone: '+64' },
|
||||
{ code: 'NI', label: 'country.nicaragua', phone: '+505' },
|
||||
{ code: 'NE', label: 'country.niger', phone: '+227' },
|
||||
{ code: 'NG', label: 'country.nigeria', phone: '+234' },
|
||||
{ code: 'NU', label: 'country.niue', phone: '+683' },
|
||||
{ code: 'NF', label: 'country.norfolk_island', phone: '+672' },
|
||||
{ code: 'KP', label: 'country.north_korea', phone: '+850' },
|
||||
{ code: 'MK', label: 'country.north_macedonia', phone: '+389' },
|
||||
{ code: 'MP', label: 'country.northern_mariana_islands', phone: '+1670' },
|
||||
{ code: 'NO', label: 'country.norway', phone: '+47' },
|
||||
{ code: 'OM', label: 'country.oman', phone: '+968' },
|
||||
{ code: 'PK', label: 'country.pakistan', phone: '+92' },
|
||||
{ code: 'PW', label: 'country.palau', phone: '+680' },
|
||||
{ code: 'PS', label: 'country.palestine', phone: '+970' },
|
||||
{ code: 'PA', label: 'country.panama', phone: '+507' },
|
||||
{ code: 'PG', label: 'country.papua_new_guinea', phone: '+675' },
|
||||
{ code: 'PY', label: 'country.paraguay', phone: '+595' },
|
||||
{ code: 'PE', label: 'country.peru', phone: '+51' },
|
||||
{ code: 'PH', label: 'country.philippines', phone: '+63' },
|
||||
{ code: 'PN', label: 'country.pitcairn_islands', phone: '+64' },
|
||||
{ code: 'PL', label: 'country.poland', phone: '+48' },
|
||||
{ code: 'PT', label: 'country.portugal', phone: '+351' },
|
||||
{ code: 'PR', label: 'country.puerto_rico', phone: '+1' },
|
||||
{ code: 'QA', label: 'country.qatar', phone: '+974' },
|
||||
{ code: 'RE', label: 'country.reunion', phone: '+262' },
|
||||
{ code: 'RO', label: 'country.romania', phone: '+40' },
|
||||
{ code: 'RU', label: 'country.russia', phone: '+7' },
|
||||
{ code: 'RW', label: 'country.rwanda', phone: '+250' },
|
||||
{ code: 'BL', label: 'country.saint_barthelemy', phone: '+590' },
|
||||
{ code: 'SH', label: 'country.saint_helena', phone: '+290' },
|
||||
{ code: 'KN', label: 'country.saint_kitts_and_nevis', phone: '+1869' },
|
||||
{ code: 'LC', label: 'country.saint_lucia', phone: '+1758' },
|
||||
{ code: 'MF', label: 'country.saint_martin', phone: '+590' },
|
||||
{ code: 'PM', label: 'country.saint_pierre_and_miquelon', phone: '+508' },
|
||||
{
|
||||
code: 'VC',
|
||||
label: 'country.saint_vincent_and_the_grenadines',
|
||||
phone: '+1784',
|
||||
},
|
||||
{ code: 'WS', label: 'country.samoa', phone: '+685' },
|
||||
{ code: 'SM', label: 'country.san_marino', phone: '+378' },
|
||||
{ code: 'ST', label: 'country.sao_tome_and_principe', phone: '+239' },
|
||||
{ code: 'SA', label: 'country.saudi_arabia', phone: '+966' },
|
||||
{ code: 'SN', label: 'country.senegal', phone: '+221' },
|
||||
{ code: 'RS', label: 'country.serbia', phone: '+381' },
|
||||
{ code: 'SC', label: 'country.seychelles', phone: '+248' },
|
||||
{ code: 'SL', label: 'country.sierra_leone', phone: '+232' },
|
||||
{ code: 'SG', label: 'country.singapore', phone: '+65' },
|
||||
{ code: 'SX', label: 'country.sint_maarten', phone: '+1721' },
|
||||
{ code: 'SK', label: 'country.slovakia', phone: '+421' },
|
||||
{ code: 'SI', label: 'country.slovenia', phone: '+386' },
|
||||
{ code: 'SB', label: 'country.solomon_islands', phone: '+677' },
|
||||
{ code: 'SO', label: 'country.somalia', phone: '+252' },
|
||||
{ code: 'ZA', label: 'country.south_africa', phone: '+27' },
|
||||
{
|
||||
code: 'GS',
|
||||
label: 'country.south_georgia_and_south_sandwich_islands',
|
||||
phone: '+500',
|
||||
},
|
||||
{ code: 'KR', label: 'country.south_korea', phone: '+82' },
|
||||
{ code: 'SS', label: 'country.south_sudan', phone: '+211' },
|
||||
{ code: 'ES', label: 'country.spain', phone: '+34' },
|
||||
{ code: 'LK', label: 'country.sri_lanka', phone: '+94' },
|
||||
{ code: 'SD', label: 'country.sudan', phone: '+249' },
|
||||
{ code: 'SR', label: 'country.suriname', phone: '+597' },
|
||||
{ code: 'SJ', label: 'country.svalbard_and_jan_mayen', phone: '+47' },
|
||||
{ code: 'SE', label: 'country.sweden', phone: '+46' },
|
||||
{ code: 'CH', label: 'country.switzerland', phone: '+41' },
|
||||
{ code: 'SY', label: 'country.syria', phone: '+963' },
|
||||
{ code: 'TW', label: 'country.taiwan', phone: '+886' },
|
||||
{ code: 'TJ', label: 'country.tajikistan', phone: '+992' },
|
||||
{ code: 'TZ', label: 'country.tanzania', phone: '+255' },
|
||||
{ code: 'TH', label: 'country.thailand', phone: '+66' },
|
||||
{ code: 'TL', label: 'country.timor_leste', phone: '+670' },
|
||||
{ code: 'TG', label: 'country.togo', phone: '+228' },
|
||||
{ code: 'TK', label: 'country.tokelau', phone: '+690' },
|
||||
{ code: 'TO', label: 'country.tonga', phone: '+676' },
|
||||
{ code: 'TT', label: 'country.trinidad_and_tobago', phone: '+1868' },
|
||||
{ code: 'TN', label: 'country.tunisia', phone: '+216' },
|
||||
{ code: 'TR', label: 'country.turkey', phone: '+90' },
|
||||
{ code: 'TM', label: 'country.turkmenistan', phone: '+993' },
|
||||
{ code: 'TC', label: 'country.turks_and_caicos_islands', phone: '+1649' },
|
||||
{ code: 'TV', label: 'country.tuvalu', phone: '+688' },
|
||||
{ code: 'VI', label: 'country.us_virgin_islands', phone: '+1340' },
|
||||
{ code: 'UG', label: 'country.uganda', phone: '+256' },
|
||||
{ code: 'UA', label: 'country.ukraine', phone: '+380' },
|
||||
{ code: 'AE', label: 'country.united_arab_emirates', phone: '+971' },
|
||||
{ code: 'GB', label: 'country.united_kingdom', phone: '+44' },
|
||||
{ code: 'US', label: 'country.united_states', phone: '+1' },
|
||||
{ code: 'UY', label: 'country.uruguay', phone: '+598' },
|
||||
{ code: 'UZ', label: 'country.uzbekistan', phone: '+998' },
|
||||
{ code: 'VU', label: 'country.vanuatu', phone: '+678' },
|
||||
{ code: 'VA', label: 'country.vatican_city', phone: '+39' },
|
||||
{ code: 'VE', label: 'country.venezuela', phone: '+58' },
|
||||
{ code: 'VN', label: 'country.vietnam', phone: '+84' },
|
||||
{ code: 'WF', label: 'country.wallis_and_futuna', phone: '+681' },
|
||||
{ code: 'EH', label: 'country.western_sahara', phone: '+212' },
|
||||
{ code: 'YE', label: 'country.yemen', phone: '+967' },
|
||||
{ code: 'ZM', label: 'country.zambia', phone: '+260' },
|
||||
{ code: 'ZW', label: 'country.zimbabwe', phone: '+263' },
|
||||
];
|
||||
32
src/features/authentication/types/completionFormApiTypes.ts
Normal file
32
src/features/authentication/types/completionFormApiTypes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface GenericApiResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errorCode?: number;
|
||||
}
|
||||
|
||||
export interface SendEmailOtpPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ConfirmEmailOtpPayload {
|
||||
email: string;
|
||||
otpCode: string;
|
||||
}
|
||||
|
||||
export interface CompleteUserInfoPayload {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
gender: 0 | 1 | 2;
|
||||
nationalId: string;
|
||||
birthDate: Date | null;
|
||||
country: string;
|
||||
savePassword?: boolean;
|
||||
password?: string;
|
||||
saveEmail?: boolean;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface CompleteUserInfoResponse extends GenericApiResponse {
|
||||
validations: { property: string; message: string }[] | null;
|
||||
}
|
||||
73
src/features/authentication/types/settingForm.ts
Normal file
73
src/features/authentication/types/settingForm.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
export enum Gender {
|
||||
None = 0,
|
||||
Female = 1,
|
||||
Male = 2,
|
||||
}
|
||||
|
||||
export interface DateOfBirthProps {
|
||||
value: Date | null;
|
||||
onChange: (date: Date | null) => void;
|
||||
}
|
||||
|
||||
export interface EmailSectionProps {
|
||||
showEmail: boolean;
|
||||
setShowEmail: (checked: boolean) => void;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
correctEmail: boolean;
|
||||
codeSent: boolean;
|
||||
verificationCode: string;
|
||||
setVerificationCode: (code: string) => void;
|
||||
buttonState: 'default' | 'counting' | 'sent';
|
||||
getButtonLabel: () => string;
|
||||
handleSendCode: () => void;
|
||||
handleVerifyCode: () => void;
|
||||
emailVerified: boolean;
|
||||
loading: boolean;
|
||||
handleEditEmail: () => void;
|
||||
}
|
||||
|
||||
export interface PasswordSectionProps {
|
||||
showPasswordSection: boolean;
|
||||
setShowPasswordSection: (checked: boolean) => void;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
confirmPassword: string;
|
||||
setConfirmPassword: (confirmPassword: string) => void;
|
||||
matchPassword: boolean;
|
||||
hasNumber: boolean;
|
||||
hasMinLength: boolean;
|
||||
hasUpperAndLower: boolean;
|
||||
hasSpecialChar: boolean;
|
||||
validPassword: boolean;
|
||||
showValidations: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationItemProps {
|
||||
isValid: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PersonalInfoFieldsProps {
|
||||
firstName: string;
|
||||
setFirstName: (v: string) => void;
|
||||
lastName: string;
|
||||
setLastName: (v: string) => void;
|
||||
sex: Gender;
|
||||
setSex: Dispatch<SetStateAction<Gender>>;
|
||||
country: string;
|
||||
setCountry: (country: string) => void;
|
||||
nationalId: string;
|
||||
setNationalId: (v: string) => void;
|
||||
birthDate: Date | null;
|
||||
setBirthDate: (d: Date | null) => void;
|
||||
}
|
||||
|
||||
export interface SubmitProps {
|
||||
onSubmit: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
}
|
||||
104
src/features/authorization/api/authorizationAPI.ts
Normal file
104
src/features/authorization/api/authorizationAPI.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ApiResponse } from '@/types/apiResponse';
|
||||
import type { FetchPromise } from '@/types/fetchPromise';
|
||||
import type {
|
||||
CompleteUserInformationRequest,
|
||||
ConfirmEmailOtpRequest,
|
||||
ConfirmForgetPassCodeRequest,
|
||||
ConfirmOtpResponse,
|
||||
ConfirmSmsOtpRequest,
|
||||
GetUserStatusByPhoneNumberOrEmailRequest,
|
||||
GetUserStatusByPhoneNumberOrEmailResponse,
|
||||
LoginOrSignUpWithGoogleRequest,
|
||||
LoginOrSignUpWithGoogleResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
PasswordLoginRequest,
|
||||
ResetPasswordRequest,
|
||||
ResetPasswordResponse,
|
||||
SendEmailOtpRequest,
|
||||
SendForgetPassCodeRequest,
|
||||
SendSmsOtpRequest,
|
||||
} from '../types/userTypes';
|
||||
|
||||
const API_URL = 'https://accounts.business-harmony.com/api';
|
||||
|
||||
export const fetchRequest = <T = ApiResponse>(
|
||||
url: string,
|
||||
body: Object | null,
|
||||
): FetchPromise<T> => {
|
||||
return fetch(`${API_URL}/${url}`, {
|
||||
body: JSON.stringify(body),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// GetUserStatusByPhoneNumberOrEmail
|
||||
|
||||
export const getUserStatusByPhoneNumberOrEmail = async (
|
||||
body: GetUserStatusByPhoneNumberOrEmailRequest,
|
||||
) => {
|
||||
return fetchRequest<GetUserStatusByPhoneNumberOrEmailResponse>(
|
||||
'User/GetUserStatusByPhoneNumberOrEmail',
|
||||
body,
|
||||
);
|
||||
};
|
||||
|
||||
export const loginOrSignUpWithOtp = async (body: LoginRequest) => {
|
||||
return fetchRequest<LoginResponse>('User/LoginOrSignUpWithOtp', body);
|
||||
};
|
||||
|
||||
export const loginWithPassword = async (body: PasswordLoginRequest) => {
|
||||
return fetchRequest<LoginResponse>('User/LoginWithPassword', body);
|
||||
};
|
||||
|
||||
export const sendSmsOtp = async (body: SendSmsOtpRequest) => {
|
||||
return fetchRequest<ApiResponse>('User/SendSmsOtp', body);
|
||||
};
|
||||
|
||||
export const sendEmailOtp = async (body: SendEmailOtpRequest) => {
|
||||
return fetchRequest<ApiResponse>('User/SendEmailOtp', body);
|
||||
};
|
||||
|
||||
export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => {
|
||||
return fetchRequest<ConfirmOtpResponse>('User/ConfirmSmsOtp', body);
|
||||
};
|
||||
|
||||
export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => {
|
||||
return fetchRequest<ConfirmOtpResponse>('User/ConfirmEmailOtp', body);
|
||||
};
|
||||
|
||||
export const resetPassword = async (body: ResetPasswordRequest) => {
|
||||
return fetchRequest<ResetPasswordResponse>('User/ResetPassword', body);
|
||||
};
|
||||
|
||||
export const sendForgetPassCode = async (body: SendForgetPassCodeRequest) => {
|
||||
return fetchRequest<ApiResponse>('User/SendForgetPassCode', body);
|
||||
};
|
||||
|
||||
export const confirmForgetPassCode = async (
|
||||
body: ConfirmForgetPassCodeRequest,
|
||||
) => {
|
||||
return fetchRequest<ConfirmOtpResponse>('User/ConfirmForgetPassCode', body);
|
||||
};
|
||||
|
||||
export const loginOrSignUpWithGoogle = async (
|
||||
body: LoginOrSignUpWithGoogleRequest,
|
||||
) => {
|
||||
return fetchRequest<LoginOrSignUpWithGoogleResponse>(
|
||||
'User/LoginOrSignUpWithGoogle',
|
||||
body,
|
||||
);
|
||||
};
|
||||
|
||||
export const completeUserInformation = async (
|
||||
body: CompleteUserInformationRequest,
|
||||
) => {
|
||||
return fetchRequest<ApiResponse>('User/CompleteUserInformation', body);
|
||||
};
|
||||
|
||||
export const logOut = async () => {
|
||||
return fetchRequest<ApiResponse>('User/LogOut', {});
|
||||
};
|
||||
23
src/features/authorization/components/AuthenticationCard.tsx
Normal file
23
src/features/authorization/components/AuthenticationCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Paper } from '@mui/material';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
// Beacuse in the otp verify there is a element outside of the authentication card
|
||||
export const AuthenticationCard = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
p: {
|
||||
xs: 4,
|
||||
md: 6,
|
||||
},
|
||||
marginInline: 2,
|
||||
width: (t) => `calc(100% - ${t.spacing(2)})`,
|
||||
maxWidth: '552px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
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, GUID } from '@/types/commonTypes';
|
||||
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export const AuthenticationSteps = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const DEFAULT_RETURN_URL = '/profile';
|
||||
const [searchParams] = useSearchParams();
|
||||
const authReturnUrl: string =
|
||||
searchParams.get('returnUrl') ?? DEFAULT_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 = (userId: GUID) => {
|
||||
localStorage.setItem('userID', userId);
|
||||
|
||||
redirectToReturnUrl();
|
||||
};
|
||||
|
||||
const handleConfrimPhoneNumber = (userId: GUID) => {
|
||||
localStorage.setItem('userID', userId);
|
||||
|
||||
setCurrentStep('addPhoneNumber');
|
||||
};
|
||||
|
||||
const handlePhoneNumberVerified = () => {
|
||||
redirectToReturnUrl();
|
||||
};
|
||||
|
||||
const redirectToReturnUrl = () => {
|
||||
if (authReturnUrl === DEFAULT_RETURN_URL) {
|
||||
navigate(DEFAULT_RETURN_URL);
|
||||
} else {
|
||||
location.href = authReturnUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentStep === 'emailOrPhone' && (
|
||||
<LoginRegisterForm
|
||||
authReturnUrl={authReturnUrl}
|
||||
onGoogleAuthenticated={handleUserLoggedIn}
|
||||
countryCode={countryCode}
|
||||
setCountryCode={setCountryCode}
|
||||
loginRegisterValue={loginRegisterValue}
|
||||
setLoginRegisterValue={setLoginRegisterValue}
|
||||
authType={authType}
|
||||
setAuthType={setAuthType}
|
||||
onLoginRegisterSubmit={handleLoginRegister}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'verify' && (
|
||||
<OtpVerifyForm
|
||||
onVerifyPhoneNumber={handleConfrimPhoneNumber}
|
||||
authReturnUrl={authReturnUrl}
|
||||
countryCode={countryCode}
|
||||
onOTPVerified={handleUserLoggedIn}
|
||||
onEditValue={() => setCurrentStep('emailOrPhone')}
|
||||
authMode={authMode}
|
||||
authType={authType}
|
||||
value={loginRegisterValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'enterPassword' && (
|
||||
<EnterPasswordForm
|
||||
authReturnUrl={authReturnUrl}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
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';
|
||||
|
||||
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 [sendOtpLoading, setSendOtpLoading] = useState<boolean>(false);
|
||||
|
||||
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 {
|
||||
setSendOtpLoading(true);
|
||||
|
||||
await sendSmsOtp({ phoneNumber: countryCode + value });
|
||||
onCompleteSignUp(countryCode, value);
|
||||
|
||||
setSendOtpLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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={sendOtpLoading} onClick={handleCompleteSignUp}>
|
||||
{t('verify.confirmAndContinue')}
|
||||
</Button>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
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 { Toast } from '@/components/Toast';
|
||||
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';
|
||||
|
||||
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 [loginLoading, setLoginLoading] = useState<boolean>(false);
|
||||
const [isLoginStatusSuccess, setIsLoginStatusSuccess] = useState<boolean>();
|
||||
const [loginAlertOpen, setLoginAlertOpen] = useState<boolean>(false);
|
||||
const [loginFailedMessage, setLoginFailedMessage] = useState<string>('');
|
||||
const [sendOtpLoading, setSendOtpLoading] = useState<boolean>(false);
|
||||
|
||||
const handleBlur = () => {
|
||||
setInputTouched(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!passValue) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
setLoginLoading(true);
|
||||
|
||||
const apiRequest: PasswordLoginRequest = {
|
||||
phoneNumber:
|
||||
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
|
||||
email: authType === 'email' ? loginRegisterValue : undefined,
|
||||
password: passValue,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const result = await loginWithPassword(apiRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
setIsLoginStatusSuccess(true);
|
||||
onLoggedIn(jsonRes.userId);
|
||||
} else {
|
||||
setIsLoginStatusSuccess(false);
|
||||
setLoginFailedMessage(jsonRes.message);
|
||||
}
|
||||
setLoginAlertOpen(true);
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithOtp = async () => {
|
||||
setSendOtpLoading(true);
|
||||
|
||||
if (authType === 'phone') {
|
||||
await sendSmsOtp({ phoneNumber: countryCode + loginRegisterValue });
|
||||
} else {
|
||||
await sendEmailOtp({ email: loginRegisterValue });
|
||||
}
|
||||
|
||||
setSendOtpLoading(false);
|
||||
onLoginWithOTP();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Toast
|
||||
open={loginAlertOpen}
|
||||
onClose={() => setLoginAlertOpen(false)}
|
||||
color={!isLoginStatusSuccess ? 'error' : 'success'}
|
||||
>
|
||||
{!isLoginStatusSuccess
|
||||
? loginFailedMessage
|
||||
: t('verify.youHaveSuccessfullyLoggedIn')}
|
||||
</Toast>
|
||||
|
||||
<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={<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 ? <Eye /> : <EyeSlash />}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleLoginWithOtp}
|
||||
sx={{ width: 'auto', mb: 2 }}
|
||||
variant="text"
|
||||
loading={sendOtpLoading}
|
||||
endIcon={<ArrowLeft />}
|
||||
>
|
||||
{t('enterPassword.loginWithOneTimeCode')}
|
||||
</Button>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button loading={loginLoading} onClick={handleSubmit}>
|
||||
{t('verify.confirmAndLogin')}
|
||||
</Button>
|
||||
<Link to="/forget-password">
|
||||
<Button variant="text">{t('enterPassword.iForgotMyPassword')}</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from '@mui/material';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GoogleCodeClientResponse } from '../../types/userTypes';
|
||||
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
|
||||
import type { GUID } from '@/types/commonTypes';
|
||||
import { Google } from 'iconsax-react';
|
||||
|
||||
export interface GoogleAuthenticationProps {
|
||||
disabled: boolean;
|
||||
authReturnUrl: string;
|
||||
onGoogleAuthenticated: (userId: GUID) => void;
|
||||
}
|
||||
|
||||
export const GoogleAuthentication = ({
|
||||
disabled,
|
||||
authReturnUrl,
|
||||
onGoogleAuthenticated,
|
||||
}: GoogleAuthenticationProps) => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const [loginWithGoogleLoading, setLoginWithGoogleLoading] =
|
||||
useState<boolean>(false);
|
||||
|
||||
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.oauth2.initCodeClient({
|
||||
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
||||
scope: 'openid email profile',
|
||||
ux_mode: 'popup',
|
||||
response_type: 'id_token',
|
||||
callback: async (resp: GoogleCodeClientResponse) => {
|
||||
setLoginWithGoogleLoading(true);
|
||||
|
||||
const result = await loginOrSignUpWithGoogle({
|
||||
idToken: resp.id_token,
|
||||
returnUrl: authReturnUrl,
|
||||
});
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
onGoogleAuthenticated(jsonRes.userId);
|
||||
} else {
|
||||
// Todo: Add useToast to handle error
|
||||
}
|
||||
|
||||
setLoginWithGoogleLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.requestCode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={disabled}
|
||||
loading={loginWithGoogleLoading}
|
||||
variant="outlined"
|
||||
startIcon={<Google variant="Bold" />}
|
||||
>
|
||||
{t('loginForm.loginWithGoogle')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
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 { Toast } from '@/components/Toast';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
import { GoogleAuthentication } from './GoogleAuthentication';
|
||||
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
|
||||
|
||||
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 [checkStatusLoading, setCheckStatusLoading] = useState<boolean>(false);
|
||||
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 [errorMessage, setErrorMessage] = useState<string>();
|
||||
const inputError: boolean = touched && !!error;
|
||||
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const showAdornment = authType === 'phone' && loginRegisterValue.length > 0;
|
||||
|
||||
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">
|
||||
{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={checkStatusLoading} onClick={handleSubmit}>
|
||||
{t('loginForm.submitButton')}
|
||||
</Button>
|
||||
|
||||
<GoogleAuthentication
|
||||
authReturnUrl={authReturnUrl}
|
||||
onGoogleAuthenticated={onGoogleAuthenticated}
|
||||
disabled={checkStatusLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
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 {
|
||||
loginOrSignUpWithOtp,
|
||||
sendEmailOtp,
|
||||
sendSmsOtp,
|
||||
} from '../../api/authorizationAPI';
|
||||
import type { CountryCode, GUID } from '@/types/commonTypes';
|
||||
|
||||
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 [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);
|
||||
|
||||
if (authType === 'phone') {
|
||||
await sendSmsOtp({ phoneNumber: countryCode + value });
|
||||
} else {
|
||||
await sendEmailOtp({ email: 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 handleVerifyOTP = () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
handleLoginOrSignUp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginOrSignUp = async () => {
|
||||
setOtpDigitInvalid(false);
|
||||
setVerifyStatusLoading(true);
|
||||
|
||||
const loginRequest: LoginRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
|
||||
email: authType === 'email' ? value : undefined,
|
||||
returnUrl: authReturnUrl,
|
||||
};
|
||||
const result = await loginOrSignUpWithOtp(loginRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
setVerifyStatus('success');
|
||||
|
||||
if (jsonRes.registeredWithOutPhoneNumber) {
|
||||
onVerifyPhoneNumber(jsonRes.userId);
|
||||
} else {
|
||||
onOTPVerified(jsonRes.userId);
|
||||
}
|
||||
} else {
|
||||
setVerifyStatus('failed');
|
||||
setErrorMessage(jsonRes.message);
|
||||
}
|
||||
|
||||
setVerifyAlertOpen(true);
|
||||
setVerifyStatusLoading(false);
|
||||
};
|
||||
|
||||
const otpMessage = (): 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 toastMessage =
|
||||
verifyStatus === 'failed'
|
||||
? (errorMessage ?? t('verify.theVerificationCodeIsIncorrect'))
|
||||
: verifyStatus === 'success' && authMode === 'register'
|
||||
? t('verify.youHaveSuccessfullySignedIn')
|
||||
: verifyStatus === 'success' && authMode === 'login'
|
||||
? t('verify.youHaveSuccessfullyLoggedIn')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<Toast
|
||||
open={verifyAlertOpen}
|
||||
onClose={() => setVerifyAlertOpen(false)}
|
||||
color={verifyStatus === 'failed' ? 'error' : 'success'}
|
||||
>
|
||||
{toastMessage}
|
||||
</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}
|
||||
>
|
||||
{authType === 'phone' ? countryCode + value : value}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
{otpMessage()}
|
||||
</Typography>
|
||||
|
||||
<DigitInput
|
||||
error={otpDigitInvalid || verifyStatus === 'failed'}
|
||||
success={verifyStatus === 'success'}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
|
||||
{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={resendLoading}
|
||||
sx={{ width: 'auto' }}
|
||||
onClick={canResend ? handleResendOTPCode : undefined}
|
||||
>
|
||||
{canResend && t('verify.resendCode')}
|
||||
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/components/DigitsInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { ConfirmSmsOtpRequest } from '../../types/userTypes';
|
||||
import { confirmSmsOtp, 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 [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 handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpDigitInvalid(true);
|
||||
} else {
|
||||
setOtpDigitInvalid(false);
|
||||
setVerifyStatusLoading(true);
|
||||
|
||||
const confirmSmsOtpRequest: ConfirmSmsOtpRequest = {
|
||||
otpCode: otpCode,
|
||||
phoneNumber: countryCode + value,
|
||||
};
|
||||
const result = await confirmSmsOtp(confirmSmsOtpRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
setVerifyStatus('success');
|
||||
onPhoneNumberVerified();
|
||||
} 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) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
254
src/features/authorization/components/CountryCodeSelector.tsx
Normal file
254
src/features/authorization/components/CountryCodeSelector.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
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 '../../../countries';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
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();
|
||||
|
||||
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 */}
|
||||
<ArrowDown2 size="24" 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')}
|
||||
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)} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import { Edit2, Eye, EyeSlash, TickCircle } from 'iconsax-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Toast } from '@/components/Toast';
|
||||
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';
|
||||
|
||||
export interface ChangePasswordProps {
|
||||
onEditInfo: () => void;
|
||||
onPasswordChanged: () => void;
|
||||
forgettedPasswordInfo: string;
|
||||
infoType: AuthType;
|
||||
countryCode: CountryCode;
|
||||
}
|
||||
|
||||
export const ChangePassword = ({
|
||||
onEditInfo,
|
||||
onPasswordChanged,
|
||||
forgettedPasswordInfo,
|
||||
infoType,
|
||||
countryCode,
|
||||
}: ChangePasswordProps) => {
|
||||
const theme = useTheme();
|
||||
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 [changePasswordLoading, setChangePasswordLoading] =
|
||||
useState<boolean>(false);
|
||||
const [changePasswordStatus, setChangePasswordStatus] = useState<
|
||||
'success' | 'failed'
|
||||
>();
|
||||
const [changePassAlertOpen, setChangePassAlertOpen] =
|
||||
useState<boolean>(false);
|
||||
const [changePassFailedMessage, setChangePassFailedMessage] =
|
||||
useState<string>('');
|
||||
|
||||
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 {
|
||||
setChangePasswordLoading(true);
|
||||
|
||||
const apiRequest: ResetPasswordRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
newPassword: passValue,
|
||||
confirmNewPassword: confirmPassValue,
|
||||
};
|
||||
|
||||
const result = await resetPassword(apiRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
setChangePasswordStatus('success');
|
||||
onPasswordChanged();
|
||||
} else {
|
||||
setChangePasswordStatus('failed');
|
||||
setChangePassFailedMessage(jsonRes.message);
|
||||
}
|
||||
setChangePassAlertOpen(true);
|
||||
|
||||
setChangePasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPassword = (value: string) => {
|
||||
return (
|
||||
containsNumber(value) &&
|
||||
containsSymbol(value) &&
|
||||
least8Chars(value) &&
|
||||
hasUpperAndLowerLetter(value)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Toast
|
||||
open={changePassAlertOpen}
|
||||
onClose={() => setChangePassAlertOpen(false)}
|
||||
color={changePasswordStatus === 'failed' ? 'error' : 'success'}
|
||||
>
|
||||
{changePasswordStatus === 'failed'
|
||||
? changePassFailedMessage
|
||||
: t('forgetPassword.passwordChangedSuccessfully')}
|
||||
</Toast>
|
||||
|
||||
<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={<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 && (
|
||||
<TickCircle variant="Bold" color={theme.palette.success.main} />
|
||||
),
|
||||
endAdornment: passValue ? (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <Eye /> : <EyeSlash />}
|
||||
</IconButton>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ mt: 4 }}
|
||||
/>
|
||||
|
||||
{!isValidPassword(passValue) && (
|
||||
<Stack spacing={1} sx={{ mt: 2 }}>
|
||||
{passwordValidationRules.map((rule) => (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TickCircle
|
||||
variant={rule.validator(passValue) ? 'Bold' : 'Linear'}
|
||||
color={
|
||||
rule.validator(passValue)
|
||||
? theme.palette.success.main
|
||||
: theme.palette.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 && (
|
||||
<TickCircle variant="Bold" color={theme.palette.success.main} />
|
||||
),
|
||||
endAdornment: confirmPassValue ? (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <Eye /> : <EyeSlash />}
|
||||
</IconButton>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ my: 4 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button loading={changePasswordLoading} onClick={handleSubmit}>
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
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';
|
||||
|
||||
export const ForgetPasswordContainer = () => {
|
||||
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 = () => {
|
||||
console.log('changingPasswordTo');
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
import DigitInput from '@/components/components/DigitsInput';
|
||||
import type { AuthType } from '../../types/authTypes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { AuthenticationCard } from '../AuthenticationCard';
|
||||
import type { ConfirmForgetPassCodeRequest } from '../../types/userTypes';
|
||||
import type { CountryCode } from '@/types/commonTypes';
|
||||
import { confirmForgetPassCode } from '../../api/authorizationAPI';
|
||||
|
||||
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 [verifyStatus, setVerifyStatus] = useState<'failed' | 'success'>();
|
||||
const [verifyStatusLoading, setVerifyStatusLoading] =
|
||||
useState<boolean>(false);
|
||||
const [verifyAlertMessage, setVerifyAlertMessage] = useState<string>();
|
||||
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 = () => {
|
||||
setResendLoading(true);
|
||||
|
||||
// TODO: Call API here instead of settimeout
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('resended');
|
||||
|
||||
setResendTimer(120);
|
||||
setCanResend(false);
|
||||
setResendLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
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);
|
||||
setVerifyStatusLoading(true);
|
||||
|
||||
// Change setTimeout to api call
|
||||
const apiRequest: ConfirmForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
code: otpCode,
|
||||
};
|
||||
|
||||
const result = await confirmForgetPassCode(apiRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (jsonRes.success) {
|
||||
setVerifyStatus('success');
|
||||
onOTPVerified(otpCode);
|
||||
} else {
|
||||
setVerifyStatus('failed');
|
||||
setVerifyAlertMessage(jsonRes.message);
|
||||
}
|
||||
|
||||
setVerifyStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<AuthenticationCard>
|
||||
<Toast
|
||||
open={!!verifyAlertMessage}
|
||||
onClose={() => setVerifyAlertMessage(undefined)}
|
||||
color={'error'}
|
||||
>
|
||||
{verifyAlertMessage}
|
||||
</Toast>
|
||||
|
||||
<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={<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 || verifyStatus === 'failed'}
|
||||
success={verifyStatus === 'success'}
|
||||
onChange={(value) => setOtpCode(value)}
|
||||
/>
|
||||
|
||||
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
|
||||
{t('forgetPassword.confirm')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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 { Toast } from '@/components/Toast';
|
||||
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
|
||||
|
||||
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 [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [sendCodeLoading, setSendCodeLoading] = useState<boolean>(false);
|
||||
const inputError: boolean = touched && !!error;
|
||||
|
||||
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)) {
|
||||
setSendCodeLoading(true);
|
||||
|
||||
const sendCodeRequest: SendForgetPassCodeRequest = {
|
||||
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
|
||||
phoneNumber:
|
||||
infoType === 'phone'
|
||||
? countryCode + forgettedPasswordInfo
|
||||
: undefined,
|
||||
};
|
||||
const result = await sendForgetPassCode(sendCodeRequest);
|
||||
const jsonRes = await result.json();
|
||||
|
||||
if (!jsonRes.success) {
|
||||
setErrorMessage(jsonRes.message);
|
||||
}
|
||||
|
||||
setSendCodeLoading(false);
|
||||
onVerifyOtp(forgettedPasswordInfo);
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
validateInput(forgettedPasswordInfo, infoType);
|
||||
}
|
||||
};
|
||||
|
||||
const showAdornment =
|
||||
infoType === 'phone' && forgettedPasswordInfo.length > 0;
|
||||
|
||||
return (
|
||||
<AuthenticationCard>
|
||||
<Toast
|
||||
color="error"
|
||||
onClose={() => setErrorMessage(undefined)}
|
||||
open={!!errorMessage}
|
||||
>
|
||||
{errorMessage}
|
||||
</Toast>
|
||||
|
||||
<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={sendCodeLoading} onClick={handleSubmit}>
|
||||
{t('forgetPassword.confirm')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</AuthenticationCard>
|
||||
);
|
||||
}
|
||||
0
src/features/authorization/index.ts
Normal file
0
src/features/authorization/index.ts
Normal file
20
src/features/authorization/routes/AuthenticationPage.tsx
Normal file
20
src/features/authorization/routes/AuthenticationPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FlexBox } from '@/components/components/common/FlexBox';
|
||||
import Logo from '@/components/Logo';
|
||||
import { AuthenticationSteps } from '../components/AuthenticationSteps/AuthenticationSteps';
|
||||
|
||||
export function AuthenticationPage() {
|
||||
return (
|
||||
<FlexBox
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<AuthenticationSteps />
|
||||
</FlexBox>
|
||||
);
|
||||
}
|
||||
20
src/features/authorization/routes/ForgetPasswordPage.tsx
Normal file
20
src/features/authorization/routes/ForgetPasswordPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FlexBox } from '@/components/components/common/FlexBox';
|
||||
import Logo from '@/components/Logo';
|
||||
import { ForgetPasswordContainer } from '../components/ForgetPassword/ForgetPasswordContainer';
|
||||
|
||||
export function ForgetPasswordPage() {
|
||||
return (
|
||||
<FlexBox
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<ForgetPasswordContainer />
|
||||
</FlexBox>
|
||||
);
|
||||
}
|
||||
10
src/features/authorization/types/authTypes.ts
Normal file
10
src/features/authorization/types/authTypes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type AuthType = 'email' | 'phone';
|
||||
|
||||
export type AuthMode = 'register' | 'login';
|
||||
|
||||
export type AuthStep =
|
||||
| 'emailOrPhone'
|
||||
| 'verify'
|
||||
| 'enterPassword'
|
||||
| 'addPhoneNumber'
|
||||
| 'addedPhoneNumberVerify';
|
||||
138
src/features/authorization/types/userTypes.ts
Normal file
138
src/features/authorization/types/userTypes.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// GetUserStatusByPhoneNumberOrEmail
|
||||
|
||||
import type { ApiResponse } from '@/types/apiResponse';
|
||||
import type { GUID } from '@/types/commonTypes';
|
||||
|
||||
export interface GetUserStatusByPhoneNumberOrEmailRequest {
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
|
||||
userStatus: UserStatus;
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
None = 0,
|
||||
RegisteredWithPassword = 1,
|
||||
RegisteredWithoutPassword = 2,
|
||||
NotRegistered = 3,
|
||||
}
|
||||
|
||||
// LoginOrSignUpWithOtp
|
||||
|
||||
export interface LoginRequest {
|
||||
otpCode: string;
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface PasswordLoginRequest {
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse extends ApiResponse {
|
||||
returnUrl: string;
|
||||
userId: GUID;
|
||||
registeredWithOutPhoneNumber: boolean;
|
||||
completedUserInformation: boolean;
|
||||
}
|
||||
|
||||
// SendSmsOtp
|
||||
|
||||
export interface SendSmsOtpRequest {
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
// SendEmailOtp
|
||||
|
||||
export interface SendEmailOtpRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ConfirmOtp
|
||||
|
||||
export interface ConfirmEmailOtpRequest {
|
||||
email: string;
|
||||
otpCode: string;
|
||||
}
|
||||
|
||||
export interface ConfirmSmsOtpRequest {
|
||||
phoneNumber: string;
|
||||
otpCode: string;
|
||||
}
|
||||
|
||||
export interface ConfirmOtpResponse extends ApiResponse {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
// ResetPassword
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse extends ApiResponse {
|
||||
passwordChanged: boolean;
|
||||
}
|
||||
|
||||
// SendForgetPassCode
|
||||
|
||||
export interface SendForgetPassCodeRequest {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
// ConfirmForgetPassCode
|
||||
|
||||
export interface ConfirmForgetPassCodeRequest {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
// LoginOrSignUpWithGoogle
|
||||
|
||||
export interface GoogleCodeClientResponse {
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
export interface LoginOrSignUpWithGoogleRequest {
|
||||
idToken: string;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface LoginOrSignUpWithGoogleResponse extends ApiResponse {
|
||||
userId: GUID;
|
||||
registeredWithOutPhoneNumber: boolean;
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user