Merge branch 'develop' into feat/auth

This commit is contained in:
SajadMRjl
2025-08-16 02:45:21 +03:30
committed by GitHub
57 changed files with 7196 additions and 394 deletions

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
interface CardContainerProps {
title: string;
subtitle: string;
action?: React.ReactNode;
children?: React.ReactNode;
highlighted?: boolean;
}
export function CardContainer({
title,
subtitle,
action,
children,
highlighted,
}: CardContainerProps) {
return (
<Box sx={{ width: '100%', bgcolor: 'background.paper' }}>
<Box
sx={{
marginInline: 'auto',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
bgcolor: highlighted ? 'primary.light' : 'background.default',
p: 2,
borderRadius: 1,
gap: { xs: 1, sm: 0 },
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography
variant="h6"
color={highlighted ? 'primary.main' : 'text.primary'}
>
{title}
</Typography>
<Typography
color={highlighted ? 'primary.main' : 'text.secondary'}
variant="body2"
>
{subtitle}
</Typography>
</Box>
{action}
</Box>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit';
interface CountdownTimerProps {
initialSeconds: number;
onComplete?: () => void;
}
export function CountDownTimer({
initialSeconds,
onComplete,
}: CountdownTimerProps) {
const { i18n } = useTranslation();
const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
useEffect(() => {
setSecondsLeft(initialSeconds);
const timer = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
clearInterval(timer);
onComplete?.();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [initialSeconds, onComplete]);
const formatTime = (totalSeconds: number) => {
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
return toLocaleDigits(`${minutes}:${seconds}`, i18n.language);
};
return <span>{formatTime(secondsLeft)}</span>;
}

View File

@@ -0,0 +1,40 @@
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
// TODO: move countries outside of feature directory
import { countries } from '@/features/authentication/data/Countries';
interface CountryFlagProps {
code: string;
}
export function CountryFlag({ code }: CountryFlagProps) {
const { t } = useTranslation();
const countryObj = code ? countries.find((c) => c.code === code) : null;
if (!countryObj) {
return null;
}
const displayName = t(countryObj.label);
const flagUrl = `https://flagcdn.com/w40/${countryObj.code.toLowerCase()}.png`;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="img"
loading="lazy"
src={flagUrl}
alt={displayName}
sx={{
width: 24,
height: 16,
borderRadius: 0.5,
border: '1px solid',
borderColor: 'divider',
}}
/>
<Typography variant="body2">{displayName}</Typography>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import { ToggleButtonGroup, ToggleButton, Box } from '@mui/material';
import { Sun1, Moon } from 'iconsax-react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@rkheftan/harmony-ui';
import { type MouseEvent } from 'react';
enum ThemeMode {
Light = 'light',
Dark = 'dark',
}
interface ThemeToggleButtonProps {
value: 'light' | 'dark';
onChange: (newMode: 'light' | 'dark') => void;
}
export const ThemeToggleButton = ({
value,
onChange,
}: ThemeToggleButtonProps) => {
const { t } = useTranslation('setting');
const handleChange = (
_event: MouseEvent<HTMLElement>,
newMode: 'light' | 'dark' | null,
) => {
if (newMode !== null) {
onChange(newMode);
}
};
return (
<Box>
<ToggleButtonGroup
value={value}
exclusive
onChange={handleChange}
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<ToggleButton
value={ThemeMode.Light}
aria-label="light theme"
sx={{
textTransform: 'none',
display: 'flex',
gap: 1,
px: 2,
py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}}
>
<Icon Component={Sun1} color="primary.main" variant="Bold" />
{t('settings.light')}
</ToggleButton>
<ToggleButton
value={ThemeMode.Dark}
aria-label="dark theme"
sx={{
textTransform: 'none',
display: 'flex',
gap: 1,
px: 2,
py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}}
>
<Icon Component={Moon} size="medium" color="primary.light" />
{t('settings.dark')}
</ToggleButton>
</ToggleButtonGroup>
</Box>
);
};

View 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;
};

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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' },
];

View 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;
}

View 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;
}

View File

@@ -0,0 +1,173 @@
import apiClient from '@/lib/apiClient';
import {
type GetProfileApiResponse,
type SaveProfileApiResponse,
type PhoneNumberApiResponse,
type ConfirmPhoneNumberApiResponse,
type SaveSettingsApiResponse,
type DeleteSessionsApiResponse,
type PasswordApiResponse,
type SendEmailCodeApiResponse,
type ConfirmEmailCodeApiResponse,
type ChangeEmailApiResponse,
} from '../types/settingsApiType';
import { type InfoRowData } from '../types/settingsType';
export async function fetchProfile(): Promise<{ data: GetProfileApiResponse }> {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
);
return { data: res.data };
}
export async function saveProfile(payload?: {
data: InfoRowData;
imageUrl: string | null;
}): Promise<{ data: SaveProfileApiResponse }> {
if (!payload) {
throw new Error('Payload for saving profile is missing.');
}
const res = await apiClient.post<SaveProfileApiResponse>(
'/Profile/SaveProfilePersonalInforamtion',
payload,
);
return { data: res.data };
}
export async function sendVerificationCode(payload?: {
phoneNumber: string;
}): Promise<{ data: PhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for sending verification code is missing.');
}
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/SendVerfiyPhoneNumberCode',
payload,
);
return { data: res.data };
}
export async function confirmPhoneNumberCode(payload?: {
phoneNumber: string;
verifyCode: string;
}): Promise<{ data: ConfirmPhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for confirming phone number is missing.');
}
const res = await apiClient.post<ConfirmPhoneNumberApiResponse>(
'/Profile/ConfirmPhoneNumberChangeCode',
payload,
);
return { data: res.data };
}
export async function changePhoneNumber(payload?: {
phoneNumber: string;
}): Promise<{ data: PhoneNumberApiResponse }> {
if (!payload) {
throw new Error('Payload for changing phone number is missing.');
}
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/ChangePhoneNumber',
payload,
);
return { data: res.data };
}
export async function saveSettings(payload?: {
theme: number;
calendarType: number;
language: number;
}): Promise<{ data: SaveSettingsApiResponse }> {
if (!payload) {
throw new Error('Payload for saving settings is missing.');
}
const res = await apiClient.post<SaveSettingsApiResponse>(
'/Profile/SaveSetting',
payload,
);
return { data: res.data };
}
export async function deleteSessions(payload?: {
keys: string[];
}): Promise<{ data: DeleteSessionsApiResponse }> {
if (!payload || payload.keys.length === 0) {
throw new Error('Payload with session keys is missing or empty.');
}
const res = await apiClient.post<DeleteSessionsApiResponse>(
'/Profile/DeleteSessions',
payload,
);
return { data: res.data };
}
export async function addPassword(payload?: {
password: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for adding password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/AddPassword',
payload,
);
return { data: res.data };
}
export async function changePassword(payload?: {
oldPassword: string;
newPassword: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for changing password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/ChangePassword',
payload,
);
return { data: res.data };
}
// ✅ NEW FUNCTIONS
export async function sendEmailCode(payload?: {
email: string;
}): Promise<{ data: SendEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for sending email code is missing.');
}
const res = await apiClient.post<SendEmailCodeApiResponse>(
'Profile/SendEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function confirmEmailCode(payload?: {
email: string;
verifyCode: string;
}): Promise<{ data: ConfirmEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for confirming email code is missing.');
}
const res = await apiClient.post<ConfirmEmailCodeApiResponse>(
'Profile/ConfirmEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function changeEmail(payload?: {
email: string;
}): Promise<{ data: ChangeEmailApiResponse }> {
if (!payload) {
throw new Error('Payload for changing email is missing.');
}
const res = await apiClient.post<ChangeEmailApiResponse>(
'Profile/ChangeEmail',
payload,
);
return { data: res.data };
}

View 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 '../data/countries';
interface CountryCodeSelectorProps {
show: boolean;
value: string;
onChange: (newValue: string) => 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, t],
);
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>
);
}

View File

@@ -0,0 +1,21 @@
import { Box } from '@mui/material';
import React from 'react';
interface PageWrapperProps {
children: React.ReactNode;
}
export function PageWrapper({ children }: PageWrapperProps) {
return (
<Box
sx={{
mx: 'auto',
backgroundColor: 'background.paper',
display: 'flex',
flexDirection: 'column',
}}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,285 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
useTheme,
useMediaQuery,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { DeviceMessage, Logout } from 'iconsax-react';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { fetchProfile, deleteSessions } from '../../api/settingsApi';
import { type ApiSession } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
import { type Device } from '../../types/settingsType';
export function ActiveDevices() {
const { t, i18n } = useTranslation('setting');
const [devices, setDevices] = useState<Device[]>([]);
const [loadingDeleteIds, setLoadingDeleteIds] = useState<string[]>([]);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: terminateData,
loading: isTerminating,
execute: executeTerminateAll,
} = useManualApi(deleteSessions);
useEffect(() => {
executeFetchProfile();
}, [i18n.language]);
useEffect(() => {
if (profileData?.success && profileData.activeSessions) {
const { sessions, currentKey } = profileData.activeSessions;
const formattedDevices = sessions.map((session: ApiSession) => ({
id: session.key,
timeAndDate: formatDate(session.created, i18n.language, t),
deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
setDevices(formattedDevices);
}
}, [profileData, i18n.language, t]);
useEffect(() => {
if (terminateData?.success) {
setDevices((prev) => prev.filter((d) => d.current));
}
}, [terminateData]);
const handleDeleteDevice = async (id: string) => {
if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]);
try {
const { data } = await deleteSessions({ keys: [id] });
if (data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
} else {
console.error('Delete failed:', data.message);
}
} catch (error: unknown) {
console.error('Delete error:', error);
} finally {
setLoadingDeleteIds((prev) =>
prev.filter((loadingId) => loadingId !== id),
);
}
};
const handleTerminateAllOtherSessions = () => {
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
if (otherSessionKeys.length > 0) {
executeTerminateAll({ keys: otherSessionKeys });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('active.activeDevices')}
subtitle={t('active.activeDevicesCaption')}
action={
<Button
size="medium"
variant="outlined"
onClick={handleTerminateAllOtherSessions}
sx={{
borderRadius: 1,
borderColor: 'error.main',
color: 'error.main',
textTransform: 'none',
}}
disabled={isLoading || isTerminating}
>
{isTerminating
? t('active.deleting')
: t('active.deleteDevicesButton')}
</Button>
}
>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
}}
>
{devices.map((device) => (
<React.Fragment key={device.id}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
minHeight: 50,
py: 1.5,
}}
>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 1, sm: 1 },
}}
>
{device.timeAndDate}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
width: '100%',
order: { xs: 2, sm: 2 },
}}
>
<Icon
Component={DeviceMessage}
size="medium"
color="primary.main"
/>
<Typography variant="body2" noWrap>
{device.deviceModel}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 3, sm: 3 },
}}
>
{device.ip}
</Typography>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 4, sm: 4 },
alignItems: 'center',
justifyContent: 'center',
}}
>
{device.current && (
<Button
variant="outlined"
size="medium"
sx={{
borderRadius: 12.5,
border: '1px solid',
borderColor: 'success.main',
whiteSpace: 'nowrap',
color: 'success.main',
textTransform: 'none',
'&.Mui-disabled': {
color: 'success.main',
borderColor: 'success.main',
},
}}
disabled
>
{t('active.currentDevice')}
</Button>
)}
</Box>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
textAlign: { xs: 'left', sm: 'center' },
minWidth: { sm: '138px' },
order: { xs: 5, sm: 5 },
}}
>
<Button
size="small"
variant="outlined"
startIcon={<Icon Component={Logout} size="small" />}
disabled={
device.current || loadingDeleteIds.includes(device.id)
}
onClick={() => handleDeleteDevice(device.id)}
sx={{
color: 'error.main',
borderRadius: 1,
borderColor: 'error.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
textTransform: 'none',
'& .MuiButton-startIcon': {
marginRight: 0.5,
marginLeft: 0,
},
}}
>
{loadingDeleteIds.includes(device.id) ? (
<CircularProgress size={20} color="error" />
) : (
t('active.deleteDevice')
)}
</Button>
</Box>
</Box>
{isXsup && (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,144 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Box,
Button,
Link,
CircularProgress,
Typography,
} from '@mui/material';
import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type PasswordDialogProps } from '../../types/settingsType';
export function PasswordDialog({
open,
fullScreen,
handleClose,
password,
setPassword,
confirmPassword,
setConfirmPassword,
currentPassword,
setCurrentPassword,
showValidation,
validPassword,
matchPassword,
loading,
handleSubmit,
changePassword,
apiError,
t,
}: PasswordDialogProps) {
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
maxWidth="xs"
scroll="body"
PaperProps={{ sx: { mx: 1 } }}
>
<DialogTitle sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={handleClose}>
<Icon Component={CloseCircle} size="large" color="primary.main" />
</IconButton>
<Box component="span" fontWeight="bold" fontSize={18}>
{changePassword
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Box>
</Box>
</DialogTitle>
<DialogContent
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 3 },
}}
>
{/* ✅ 4. API ERROR DISPLAY ADDED */}
{apiError && (
<Typography color="error" variant="body2" textAlign="center">
{apiError}
</Typography>
)}
{changePassword && (
<>
<TextField
label={t('securityForm.currentPassword')}
type="password"
fullWidth
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
/>
<Link sx={{ fontSize: '15px', cursor: 'pointer' }}>
{t('securityForm.forgetPassword')}
</Link>
</>
)}
<TextField
label={t('securityForm.newPassword')}
type="password"
fullWidth
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
{showValidation && (
<Typography
variant="body2"
color={validPassword ? 'success.main' : 'error.main'}
>
{validPassword
? t('securityForm.passwordIsValid')
: t('securityForm.passwordIsInvalid')}
</Typography>
)}
<TextField
label={t('securityForm.confirmPassword')}
type="password"
fullWidth
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={confirmPassword.length > 0 && !matchPassword}
helperText={
confirmPassword.length > 0 && !matchPassword
? t('securityForm.notCompatibility')
: ' '
}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'center' }}>
<Button
fullWidth
sx={{ height: 48, textTransform: 'none' }}
variant="contained"
onClick={handleSubmit}
disabled={!validPassword || !matchPassword || loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
t('securityForm.confirm')
)}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Button,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { PasswordDialog } from './PasswordDialog';
import { regex } from '@/utils/regex';
import { useManualApi } from '@/hooks/useApi';
import {
addPassword,
changePassword,
fetchProfile,
} from '../../api/settingsApi';
export function PasswordSecurity() {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation('setting');
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [showValidation, setShowValidation] = useState(false);
const [showPasswordAlert, setShowPasswordAlert] = useState(false);
const [changePasswordUI, setChangePasswordUI] = useState(false);
const { validPassword } = regex(password);
const matchPassword = password === confirmPassword;
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: addData,
loading: isAdding,
error: addError,
execute: executeAddPassword,
} = useManualApi(addPassword);
const {
data: changeData,
loading: isChanging,
error: changeError,
execute: executeChangePassword,
} = useManualApi(changePassword);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success) {
setChangePasswordUI(profileData.hasPassword || false);
}
}, [profileData]);
useEffect(() => {
if (password) {
setShowValidation(!validPassword);
} else {
setShowValidation(false);
}
}, [password, validPassword]);
useEffect(() => {
if (addData?.success || changeData?.success) {
setShowPasswordAlert(true);
if (!changePasswordUI) {
setChangePasswordUI(true);
}
setOpen(false);
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
}
}, [addData, changeData, changePasswordUI]);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handlePasswordSubmit = () => {
if (changePasswordUI) {
executeChangePassword({
oldPassword: currentPassword,
newPassword: password,
});
} else {
executeAddPassword({ password });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => addError || changeError,
[addError, changeError],
);
return (
<PageWrapper>
<CardContainer
title={t('securityForm.password')}
subtitle={t('securityForm.determinePassword')}
action={
<Button variant="contained" onClick={handleOpen}>
{changePasswordUI
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Button>
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '120px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box sx={{ px: 4, py: 2 }}>
{changePasswordUI ? (
<Box>
<Typography variant="h6">
{t('securityForm.activePassword')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('securityForm.lastChange')}
</Typography>
</Box>
) : (
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
{t('securityForm.notDeterminedPassword')}
</Typography>
)}
<PasswordDialog
open={open}
fullScreen={fullScreen}
handleClose={handleClose}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
currentPassword={currentPassword}
setCurrentPassword={setCurrentPassword}
showValidation={showValidation}
validPassword={validPassword}
matchPassword={matchPassword}
loading={isAdding || isChanging}
handleSubmit={handlePasswordSubmit}
changePassword={changePasswordUI}
apiError={getErrorMessage(apiError)}
t={t}
/>
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -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/settingsType';
export function PasswordValidationItem({
isValid,
label,
}: ValidationItemProps) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
flexWrap: 'wrap',
}}
>
<Icon
Component={TickCircle}
color={isValid ? 'success.main' : 'primary.main'}
variant={isValid ? 'Bold' : 'Outline'}
size="small"
/>
<Typography
variant="body2"
color="text.primary"
sx={{
fontSize: { xs: '0.85rem', sm: '0.875rem' },
wordBreak: 'break-word',
flex: 1,
}}
>
{label}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { fetchProfile } from '../../api/settingsApi';
import type { LoginLog } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
export function RecentLogins() {
const { t, i18n } = useTranslation('setting');
const [logs, setLogs] = useState<LoginLog[]>([]);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
useEffect(() => {
executeFetchProfile();
}, [i18n.language]);
useEffect(() => {
if (profileData?.success && Array.isArray(profileData.loginLogs)) {
setLogs(profileData.loginLogs);
}
}, [profileData]);
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('securityForm.recentLogins')}
subtitle={t('securityForm.description')}
>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError) || t('active.errorFetch')}
</Typography>
</Box>
) : (
<Box sx={{ px: 4 }}>
{logs.map((log, index) => (
<React.Fragment key={index}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
minHeight: 50,
py: 1.5,
flexWrap: 'wrap',
gap: 2,
}}
>
<Typography sx={{ flex: 1, minWidth: 160 }}>
{formatDate(log.loginDateTime, i18n.language, t)}
</Typography>
<Typography sx={{ flex: 1, minWidth: 160 }}>
{`${log.deviceOs} ${log.deviceName}`}
</Typography>
<Typography sx={{ flex: 1, minWidth: 120 }}>
{log.ipAddress}
</Typography>
</Box>
{isXsup && index < logs.length - 1 && (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,316 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
useColorScheme,
Autocomplete,
TextField,
CircularProgress,
} from '@mui/material';
import { CardContainer } from '@/components/CardContainer';
import { useTranslation } from 'react-i18next';
import { ThemeToggleButton } from '@/components/ThemToggle';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
import { useManualApi } from '@/hooks/useApi';
import { fetchProfile, saveSettings } from '../../api/settingsApi';
type ThemeMode = 'light' | 'dark';
type CalendarType = 'christian' | 'solar' | 'lunar';
interface SettingsState {
language: string;
calendar: CalendarType;
theme: ThemeMode;
}
const languageOptions = [
{ code: 'en', label: 'English', apiValue: 1 },
{ code: 'fa', label: 'فارسی', apiValue: 2 },
];
const calendarOptions: { key: CalendarType; apiValue: number }[] = [
{ key: 'christian', apiValue: 1 },
{ key: 'solar', apiValue: 2 },
{ key: 'lunar', apiValue: 3 },
];
const themeApiMap: Record<ThemeMode, number> = { light: 1, dark: 2 };
export function Setting() {
const { t, i18n } = useTranslation(['setting']);
const { mode, setMode } = useColorScheme();
const [savedSettings, setSavedSettings] = useState<SettingsState>({
language: i18n.language,
calendar: 'solar',
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
const [draftSettings, setDraftSettings] =
useState<SettingsState>(savedSettings);
const [isEditing, setIsEditing] = useState(false);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: saveData,
loading: isSaving,
error: saveError,
execute: executeSaveSettings,
} = useManualApi(saveSettings);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success && profileData.userSettings) {
const { theme, calendarType, language } = profileData.userSettings;
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light',
2: 'dark',
};
const newSettings: SettingsState = {
theme: themeReverseMap[theme] || 'light',
calendar:
calendarOptions.find((c) => c.apiValue === calendarType)?.key ||
'solar',
language:
languageOptions.find((l) => l.apiValue === language)?.code || 'en',
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(newSettings.theme);
i18n.changeLanguage(newSettings.language);
}
}, [profileData, setMode, i18n]);
useEffect(() => {
if (saveData?.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
}
}, [saveData, draftSettings, setMode, i18n]);
useEffect(() => {
if (isEditing) {
const resolvedMode = mode === 'light' || mode === 'dark' ? mode : 'light';
setDraftSettings({ ...savedSettings, theme: resolvedMode });
}
}, [isEditing, savedSettings, mode]);
const handleCancel = () => {
setIsEditing(false);
setDraftSettings(savedSettings);
setMode(savedSettings.theme);
};
const handleEditToggle = () => {
if (isEditing) {
const languageObj = languageOptions.find(
(o) => o.code === draftSettings.language,
);
const calendarObj = calendarOptions.find(
(c) => c.key === draftSettings.calendar,
);
if (languageObj && calendarObj) {
executeSaveSettings({
theme: themeApiMap[draftSettings.theme],
calendarType: calendarObj.apiValue,
language: languageObj.apiValue,
});
}
} else {
setIsEditing(true);
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('settings.title')}
subtitle={t('settings.description')}
highlighted={isEditing}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isEditing && (
<Button
variant="text"
onClick={handleCancel}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
fontSize: { xs: '0.85rem', sm: '1rem' },
}}
disabled={isSaving || isFetching}
>
{t('settings.rejectButton')}
</Button>
)}
<Button
onClick={handleEditToggle}
size="large"
variant={isEditing ? 'contained' : 'outlined'}
disabled={isSaving || isFetching}
>
{isSaving
? t('settings.saving')
: isEditing
? t('settings.saveButton')
: t('settings.editButton')}
</Button>
</Box>
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{getErrorMessage(saveError) && (
<Typography color="error.main" variant="body2" mb={2}>
{getErrorMessage(saveError)}
</Typography>
)}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 4,
mt: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
{isEditing ? (
<ThemeToggleButton
value={draftSettings.theme}
onChange={(newTheme) => {
setDraftSettings((prev) => ({
...prev,
theme: newTheme,
}));
}}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon
Component={savedSettings.theme === 'light' ? Sun1 : Moon}
size="medium"
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find(
(o) => o.code === draftSettings.language,
) // ✅ FIX: Removed '|| null'
}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({
...prev,
language: v.code,
}))
}
renderInput={(p) => <TextField {...p} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Typography variant="body1">
{
languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
)}
</Box>
</Box>
<Box sx={{ mt: 2, width: { xs: '100%', sm: 'calc(50% - 16px)' } }}>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
{isEditing ? (
<Autocomplete
options={calendarOptions.map((c) => c.key)}
getOptionLabel={(key) => t(`settings.${key}`)}
value={draftSettings.calendar}
onChange={(_, v) =>
v && setDraftSettings((prev) => ({ ...prev, calendar: v }))
}
renderInput={(params) => <TextField {...params} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={Calendar1} size="medium" variant="Bold" />
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
)}
</Box>
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { Box, Button, Typography, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { ProfileImage } from './personalInformation/ProfileImage';
import { InfoRowDisplay } from './personalInformation/InfoRowDisplay';
import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper';
import { useManualApi } from '@/hooks/useApi';
import { Gender, type InfoRowData } from '../../types/settingsType';
import { fetchProfile, saveProfile } from '../../api/settingsApi';
export function PersonalInformation() {
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [data, setData] = useState<InfoRowData>({
firstName: '',
lastName: '',
nationalCode: '',
gender: Gender.None,
country: '',
});
const [originalData, setOriginalData] = useState<InfoRowData | null>(null);
const {
data: profileData,
loading: isLoadingProfile,
error: fetchProfileError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: saveData,
loading: isSavingProfile,
error: saveProfileError,
execute: executeSaveProfile,
} = useManualApi(saveProfile);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success) {
const fetchedData = {
firstName: profileData.firstName ?? '',
lastName: profileData.lastName ?? '',
nationalCode: profileData.nationalCode ?? '',
gender: Object.values(Gender).includes(profileData.gender as Gender)
? (profileData.gender as Gender)
: Gender.None,
country: profileData.countryCode ?? '',
};
setData(fetchedData);
setOriginalData(fetchedData);
setUploadedImageUrl(profileData.profileImageUrl || null);
}
}, [profileData]);
useEffect(() => {
if (saveData?.success) {
setIsEditing(false);
setOriginalData(data);
}
}, [saveData, data]);
const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`;
const handleEditClick = () => {
setIsEditing(true);
setOriginalData(data);
};
const handleCancelClick = () => {
setIsEditing(false);
if (originalData) {
setData(originalData);
}
};
const handleSaveClick = async () => {
if (!data) return;
executeSaveProfile({ data, imageUrl: uploadedImageUrl });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titlePersonalInfo')}
subtitle={t('settingForm.descriptionPersonalInfo')}
highlighted={isEditing}
action={
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
gap: 1,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
{isEditing ? (
<>
<Button
variant="text"
onClick={handleCancelClick}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
>
{t('settingForm.rejectButton')}
</Button>
<Button
onClick={handleSaveClick}
size="large"
variant="contained"
sx={{
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
disabled={isSavingProfile}
>
{isSavingProfile ? (
<CircularProgress size={24} color="inherit" />
) : (
t('settingForm.saveButton')
)}
</Button>
</>
) : (
<Button
onClick={handleEditClick}
size="large"
variant="outlined"
sx={{ borderRadius: 1 }}
disabled={isLoadingProfile}
>
{t('settingForm.editButton')}
</Button>
)}
</Box>
{getErrorMessage(saveProfileError) && (
<Typography
color="error"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
{getErrorMessage(saveProfileError)}
</Typography>
)}
</Box>
}
>
{isLoadingProfile ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchProfileError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchProfileError) ||
t('settingForm.errorFetch')}
</Typography>
</Box>
) : (
<>
<Box
sx={{
mx: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{isEditing && (
<ProfileImage
initials={initials}
uploadedImageUrl={uploadedImageUrl}
onImageChange={(file) => {
const reader = new FileReader();
reader.onload = () =>
setUploadedImageUrl(reader.result as string);
reader.readAsDataURL(file);
}}
onRemoveImage={() => setUploadedImageUrl(null)}
/>
)}
{data &&
(isEditing ? (
<InfoRowEdit data={data} setData={setData} />
) : (
<InfoRowDisplay
data={data}
uploadedImageUrl={uploadedImageUrl}
initials={initials}
/>
))}
</Box>
</>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,256 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { PageWrapper } from '../PageWrapper';
import { CardContainer } from '@/components/CardContainer';
import PhoneDisplay from './phoneNumber/PhoneDisplay';
import PhoneEditForm from './phoneNumber/PhoneEditForm';
import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
import { CircularProgress, Box, Typography } from '@mui/material';
import { toLocaleDigits } from '@/utils/persianDigit';
import { useManualApi } from '@/hooks/useApi';
import {
fetchProfile,
sendVerificationCode,
confirmPhoneNumberCode,
changePhoneNumber,
} from '../../api/settingsApi';
import { type Phone } from '../../types/settingsType';
export function PhoneNumber() {
const { t, i18n } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [phoneNumber, setPhoneNumber] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showToast, setShowToast] = useState(false);
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
'default',
);
const [isVerified, setIsVerified] = useState(false);
const [phones, setPhones] = useState<Phone[]>([]);
const [countryCode, setCountryCode] = useState('+98');
const [formError, setFormError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: sendCodeData,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useManualApi(sendVerificationCode);
const {
data: confirmData,
loading: isVerifying,
error: confirmError,
execute: executeConfirmCode,
} = useManualApi(confirmPhoneNumberCode);
const {
data: changePhoneData,
loading: isChangingPhone,
error: changePhoneError,
execute: executeChangePhone,
} = useManualApi(changePhoneNumber);
useEffect(() => {
if (!isEditing) {
executeFetchProfile();
}
}, [isEditing]);
useEffect(() => {
if (profileData?.success && profileData.phoneNumber) {
setPhones([
{
phone: profileData.phoneNumber,
time: '',
withCode: profileData.phoneNumber,
},
]);
}
}, [profileData]);
useEffect(() => {
if (sendCodeData?.success) {
setButtonState('counting');
setIsVerified(false);
}
}, [sendCodeData]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
setIsVerified(true);
setShowToast(true);
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
executeChangePhone({ phoneNumber: fullPhoneNumber });
}
}, [confirmData, countryCode, phoneNumber, executeChangePhone]);
useEffect(() => {
if (changePhoneData?.success) {
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
setPhones([
{
phone: phoneNumber,
time: t('settingForm.justNow'),
withCode: fullPhoneNumber,
},
]);
setIsEditing(false);
}
}, [changePhoneData, countryCode, phoneNumber, t]);
const apiError = useMemo(
() => sendCodeError || confirmError || changePhoneError,
[sendCodeError, confirmError, changePhoneError],
);
const getErrorMessage = (error: unknown): string | undefined => {
if (!error) return undefined;
if (error instanceof Error) return error.message;
return String(error);
};
const isPhoneValid = (code: string, phone: string) => {
const phoneNum = parsePhoneNumberFromString(code + phone);
return phoneNum?.isValid();
};
const handleBlur = () => {
setTouched(true);
if (!phoneNumber) {
setFormError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setFormError(t('settingForm.phoneNumberIsInvalid'));
} else {
setFormError(undefined);
}
};
const toggleEdit = () => {
setIsEditing((prev) => {
if (!prev) {
setPhoneNumber('');
setVerificationCode('');
setIsVerified(false);
setButtonState('default');
setShowToast(false);
setFormError(undefined);
setTouched(false);
}
return !prev;
});
};
const handleSendCode = () => {
handleBlur();
if (formError || !isPhoneValid(countryCode, phoneNumber)) return;
executeSendCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
});
};
const handleVerifyCode = () => {
if (!verificationCode) {
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setFormError(undefined);
executeConfirmCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
verifyCode: verificationCode,
});
};
const combinedError = formError || getErrorMessage(apiError);
const inputError: boolean = touched && !!combinedError;
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titlePhoneNumber')}
subtitle={t('settingForm.descriptionPhoneNumber')}
highlighted={isEditing}
action={
<PhoneActionButtons
isEditing={isEditing}
toggleEdit={toggleEdit}
t={t}
/>
}
>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '150px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchError) ||
t('settingForm.errorFetchPhoneNumber')}
</Typography>
</Box>
) : isEditing ? (
<PhoneEditForm
phoneNumber={phoneNumber}
setPhoneNumber={setPhoneNumber}
countryCode={countryCode}
setCountryCode={setCountryCode}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
isVerified={isVerified}
isVerifying={isVerifying || isSendingCode || isChangingPhone}
buttonState={buttonState}
setButtonState={setButtonState}
handleSendCode={handleSendCode}
handleVerifyClick={handleVerifyCode}
error={combinedError}
inputError={inputError}
handleBlur={handleBlur}
textFieldRef={textFieldRef as React.RefObject<HTMLDivElement>}
inputRef={inputRef as React.RefObject<HTMLInputElement>}
phones={phones}
showToast={showToast}
setShowToast={setShowToast}
t={t}
/>
) : (
<PhoneDisplay
phones={phones.map((p) => {
let localPhone = p.withCode;
if (localPhone.startsWith('+98')) {
localPhone = '0' + localPhone.slice(3);
}
return {
...p,
phone: toLocaleDigits(localPhone, i18n.language),
};
})}
/>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import SocialMediaList from './socialMedia/SocialMediaList';
import SocialMediaMenu from './socialMedia/SocialMediaMenu';
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
import useMediaQuery from '@mui/material/useMediaQuery';
import type { Theme } from '@mui/material/styles';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useManualApi } from '@/hooks/useApi';
import {
fetchProfile,
sendEmailCode,
confirmEmailCode,
changeEmail,
} from '../../api/settingsApi';
import { type EmailAccount } from '../../types/settingsType';
export function SocialMedia() {
const { t } = useTranslation('setting');
const [openDialog, setOpenDialog] = useState(false);
const [emailInput, setEmailInput] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>(
'enterEmail',
);
const [emailList, setEmailList] = useState<EmailAccount[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: sendCodeData,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useManualApi(sendEmailCode);
const {
data: confirmData,
loading: isConfirming,
error: confirmError,
execute: executeConfirmCode,
} = useManualApi(confirmEmailCode);
const {
data: changeEmailData,
loading: isChangingEmail,
error: changeEmailError,
execute: executeChangeEmail,
} = useManualApi(changeEmail);
const fullScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down('sm'),
);
const downMd = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
const computedMaxWidth = (fullScreen ? 'xs' : downMd ? 'sm' : 'md') as
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'xl';
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success && profileData.email) {
const { email } = profileData;
setEmailList([
{
email,
provider: email.includes('@gmail.') ? 'google' : 'email',
time: '',
},
]);
}
}, [profileData]);
useEffect(() => {
if (sendCodeData?.success) {
setDialogStep('enterCode');
}
}, [sendCodeData]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
executeChangeEmail({ email: emailInput });
}
}, [confirmData, emailInput, executeChangeEmail]);
useEffect(() => {
if (changeEmailData?.success) {
setEmailList((prev) => [
...prev,
{
email: emailInput,
provider: emailInput.includes('@gmail.') ? 'google' : 'email',
time: t('settingForm.justNow'),
},
]);
resetDialog();
}
}, [changeEmailData, emailInput, t]);
const resetDialog = () => {
setOpenDialog(false);
setEmailInput('');
setVerificationCode('');
setFormError(null);
setDialogStep('enterEmail');
};
const handleSendCode = () => {
if (!/^\S+@\S+\.\S+$/.test(emailInput)) {
setFormError(t('settingForm.emailIsInvalid'));
return;
}
setFormError(null);
executeSendCode({ email: emailInput });
};
const handleConfirmAndChangeEmail = () => {
if (verificationCode.length < 4) {
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setFormError(null);
executeConfirmCode({ email: emailInput, verifyCode: verificationCode });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => getErrorMessage(sendCodeError || confirmError || changeEmailError),
[sendCodeError, confirmError, changeEmailError],
);
return (
<PageWrapper>
<CardContainer
title={t('settingForm.titleSocial')}
subtitle={t('settingForm.descriptionSocial')}
action={
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '100px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<SocialMediaList t={t} emailList={emailList} />
)}
<SocialMediaDialog
open={openDialog}
onClose={resetDialog}
t={t}
emailInput={emailInput}
setEmailInput={setEmailInput}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
apiError={formError || apiError}
isLoading={isSendingCode || isConfirming || isChangingEmail}
dialogStep={dialogStep}
onSendCode={handleSendCode}
onConfirmEmail={handleConfirmAndChangeEmail}
fullScreen={fullScreen}
computedMaxWidth={computedMaxWidth}
/>
</CardContainer>
</PageWrapper>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { type DisplayFieldProps } from '@/features/profile/types/settingsType';
export function DisplayField({ label, value }: DisplayFieldProps) {
const { t } = useTranslation('setting');
const displayValue = value?.trim() || t('settingForm.notDetermined');
return (
<Box
sx={{
flex: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="body1" color="text.primary">
{displayValue}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import { Box, Typography, Avatar } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { DisplayField } from './DisplayField';
import { CountryFlag } from '@/components/CountryFlag';
import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowDisplayProps } from '@/features/profile/types/settingsType';
export function InfoRowDisplay({
data,
uploadedImageUrl,
initials,
}: InfoRowDisplayProps) {
const { t } = useTranslation('setting');
const displayValue = (value: string) =>
value?.trim() || t('settingForm.notDetermined');
const getGenderLabel = (gender: Gender | '') => {
switch (gender) {
case Gender.Male:
return t('settingForm.man');
case Gender.Female:
return t('settingForm.woman');
default:
return t('settingForm.notDetermined');
}
};
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ width: '100%' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{t('settingForm.name')} و {t('settingForm.familyName')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={uploadedImageUrl || undefined}
sx={{
width: 32,
height: 32,
bgcolor: 'secondary.main',
fontSize: '16px',
}}
>
{initials}
</Avatar>
<Typography variant="body1" color="text.primary">
{`${displayValue(data.firstName)} ${displayValue(
data.lastName,
)}`}
</Typography>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settingForm.country')}
</Typography>
<Box sx={{ mt: 0.5 }}>
{data.country ? (
<CountryFlag code={data.country} />
) : (
<Typography variant="body1" color="text.primary">
{t('settingForm.notDetermined')}
</Typography>
)}
</Box>
</Box>
</Box>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 2,
}}
>
<DisplayField
label={t('settingForm.gender')}
value={getGenderLabel(data.gender)}
/>
<DisplayField
label={t('settingForm.nationalCode')}
value={displayValue(data.nationalCode)}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,118 @@
import {
Box,
TextField,
FormControl,
InputLabel,
MenuItem,
Select,
Autocomplete,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { countries } from '@/features/profile/data/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowEditProps } from '@/features/profile/types/settingsType';
export function InfoRowEdit({ data, setData }: InfoRowEditProps) {
const { t } = useTranslation(['countries', 'setting']);
const countryOptions = countries.map((c) => ({
code: c.code,
label: t(c.label, { ns: 'countries' }),
}));
const currentCountry =
countryOptions.find((c) => c.code === data.country) || null;
const fields = [
{
name: 'firstName' as const,
label: t('settingForm.name', { ns: 'setting' }),
value: data.firstName,
},
{
name: 'lastName' as const,
label: t('settingForm.familyName', { ns: 'setting' }),
value: data.lastName,
},
{
name: 'nationalCode' as const,
label: t('settingForm.nationalCode', { ns: 'setting' }),
value: data.nationalCode,
},
];
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{fields.map(({ name, label, value }) => (
<Box
key={name}
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
>
<TextField
fullWidth
name={name}
value={value}
onChange={(e) =>
setData((prev) => ({
...prev,
[name]: e.target.value,
}))
}
label={label}
/>
</Box>
))}
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
<FormControl fullWidth>
<InputLabel>
{t('settingForm.genderPlaceholder', { ns: 'setting' })}
</InputLabel>
<Select
value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'setting' })}
onChange={(e) =>
setData((prev) => ({
...prev,
gender: e.target.value as Gender,
}))
}
>
<MenuItem value={Gender.Male}>
{t('settingForm.man', { ns: 'setting' })}
</MenuItem>
<MenuItem value={Gender.Female}>
{t('settingForm.woman', { ns: 'setting' })}
</MenuItem>
</Select>
</FormControl>
</Box>
<Autocomplete
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
options={countryOptions}
getOptionLabel={(option) => option.label}
value={currentCountry}
onChange={(_, newValue) =>
setData((prev) => ({
...prev,
country: 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('settingForm.country', { ns: 'setting' })}
/>
)}
clearOnEscape
/>
</Box>
);
}

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Box, Avatar, Typography, Button, IconButton } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Camera, Trash } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type ProfileImageProps } from '@/features/profile/types/settingsType';
const MAX_FILE_SIZE_MB = 10;
export function ProfileImage({
initials,
uploadedImageUrl,
onImageChange,
onRemoveImage,
}: ProfileImageProps) {
const { t } = useTranslation('setting');
const [error, setError] = useState<string | null>(null);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (!file) return;
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > MAX_FILE_SIZE_MB) {
setError(t('settingForm.fileSizeError', { size: MAX_FILE_SIZE_MB }));
return;
}
onImageChange(file);
};
return (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}
>
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 88,
height: 88,
fontSize: '20px',
}}
src={uploadedImageUrl || undefined}
>
{initials}
</Avatar>
<Box>
<Typography variant="body1">
{t('settingForm.profilePicture')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.allowedFormat')}
</Typography>
{error && (
<Typography variant="body2" color="error" mt={1}>
{error}
</Typography>
)}
<Box mt={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant={
uploadedImageUrl && onRemoveImage ? 'outlined' : 'contained'
}
component="label"
size="small"
sx={{
borderRadius: 2,
textTransform: 'none',
}}
startIcon={
<Icon
Component={Camera}
size="small"
color={
uploadedImageUrl && onRemoveImage
? 'primary.main'
: 'background.paper'
}
/>
}
>
{uploadedImageUrl && onRemoveImage
? t('settingForm.changePicture')
: t('settingForm.uploadPicture')}
<input
hidden
accept="image/png, image/jpeg, image/gif"
type="file"
onChange={handleImageChange}
/>
</Button>
{uploadedImageUrl && onRemoveImage && (
<IconButton
size="small"
color="error"
onClick={onRemoveImage}
aria-label={t('settingForm.removePicture')}
>
<Icon Component={Trash} color="error.main" />
</IconButton>
)}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,43 @@
import { Box, Button } from '@mui/material';
import { type PhoneActionButtonsProps } from '@/features/profile/types/settingsType';
export default function PhoneActionButtons({
isEditing,
toggleEdit,
t,
}: PhoneActionButtonsProps) {
return (
<Box sx={{ display: 'flex', gap: 1 }}>
{isEditing && (
<Button
variant="text"
onClick={toggleEdit}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
>
{t('settingForm.rejectButton')}
</Button>
)}
<Button
onClick={toggleEdit}
size="large"
variant="outlined"
sx={{
borderRadius: 1,
bgcolor: isEditing ? 'primary.main' : 'background.default',
color: isEditing ? 'primary.contrastText' : 'primary.main',
whiteSpace: 'nowrap',
textTransform: 'none',
}}
>
{isEditing
? t('settingForm.saveButton')
: t('settingForm.editPhoneNumber')}
</Button>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { Box, Typography } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { Mobile } from 'iconsax-react';
import { type PhoneDisplayProps } from '@/features/profile/types/settingsType';
export default function PhoneDisplay({ phones }: PhoneDisplayProps) {
return (
<>
{phones.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
py: 2,
mx: 3,
gap: 1,
}}
>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: 'primary.light',
justifyContent: 'center',
alignItems: 'center',
display: 'flex',
borderRadius: 0.5,
}}
>
<Icon
Component={Mobile}
size="medium"
variant="Bold"
color="primary.main"
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" color="text.primary">
{item.phone}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.time}
</Typography>
</Box>
</Box>
))}
</>
);
}

View File

@@ -0,0 +1,196 @@
import {
Box,
Typography,
TextField,
Button,
IconButton,
InputAdornment,
CircularProgress,
} from '@mui/material';
import { Edit2, TickCircle } from 'iconsax-react';
import { CountDownTimer } from '@/components/CountDownTimer';
import { CountryCodeSelector } from '../../CountryCodeSelector';
import { Icon } from '@rkheftan/harmony-ui';
import { type PhoneEditFormProps } from '@/features/profile/types/settingsType';
export default function PhoneEditForm({
phoneNumber,
setPhoneNumber,
countryCode,
setCountryCode,
verificationCode,
setVerificationCode,
isVerified,
isVerifying,
buttonState,
setButtonState,
handleSendCode,
handleVerifyClick,
error,
inputError,
handleBlur,
textFieldRef,
inputRef,
phones,
t,
}: PhoneEditFormProps) {
const isValidPhoneNumber = (phone: string) => {
const digitsOnly = phone.replace(/\D/g, '');
return digitsOnly.length >= 8 && digitsOnly.length <= 15;
};
return (
<>
<Box sx={{ width: '100%' }}>
<Box sx={{ mb: 2, mx: 3 }}>
<Typography variant="h6">
{t('settingForm.editPhoneNumber')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.phoneNumberText')}({phones.map((p) => p.withCode)})
{t('settingForm.verb')}
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
alignItems: 'center',
mx: 3,
}}
>
<TextField
name="phoneNumber"
label={t('settingForm.newPhoneNumber')}
type="tel"
value={phoneNumber}
ref={textFieldRef}
inputRef={inputRef}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
onChange={(e) => setPhoneNumber(e.target.value)}
sx={{ flex: '1 1 220px', minWidth: 0 }}
placeholder="09123456789"
InputProps={{
endAdornment:
buttonState === 'counting' ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => {
setButtonState('default');
setPhoneNumber('');
setVerificationCode('');
}}
edge="end"
>
<Icon
Component={Edit2}
color="primary.main"
variant="Bold"
/>
</IconButton>
</InputAdornment>
) : (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={true}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
}}
/>
{isVerified ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'success.main',
}}
>
<Icon Component={TickCircle} />
<Typography>{t('settingForm.successButton')}</Typography>
</Box>
) : (
<Button
variant="text"
onClick={() => {
if (isValidPhoneNumber(phoneNumber)) {
handleSendCode();
}
}}
disabled={
buttonState === 'counting' ||
phoneNumber.length === 0 ||
!isValidPhoneNumber(phoneNumber)
}
sx={{
minWidth: { xs: '100%', sm: 220 },
color: 'primary.main',
textTransform: 'none',
}}
>
{buttonState === 'counting' ? (
<CountDownTimer
initialSeconds={60}
onComplete={() => setButtonState('default')}
/>
) : (
t('settingForm.verificationCodeButton')
)}
</Button>
)}
</Box>
{buttonState === 'counting' && !isVerified && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
mt: 2,
alignItems: 'center',
mx: 3,
}}
>
<TextField
name="verificationCode"
label={t('settingForm.verificationCode')}
type="tel"
value={verificationCode}
onChange={(e) =>
setVerificationCode(e.target.value.replace(/\D/g, ''))
}
sx={{ flex: '1 1 240px', minWidth: 0 }}
placeholder={t('settingForm.verificationCode')}
/>
<Button
variant="contained"
onClick={handleVerifyClick}
disabled={isVerifying || verificationCode.length === 0}
sx={{
minWidth: { xs: '100%', sm: 220 },
bgcolor: 'primary.main',
textTransform: 'none',
}}
>
{isVerifying ? (
<CircularProgress size={20} />
) : (
t('settingForm.checkCode')
)}
</Button>
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,148 @@
import React, { type ReactElement, type ElementType } from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import Slide from '@mui/material/Slide';
import type { TransitionProps } from '@mui/material/transitions';
import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaDialogProps } from '@/features/profile/types/settingsType';
const MobileSlide = React.forwardRef(function MobileSlide(
props: TransitionProps & { children: ReactElement<unknown, ElementType> },
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export default function SocialMediaDialog({
open,
onClose,
t,
emailInput,
setEmailInput,
fullScreen,
computedMaxWidth,
verificationCode,
setVerificationCode,
apiError,
isLoading,
dialogStep,
onSendCode,
onConfirmEmail,
}: SocialMediaDialogProps) {
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
fullScreen={fullScreen}
maxWidth={computedMaxWidth}
scroll="paper"
keepMounted
TransitionComponent={fullScreen ? MobileSlide : undefined}
PaperProps={{
sx: {
borderRadius: { xs: 0, sm: 2 },
width: { xs: '100%', sm: '30%' },
height: 'auto',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
fontSize: '16px',
gap: 1,
p: { xs: 1.5, sm: 2 },
bgcolor: 'background.default',
}}
>
<IconButton onClick={onClose} aria-label="Close" disabled={isLoading}>
<Icon Component={CloseCircle} size="medium" color="primary.main" />
</IconButton>
{t('settingForm.addEmailButton')}
</DialogTitle>
<DialogContent
dividers
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
py: { xs: 1.5, sm: 2 },
}}
>
<Box>
<Typography fontWeight="bold">{t('settingForm.newEmail')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('settingForm.dialogHeader')}
</Typography>
</Box>
<TextField
fullWidth
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
label={t('settingForm.email')}
placeholder="abc@email.com"
autoComplete="email"
inputMode="email"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
autoFocus
disabled={isLoading || dialogStep === 'enterCode'}
/>
{dialogStep === 'enterCode' && (
<>
<TextField
fullWidth
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
label={t('settingForm.verificationCode')}
autoComplete="one-time-code"
inputMode="numeric"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
autoFocus
disabled={isLoading}
/>
</>
)}
{apiError && (
<Typography color="error" variant="caption" sx={{ mt: 1 }}>
{apiError}
</Typography>
)}
<Button
variant="contained"
fullWidth
sx={{ textTransform: 'none', borderRadius: 2, mt: 1 }}
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}
onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : dialogStep === 'enterEmail' ? (
t('settingForm.verificationCodeButton')
) : (
t('settingForm.confirmAndSave')
)}
</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import { Box, Typography, IconButton } from '@mui/material';
import { Google, Sms, Trash } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaListProps } from '@/features/profile/types/settingsType';
export default function SocialMediaList({ emailList }: SocialMediaListProps) {
return (
<Box sx={{ width: '100%', borderRadius: 1, p: { xs: 1, sm: 2 } }}>
{emailList.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
py: 1,
gap: 1,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
}}
>
<Box
sx={{
backgroundColor: 'primary.light',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
borderRadius: 0.5,
}}
>
{item.provider === 'google' && (
<Icon
Component={Google}
size="medium"
color="primary.main"
variant="Bold"
/>
)}
{item.provider === 'email' && (
<Icon
Component={Sms}
size="medium"
variant="Bold"
color="primary.main"
/>
)}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" noWrap>
{item.email}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.time}
</Typography>
</Box>
</Box>
<IconButton aria-label="Delete">
<Icon Component={Trash} size="medium" variant="Outline" />
</IconButton>
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import {
Box,
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { Message, Google, Apple, ArrowDown3 } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaMenuProps } from '@/features/profile/types/settingsType';
export default function SocialMediaMenu({
t,
onOpenDialog,
}: SocialMediaMenuProps) {
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
const openMenu = Boolean(anchor);
const [open, setOpen] = useState(false);
const handleClickMenu = (e: React.MouseEvent<HTMLButtonElement>) => {
setOpen(true);
setAnchor(e.currentTarget);
};
const handleCloseMenu = () => {
setOpen(false);
setAnchor(null);
};
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'stretch', sm: 'flex-start' },
gap: 1,
}}
>
<Button
onClick={handleClickMenu}
variant="outlined"
sx={{
border: '1px solid',
borderRadius: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
whiteSpace: 'nowrap',
backgroundColor: open ? 'primary.light' : 'primary.default',
textTransform: 'none',
}}
>
<Box component="span">{t('settingForm.addEmailOrSocialButton')}</Box>
<Icon
Component={ArrowDown3}
size="medium"
color="primary.main"
variant={open ? 'Bold' : 'Outline'}
/>
</Button>
</Box>
<Menu
anchorEl={anchor}
open={openMenu}
onClose={handleCloseMenu}
PaperProps={{
sx: {
minWidth: 187,
maxWidth: '90vw',
},
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<MenuItem
onClick={() => {
handleCloseMenu();
onOpenDialog();
}}
>
<ListItemIcon>
<Icon Component={Message} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.email')}</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<Icon Component={Google} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.google')}</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<Icon Component={Apple} size="medium" color="primary.main" />
</ListItemIcon>
<ListItemText>{t('settingForm.apple')}</ListItemText>
</MenuItem>
</Menu>
</Box>
);
}

View 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' },
];

View File

@@ -0,0 +1,91 @@
import { Gender } from './settingsType';
/* General API Types */
export interface UserSettingsFromApi {
theme: number;
calendarType: number;
language: number;
}
export interface ApiSession {
key: string;
created: string;
deviceOs: string;
deviceName: string;
ipAddress: string;
}
export interface ActiveSessionsData {
sessions: ApiSession[];
currentKey: string;
}
export interface LoginLog {
loginDateTime: string;
ipAddress: string;
deviceName: string;
deviceOs: string;
}
/* Profile API Types */
export interface GetProfileApiResponse {
success: boolean;
message?: string;
firstName?: string;
lastName?: string;
nationalCode?: string;
gender?: Gender;
countryCode?: string;
profileImageUrl?: string;
phoneNumber?: string;
userSettings?: UserSettingsFromApi;
activeSessions?: ActiveSessionsData;
loginLogs?: LoginLog[];
email?: string;
hasPassword?: boolean;
}
export interface SaveProfileApiResponse {
success: boolean;
message?: string;
phoneNumber?: string;
email?: string;
}
/* Settings API Types */
export interface SaveSettingsApiResponse {
success: boolean;
message?: string;
}
/* Session API Types */
export interface DeleteSessionsApiResponse {
success: boolean;
message?: string;
}
/* Password API Types */
export interface PasswordApiResponse {
success: boolean;
message?: string;
}
/* Email API Types */
export interface SendEmailCodeApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmEmailCodeApiResponse {
success: boolean;
confirm?: boolean;
message?: string;
}
export interface ChangeEmailApiResponse {
success: boolean;
message?: string;
}
/* Phone Number API Types */
export interface PhoneNumberApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmPhoneNumberApiResponse extends PhoneNumberApiResponse {
confirm?: boolean;
}

View File

@@ -0,0 +1,146 @@
export enum Gender {
Male = 2,
Female = 1,
None = 0,
}
export interface InfoRowData {
firstName: string;
lastName: string;
nationalCode: string;
country: string | '';
gender: Gender;
}
export interface Device {
id: string;
timeAndDate: string;
deviceModel: string;
ip: string;
current: boolean;
}
export interface PasswordDialogProps {
open: boolean;
fullScreen: boolean;
handleClose: () => void;
password: string;
setPassword: (val: string) => void;
confirmPassword: string;
setConfirmPassword: (val: string) => void;
currentPassword: string;
setCurrentPassword: (val: string) => void;
showValidation: boolean;
validPassword: boolean;
matchPassword: boolean;
loading: boolean;
handleSubmit: () => void;
changePassword: boolean;
apiError: string | null;
t: (key: string) => string;
}
export interface ValidationItemProps {
isValid: boolean;
label: string;
}
export interface SocialMediaDialogProps {
open: boolean;
onClose: () => void;
t: (key: string) => string;
emailInput: string;
setEmailInput: (val: string) => void;
fullScreen: boolean;
computedMaxWidth: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
verificationCode: string;
setVerificationCode: (value: string) => void;
apiError: string | null;
isLoading: boolean;
dialogStep: 'enterEmail' | 'enterCode';
onSendCode: () => void;
onConfirmEmail: () => void;
}
export interface SocialMediaListProps {
t: (key: string) => string;
emailList: readonly {
email: string;
provider: 'email' | 'google' | 'apple';
time: string;
}[];
}
export interface SocialMediaMenuProps {
t: (key: string) => string;
onOpenDialog: () => void;
}
export interface Phone {
phone: string;
time: string;
withCode: string;
}
export interface EmailAccount {
email: string;
provider: 'email' | 'google';
time: string;
}
export interface PhoneActionButtonsProps {
isEditing: boolean;
toggleEdit: () => void;
t: (key: string) => string;
}
export interface PhoneDisplayProps {
phones: { phone: string; time: string }[];
}
export interface PhoneEditFormProps {
phoneNumber: string;
setPhoneNumber: (v: string) => void;
countryCode: string;
setCountryCode: (v: string) => void;
verificationCode: string;
setVerificationCode: (v: string) => void;
isVerified: boolean;
isVerifying: boolean;
buttonState: 'default' | 'counting';
setButtonState: (v: 'default' | 'counting') => void;
handleSendCode: () => void;
handleVerifyClick: () => void;
error?: string;
inputError: boolean;
handleBlur: () => void;
textFieldRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
phones: { phone: string; time: string; withCode: string }[];
showToast: boolean;
setShowToast: (v: boolean) => void;
t: (key: string) => string;
}
export interface DisplayFieldProps {
label: string;
value: string;
}
export interface InfoRowDisplayProps {
data: InfoRowData;
uploadedImageUrl: string | null;
initials: string;
}
export interface InfoRowEditProps {
data: InfoRowData;
setData: React.Dispatch<React.SetStateAction<InfoRowData>>;
}
export interface ProfileImageProps {
initials: string;
uploadedImageUrl: string | null;
onImageChange: (file: File) => void;
onRemoveImage?: () => void;
}

View File

@@ -1,17 +1,17 @@
import type { ApiResponse } from '@/types/apiResponse';
// FIXME: all the responses should extend this interface
// import type { ApiResponse } from '@/types/apiResponse';
import { useToast } from '@rkheftan/harmony-ui';
import type { AxiosError } from 'axios';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
// Define options for the hook
interface UseApiOptions {
// If true, the API call will be executed immediately on mount
immediate?: boolean;
}
export function useApi<T extends ApiResponse, P extends any[]>(
export function useApi<T, P extends any[]>(
apiFunction: (...args: P) => Promise<{ data: T }>,
options: UseApiOptions = {},
) {
@@ -51,7 +51,8 @@ export function useApi<T extends ApiResponse, P extends any[]>(
}
},
[apiFunction, navigate, t, toast],
);
)
// If the 'immediate' option is true, execute the function on mount
useEffect(() => {

View File

@@ -25,23 +25,36 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({
cssVariables: {
colorSchemeSelector: 'class',
},
spacing: 8,
typography: typography,
components: {
MuiButton: {
defaultProps: {
size: 'large',
fullWidth: true,
variant: 'contained',
sx: {
textTransform: 'none',
},
},
},
MuiTextField: {
defaultProps: {
variant: 'outlined',
fullWidth: true,
},
},
MuiButton: {
defaultProps: {
size: 'large',
fullWidth: true,
variant: 'contained',
MuiSelect: {
styleOverrides: {
root: {
textAlign: 'left',
},
select: {
textAlign: 'left',
},
},
},
},
spacing: 8,
typography: typography,
});
}, [i18n]);

View File

@@ -0,0 +1,38 @@
import { type TFunction } from 'i18next';
import { toLocaleDigits } from './persianDigit';
export function formatDate(
isoDate: string,
lang: string,
t: TFunction,
): string {
const date = new Date(isoDate);
const now = new Date();
const diffInMinutes = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 1) return t('active.justNow');
if (diffInMinutes < 60) {
return toLocaleDigits(
t('active.minutesAgo', { count: diffInMinutes }),
lang,
);
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
calendar: lang.startsWith('fa') ? 'persian' : 'gregory',
numberingSystem: lang.startsWith('fa') ? 'arab' : 'latn',
};
const displayLocale = lang.startsWith('fa') ? 'fa-IR' : 'en-US';
const formattedDate = new Intl.DateTimeFormat(displayLocale, options).format(
date,
);
return toLocaleDigits(formattedDate, lang);
}

View File

@@ -0,0 +1,12 @@
export const toLocaleDigits = (
input: string | number,
lang: string,
): string => {
const str = String(input);
if (lang.startsWith('fa')) {
return str.replace(/\d/g, (d: string) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d, 10)]);
}
return str;
};

15
src/utils/regex.ts Normal file
View File

@@ -0,0 +1,15 @@
export function regex(password: string) {
const hasNumber = /\d/.test(password);
const hasMinLength = password.length >= 8;
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
const hasSpecialChar = /[!@#$%^&*]/.test(password);
return {
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword:
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar,
};
}