Merge pull request #29 from rkheftan/fix/profile

fix: dashboard api calls, multi profile fetch
This commit is contained in:
SajadMRjl
2025-08-21 14:52:41 +03:30
committed by GitHub
24 changed files with 656 additions and 679 deletions

View File

@@ -2,13 +2,12 @@
"side": { "side": {
"account": "Account", "account": "Account",
"personalInfo": "Personal information", "personalInfo": "Personal information",
"phoneNumber": "Phone number",
"contactInfo": "Contact info", "contactInfo": "Contact info",
"email": "Email", "email": "Email",
"security": "Security", "security": "Security",
"password": "Password", "password": "Password",
"recentSessions": "Recent Sessions", "recentSessions": "Recent sessions",
"activeDevices": "Active devices", "activeSessions": "Active sessions",
"settings": "Settings" "setting": "Settings"
} }
} }

View File

@@ -6,7 +6,6 @@ export const Loading = () => {
sx={{ sx={{
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'background.default',
position: 'fixed', position: 'fixed',
inset: '0', inset: '0',
zIndex: (t) => t.zIndex.tooltip + 1, zIndex: (t) => t.zIndex.tooltip + 1,

View File

@@ -1,5 +1,5 @@
import { useToast, type ToastOptions } from '@rkheftan/harmony-ui'; import { useToast, type ToastOptions } from '@rkheftan/harmony-ui';
import React, { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';

View File

@@ -9,9 +9,9 @@ i18n
.use(initReactI18next) // Passes i18n down to react-i18next .use(initReactI18next) // Passes i18n down to react-i18next
.init({ .init({
supportedLngs: ['en', 'fa'], // Supported languages supportedLngs: ['en', 'fa'], // Supported languages
fallbackLng: 'en', fallbackLng: 'fa',
detection: { detection: {
order: ['localStorage', 'cookie', 'navigator'], order: ['localStorage', 'cookie'],
caches: ['localStorage', 'cookie'], caches: ['localStorage', 'cookie'],
lookupLocalStorage: 'language', // The key to use in localStorage lookupLocalStorage: 'language', // The key to use in localStorage
}, },

View File

@@ -188,13 +188,13 @@ export function UserCompletionPage() {
firstName, firstName,
lastName, lastName,
gender: sex || 0, gender: sex || 0,
nationalId, nationalCode: nationalId,
savePassword: showPasswordSection, savePassword: showPasswordSection,
password: showPasswordSection ? password : undefined, password: showPasswordSection ? password : undefined,
saveEmail: showEmail, saveEmail: showEmail,
email: showEmail ? email : undefined, email: showEmail ? email : undefined,
birthDate, birthDate,
country, countryCode: country,
}); });
if (res) { if (res) {
@@ -351,6 +351,7 @@ export function UserCompletionPage() {
if (Object.keys(touched).length > 0) { if (Object.keys(touched).length > 0) {
validateForm(); validateForm();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
firstName, firstName,
lastName, lastName,

View File

@@ -18,9 +18,9 @@ export interface CompleteUserInfoPayload {
lastName: string; lastName: string;
// TODO: fix this // TODO: fix this
gender: 0 | 1 | 2; gender: 0 | 1 | 2;
nationalId: string; nationalCode: string;
birthDate: Date | null; birthDate: Date | null;
country: string; countryCode: string;
savePassword?: boolean; savePassword?: boolean;
password?: string; password?: string;
saveEmail?: boolean; saveEmail?: boolean;

View File

@@ -9,7 +9,6 @@ import {
Button, Button,
Link, Link,
CircularProgress, CircularProgress,
Typography,
InputAdornment, InputAdornment,
} from '@mui/material'; } from '@mui/material';
import { CloseCircle, Eye, EyeSlash } from 'iconsax-react'; import { CloseCircle, Eye, EyeSlash } from 'iconsax-react';
@@ -18,6 +17,7 @@ import { type PasswordDialogProps } from '../../types/settingsType';
import { PasswordValidationItem } from './PasswordValidation'; import { PasswordValidationItem } from './PasswordValidation';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function PasswordDialog({ export function PasswordDialog({
open, open,
@@ -34,13 +34,13 @@ export function PasswordDialog({
loading, loading,
handleSubmit, handleSubmit,
changePassword, changePassword,
apiError,
t,
hasMinLength, hasMinLength,
hasNumber, hasNumber,
hasUpperAndLower, hasUpperAndLower,
hasSpecialChar, hasSpecialChar,
}: PasswordDialogProps) { }: PasswordDialogProps) {
const { t } = useTranslation('setting');
const [showCurrent, setShowCurrent] = useState(false); const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
@@ -77,12 +77,6 @@ export function PasswordDialog({
px: { xs: 2, sm: 3 }, px: { xs: 2, sm: 3 },
}} }}
> >
{apiError && (
<Typography color="error" variant="body2" textAlign="center">
{apiError}
</Typography>
)}
{changePassword && ( {changePassword && (
<> <>
<TextField <TextField

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -12,16 +12,13 @@ import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper'; import { PageWrapper } from '../PageWrapper';
import { PasswordDialog } from './PasswordDialog'; import { PasswordDialog } from './PasswordDialog';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { import { addPassword, changePassword } from '../../api/settingsApi';
addPassword,
changePassword,
fetchProfile,
} from '../../api/settingsApi';
import { containsNumber } from '@/utils/regexes/containsNumber'; import { containsNumber } from '@/utils/regexes/containsNumber';
import { least8Chars } from '@/utils/regexes/least8Chars'; import { least8Chars } from '@/utils/regexes/least8Chars';
import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter'; import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter';
import { containsSymbol } from '@/utils/regexes/containsSymbol'; import { containsSymbol } from '@/utils/regexes/containsSymbol';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function PasswordSecurity() { export function PasswordSecurity() {
const theme = useTheme(); const theme = useTheme();
@@ -45,31 +42,33 @@ export function PasswordSecurity() {
const matchPassword = password === confirmPassword; const matchPassword = password === confirmPassword;
const showToast = useToast(); const showToast = useToast();
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isFetching,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { const { loading: isAdding, execute: executeAddPassword } =
data: addData, useApi(addPassword);
loading: isAdding,
error: addError,
execute: executeAddPassword,
} = useApi(addPassword);
const { const { loading: isChanging, execute: executeChangePassword } =
data: changeData, useApi(changePassword);
loading: isChanging,
error: changeError,
execute: executeChangePassword,
} = useApi(changePassword);
useEffect(() => { useEffect(() => {
if (profileData?.success) { const loadProfile = async () => {
setChangePasswordUI(profileData.hasPassword || false); const profileData = await refetchProfile();
}
}, [profileData]); if (!profileData) return;
if (profileData.success) {
setChangePasswordUI(profileData.hasPassword || false);
} else {
showToast({
message: profileData.message || t('message.serverError'),
severity: 'error',
});
}
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (password) { if (password) {
@@ -79,30 +78,6 @@ export function PasswordSecurity() {
} }
}, [password, validPassword]); }, [password, validPassword]);
useEffect(() => {
if (addData?.success || changeData?.success) {
showToast({
message: changePasswordUI
? t('securityForm.passwordChanged')
: t('securityForm.passwordAdded'),
severity: 'success',
});
if (!changePasswordUI) {
setChangePasswordUI(true);
}
setOpen(false);
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
} else if (addData || changeData) {
showToast({
message:
addData?.message || changeData?.message || t('securityForm.error'),
severity: 'error',
});
}
}, [addData, changeData, changePasswordUI, showToast, t]);
const handleOpen = () => setOpen(true); const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false); const handleClose = () => setOpen(false);
@@ -115,26 +90,48 @@ export function PasswordSecurity() {
return; return;
} }
if (changePasswordUI) { if (changePasswordUI) {
await executeChangePassword({ const changeData = await executeChangePassword({
oldPassword: currentPassword, oldPassword: currentPassword,
newPassword: password, newPassword: password,
}); });
if (!changeData) return;
if (!changeData.success) {
showToast({
message: changeData?.message || t('securityForm.error'),
severity: 'error',
});
return;
}
} else { } else {
await executeAddPassword({ newPassword: password }); const addData = await executeAddPassword({ newPassword: password });
if (!addData) return;
if (!addData.success) {
showToast({
message: addData?.message || t('securityForm.error'),
severity: 'error',
});
return;
}
} }
showToast({
message: changePasswordUI
? t('securityForm.passwordChanged')
: t('securityForm.passwordAdded'),
severity: 'success',
});
if (!changePasswordUI) {
setChangePasswordUI(true);
}
setOpen(false);
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
}; };
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 ( return (
<PageWrapper> <PageWrapper>
<CardContainer <CardContainer
@@ -153,7 +150,7 @@ export function PasswordSecurity() {
</Button> </Button>
} }
> >
{isFetching ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -165,12 +162,6 @@ export function PasswordSecurity() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : ( ) : (
<Box sx={{ px: 4, py: 2 }}> <Box sx={{ px: 4, py: 2 }}>
{changePasswordUI ? ( {changePasswordUI ? (
@@ -208,8 +199,6 @@ export function PasswordSecurity() {
loading={isAdding || isChanging} loading={isAdding || isChanging}
handleSubmit={handlePasswordSubmit} handleSubmit={handlePasswordSubmit}
changePassword={changePasswordUI} changePassword={changePasswordUI}
apiError={getErrorMessage(apiError)}
t={t}
hasMinLength={hasMinLength} hasMinLength={hasMinLength}
hasNumber={hasNumber} hasNumber={hasNumber}
hasUpperAndLower={hasUpperAndLower} hasUpperAndLower={hasUpperAndLower}

View File

@@ -9,11 +9,10 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer'; import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper'; import { PageWrapper } from '../PageWrapper';
import { fetchProfile } from '../../api/settingsApi';
import type { LoginLog } from '../../types/settingsApiType'; import type { LoginLog } from '../../types/settingsApiType';
import { useApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate'; import { formatDate } from '@/utils/formatSessionDate';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function RecentLogins() { export function RecentLogins() {
const { t, i18n } = useTranslation('setting'); const { t, i18n } = useTranslation('setting');
@@ -22,31 +21,43 @@ export function RecentLogins() {
const isXsup = useMediaQuery(theme.breakpoints.up('xs')); const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const showToast = useToast(); const showToast = useToast();
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isLoading, // useEffect(() => {
error: fetchError, // if (profileData?.success && Array.isArray(profileData.loginLogs)) {
} = useApi(fetchProfile, { immediate: true }); // setLogs(profileData.loginLogs);
// }
// }, [profileData]);
// useEffect(() => {
// if (fetchError) {
// showToast({
// message: getErrorMessage(fetchError) || t('active.errorFetch'),
// severity: 'error',
// });
// }
// }, [fetchError, t, showToast]);
useEffect(() => { useEffect(() => {
if (profileData?.success && Array.isArray(profileData.loginLogs)) { const loadProfile = async () => {
setLogs(profileData.loginLogs); const profileData = await refetchProfile();
}
}, [profileData]);
useEffect(() => { if (!profileData) return;
if (fetchError) {
showToast({ if (profileData.success) {
message: getErrorMessage(fetchError) || t('active.errorFetch'), if (!Array.isArray(profileData.loginLogs)) return;
severity: 'error', setLogs(profileData.loginLogs);
}); } else {
} showToast({
}, [fetchError, t, showToast]); message: profileData.message || t('active.errorFetch'),
const getErrorMessage = (error: unknown): string | null => { severity: 'error',
if (!error) return null; });
if (error instanceof Error) return error.message; }
return String(error); };
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ( return (
<PageWrapper> <PageWrapper>
@@ -54,7 +65,7 @@ export function RecentLogins() {
title={t('securityForm.recentLogins')} title={t('securityForm.recentLogins')}
subtitle={t('securityForm.description')} subtitle={t('securityForm.description')}
> >
{isLoading ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -66,12 +77,6 @@ export function RecentLogins() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError) || t('active.errorFetch')}
</Typography>
</Box>
) : ( ) : (
<Box sx={{ px: 4 }}> <Box sx={{ px: 4 }}>
{logs.map((log, index) => ( {logs.map((log, index) => (

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Box, Button, Typography, CircularProgress } from '@mui/material'; import { Box, Button, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer'; import { CardContainer } from '@/components/CardContainer';
import { ProfileImage } from './personalInformation/ProfileImage'; import { ProfileImage } from './personalInformation/ProfileImage';
@@ -8,10 +8,13 @@ import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper'; import { PageWrapper } from '../PageWrapper';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { Gender, type InfoRowData } from '../../types/settingsType'; import { Gender, type InfoRowData } from '../../types/settingsType';
import { fetchProfile, saveProfile } from '../../api/settingsApi'; import { saveProfile } from '../../api/settingsApi';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function PersonalInformation() { export function PersonalInformation() {
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
const { t } = useTranslation('setting'); const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null); const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
@@ -27,52 +30,46 @@ export function PersonalInformation() {
const showToast = useToast(); const showToast = useToast();
const infoRowEditRef = useRef<{ validateFields: () => boolean }>(null); const infoRowEditRef = useRef<{ validateFields: () => boolean }>(null);
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isLoadingProfile, const { loading: isSavingProfile, execute: executeSaveProfile } =
error: fetchProfileError, useApi(saveProfile);
} = useApi(fetchProfile, { immediate: true });
const {
data: saveData,
loading: isSavingProfile,
error: saveProfileError,
execute: executeSaveProfile,
} = useApi(saveProfile);
useEffect(() => { useEffect(() => {
if (profileData?.success) { const loadProfile = async () => {
const fetchedData = { const profileData = await refetchProfile();
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);
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
setUploadedImageUrl(
profileData.profileImageUrl
? `${imageBaseUrl}${profileData.profileImageUrl}`
: null,
);
setUploadedImageFile(null);
}
}, [profileData]);
useEffect(() => { if (!profileData) return;
if (saveData?.success) {
showToast({ if (profileData.success) {
message: t('settingForm.successSaveProfile'), const fetchedData = {
severity: 'success', firstName: profileData.firstName ?? '',
}); lastName: profileData.lastName ?? '',
setIsEditing(false); nationalCode: profileData.nationalCode ?? '',
setOriginalData(data); gender: Object.values(Gender).includes(profileData.gender as Gender)
setUploadedImageFile(null); ? (profileData.gender as Gender)
} : Gender.None,
}, [saveData, data, showToast, t]); country: profileData.countryCode ?? '',
};
setData(fetchedData);
setOriginalData(fetchedData);
setUploadedImageUrl(
profileData.profileImageUrl
? `${imageBaseUrl}${profileData.profileImageUrl}`
: null,
);
setUploadedImageFile(null);
} else {
showToast({
message: profileData.message || t('message.serverError'),
severity: 'error',
});
}
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`; const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`;
@@ -84,36 +81,35 @@ export function PersonalInformation() {
setUploadedImageFile(null); setUploadedImageFile(null);
}; };
const handleSaveClick = () => { const handleSaveClick = async () => {
if (!data) return; if (!data) return;
const isValid = infoRowEditRef.current?.validateFields?.(); const isValid = infoRowEditRef.current?.validateFields?.();
if (!isValid) return; if (!isValid) return;
executeSaveProfile({ data, imageUrl: uploadedImageFile });
};
const getErrorMessage = (error: unknown): string | null => { const saveData = await executeSaveProfile({
if (!error) return null; data,
if (error instanceof Error) return error.message; imageUrl: uploadedImageFile,
return String(error); });
};
useEffect(() => { if (!saveData) return;
if (saveProfileError)
if (saveData?.success) {
showToast({ showToast({
message: message: t('settingForm.successSaveProfile'),
getErrorMessage(saveProfileError) || t('settingForm.errorSave'), severity: 'success',
});
setIsEditing(false);
setOriginalData(data);
setUploadedImageFile(null);
// Force a refetch to update the cache with the new data
refetchProfile({ force: true });
} else {
showToast({
message: saveData.message || t('message.serverError'),
severity: 'error', severity: 'error',
}); });
}, [saveProfileError, showToast, t]); }
};
useEffect(() => {
if (fetchProfileError)
showToast({
message:
getErrorMessage(fetchProfileError) || t('settingForm.errorFetch'),
severity: 'error',
});
}, [fetchProfileError, showToast, t]);
return ( return (
<PageWrapper> <PageWrapper>
@@ -163,14 +159,6 @@ export function PersonalInformation() {
{t('settingForm.editButton')} {t('settingForm.editButton')}
</Button> </Button>
)} )}
{getErrorMessage(saveProfileError) && (
<Typography
color="error"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
{getErrorMessage(saveProfileError)}
</Typography>
)}
</Box> </Box>
} }
> >
@@ -186,13 +174,6 @@ export function PersonalInformation() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchProfileError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchProfileError) ||
t('settingForm.errorFetch')}
</Typography>
</Box>
) : ( ) : (
<Box <Box
sx={{ sx={{

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import parsePhoneNumberFromString from 'libphonenumber-js'; import parsePhoneNumberFromString from 'libphonenumber-js';
import { PageWrapper } from '../PageWrapper'; import { PageWrapper } from '../PageWrapper';
@@ -6,17 +6,17 @@ import { CardContainer } from '@/components/CardContainer';
import PhoneDisplay from './phoneNumber/PhoneDisplay'; import PhoneDisplay from './phoneNumber/PhoneDisplay';
import PhoneEditForm from './phoneNumber/PhoneEditForm'; import PhoneEditForm from './phoneNumber/PhoneEditForm';
import PhoneActionButtons from './phoneNumber/PhoneActionButtons'; import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
import { CircularProgress, Box, Typography } from '@mui/material'; import { CircularProgress, Box } from '@mui/material';
import { toLocaleDigits } from '@/utils/persianDigit'; import { toLocaleDigits } from '@/utils/persianDigit';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { import {
fetchProfile,
sendVerificationCode, sendVerificationCode,
confirmPhoneNumberCode, confirmPhoneNumberCode,
changePhoneNumber, changePhoneNumber,
} from '../../api/settingsApi'; } from '../../api/settingsApi';
import { type Phone } from '../../types/settingsType'; import { type Phone } from '../../types/settingsType';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function PhoneNumber() { export function PhoneNumber() {
const { t, i18n } = useTranslation('setting'); const { t, i18n } = useTranslation('setting');
@@ -24,167 +24,87 @@ export function PhoneNumber() {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(''); const [phoneNumber, setPhoneNumber] = useState('');
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState('');
const [showToast, setShowToast] = useState(false);
const [buttonState, setButtonState] = useState<'default' | 'counting'>( const [buttonState, setButtonState] = useState<'default' | 'counting'>(
'default', 'default',
); );
const [isVerified, setIsVerified] = useState(false); const [isVerified, setIsVerified] = useState(false);
const [phones, setPhones] = useState<Phone[]>([]); const [phones, setPhones] = useState<Phone[]>([]);
const [countryCode, setCountryCode] = useState('+98'); const [countryCode, setCountryCode] = useState('+98');
const [formError, setFormError] = useState<string>(); const [phoneNumberError, setPhoneNumberError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false); const [verificationCodeError, setVerificationCodeError] = useState<string>();
const [phoneNumberTouched, setPhoneNumberTouched] = useState<boolean>(false);
const [verificationCodeTouched, setVerificationCodeTouched] =
useState<boolean>(false);
const textFieldRef = useRef<HTMLDivElement>(null); const { isLoadingProfile, refetchProfile } = useProfile();
const inputRef = useRef<HTMLInputElement>(null);
const { const { loading: isSendingCode, execute: executeSendCode } =
data: profileData, useApi(sendVerificationCode);
loading: isLoading,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { const { loading: isVerifying, execute: executeConfirmCode } = useApi(
data: sendCodeData, confirmPhoneNumberCode,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useApi(sendVerificationCode);
const {
data: confirmData,
loading: isVerifying,
error: confirmError,
execute: executeConfirmCode,
} = useApi(confirmPhoneNumberCode);
const {
data: changePhoneData,
loading: isChangingPhone,
error: changePhoneError,
execute: executeChangePhone,
} = useApi(changePhoneNumber);
useEffect(() => {
if (profileData?.success && profileData.phoneNumber) {
setPhones([
{
phone: profileData.phoneNumber,
time: '',
withCode: profileData.phoneNumber,
},
]);
}
}, [profileData]);
useEffect(() => {
if (fetchError) {
toast({
message:
getErrorMessage(fetchError) || t('settingForm.errorFetchPhoneNumber'),
severity: 'error',
});
}
}, [fetchError, toast, t]);
useEffect(() => {
if (sendCodeData?.success) {
setButtonState('counting');
setIsVerified(false);
toast({
message: t('settingForm.codeSentSuccessfully'),
severity: 'success',
});
}
}, [sendCodeData, t, toast]);
useEffect(() => {
if (sendCodeError) {
toast({
message:
getErrorMessage(sendCodeError) || t('settingForm.errorSendCode'),
severity: 'error',
});
}
}, [sendCodeError, toast, t]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
setIsVerified(true);
setShowToast(true);
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
executeChangePhone({ phoneNumber: fullPhoneNumber });
toast({
message: t('settingForm.phoneVerified'),
severity: 'success',
});
}
}, [confirmData, countryCode, phoneNumber, executeChangePhone, t, toast]);
useEffect(() => {
if (confirmError) {
toast({
message:
getErrorMessage(confirmError) || t('settingForm.errorConfirmCode'),
severity: 'error',
});
}
}, [confirmError, toast, t]);
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]);
useEffect(() => {
if (changePhoneError) {
toast({
message:
getErrorMessage(changePhoneError) ||
t('settingForm.errorChangePhone'),
severity: 'error',
});
}
}, [changePhoneError, toast, t]);
const apiError = useMemo(
() => sendCodeError || confirmError || changePhoneError,
[sendCodeError, confirmError, changePhoneError],
); );
const getErrorMessage = (error: unknown): string | undefined => { const { loading: isChangingPhone, execute: executeChangePhone } =
if (!error) return undefined; useApi(changePhoneNumber);
if (error instanceof Error) return error.message;
return String(error); useEffect(() => {
}; const loadProfile = async () => {
const profileData = await refetchProfile();
if (!profileData) return;
if (profileData?.success) {
setPhones([
{
phone: profileData.phoneNumber,
time: '',
withCode: profileData.phoneNumber,
},
]);
} else {
toast({
message: profileData.message,
severity: 'error',
});
}
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!phoneNumber) {
setPhoneNumberError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setPhoneNumberError(t('settingForm.phoneNumberIsInvalid'));
} else {
setPhoneNumberError(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [countryCode, phoneNumber, phoneNumberTouched]);
useEffect(() => {
if (!verificationCode) {
setVerificationCodeError(t('settingForm.verificationCodeRequired'));
return;
}
setVerificationCodeError(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [verificationCode, verificationCodeTouched]);
const isPhoneValid = (code: string, phone: string) => { const isPhoneValid = (code: string, phone: string) => {
const phoneNum = parsePhoneNumberFromString(code + phone); const phoneNum = parsePhoneNumberFromString(code + phone);
return phoneNum?.isValid(); return phoneNum?.isValid();
}; };
const handleBlur = () => { const handleBlur = (key: string) => {
setTouched(true); if (key === 'phoneNumber') {
if (!phoneNumber) { setPhoneNumberTouched(true);
toast({
message: t('settingForm.phoneNumberIsInvalid'),
severity: 'error',
});
setFormError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setFormError(t('settingForm.phoneNumberIsInvalid'));
} else { } else {
setFormError(undefined); setVerificationCodeTouched(true);
} }
}; };
@@ -195,36 +115,91 @@ export function PhoneNumber() {
setVerificationCode(''); setVerificationCode('');
setIsVerified(false); setIsVerified(false);
setButtonState('default'); setButtonState('default');
setShowToast(false); setPhoneNumberError(undefined);
setFormError(undefined); setPhoneNumberTouched(false);
setTouched(false); setVerificationCodeError(undefined);
setVerificationCodeTouched(false);
} }
return !prev; return !prev;
}); });
}; };
const handleSendCode = async () => { const handleSendCode = async () => {
handleBlur(); setPhoneNumberTouched(true);
if (formError || !isPhoneValid(countryCode, phoneNumber)) return; if (phoneNumberError) return;
return executeSendCode({
const result = await executeSendCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''), phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
}); });
if (!result) return;
if (result?.success) {
setButtonState('counting');
setIsVerified(false);
toast({
message: result.message || t('settingForm.codeSentSuccessfully'),
severity: 'success',
});
} else {
toast({
message: result.message || t('message.serverError'),
severity: 'error',
});
}
}; };
const handleVerifyCode = () => { const handleVerifyCode = async () => {
if (!verificationCode) { setVerificationCodeTouched(true);
setFormError(t('settingForm.verificationCodeRequired'));
return; if (verificationCodeError) return;
}
setFormError(undefined); const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
executeConfirmCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''), const confirmResult = await executeConfirmCode({
phoneNumber: fullPhoneNumber,
verifyCode: verificationCode, verifyCode: verificationCode,
}); });
};
const combinedError = formError || getErrorMessage(apiError); if (!confirmResult) return;
const inputError: boolean = touched && !!combinedError;
if (!confirmResult.success || !confirmResult.confirm) {
toast({
message: confirmResult?.message || t('message.serverError'),
severity: 'error',
});
return;
}
setIsVerified(true);
toast({
message: t('settingForm.phoneVerified'),
severity: 'success',
});
const changeResult = await executeChangePhone({
phoneNumber: fullPhoneNumber,
});
if (!changeResult) return;
if (changeResult?.success) {
setPhones([
{
phone: phoneNumber,
time: t('settingForm.justNow'),
withCode: fullPhoneNumber,
},
]);
setIsEditing(false);
refetchProfile({ force: true });
} else {
toast({
message: changeResult?.message || t('message.serverError'),
severity: 'error',
});
}
};
return ( return (
<PageWrapper> <PageWrapper>
@@ -240,7 +215,7 @@ export function PhoneNumber() {
/> />
} }
> >
{isLoading ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -252,13 +227,6 @@ export function PhoneNumber() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchError) ||
t('settingForm.errorFetchPhoneNumber')}
</Typography>
</Box>
) : isEditing ? ( ) : isEditing ? (
<PhoneEditForm <PhoneEditForm
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
@@ -269,19 +237,17 @@ export function PhoneNumber() {
setVerificationCode={setVerificationCode} setVerificationCode={setVerificationCode}
isVerified={isVerified} isVerified={isVerified}
isVerifying={isVerifying || isSendingCode || isChangingPhone} isVerifying={isVerifying || isSendingCode || isChangingPhone}
isSendingCode={isSendingCode}
buttonState={buttonState} buttonState={buttonState}
setButtonState={setButtonState} setButtonState={setButtonState}
handleSendCode={handleSendCode} handleSendCode={handleSendCode}
handleVerifyClick={handleVerifyCode} handleVerifyClick={handleVerifyCode}
error={combinedError} phoneNumberError={phoneNumberTouched ? phoneNumberError : undefined}
inputError={inputError} verificationCodeError={
verificationCodeTouched ? verificationCodeError : undefined
}
handleBlur={handleBlur} handleBlur={handleBlur}
textFieldRef={textFieldRef as React.RefObject<HTMLDivElement>}
inputRef={inputRef as React.RefObject<HTMLInputElement>}
phones={phones} phones={phones}
showToast={showToast}
setShowToast={setShowToast}
t={t}
/> />
) : ( ) : (
<PhoneDisplay <PhoneDisplay

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer'; import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper'; import { PageWrapper } from '../PageWrapper';
@@ -7,16 +7,16 @@ import SocialMediaMenu from './socialMedia/SocialMediaMenu';
import SocialMediaDialog from './socialMedia/SocialMediaDialog'; import SocialMediaDialog from './socialMedia/SocialMediaDialog';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import type { Theme } from '@mui/material/styles'; import type { Theme } from '@mui/material/styles';
import { Box, CircularProgress, Typography } from '@mui/material'; import { Box, CircularProgress } from '@mui/material';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { import {
fetchProfile,
sendEmailCode, sendEmailCode,
confirmEmailCode, confirmEmailCode,
changeEmail, changeEmail,
} from '../../api/settingsApi'; } from '../../api/settingsApi';
import { type EmailAccount } from '../../types/settingsType'; import { type EmailAccount } from '../../types/settingsType';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function SocialMedia() { export function SocialMedia() {
const { t } = useTranslation('setting'); const { t } = useTranslation('setting');
@@ -29,39 +29,23 @@ export function SocialMedia() {
); );
const [emailList, setEmailList] = useState<EmailAccount[]>([]); const [emailList, setEmailList] = useState<EmailAccount[]>([]);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const showToast = useToast(); const toast = useToast();
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isFetching,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { const { loading: isSendingCode, execute: executeSendCode } =
data: sendCodeData, useApi(sendEmailCode);
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useApi(sendEmailCode);
const { const { loading: isConfirming, execute: executeConfirmCode } =
data: confirmData, useApi(confirmEmailCode);
loading: isConfirming,
error: confirmError,
execute: executeConfirmCode,
} = useApi(confirmEmailCode);
const { const { loading: isChangingEmail, execute: executeChangeEmail } =
data: changeEmailData, useApi(changeEmail);
loading: isChangingEmail,
error: changeEmailError,
execute: executeChangeEmail,
} = useApi(changeEmail);
const fullScreen = useMediaQuery((theme: Theme) => const fullScreen = useMediaQuery((theme: Theme) =>
theme?.breakpoints.down('sm'), theme?.breakpoints.down('sm'),
); );
const downMd = useMediaQuery((theme: Theme) => theme.breakpoints.down('md')); const downMd = useMediaQuery((theme: Theme) => theme?.breakpoints.down('md'));
const computedMaxWidth = (fullScreen ? 'xs' : downMd ? 'sm' : 'md') as const computedMaxWidth = (fullScreen ? 'xs' : downMd ? 'sm' : 'md') as
| 'xs' | 'xs'
| 'sm' | 'sm'
@@ -70,76 +54,34 @@ export function SocialMedia() {
| 'xl'; | 'xl';
useEffect(() => { useEffect(() => {
if (profileData?.success && profileData.email) { const loadProfile = async () => {
const { email } = profileData; const profileData = await refetchProfile();
setEmailList([
{
email,
provider: email.includes('@gmail.') ? 'google' : 'email',
time: '',
},
]);
}
}, [profileData]);
useEffect(() => { if (!profileData) return;
if (sendCodeData?.success) {
showToast({
message: t('settingForm.verificationCodeSent'),
severity: 'success',
});
setDialogStep('enterCode');
}
}, [sendCodeData, t, showToast]);
useEffect(() => { if (profileData?.success) {
if (confirmData?.success && confirmData.confirm) { const { email } = profileData;
showToast({
message: t('settingForm.verificationSuccessful'),
severity: 'success',
});
executeChangeEmail({ email: emailInput });
}
}, [confirmData, emailInput, executeChangeEmail, t, showToast]);
useEffect(() => { if (!email) return;
if (changeEmailData?.success) {
setEmailList((prev) => [
...prev,
{
email: emailInput,
provider: emailInput.includes('@gmail.') ? 'google' : 'email',
time: t('settingForm.justNow'),
},
]);
showToast({
message: t('settingForm.emailChangedSuccessfully'),
severity: 'success',
});
resetDialog();
}
}, [changeEmailData, emailInput, t, showToast]);
useEffect(() => { setEmailList([
if (sendCodeError) { {
showToast({ email,
message: getErrorMessage(sendCodeError) || t('settingForm.error'), provider: email.includes('@gmail.') ? 'google' : 'email',
severity: 'error', time: '',
}); },
} ]);
if (confirmError) { } else {
showToast({ toast({
message: getErrorMessage(confirmError) || t('settingForm.error'), message: profileData.message,
severity: 'error', severity: 'error',
}); });
} }
if (changeEmailError) { };
showToast({
message: getErrorMessage(changeEmailError) || t('settingForm.error'), loadProfile();
severity: 'error', // eslint-disable-next-line react-hooks/exhaustive-deps
}); }, []);
}
}, [sendCodeError, confirmError, changeEmailError, t, showToast]);
const resetDialog = () => { const resetDialog = () => {
setOpenDialog(false); setOpenDialog(false);
@@ -149,34 +91,74 @@ export function SocialMedia() {
setDialogStep('enterEmail'); setDialogStep('enterEmail');
}; };
const handleSendCode = () => { const handleSendCode = async () => {
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(emailInput)) { if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(emailInput)) {
setFormError(t('settingForm.emailIsInvalid')); setFormError(t('settingForm.emailIsInvalid'));
return; return;
} }
setFormError(null); setFormError(null);
executeSendCode({ email: emailInput }); const sendCodeData = await executeSendCode({ email: emailInput });
if (!sendCodeData) return;
if (sendCodeData.success) {
toast({
message: sendCodeData.message || t('settingForm.verificationCodeSent'),
severity: 'success',
});
setDialogStep('enterCode');
}
}; };
const handleConfirmAndChangeEmail = () => { const handleConfirmAndChangeEmail = async () => {
if (verificationCode.length < 4) { if (verificationCode.length < 4) {
setFormError(t('settingForm.verificationCodeRequired')); setFormError(t('settingForm.verificationCodeRequired'));
return; return;
} }
setFormError(null); setFormError(null);
executeConfirmCode({ email: emailInput, verifyCode: verificationCode }); const confirmData = await executeConfirmCode({
}; email: emailInput,
verifyCode: verificationCode,
});
const getErrorMessage = (error: unknown): string | null => { if (!confirmData) return;
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo( if (!confirmData.success || !confirmData.confirm) {
() => getErrorMessage(sendCodeError || confirmError || changeEmailError), toast({
[sendCodeError, confirmError, changeEmailError], message: confirmData.message || t('settingForm.error'),
); severity: 'error',
});
return;
}
toast({
message: confirmData.message || t('settingForm.verificationSuccessful'),
severity: 'success',
});
const changeEmailData = await executeChangeEmail({ email: emailInput });
if (!changeEmailData) return;
if (changeEmailData.success) {
setEmailList((prev) => [
...prev,
{
email: emailInput,
provider: emailInput.includes('@gmail.') ? 'google' : 'email',
time: t('settingForm.justNow'),
},
]);
toast({
message: t('settingForm.emailChangedSuccessfully'),
severity: 'success',
});
resetDialog();
refetchProfile({ force: true });
}
};
return ( return (
<PageWrapper> <PageWrapper>
@@ -187,7 +169,7 @@ export function SocialMedia() {
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} /> <SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
} }
> >
{isFetching ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -199,12 +181,6 @@ export function SocialMedia() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : ( ) : (
<SocialMediaList t={t} emailList={emailList} /> <SocialMediaList t={t} emailList={emailList} />
)} )}
@@ -216,7 +192,7 @@ export function SocialMedia() {
setEmailInput={setEmailInput} setEmailInput={setEmailInput}
verificationCode={verificationCode} verificationCode={verificationCode}
setVerificationCode={setVerificationCode} setVerificationCode={setVerificationCode}
apiError={formError || apiError} apiError={formError}
isLoading={isSendingCode || isConfirming || isChangingEmail} isLoading={isSendingCode || isConfirming || isChangingEmail}
dialogStep={dialogStep} dialogStep={dialogStep}
onSendCode={handleSendCode} onSendCode={handleSendCode}

View File

@@ -110,11 +110,11 @@ export const InfoRowEdit = forwardRef(
error={touched.gender && data.gender === Gender.None} error={touched.gender && data.gender === Gender.None}
> >
<InputLabel> <InputLabel>
{t('settingForm.genderPlaceholder', { ns: 'setting' })} {t('settingForm.gender', { ns: 'setting' })}
</InputLabel> </InputLabel>
<Select <Select
value={data.gender === Gender.None ? '' : data.gender} value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'setting' })} label={t('settingForm.gender', { ns: 'setting' })}
onChange={(e) => onChange={(e) =>
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,

View File

@@ -13,8 +13,8 @@ export default function PhoneDisplay({ phones }: PhoneDisplayProps) {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
py: 2, py: 6,
mx: 3, mx: 2,
gap: 1, gap: 1,
}} }}
> >

View File

@@ -12,7 +12,8 @@ import { CountDownTimer } from '@/components/CountDownTimer';
import { CountryCodeSelector } from '../../CountryCodeSelector'; import { CountryCodeSelector } from '../../CountryCodeSelector';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { type PhoneEditFormProps } from '@/features/profile/types/settingsType'; import { type PhoneEditFormProps } from '@/features/profile/types/settingsType';
import { useState } from 'react'; import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
export default function PhoneEditForm({ export default function PhoneEditForm({
phoneNumber, phoneNumber,
@@ -23,63 +24,51 @@ export default function PhoneEditForm({
setVerificationCode, setVerificationCode,
isVerified, isVerified,
isVerifying, isVerifying,
isSendingCode,
buttonState, buttonState,
setButtonState, setButtonState,
handleSendCode, handleSendCode,
handleVerifyClick, handleVerifyClick,
error,
inputError,
handleBlur, handleBlur,
textFieldRef,
inputRef,
phones, phones,
t, phoneNumberError,
verificationCodeError,
}: PhoneEditFormProps) { }: PhoneEditFormProps) {
const isValidPhoneNumber = (phone: string) => { const { t } = useTranslation('setting');
const digitsOnly = phone.replace(/\D/g, '');
return digitsOnly.length >= 8 && digitsOnly.length <= 15;
};
const [isSending, setIsSending] = useState(false); const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
return ( return (
<> <Box sx={{ px: { sm: 4, xs: 2 }, my: 4 }}>
<Box sx={{ width: '100%' }}> <Box sx={{ mb: 4 }}>
<Box sx={{ mb: 2, mx: 6 }}> <Typography variant="h6">{t('settingForm.editPhoneNumber')}</Typography>
<Typography variant="h6"> <Typography variant="body2" color="text.secondary">
{t('settingForm.editPhoneNumber')} {t('settingForm.phoneNumberText')}({phones.map((p) => p.withCode)})
</Typography> {t('settingForm.verb')}
<Typography variant="body2" color="text.secondary"> </Typography>
{t('settingForm.phoneNumberText')}({phones.map((p) => p.withCode)})
{t('settingForm.verb')}
</Typography>
</Box>
</Box> </Box>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexWrap: 'nowrap',
gap: 2, gap: 2,
alignItems: 'center', alignItems: 'center',
mx: 6,
flexDirection: { xs: 'column', sm: 'row' }, flexDirection: { xs: 'column', sm: 'row' },
mb: 1,
}} }}
> >
<TextField <TextField
fullWidth
name="phoneNumber" name="phoneNumber"
label={t('settingForm.newPhoneNumber')} label={t('settingForm.newPhoneNumber')}
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}
ref={textFieldRef} ref={textFieldRef}
inputRef={inputRef} inputRef={inputRef}
onBlur={handleBlur} onBlur={() => handleBlur('phoneNumber')}
error={inputError} error={!!phoneNumberError}
helperText={inputError ? error : ''} helperText={phoneNumberError}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="09123456789" placeholder="09123456789"
sx={{ width: { xs: '100%', sm: 474 } }}
InputProps={{ InputProps={{
endAdornment: endAdornment:
buttonState === 'counting' ? ( buttonState === 'counting' ? (
@@ -127,26 +116,14 @@ export default function PhoneEditForm({
) : ( ) : (
<Button <Button
variant="text" variant="text"
onClick={async () => { loading={isSendingCode}
if (isValidPhoneNumber(phoneNumber)) { onClick={handleSendCode}
setIsSending(true);
try {
await handleSendCode();
} finally {
setIsSending(false);
}
}
}}
sx={{ sx={{
whiteSpace: 'nowrap',
color: 'primary.main', color: 'primary.main',
textTransform: 'none', width: { xs: '100%', sm: 208 },
width: { xs: '100%', sm: 210 },
}} }}
> >
{isSending ? ( {buttonState === 'counting' ? (
<CircularProgress size={20} />
) : buttonState === 'counting' ? (
<CountDownTimer <CountDownTimer
initialSeconds={60} initialSeconds={60}
onComplete={() => setButtonState('default')} onComplete={() => setButtonState('default')}
@@ -157,28 +134,29 @@ export default function PhoneEditForm({
</Button> </Button>
)} )}
</Box> </Box>
{/* buttonState === 'counting' && !isVerified && */}
{buttonState === 'counting' && !isVerified && ( {buttonState === 'counting' && !isVerified && (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexWrap: 'nowrap',
gap: 2, gap: 2,
alignItems: 'center', alignItems: 'center',
mx: 6,
flexDirection: { xs: 'column', sm: 'row' }, flexDirection: { xs: 'column', sm: 'row' },
mb: 1, mb: 1,
}} }}
> >
<TextField <TextField
fullWidth
name="verificationCode" name="verificationCode"
label={t('settingForm.verificationCode')} label={t('settingForm.verificationCode')}
type="tel" type="tel"
value={verificationCode} value={verificationCode}
error={!!verificationCodeError}
helperText={verificationCodeError}
onBlur={() => handleBlur('verificationCode')}
onChange={(e) => onChange={(e) =>
setVerificationCode(e.target.value.replace(/\D/g, '')) setVerificationCode(e.target.value.replace(/\D/g, ''))
} }
sx={{ width: { xs: '100%', sm: 474 } }}
placeholder={t('settingForm.verificationCode')} placeholder={t('settingForm.verificationCode')}
/> />
@@ -187,9 +165,8 @@ export default function PhoneEditForm({
onClick={handleVerifyClick} onClick={handleVerifyClick}
disabled={isVerifying || verificationCode.length === 0} disabled={isVerifying || verificationCode.length === 0}
sx={{ sx={{
width: { xs: '100%', sm: 210 },
bgcolor: 'primary.main', bgcolor: 'primary.main',
textTransform: 'none', width: { xs: '100%', sm: 200 },
}} }}
> >
{isVerifying ? ( {isVerifying ? (
@@ -200,6 +177,6 @@ export default function PhoneEditForm({
</Button> </Button>
</Box> </Box>
)} )}
</> </Box>
); );
} }

View File

@@ -0,0 +1,13 @@
import { createContext } from 'react';
import type { GetProfileApiResponse } from '../types/settingsApiType';
export interface ProfileContextType {
isLoadingProfile: boolean;
refetchProfile: (options?: {
force?: boolean;
}) => Promise<GetProfileApiResponse | undefined>;
}
export const ProfileContext = createContext<ProfileContextType | undefined>(
undefined,
);

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { ProfileContext } from '../contexts/ProfileContext';
export const useProfile = () => {
const context = useContext(ProfileContext);
if (context === undefined) {
throw new Error('useProfile must be used within a ProfileProvider');
}
return context;
};

View File

@@ -0,0 +1,62 @@
import { useApi } from '@/hooks/useApi';
import { fetchProfile } from '../api/settingsApi';
import { useCallback, useRef, useMemo, type ReactNode } from 'react';
import { ProfileContext } from '../contexts/ProfileContext';
import type { GetProfileApiResponse } from '../types/settingsApiType';
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const profileCache = useRef<GetProfileApiResponse | null>(null);
const pendingRequestRef = useRef<Promise<
GetProfileApiResponse | undefined
> | null>(null);
const { loading: isLoadingProfile, execute } = useApi(fetchProfile);
const refetchProfile = useCallback(
async (options?: {
force?: boolean;
}): Promise<GetProfileApiResponse | undefined> => {
// If forcing a refetch, clear the cache first
if (options?.force) {
profileCache.current = null;
}
// Return cached data if it exists
if (profileCache.current) {
return profileCache.current;
}
if (pendingRequestRef.current) {
return pendingRequestRef.current;
}
// If no request is pending, start a new one and store its promise
try {
pendingRequestRef.current = execute();
const result = await pendingRequestRef.current;
// Update cache on success
if (result?.success) {
profileCache.current = result;
}
return result;
} finally {
// Clear the pending request ref once the request is complete
pendingRequestRef.current = null;
}
},
[execute],
);
const value = useMemo(
() => ({
isLoadingProfile,
refetchProfile,
}),
[isLoadingProfile, refetchProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
};

View File

@@ -12,12 +12,13 @@ import { DeviceMessage, Logout } from 'iconsax-react';
import { CardContainer } from '@/components/CardContainer'; import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../components/PageWrapper'; import { PageWrapper } from '../components/PageWrapper';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { fetchProfile, deleteSessions } from '../api/settingsApi'; import { deleteSessions } from '../api/settingsApi';
import { type ApiSession } from '../types/settingsApiType'; import { type ApiSession } from '../types/settingsApiType';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate'; import { formatDate } from '@/utils/formatSessionDate';
import { type Device } from '../types/settingsType'; import { type Device } from '../types/settingsType';
import { useToast } from '@rkheftan/harmony-ui'; import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../hooks/useProfile';
export function ActiveDevicesPage() { export function ActiveDevicesPage() {
const { t, i18n } = useTranslation('setting'); const { t, i18n } = useTranslation('setting');
@@ -27,50 +28,56 @@ export function ActiveDevicesPage() {
const isXsup = useMediaQuery(theme.breakpoints.up('xs')); const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const showToast = useToast(); const showToast = useToast();
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isLoading,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { const { loading: isTerminating, execute: executeTerminate } =
data: terminateData, useApi(deleteSessions);
loading: isTerminating,
execute: executeTerminateAll,
} = useApi(deleteSessions);
useEffect(() => { useEffect(() => {
if (profileData?.success && profileData.activeSessions) { const loadProfile = async () => {
const { sessions, currentKey } = profileData.activeSessions; const profileData = await refetchProfile();
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,
}));
const sortedDevices = formattedDevices.sort((a, b) => { if (!profileData) return;
if (a.current && !b.current) return -1;
if (!a.current && b.current) return 1;
return 0;
});
setDevices(sortedDevices); if (profileData.success) {
} if (!profileData.activeSessions) return;
}, [profileData, i18n.language, t]);
useEffect(() => { const { sessions, currentKey } = profileData.activeSessions;
if (terminateData?.success) { const formattedDevices = sessions.map((session: ApiSession) => ({
setDevices((prev) => prev.filter((d) => d.current)); id: session.key,
} timeAndDate: formatDate(session.created, i18n.language, t),
}, [terminateData]); deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
const sortedDevices = formattedDevices.sort((a, b) => {
if (a.current && !b.current) return -1;
if (!a.current && b.current) return 1;
return 0;
});
setDevices(sortedDevices);
} else {
showToast({
message: profileData.message || t('message.serverError'),
severity: 'error',
});
}
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDeleteDevice = async (id: string) => { const handleDeleteDevice = async (id: string) => {
if (loadingDeleteIds.includes(id)) return; if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]); setLoadingDeleteIds((prev) => [...prev, id]);
try { try {
const { data } = await deleteSessions({ keys: [id] }); const data = await executeTerminate({ keys: [id] });
if (!data) return;
if (data.success) { if (data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id)); setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
showToast({ message: t('active.successDelete'), severity: 'success' }); showToast({ message: t('active.successDelete'), severity: 'success' });
@@ -80,11 +87,6 @@ export function ActiveDevicesPage() {
severity: 'error', severity: 'error',
}); });
} }
} catch (error: unknown) {
showToast({
message: t('active.deleteFailed'),
severity: 'error',
});
} finally { } finally {
setLoadingDeleteIds((prev) => setLoadingDeleteIds((prev) =>
prev.filter((loadingId) => loadingId !== id), prev.filter((loadingId) => loadingId !== id),
@@ -92,17 +94,22 @@ export function ActiveDevicesPage() {
} }
}; };
const handleTerminateAllOtherSessions = () => { const handleTerminateAllOtherSessions = async () => {
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id); const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
if (otherSessionKeys.length > 0) { if (otherSessionKeys.length > 0) {
executeTerminateAll({ keys: otherSessionKeys }); const terminateData = await executeTerminate({ keys: otherSessionKeys });
}
};
const getErrorMessage = (error: unknown): string | null => { if (!terminateData) return;
if (!error) return null;
if (error instanceof Error) return error.message; if (terminateData?.success) {
return String(error); setDevices((prev) => prev.filter((d) => d.current));
} else {
showToast({
message: terminateData.message || t('active.deleteFailed'),
severity: 'error',
});
}
}
}; };
return ( return (
@@ -124,7 +131,7 @@ export function ActiveDevicesPage() {
flexGrow: 0, flexGrow: 0,
width: 'auto', width: 'auto',
}} }}
disabled={isLoading || isTerminating} disabled={isLoadingProfile || isTerminating}
> >
{isTerminating {isTerminating
? t('active.deleting') ? t('active.deleting')
@@ -132,7 +139,7 @@ export function ActiveDevicesPage() {
</Button> </Button>
} }
> >
{isLoading ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -144,12 +151,6 @@ export function ActiveDevicesPage() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : ( ) : (
<Box <Box
sx={{ sx={{

View File

@@ -12,10 +12,11 @@ import { CardContainer } from '@/components/CardContainer';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ThemeToggleButton } from '@/components/ThemToggle'; import { ThemeToggleButton } from '@/components/ThemToggle';
import { PageWrapper } from '../components/PageWrapper'; import { PageWrapper } from '../components/PageWrapper';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon, useToast } from '@rkheftan/harmony-ui';
import { Sun1, Moon, Calendar1 } from 'iconsax-react'; import { Sun1, Moon, Calendar1 } from 'iconsax-react';
import { useApi } from '@/hooks/useApi'; import { useApi } from '@/hooks/useApi';
import { fetchProfile, saveSettings } from '../api/settingsApi'; import { saveSettings } from '../api/settingsApi';
import { useProfile } from '../hooks/useProfile';
type ThemeMode = 'light' | 'dark'; type ThemeMode = 'light' | 'dark';
type CalendarType = 'christian' | 'solar' | 'lunar'; type CalendarType = 'christian' | 'solar' | 'lunar';
@@ -51,40 +52,50 @@ export function SettingPage() {
const [draftSettings, setDraftSettings] = const [draftSettings, setDraftSettings] =
useState<SettingsState>(savedSettings); useState<SettingsState>(savedSettings);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const showToast = useToast();
const { const { isLoadingProfile, refetchProfile } = useProfile();
data: profileData,
loading: isFetching,
error: fetchError,
} = useApi(fetchProfile);
const { const { loading: isSaving, execute: executeSaveSettings } =
loading: isSaving, useApi(saveSettings);
error: saveError,
execute: executeSaveSettings,
} = useApi(saveSettings);
useEffect(() => { useEffect(() => {
if (profileData?.success && profileData.userSettings) { const loadProfile = async () => {
const { theme, calendarType, language } = profileData.userSettings; const profileData = await refetchProfile();
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light', if (!profileData) return;
2: 'dark',
}; if (profileData.success) {
const newSettings: SettingsState = { if (!profileData.userSettings) return;
theme: themeReverseMap[theme] || 'light',
calendar: const { theme, calendarType, language } = profileData.userSettings;
calendarOptions.find((c) => c.apiValue === calendarType)?.key ||
'solar', const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
language: 1: 'light',
languageOptions.find((l) => l.apiValue === language)?.code || 'en', 2: 'dark',
}; };
setSavedSettings(newSettings); const newSettings: SettingsState = {
setDraftSettings(newSettings); theme: themeReverseMap[theme] || 'light',
setMode(newSettings.theme); calendar:
i18n.changeLanguage(newSettings.language); calendarOptions.find((c) => c.apiValue === calendarType)?.key ||
} 'solar',
}, [profileData, setMode, i18n]); language:
languageOptions.find((l) => l.apiValue === language)?.code || 'en',
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(newSettings.theme);
i18n.changeLanguage(newSettings.language);
} else {
showToast({
message: profileData.message || t('message.serverError'),
severity: 'error',
});
}
};
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
@@ -115,22 +126,27 @@ export function SettingPage() {
language: languageObj.apiValue, language: languageObj.apiValue,
}); });
if (!result) return;
if (result?.success) { if (result?.success) {
setMode(draftSettings.theme); setMode(draftSettings.theme);
setSavedSettings(draftSettings); setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language); i18n.changeLanguage(draftSettings.language);
setIsEditing(false); setIsEditing(false);
refetchProfile({ force: true });
} else {
// Handle save failure
showToast({
message: result.message || t('message.serverError'),
severity: 'error',
});
} }
} }
} else { } else {
setIsEditing(true); setIsEditing(true);
} }
}; };
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return ( return (
<PageWrapper> <PageWrapper>
@@ -150,7 +166,7 @@ export function SettingPage() {
textTransform: 'none', textTransform: 'none',
fontSize: { xs: '0.85rem', sm: '1rem' }, fontSize: { xs: '0.85rem', sm: '1rem' },
}} }}
disabled={isSaving || isFetching} disabled={isSaving || isLoadingProfile}
> >
{t('settings.rejectButton')} {t('settings.rejectButton')}
</Button> </Button>
@@ -159,7 +175,7 @@ export function SettingPage() {
onClick={handleEditToggle} onClick={handleEditToggle}
size="large" size="large"
variant={isEditing ? 'contained' : 'outlined'} variant={isEditing ? 'contained' : 'outlined'}
disabled={isSaving || isFetching} disabled={isSaving || isLoadingProfile}
> >
{isSaving ? ( {isSaving ? (
<CircularProgress size={24} /> <CircularProgress size={24} />
@@ -172,7 +188,7 @@ export function SettingPage() {
</Box> </Box>
} }
> >
{isFetching ? ( {isLoadingProfile ? (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -184,12 +200,6 @@ export function SettingPage() {
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : ( ) : (
<Box <Box
sx={{ sx={{
@@ -200,11 +210,6 @@ export function SettingPage() {
gap: 2, gap: 2,
}} }}
> >
{getErrorMessage(saveError) && (
<Typography color="error.main" variant="body2" mb={2}>
{getErrorMessage(saveError)}
</Typography>
)}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',

View File

@@ -27,14 +27,14 @@ export interface LoginLog {
/* Profile API Types */ /* Profile API Types */
export interface GetProfileApiResponse { export interface GetProfileApiResponse {
success: boolean; success: boolean;
message?: string; message: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
nationalCode?: string; nationalCode?: string;
gender?: Gender; gender?: Gender;
countryCode?: string; countryCode?: string;
profileImageUrl?: string; profileImageUrl?: string;
phoneNumber?: string; phoneNumber: string;
userSettings?: UserSettingsFromApi; userSettings?: UserSettingsFromApi;
activeSessions?: ActiveSessionsData; activeSessions?: ActiveSessionsData;
loginLogs?: LoginLog[]; loginLogs?: LoginLog[];

View File

@@ -36,8 +36,6 @@ export interface PasswordDialogProps {
loading: boolean; loading: boolean;
handleSubmit: () => void; handleSubmit: () => void;
changePassword: boolean; changePassword: boolean;
apiError: string | null;
t: (key: string) => string;
hasMinLength: boolean; hasMinLength: boolean;
hasNumber: boolean; hasNumber: boolean;
hasUpperAndLower: boolean; hasUpperAndLower: boolean;
@@ -111,19 +109,15 @@ export interface PhoneEditFormProps {
setVerificationCode: (v: string) => void; setVerificationCode: (v: string) => void;
isVerified: boolean; isVerified: boolean;
isVerifying: boolean; isVerifying: boolean;
isSendingCode: boolean;
buttonState: 'default' | 'counting'; buttonState: 'default' | 'counting';
setButtonState: (v: 'default' | 'counting') => void; setButtonState: (v: 'default' | 'counting') => void;
handleSendCode: () => void; handleSendCode: () => void;
handleVerifyClick: () => void; handleVerifyClick: () => void;
error?: string; phoneNumberError?: string;
inputError: boolean; verificationCodeError?: string;
handleBlur: () => void; handleBlur: (key: string) => void;
textFieldRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
phones: { phone: string; time: string; withCode: string }[]; phones: { phone: string; time: string; withCode: string }[];
showToast: boolean;
setShowToast: (v: boolean) => void;
t: (key: string) => string;
} }
export interface DisplayFieldProps { export interface DisplayFieldProps {

View File

@@ -17,7 +17,7 @@ export const RtlProvider: React.FC<{ children: React.ReactNode }> = ({
key: isRtl ? 'muirtl' : 'muiltr', key: isRtl ? 'muirtl' : 'muiltr',
stylisPlugins: isRtl ? [prefixer, rtlPlugin] : [], stylisPlugins: isRtl ? [prefixer, rtlPlugin] : [],
}); });
}, [i18n]); }, [i18n, i18n.language]);
return <CacheProvider value={cacheRtl}>{children}</CacheProvider>; return <CacheProvider value={cacheRtl}>{children}</CacheProvider>;
}; };

View File

@@ -1,5 +1,6 @@
import { Layout } from '@/components'; import { Layout } from '@/components';
import { NavigateWithToast } from '@/components/NavigateWithToast'; import { NavigateWithToast } from '@/components/NavigateWithToast';
import { ProfileProvider } from '@/features/profile/providers/ProfileProvider';
import { import {
Calendar, Calendar,
Devices, Devices,
@@ -93,7 +94,7 @@ export const appRoutes: RouteConfig[] = [
element: <AccountCreatedPage />, element: <AccountCreatedPage />,
authorize: true, authorize: true,
}, },
{ path: '/signup', element: <UserCompletionPage />, authorize: true }, { path: '/signup', element: <UserCompletionPage /> },
{ {
path: '/forget-password', path: '/forget-password',
element: <ForgetPasswordPage />, element: <ForgetPasswordPage />,
@@ -101,7 +102,11 @@ export const appRoutes: RouteConfig[] = [
{ {
path: '/setting', path: '/setting',
element: <Layout />, element: (
<ProfileProvider>
<Layout />
</ProfileProvider>
),
authorize: true, authorize: true,
children: [ children: [
{ {