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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { CardContainer } from '@/components/CardContainer';
import { ProfileImage } from './personalInformation/ProfileImage';
@@ -8,10 +8,13 @@ import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper';
import { useApi } from '@/hooks/useApi';
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 { useProfile } from '../../hooks/useProfile';
export function PersonalInformation() {
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
@@ -27,52 +30,46 @@ export function PersonalInformation() {
const showToast = useToast();
const infoRowEditRef = useRef<{ validateFields: () => boolean }>(null);
const {
data: profileData,
loading: isLoadingProfile,
error: fetchProfileError,
} = useApi(fetchProfile, { immediate: true });
const {
data: saveData,
loading: isSavingProfile,
error: saveProfileError,
execute: executeSaveProfile,
} = useApi(saveProfile);
const { isLoadingProfile, refetchProfile } = useProfile();
const { loading: isSavingProfile, execute: executeSaveProfile } =
useApi(saveProfile);
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);
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
setUploadedImageUrl(
profileData.profileImageUrl
? `${imageBaseUrl}${profileData.profileImageUrl}`
: null,
);
setUploadedImageFile(null);
}
}, [profileData]);
const loadProfile = async () => {
const profileData = await refetchProfile();
useEffect(() => {
if (saveData?.success) {
showToast({
message: t('settingForm.successSaveProfile'),
severity: 'success',
});
setIsEditing(false);
setOriginalData(data);
setUploadedImageFile(null);
}
}, [saveData, data, showToast, t]);
if (!profileData) return;
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
? `${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] || ''}`;
@@ -84,36 +81,35 @@ export function PersonalInformation() {
setUploadedImageFile(null);
};
const handleSaveClick = () => {
const handleSaveClick = async () => {
if (!data) return;
const isValid = infoRowEditRef.current?.validateFields?.();
if (!isValid) return;
executeSaveProfile({ data, imageUrl: uploadedImageFile });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const saveData = await executeSaveProfile({
data,
imageUrl: uploadedImageFile,
});
useEffect(() => {
if (saveProfileError)
if (!saveData) return;
if (saveData?.success) {
showToast({
message:
getErrorMessage(saveProfileError) || t('settingForm.errorSave'),
message: t('settingForm.successSaveProfile'),
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',
});
}, [saveProfileError, showToast, t]);
useEffect(() => {
if (fetchProfileError)
showToast({
message:
getErrorMessage(fetchProfileError) || t('settingForm.errorFetch'),
severity: 'error',
});
}, [fetchProfileError, showToast, t]);
}
};
return (
<PageWrapper>
@@ -163,14 +159,6 @@ export function PersonalInformation() {
{t('settingForm.editButton')}
</Button>
)}
{getErrorMessage(saveProfileError) && (
<Typography
color="error"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
{getErrorMessage(saveProfileError)}
</Typography>
)}
</Box>
}
>
@@ -186,13 +174,6 @@ export function PersonalInformation() {
>
<CircularProgress />
</Box>
) : fetchProfileError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchProfileError) ||
t('settingForm.errorFetch')}
</Typography>
</Box>
) : (
<Box
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 parsePhoneNumberFromString from 'libphonenumber-js';
import { PageWrapper } from '../PageWrapper';
@@ -6,17 +6,17 @@ 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 { CircularProgress, Box } from '@mui/material';
import { toLocaleDigits } from '@/utils/persianDigit';
import { useApi } from '@/hooks/useApi';
import {
fetchProfile,
sendVerificationCode,
confirmPhoneNumberCode,
changePhoneNumber,
} from '../../api/settingsApi';
import { type Phone } from '../../types/settingsType';
import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../../hooks/useProfile';
export function PhoneNumber() {
const { t, i18n } = useTranslation('setting');
@@ -24,167 +24,87 @@ export function PhoneNumber() {
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 [phoneNumberError, setPhoneNumberError] = useState<string>();
const [verificationCodeError, setVerificationCodeError] = useState<string>();
const [phoneNumberTouched, setPhoneNumberTouched] = useState<boolean>(false);
const [verificationCodeTouched, setVerificationCodeTouched] =
useState<boolean>(false);
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { isLoadingProfile, refetchProfile } = useProfile();
const {
data: profileData,
loading: isLoading,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { loading: isSendingCode, execute: executeSendCode } =
useApi(sendVerificationCode);
const {
data: sendCodeData,
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 { loading: isVerifying, execute: executeConfirmCode } = useApi(
confirmPhoneNumberCode,
);
const getErrorMessage = (error: unknown): string | undefined => {
if (!error) return undefined;
if (error instanceof Error) return error.message;
return String(error);
};
const { loading: isChangingPhone, execute: executeChangePhone } =
useApi(changePhoneNumber);
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 phoneNum = parsePhoneNumberFromString(code + phone);
return phoneNum?.isValid();
};
const handleBlur = () => {
setTouched(true);
if (!phoneNumber) {
toast({
message: t('settingForm.phoneNumberIsInvalid'),
severity: 'error',
});
setFormError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setFormError(t('settingForm.phoneNumberIsInvalid'));
const handleBlur = (key: string) => {
if (key === 'phoneNumber') {
setPhoneNumberTouched(true);
} else {
setFormError(undefined);
setVerificationCodeTouched(true);
}
};
@@ -195,36 +115,91 @@ export function PhoneNumber() {
setVerificationCode('');
setIsVerified(false);
setButtonState('default');
setShowToast(false);
setFormError(undefined);
setTouched(false);
setPhoneNumberError(undefined);
setPhoneNumberTouched(false);
setVerificationCodeError(undefined);
setVerificationCodeTouched(false);
}
return !prev;
});
};
const handleSendCode = async () => {
handleBlur();
if (formError || !isPhoneValid(countryCode, phoneNumber)) return;
return executeSendCode({
setPhoneNumberTouched(true);
if (phoneNumberError) return;
const result = await executeSendCode({
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 = () => {
if (!verificationCode) {
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setFormError(undefined);
executeConfirmCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
const handleVerifyCode = async () => {
setVerificationCodeTouched(true);
if (verificationCodeError) return;
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
const confirmResult = await executeConfirmCode({
phoneNumber: fullPhoneNumber,
verifyCode: verificationCode,
});
};
const combinedError = formError || getErrorMessage(apiError);
const inputError: boolean = touched && !!combinedError;
if (!confirmResult) return;
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 (
<PageWrapper>
@@ -240,7 +215,7 @@ export function PhoneNumber() {
/>
}
>
{isLoading ? (
{isLoadingProfile ? (
<Box
sx={{
display: 'flex',
@@ -252,13 +227,6 @@ export function PhoneNumber() {
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">
{getErrorMessage(fetchError) ||
t('settingForm.errorFetchPhoneNumber')}
</Typography>
</Box>
) : isEditing ? (
<PhoneEditForm
phoneNumber={phoneNumber}
@@ -269,19 +237,17 @@ export function PhoneNumber() {
setVerificationCode={setVerificationCode}
isVerified={isVerified}
isVerifying={isVerifying || isSendingCode || isChangingPhone}
isSendingCode={isSendingCode}
buttonState={buttonState}
setButtonState={setButtonState}
handleSendCode={handleSendCode}
handleVerifyClick={handleVerifyCode}
error={combinedError}
inputError={inputError}
phoneNumberError={phoneNumberTouched ? phoneNumberError : undefined}
verificationCodeError={
verificationCodeTouched ? verificationCodeError : undefined
}
handleBlur={handleBlur}
textFieldRef={textFieldRef as React.RefObject<HTMLDivElement>}
inputRef={inputRef as React.RefObject<HTMLInputElement>}
phones={phones}
showToast={showToast}
setShowToast={setShowToast}
t={t}
/>
) : (
<PhoneDisplay

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,8 @@ import { CountDownTimer } from '@/components/CountDownTimer';
import { CountryCodeSelector } from '../../CountryCodeSelector';
import { Icon } from '@rkheftan/harmony-ui';
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({
phoneNumber,
@@ -23,63 +24,51 @@ export default function PhoneEditForm({
setVerificationCode,
isVerified,
isVerifying,
isSendingCode,
buttonState,
setButtonState,
handleSendCode,
handleVerifyClick,
error,
inputError,
handleBlur,
textFieldRef,
inputRef,
phones,
t,
phoneNumberError,
verificationCodeError,
}: PhoneEditFormProps) {
const isValidPhoneNumber = (phone: string) => {
const digitsOnly = phone.replace(/\D/g, '');
return digitsOnly.length >= 8 && digitsOnly.length <= 15;
};
const { t } = useTranslation('setting');
const [isSending, setIsSending] = useState(false);
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<Box sx={{ width: '100%' }}>
<Box sx={{ mb: 2, mx: 6 }}>
<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 sx={{ px: { sm: 4, xs: 2 }, my: 4 }}>
<Box sx={{ mb: 4 }}>
<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
sx={{
display: 'flex',
flexWrap: 'nowrap',
gap: 2,
alignItems: 'center',
mx: 6,
flexDirection: { xs: 'column', sm: 'row' },
mb: 1,
}}
>
<TextField
fullWidth
name="phoneNumber"
label={t('settingForm.newPhoneNumber')}
type="tel"
value={phoneNumber}
ref={textFieldRef}
inputRef={inputRef}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
onBlur={() => handleBlur('phoneNumber')}
error={!!phoneNumberError}
helperText={phoneNumberError}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="09123456789"
sx={{ width: { xs: '100%', sm: 474 } }}
InputProps={{
endAdornment:
buttonState === 'counting' ? (
@@ -127,26 +116,14 @@ export default function PhoneEditForm({
) : (
<Button
variant="text"
onClick={async () => {
if (isValidPhoneNumber(phoneNumber)) {
setIsSending(true);
try {
await handleSendCode();
} finally {
setIsSending(false);
}
}
}}
loading={isSendingCode}
onClick={handleSendCode}
sx={{
whiteSpace: 'nowrap',
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 210 },
width: { xs: '100%', sm: 208 },
}}
>
{isSending ? (
<CircularProgress size={20} />
) : buttonState === 'counting' ? (
{buttonState === 'counting' ? (
<CountDownTimer
initialSeconds={60}
onComplete={() => setButtonState('default')}
@@ -157,28 +134,29 @@ export default function PhoneEditForm({
</Button>
)}
</Box>
{/* buttonState === 'counting' && !isVerified && */}
{buttonState === 'counting' && !isVerified && (
<Box
sx={{
display: 'flex',
flexWrap: 'nowrap',
gap: 2,
alignItems: 'center',
mx: 6,
flexDirection: { xs: 'column', sm: 'row' },
mb: 1,
}}
>
<TextField
fullWidth
name="verificationCode"
label={t('settingForm.verificationCode')}
type="tel"
value={verificationCode}
error={!!verificationCodeError}
helperText={verificationCodeError}
onBlur={() => handleBlur('verificationCode')}
onChange={(e) =>
setVerificationCode(e.target.value.replace(/\D/g, ''))
}
sx={{ width: { xs: '100%', sm: 474 } }}
placeholder={t('settingForm.verificationCode')}
/>
@@ -187,9 +165,8 @@ export default function PhoneEditForm({
onClick={handleVerifyClick}
disabled={isVerifying || verificationCode.length === 0}
sx={{
width: { xs: '100%', sm: 210 },
bgcolor: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 200 },
}}
>
{isVerifying ? (
@@ -200,6 +177,6 @@ export default function PhoneEditForm({
</Button>
</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 { PageWrapper } from '../components/PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { fetchProfile, deleteSessions } from '../api/settingsApi';
import { deleteSessions } from '../api/settingsApi';
import { type ApiSession } from '../types/settingsApiType';
import { useApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
import { type Device } from '../types/settingsType';
import { useToast } from '@rkheftan/harmony-ui';
import { useProfile } from '../hooks/useProfile';
export function ActiveDevicesPage() {
const { t, i18n } = useTranslation('setting');
@@ -27,50 +28,56 @@ export function ActiveDevicesPage() {
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const showToast = useToast();
const {
data: profileData,
loading: isLoading,
error: fetchError,
} = useApi(fetchProfile, { immediate: true });
const { isLoadingProfile, refetchProfile } = useProfile();
const {
data: terminateData,
loading: isTerminating,
execute: executeTerminateAll,
} = useApi(deleteSessions);
const { loading: isTerminating, execute: executeTerminate } =
useApi(deleteSessions);
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,
}));
const loadProfile = async () => {
const profileData = await refetchProfile();
const sortedDevices = formattedDevices.sort((a, b) => {
if (a.current && !b.current) return -1;
if (!a.current && b.current) return 1;
return 0;
});
if (!profileData) return;
setDevices(sortedDevices);
}
}, [profileData, i18n.language, t]);
if (profileData.success) {
if (!profileData.activeSessions) return;
useEffect(() => {
if (terminateData?.success) {
setDevices((prev) => prev.filter((d) => d.current));
}
}, [terminateData]);
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,
}));
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) => {
if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]);
try {
const { data } = await deleteSessions({ keys: [id] });
const data = await executeTerminate({ keys: [id] });
if (!data) return;
if (data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
showToast({ message: t('active.successDelete'), severity: 'success' });
@@ -80,11 +87,6 @@ export function ActiveDevicesPage() {
severity: 'error',
});
}
} catch (error: unknown) {
showToast({
message: t('active.deleteFailed'),
severity: 'error',
});
} finally {
setLoadingDeleteIds((prev) =>
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);
if (otherSessionKeys.length > 0) {
executeTerminateAll({ keys: otherSessionKeys });
}
};
const terminateData = await executeTerminate({ keys: otherSessionKeys });
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
if (!terminateData) return;
if (terminateData?.success) {
setDevices((prev) => prev.filter((d) => d.current));
} else {
showToast({
message: terminateData.message || t('active.deleteFailed'),
severity: 'error',
});
}
}
};
return (
@@ -124,7 +131,7 @@ export function ActiveDevicesPage() {
flexGrow: 0,
width: 'auto',
}}
disabled={isLoading || isTerminating}
disabled={isLoadingProfile || isTerminating}
>
{isTerminating
? t('active.deleting')
@@ -132,7 +139,7 @@ export function ActiveDevicesPage() {
</Button>
}
>
{isLoading ? (
{isLoadingProfile ? (
<Box
sx={{
display: 'flex',
@@ -144,12 +151,6 @@ export function ActiveDevicesPage() {
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{

View File

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

View File

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

View File

@@ -36,8 +36,6 @@ export interface PasswordDialogProps {
loading: boolean;
handleSubmit: () => void;
changePassword: boolean;
apiError: string | null;
t: (key: string) => string;
hasMinLength: boolean;
hasNumber: boolean;
hasUpperAndLower: boolean;
@@ -111,19 +109,15 @@ export interface PhoneEditFormProps {
setVerificationCode: (v: string) => void;
isVerified: boolean;
isVerifying: boolean;
isSendingCode: 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>;
phoneNumberError?: string;
verificationCodeError?: string;
handleBlur: (key: string) => void;
phones: { phone: string; time: string; withCode: string }[];
showToast: boolean;
setShowToast: (v: boolean) => void;
t: (key: string) => string;
}
export interface DisplayFieldProps {

View File

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

View File

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