Merge pull request #24 from rkheftan/fix/userProfile

Fix/user profile
This commit is contained in:
SajadMRjl
2025-08-20 23:05:26 +03:30
committed by GitHub
23 changed files with 507 additions and 382 deletions

View File

@@ -16,7 +16,7 @@
"familyName": "Family Name", "familyName": "Family Name",
"country": "Country", "country": "Country",
"gender": "Gender", "gender": "Gender",
"nationalCode": "National code", "nationalCode": "National code(Op",
"man": "Male", "man": "Male",
"woman": "Female", "woman": "Female",
"genderPlaceholder": "Male", "genderPlaceholder": "Male",
@@ -62,7 +62,14 @@
"emailIsInvalid": "Email is invalid", "emailIsInvalid": "Email is invalid",
"changeEmailFailed": "Change of email failed", "changeEmailFailed": "Change of email failed",
"anErrorOccurred": "An error occurred.", "anErrorOccurred": "An error occurred.",
"saving": "Saving..." "saving": "Saving...",
"successSaveProfile": "Profile saved successfully",
"errorSave": "Saving failed",
"codeSentSuccessfully": "Code sent successfully",
"errorSendCode": "Failed to send code",
"phoneVerified": "Phone number verified",
"errorConfirmCode": "Failed to confirm code",
"errorChangePhone": "Failed to change phone number"
}, },
"active": { "active": {
@@ -76,7 +83,9 @@
"notLoggedIn": "You are not logged in", "notLoggedIn": "You are not logged in",
"failFetchActiveSessions": "Failed to fetch active sessions.", "failFetchActiveSessions": "Failed to fetch active sessions.",
"errorFetch": "An error occurred while fetching your active sessions.", "errorFetch": "An error occurred while fetching your active sessions.",
"deleting": "Deleting..." "deleting": "Deleting...",
"successDelete": "Deleted successfully",
"deleteFailed": "Deletion failed"
}, },
"settings": { "settings": {
@@ -123,6 +132,9 @@
"currentDevice": "Current device", "currentDevice": "Current device",
"changePassword": "Change password", "changePassword": "Change password",
"currentPassword": "Current password", "currentPassword": "Current password",
"forgetPassword": "Forgot your password?" "forgetPassword": "Forgot your password?",
"passwordChanged": "Password changed",
"passwordAdded": "Password added",
"error": "Password change failed"
} }
} }

View File

@@ -17,6 +17,7 @@
"country": "کشور", "country": "کشور",
"gender": "جنسیت", "gender": "جنسیت",
"nationalCode": "کد ملی", "nationalCode": "کد ملی",
"optionalNationalCode": "کد ملی(اختیاری)",
"man": "مرد", "man": "مرد",
"woman": "زن", "woman": "زن",
"genderPlaceholder": "مرد", "genderPlaceholder": "مرد",
@@ -62,7 +63,14 @@
"emailIsInvalid": "ایمیل نامعتبر است", "emailIsInvalid": "ایمیل نامعتبر است",
"changeEmailFailed": "تغییر ایمیل با خطا مواجه شد", "changeEmailFailed": "تغییر ایمیل با خطا مواجه شد",
"anErrorOccurred": "خطایی رخ داد", "anErrorOccurred": "خطایی رخ داد",
"saving": "در حال ذخیره‌سازی..." "saving": "در حال ذخیره‌سازی...",
"successSaveProfile": "پروفایل با موفقیت ذخیره شد",
"errorSave": "ذخیره با مشکل مواجه شد",
"codeSentSuccessfully": "کد با موفقیت ارسال شد",
"errorSendCode": "ارسال کد با خطا مواجه شد",
"phoneVerified": "تلفن همراه تایید شد",
"errorConfirmCode": "تایید کد با خطا مواجه شد",
"errorChangePhone": "تغییر تلفن همراه با خطا مواجه شد"
}, },
"active": { "active": {
@@ -76,7 +84,9 @@
"notLoggedIn": "شما وارد سیستم نشده‌اید", "notLoggedIn": "شما وارد سیستم نشده‌اید",
"failFetchActiveSessions": "دریافت نشست های فعال ناموفق بود.", "failFetchActiveSessions": "دریافت نشست های فعال ناموفق بود.",
"errorFetch": "هنگام دریافت جلسات فعال شما خطایی روی داد.", "errorFetch": "هنگام دریافت جلسات فعال شما خطایی روی داد.",
"deleting": "در حال حذف..." "deleting": "در حال حذف...",
"successDelete": "با موفقیت حذف شد",
"deleteFailed": "حذف با مشکل مواجه شد"
}, },
"settings": { "settings": {
@@ -123,6 +133,9 @@
"currentDevice": "دستگاه فعلی", "currentDevice": "دستگاه فعلی",
"changePassword": "تغییر رمز عبور", "changePassword": "تغییر رمز عبور",
"currentPassword": "رمز عبور فعلی", "currentPassword": "رمز عبور فعلی",
"forgetPassword": "رمز عبور را فراموش کرده اید؟" "forgetPassword": "رمز عبور را فراموش کرده اید؟",
"passwordChanged": "رمز عبور تعویض شد",
"passwordAdded": "رمز عبور اضافه شد",
"error": "تعویض رمز عبور با مشکل مواجه شد"
} }
} }

View File

@@ -25,6 +25,7 @@ export function CardContainer({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 2, gap: 2,
borderRadius: 1,
}} }}
> >
<Box <Box

View File

@@ -1,4 +1,4 @@
import { Box, IconButton, Skeleton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { More } from 'iconsax-react'; import { More } from 'iconsax-react';
import type { UserInfo } from '@/contexts/AuthContext'; import type { UserInfo } from '@/contexts/AuthContext';
@@ -7,7 +7,7 @@ interface HeaderProps {
user: UserInfo; user: UserInfo;
} }
export const Header: React.FC<HeaderProps> = ({ user, loading }) => { export const Header: React.FC<HeaderProps> = ({ user }) => {
return ( return (
<Box <Box
sx={{ sx={{

View File

@@ -1,14 +1,9 @@
import { import { Avatar, Box, IconButton, Toolbar as MuiToolbar } from '@mui/material';
Avatar,
Box,
IconButton,
Toolbar as MuiToolbar,
Skeleton,
} from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { HambergerMenu, Menu } from 'iconsax-react'; import { HambergerMenu, Menu } from 'iconsax-react';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { UserInfo } from '@/contexts/AuthContext'; import type { UserInfo } from '@/contexts/AuthContext';
import Logo from '../Logo';
interface ToolbarProps { interface ToolbarProps {
sideNavOpen: boolean; sideNavOpen: boolean;
@@ -22,7 +17,6 @@ export const Toolbar: React.FC<ToolbarProps> = ({
setSideNavOpen, setSideNavOpen,
isMobile, isMobile,
user, user,
loading,
}) => { }) => {
return ( return (
<MuiToolbar <MuiToolbar

View File

@@ -1,5 +1,4 @@
import { CircularProgress, Stack } from '@mui/material'; import { CircularProgress, Stack } from '@mui/material';
import React from 'react';
export const Loading = () => { export const Loading = () => {
return ( return (

View File

@@ -34,7 +34,9 @@ export const ThemeToggleButton = ({
<ToggleButtonGroup <ToggleButtonGroup
value={value} value={value}
exclusive exclusive
color="primary"
onChange={handleChange} onChange={handleChange}
size="medium"
sx={{ sx={{
borderRadius: 1.5, borderRadius: 1.5,
border: '1px solid', border: '1px solid',
@@ -51,10 +53,6 @@ export const ThemeToggleButton = ({
gap: 1, gap: 1,
px: 2, px: 2,
py: 1, py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}} }}
> >
<Icon Component={Sun1} color="primary.main" variant="Bold" /> <Icon Component={Sun1} color="primary.main" variant="Bold" />
@@ -70,10 +68,6 @@ export const ThemeToggleButton = ({
gap: 1, gap: 1,
px: 2, px: 2,
py: 1, py: 1,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.main',
},
}} }}
> >
<Icon Component={Moon} size="medium" color="primary.light" /> <Icon Component={Moon} size="medium" color="primary.light" />

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { AuthenticationCard } from '../AuthenticationCard'; import { AuthenticationCard } from '../AuthenticationCard';
import { Box, CardHeader, Divider, Stack, Typography } from '@mui/material'; import { Box, Divider, Stack, Typography } from '@mui/material';
import AccountCreatedIcon from '@/assets/account-created.svg'; import AccountCreatedIcon from '@/assets/account-created.svg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link as RouterLink, useSearchParams } from 'react-router-dom'; import { Link as RouterLink, useSearchParams } from 'react-router-dom';

View File

@@ -1,7 +1,5 @@
import { Stack, Typography, useTheme } from '@mui/material'; import { Stack, Typography, useTheme } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui'; import { Profile2User } from 'iconsax-react';
import { Box, Profile2User } from 'iconsax-react';
import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export const AccountCreatedClubBanner = () => { export const AccountCreatedClubBanner = () => {

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { RequestedApplication } from './AccountCreated'; import type { RequestedApplication } from './AccountCreated';
import { Box, Button, useTheme } from '@mui/material'; import { Box, Button } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CountDownTimer } from '@/components/CountDownTimer'; import { CountDownTimer } from '@/components/CountDownTimer';
import type { PalleteColor } from '@/theme/palette'; import type { PalleteColor } from '@/theme/palette';

View File

@@ -6,7 +6,7 @@ import { isNumeric } from '@/utils/regexes/isNumeric';
import { CompleteSignUp } from './CompleteSignUp'; import { CompleteSignUp } from './CompleteSignUp';
import { EnterPasswordForm } from './EnterPasswordForm'; import { EnterPasswordForm } from './EnterPasswordForm';
import { UserStatus } from '../../types/userTypes'; import { UserStatus } from '../../types/userTypes';
import type { CountryCode, GUID } from '@/types/commonTypes'; import type { CountryCode } from '@/types/commonTypes';
import { VerifyPhoneNumber } from './VerifyPhoneNumber'; import { VerifyPhoneNumber } from './VerifyPhoneNumber';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';

View File

@@ -10,11 +10,14 @@ import {
Link, Link,
CircularProgress, CircularProgress,
Typography, Typography,
InputAdornment,
} from '@mui/material'; } from '@mui/material';
import { CloseCircle } from 'iconsax-react'; import { CloseCircle, Eye, EyeSlash } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { type PasswordDialogProps } from '../../types/settingsType'; import { type PasswordDialogProps } from '../../types/settingsType';
import { PasswordValidationItem } from './PasswordValidation'; import { PasswordValidationItem } from './PasswordValidation';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export function PasswordDialog({ export function PasswordDialog({
open, open,
@@ -27,7 +30,6 @@ export function PasswordDialog({
currentPassword, currentPassword,
setCurrentPassword, setCurrentPassword,
showValidation, showValidation,
validPassword,
matchPassword, matchPassword,
loading, loading,
handleSubmit, handleSubmit,
@@ -39,6 +41,11 @@ export function PasswordDialog({
hasUpperAndLower, hasUpperAndLower,
hasSpecialChar, hasSpecialChar,
}: PasswordDialogProps) { }: PasswordDialogProps) {
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const navigate = useNavigate();
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -80,13 +87,25 @@ export function PasswordDialog({
<> <>
<TextField <TextField
label={t('securityForm.currentPassword')} label={t('securityForm.currentPassword')}
type="password" type={showCurrent ? 'text' : 'password'}
fullWidth fullWidth
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowCurrent(!showCurrent)}>
<Icon Component={showCurrent ? Eye : EyeSlash} />
</IconButton>
</InputAdornment>
),
}}
/> />
<Link sx={{ fontSize: '15px', cursor: 'pointer' }}> <Link
sx={{ fontSize: '15px', cursor: 'pointer' }}
onClick={() => navigate('/forget-password')}
>
{t('securityForm.forgetPassword')} {t('securityForm.forgetPassword')}
</Link> </Link>
</> </>
@@ -94,11 +113,20 @@ export function PasswordDialog({
<TextField <TextField
label={t('securityForm.newPassword')} label={t('securityForm.newPassword')}
type="password" type={showNew ? 'text' : 'password'}
fullWidth fullWidth
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowNew(!showNew)}>
<Icon Component={showNew ? Eye : EyeSlash} />
</IconButton>
</InputAdornment>
),
}}
/> />
{showValidation && ( {showValidation && (
@@ -124,7 +152,7 @@ export function PasswordDialog({
<TextField <TextField
label={t('securityForm.confirmPassword')} label={t('securityForm.confirmPassword')}
type="password" type={showConfirm ? 'text' : 'password'}
fullWidth fullWidth
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
@@ -135,6 +163,15 @@ export function PasswordDialog({
: ' ' : ' '
} }
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowConfirm(!showConfirm)}>
<Icon Component={showConfirm ? Eye : EyeSlash} />
</IconButton>
</InputAdornment>
),
}}
/> />
</DialogContent> </DialogContent>
@@ -144,7 +181,6 @@ export function PasswordDialog({
sx={{ height: 48, textTransform: 'none' }} sx={{ height: 48, textTransform: 'none' }}
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!validPassword || !matchPassword || loading}
> >
{loading ? ( {loading ? (
<CircularProgress size={24} color="inherit" /> <CircularProgress size={24} color="inherit" />

View File

@@ -94,23 +94,26 @@ export function PasswordSecurity() {
setPassword(''); setPassword('');
setConfirmPassword(''); setConfirmPassword('');
setCurrentPassword(''); setCurrentPassword('');
} } else if (addData || changeData) {
}, [addData, changeData, changePasswordUI, showToast, t]);
useEffect(() => {
if (addError || changeError) {
showToast({ showToast({
message: message:
getErrorMessage(addError || changeError) || t('securityForm.general'), addData?.message || changeData?.message || t('securityForm.error'),
severity: 'error', severity: 'error',
}); });
} }
}, [addError, changeError, t, showToast]); }, [addData, changeData, changePasswordUI, showToast, t]);
const handleOpen = () => setOpen(true); const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false); const handleClose = () => setOpen(false);
const handlePasswordSubmit = async () => { const handlePasswordSubmit = async () => {
if (!matchPassword) {
showToast({
message: t('securityForm.notCompatibility'),
severity: 'error',
});
return;
}
if (changePasswordUI) { if (changePasswordUI) {
await executeChangePassword({ await executeChangePassword({
oldPassword: currentPassword, oldPassword: currentPassword,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Box, Button, Typography, CircularProgress } from '@mui/material'; import { Box, Button, Typography, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer'; import { CardContainer } from '@/components/CardContainer';
@@ -25,13 +25,13 @@ export function PersonalInformation() {
}); });
const [originalData, setOriginalData] = useState<InfoRowData | null>(null); const [originalData, setOriginalData] = useState<InfoRowData | null>(null);
const showToast = useToast(); const showToast = useToast();
const infoRowEditRef = useRef<{ validateFields: () => boolean }>(null);
const { const {
data: profileData, data: profileData,
loading: isLoadingProfile, loading: isLoadingProfile,
error: fetchProfileError, error: fetchProfileError,
} = useApi(fetchProfile, { immediate: true }); } = useApi(fetchProfile, { immediate: true });
const { const {
data: saveData, data: saveData,
loading: isSavingProfile, loading: isSavingProfile,
@@ -50,18 +50,14 @@ export function PersonalInformation() {
: Gender.None, : Gender.None,
country: profileData.countryCode ?? '', country: profileData.countryCode ?? '',
}; };
setData(fetchedData); setData(fetchedData);
setOriginalData(fetchedData); setOriginalData(fetchedData);
const imageBaseUrl = import.meta.env.IMAGE_BASE_URL;
const imageBaseUrl = process.env.IMAGE_BASE_URL; setUploadedImageUrl(
profileData.profileImageUrl
if (profileData.profileImageUrl) { ? `${imageBaseUrl}${profileData.profileImageUrl}`
setUploadedImageUrl(`${imageBaseUrl}${profileData.profileImageUrl}`); : null,
} else { );
setUploadedImageUrl(null);
}
setUploadedImageFile(null); setUploadedImageFile(null);
} }
}, [profileData]); }, [profileData]);
@@ -80,21 +76,18 @@ export function PersonalInformation() {
const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`; const initials = `${data?.firstName?.trim()[0] || ''}${data?.lastName?.trim()[0] || ''}`;
const handleEditClick = () => { const handleEditClick = () => setIsEditing(true);
setIsEditing(true);
setOriginalData(data);
};
const handleCancelClick = () => { const handleCancelClick = () => {
setIsEditing(false); setIsEditing(false);
if (originalData) { if (originalData) setData(originalData);
setData(originalData);
}
setUploadedImageFile(null); setUploadedImageFile(null);
}; };
const handleSaveClick = async () => { const handleSaveClick = () => {
if (!data) return; if (!data) return;
const isValid = infoRowEditRef.current?.validateFields?.();
if (!isValid) return;
executeSaveProfile({ data, imageUrl: uploadedImageFile }); executeSaveProfile({ data, imageUrl: uploadedImageFile });
}; };
@@ -105,23 +98,21 @@ export function PersonalInformation() {
}; };
useEffect(() => { useEffect(() => {
if (saveProfileError) { if (saveProfileError)
showToast({ showToast({
message: message:
getErrorMessage(saveProfileError) || t('settingForm.errorSave'), getErrorMessage(saveProfileError) || t('settingForm.errorSave'),
severity: 'error', severity: 'error',
}); });
}
}, [saveProfileError, showToast, t]); }, [saveProfileError, showToast, t]);
useEffect(() => { useEffect(() => {
if (fetchProfileError) { if (fetchProfileError)
showToast({ showToast({
message: message:
getErrorMessage(fetchProfileError) || t('settingForm.errorFetch'), getErrorMessage(fetchProfileError) || t('settingForm.errorFetch'),
severity: 'error', severity: 'error',
}); });
}
}, [fetchProfileError, showToast, t]); }, [fetchProfileError, showToast, t]);
return ( return (
@@ -131,12 +122,7 @@ export function PersonalInformation() {
subtitle={t('settingForm.descriptionPersonalInfo')} subtitle={t('settingForm.descriptionPersonalInfo')}
highlighted={isEditing} highlighted={isEditing}
action={ action={
<Box <Box sx={{ display: 'flex', gap: 1 }}>
sx={{
display: 'flex',
gap: 1,
}}
>
{isEditing ? ( {isEditing ? (
<> <>
<Button <Button
@@ -159,7 +145,6 @@ export function PersonalInformation() {
textTransform: 'none', textTransform: 'none',
width: { xs: '100%', sm: 'auto' }, width: { xs: '100%', sm: 'auto' },
}} }}
disabled={isSavingProfile}
> >
{isSavingProfile ? ( {isSavingProfile ? (
<CircularProgress size={24} color="inherit" /> <CircularProgress size={24} color="inherit" />
@@ -174,7 +159,6 @@ export function PersonalInformation() {
size="large" size="large"
variant="outlined" variant="outlined"
sx={{ borderRadius: 1 }} sx={{ borderRadius: 1 }}
disabled={isLoadingProfile}
> >
{t('settingForm.editButton')} {t('settingForm.editButton')}
</Button> </Button>
@@ -210,46 +194,48 @@ export function PersonalInformation() {
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<> <Box
<Box sx={{
sx={{ mx: { xs: 2, sm: 3, md: 4 },
mx: { xs: 2, sm: 3, md: 4 }, py: 2,
py: 2, display: 'flex',
display: 'flex', flexDirection: 'column',
flexDirection: 'column', gap: 2,
gap: 2, bgcolor: 'background.paper',
bgcolor: 'background.paper', }}
}} >
> {isEditing && (
{isEditing && ( <ProfileImage
<ProfileImage initials={initials}
initials={initials} uploadedImageUrl={uploadedImageUrl}
uploadedImageUrl={uploadedImageUrl} onImageChange={(file) => {
onImageChange={(file) => { setUploadedImageFile(file);
setUploadedImageFile(file); const reader = new FileReader();
const reader = new FileReader(); reader.onload = () =>
reader.onload = () => setUploadedImageUrl(reader.result as string);
setUploadedImageUrl(reader.result as string); reader.readAsDataURL(file);
reader.readAsDataURL(file); }}
}} onRemoveImage={() => {
onRemoveImage={() => { setUploadedImageFile(null);
setUploadedImageFile(null); setUploadedImageUrl(null);
setUploadedImageUrl(null); }}
}} />
)}
{data &&
(isEditing ? (
<InfoRowEdit
ref={infoRowEditRef}
data={data}
setData={setData}
/> />
)} ) : (
{data && <InfoRowDisplay
(isEditing ? ( data={data}
<InfoRowEdit data={data} setData={setData} /> uploadedImageUrl={uploadedImageUrl}
) : ( initials={initials}
<InfoRowDisplay />
data={data} ))}
uploadedImageUrl={uploadedImageUrl} </Box>
initials={initials}
/>
))}
</Box>
</>
)} )}
</CardContainer> </CardContainer>
</PageWrapper> </PageWrapper>

View File

@@ -141,12 +141,8 @@ export function PhoneNumber() {
}, },
]); ]);
setIsEditing(false); setIsEditing(false);
toast({
message: t('settingForm.phoneChangedSuccessfully'),
severity: 'success',
});
} }
}, [changePhoneData, countryCode, phoneNumber, t, toast]); }, [changePhoneData, countryCode, phoneNumber, t]);
useEffect(() => { useEffect(() => {
if (changePhoneError) { if (changePhoneError) {
@@ -178,6 +174,10 @@ export function PhoneNumber() {
const handleBlur = () => { const handleBlur = () => {
setTouched(true); setTouched(true);
if (!phoneNumber) { if (!phoneNumber) {
toast({
message: t('settingForm.phoneNumberIsInvalid'),
severity: 'error',
});
setFormError(t('settingForm.thisFieldIsRequired')); setFormError(t('settingForm.thisFieldIsRequired'));
return; return;
} }
@@ -203,10 +203,10 @@ export function PhoneNumber() {
}); });
}; };
const handleSendCode = () => { const handleSendCode = async () => {
handleBlur(); handleBlur();
if (formError || !isPhoneValid(countryCode, phoneNumber)) return; if (formError || !isPhoneValid(countryCode, phoneNumber)) return;
executeSendCode({ return executeSendCode({
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''), phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
}); });
}; };

View File

@@ -4,15 +4,21 @@ import { DisplayField } from './DisplayField';
import { Gender } from '@/features/profile/types/settingsType'; import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowDisplayProps } from '@/features/profile/types/settingsType'; import { type InfoRowDisplayProps } from '@/features/profile/types/settingsType';
import ReactCountryFlag from 'react-country-flag'; import ReactCountryFlag from 'react-country-flag';
import { countries } from '@/data/countries';
export function InfoRowDisplay({ export function InfoRowDisplay({
data, data,
uploadedImageUrl, uploadedImageUrl,
initials, initials,
}: InfoRowDisplayProps) { }: InfoRowDisplayProps) {
const { t } = useTranslation('setting'); const { t } = useTranslation(['setting', 'country']);
const displayValue = (value: string) => const displayValue = (value: string) =>
value?.trim() || t('settingForm.notDetermined'); value?.trim() || t('settingForm.notDetermined');
const countryLabel =
data.country &&
t(countries.find((c) => c.code === data.country)?.label || '', {
ns: 'country',
});
const getGenderLabel = (gender: Gender | '') => { const getGenderLabel = (gender: Gender | '') => {
switch (gender) { switch (gender) {
@@ -70,19 +76,20 @@ export function InfoRowDisplay({
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{t('settingForm.country')} {t('settingForm.country')}
</Typography> </Typography>
<Box sx={{ mt: 0.5 }}> <Box
sx={{ mt: 0.5, display: 'flex', alignItems: 'center', gap: 1 }}
>
{data.country ? ( {data.country ? (
<ReactCountryFlag <>
countryCode={data.country} <ReactCountryFlag
svg countryCode={data.country}
style={{ svg
height: '1.5rem', style={{ height: '1.5rem', width: '1.5rem' }}
width: '1.5rem', />
// TODO: Check alignment for better styling definition <Typography variant="body1" color="text.primary">
marginTop: '-2px', {countryLabel}
marginRight: '4px', </Typography>
}} </>
/>
) : ( ) : (
<Typography variant="body1" color="text.primary"> <Typography variant="body1" color="text.primary">
{t('settingForm.notDetermined')} {t('settingForm.notDetermined')}

View File

@@ -6,123 +6,175 @@ import {
MenuItem, MenuItem,
Select, Select,
Autocomplete, Autocomplete,
FormHelperText,
} from '@mui/material'; } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { countries } from '@/data/countries'; import { countries } from '@/data/countries';
import { Gender } from '@/features/profile/types/settingsType'; import { Gender } from '@/features/profile/types/settingsType';
import { type InfoRowEditProps } from '@/features/profile/types/settingsType'; import { type InfoRowEditProps } from '@/features/profile/types/settingsType';
import ReactCountryFlag from 'react-country-flag'; import ReactCountryFlag from 'react-country-flag';
import { useState, forwardRef, useImperativeHandle } from 'react';
export function InfoRowEdit({ data, setData }: InfoRowEditProps) { export const InfoRowEdit = forwardRef(
const { t } = useTranslation(['countries', 'setting']); ({ data, setData }: InfoRowEditProps, ref) => {
const { t } = useTranslation(['country', 'setting']);
const [touched, setTouched] = useState({
firstName: false,
lastName: false,
gender: false,
country: false,
});
const countryOptions = countries.map((c) => ({ const isValidName = (str: string) => /[A-Za-z\u0600-\u06FF]+/.test(str);
code: c.code,
label: t(c.label, { ns: 'countries' }),
}));
const currentCountry = useImperativeHandle(ref, () => ({
countryOptions.find((c) => c.code === data.country) || null; validateFields: () => {
const fields = [ const newTouched = {
{ firstName: true,
name: 'firstName' as const, lastName: true,
label: t('settingForm.name', { ns: 'setting' }), gender: true,
value: data.firstName, country: true,
}, };
{ setTouched(newTouched);
name: 'lastName' as const,
label: t('settingForm.familyName', { ns: 'setting' }),
value: data.lastName,
},
{
name: 'nationalCode' as const,
label: t('settingForm.nationalCode', { ns: 'setting' }),
value: data.nationalCode,
},
];
return ( return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> data.firstName.trim() !== '' &&
{fields.map(({ name, label, value }) => ( isValidName(data.firstName) &&
<Box data.lastName.trim() !== '' &&
key={name} isValidName(data.lastName) &&
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }} data.gender !== Gender.None &&
> data.country !== ''
<TextField );
fullWidth },
name={name} }));
value={value}
onChange={(e) =>
setData((prev) => ({
...prev,
[name]: e.target.value,
}))
}
label={label}
/>
</Box>
))}
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}> const countryOptions = countries.map((c) => ({
<FormControl fullWidth> code: c.code,
<InputLabel> label: t(c.label, { ns: 'country' }),
{t('settingForm.genderPlaceholder', { ns: 'setting' })} }));
</InputLabel> const currentCountry =
<Select countryOptions.find((c) => c.code === data.country) || null;
value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'setting' })} const fields = [
onChange={(e) => {
setData((prev) => ({ name: 'firstName' as const,
...prev, label: t('settingForm.name', { ns: 'setting' }),
gender: e.target.value as Gender, value: data.firstName,
})) },
} {
name: 'lastName' as const,
label: t('settingForm.familyName', { ns: 'setting' }),
value: data.lastName,
},
{
name: 'nationalCode' as const,
label: t('settingForm.optionalNationalCode', { ns: 'setting' }),
value: data.nationalCode,
},
];
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{fields.map(({ name, label, value }) => (
<Box
key={name}
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
> >
<MenuItem value={Gender.Male}> <TextField
{t('settingForm.man', { ns: 'setting' })} fullWidth
</MenuItem> name={name}
<MenuItem value={Gender.Female}> value={value}
{t('settingForm.woman', { ns: 'setting' })} onChange={(e) =>
</MenuItem> setData((prev) => ({ ...prev, [name]: e.target.value }))
</Select> }
</FormControl> label={label}
</Box> error={
name !== 'nationalCode' &&
<Autocomplete touched[name] &&
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }} (value.trim() === '' || !isValidName(value))
options={countryOptions} }
getOptionLabel={(option) => option.label} helperText={
value={currentCountry} name !== 'nationalCode' &&
onChange={(_, newValue) => touched[name] &&
setData((prev) => ({ (value.trim() === '' || !isValidName(value))
...prev, ? t('settingForm.thisFieldIsRequired', { ns: 'setting' })
country: newValue?.code || '', : undefined
})) }
}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<ReactCountryFlag
countryCode={option.code}
svg
style={{
height: '1.5rem',
width: '1.5rem',
// TODO: Check alignment for better styling definition
marginTop: '-2px',
marginRight: '4px',
}}
/> />
{option.label}
</Box> </Box>
)} ))}
renderInput={(params) => (
<TextField <Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
{...params} <FormControl
label={t('settingForm.country', { ns: 'setting' })} fullWidth
/> error={touched.gender && data.gender === Gender.None}
)} >
clearOnEscape <InputLabel>
/> {t('settingForm.genderPlaceholder', { ns: 'setting' })}
</Box> </InputLabel>
); <Select
} value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'setting' })}
onChange={(e) =>
setData((prev) => ({
...prev,
gender: e.target.value as Gender,
}))
}
>
<MenuItem value={Gender.Male}>
{t('settingForm.man', { ns: 'setting' })}
</MenuItem>
<MenuItem value={Gender.Female}>
{t('settingForm.woman', { ns: 'setting' })}
</MenuItem>
</Select>
<FormHelperText>
{touched.gender && data.gender === Gender.None
? t('settingForm.thisFieldIsRequired', { ns: 'setting' })
: ''}
</FormHelperText>
</FormControl>
</Box>
<Autocomplete
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
options={countryOptions}
getOptionLabel={(option) => option.label}
value={currentCountry}
onChange={(_, newValue) =>
setData((prev) => ({ ...prev, country: newValue?.code || '' }))
}
renderOption={(props, option) => (
<Box
component="li"
{...props}
key={option.code}
sx={{ gap: 1, alignItems: 'center' }}
>
<ReactCountryFlag
countryCode={option.code}
svg
style={{ height: '1.5rem', width: '1.5rem' }}
/>
{option.label}
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label={t('settingForm.country', { ns: 'setting' })}
error={touched.country && data.country === ''}
helperText={
touched.country && data.country === ''
? t('settingForm.thisFieldIsRequired', { ns: 'setting' })
: ''
}
/>
)}
clearOnEscape
/>
</Box>
);
},
);

View File

@@ -45,7 +45,7 @@ export default function PhoneEditForm({
return ( return (
<> <>
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Box sx={{ mb: 2, mx: 3 }}> <Box sx={{ mb: 2, mx: 6 }}>
<Typography variant="h6"> <Typography variant="h6">
{t('settingForm.editPhoneNumber')} {t('settingForm.editPhoneNumber')}
</Typography> </Typography>
@@ -59,10 +59,12 @@ export default function PhoneEditForm({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'nowrap',
gap: 2, gap: 2,
alignItems: 'center', alignItems: 'center',
mx: 3, mx: 6,
flexDirection: { xs: 'column', sm: 'row' },
mb: 1,
}} }}
> >
<TextField <TextField
@@ -76,8 +78,8 @@ export default function PhoneEditForm({
error={inputError} error={inputError}
helperText={inputError ? error : ''} helperText={inputError ? error : ''}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
sx={{ flex: '1 1 220px' }}
placeholder="09123456789" placeholder="09123456789"
sx={{ width: { xs: '100%', sm: 474 } }}
InputProps={{ InputProps={{
endAdornment: endAdornment:
buttonState === 'counting' ? ( buttonState === 'counting' ? (
@@ -135,16 +137,11 @@ export default function PhoneEditForm({
} }
} }
}} }}
disabled={
isSending ||
buttonState === 'counting' ||
phoneNumber.length === 0 ||
!isValidPhoneNumber(phoneNumber)
}
sx={{ sx={{
minWidth: { xs: '100%', sm: 220 }, whiteSpace: 'nowrap',
color: 'primary.main', color: 'primary.main',
textTransform: 'none', textTransform: 'none',
width: { xs: '100%', sm: 210 },
}} }}
> >
{isSending ? ( {isSending ? (
@@ -165,11 +162,12 @@ export default function PhoneEditForm({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'nowrap',
gap: 2, gap: 2,
mt: 2,
alignItems: 'center', alignItems: 'center',
mx: 3, mx: 6,
flexDirection: { xs: 'column', sm: 'row' },
mb: 1,
}} }}
> >
<TextField <TextField
@@ -180,7 +178,7 @@ export default function PhoneEditForm({
onChange={(e) => onChange={(e) =>
setVerificationCode(e.target.value.replace(/\D/g, '')) setVerificationCode(e.target.value.replace(/\D/g, ''))
} }
sx={{ flex: '1 1 240px', minWidth: 0 }} sx={{ width: { xs: '100%', sm: 474 } }}
placeholder={t('settingForm.verificationCode')} placeholder={t('settingForm.verificationCode')}
/> />
@@ -189,7 +187,7 @@ export default function PhoneEditForm({
onClick={handleVerifyClick} onClick={handleVerifyClick}
disabled={isVerifying || verificationCode.length === 0} disabled={isVerifying || verificationCode.length === 0}
sx={{ sx={{
minWidth: { xs: '100%', sm: 220 }, width: { xs: '100%', sm: 210 },
bgcolor: 'primary.main', bgcolor: 'primary.main',
textTransform: 'none', textTransform: 'none',
}} }}

View File

@@ -1,4 +1,4 @@
import React, { type ReactElement, type ElementType } from 'react'; import React, { type ReactElement, type ElementType, useState } from 'react';
import { import {
Box, Box,
Button, Button,
@@ -15,6 +15,7 @@ import type { TransitionProps } from '@mui/material/transitions';
import { CloseCircle } from 'iconsax-react'; import { CloseCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaDialogProps } from '@/features/profile/types/settingsType'; import { type SocialMediaDialogProps } from '@/features/profile/types/settingsType';
import { isEmail } from '@/utils/regexes/isEmail';
const MobileSlide = React.forwardRef(function MobileSlide( const MobileSlide = React.forwardRef(function MobileSlide(
props: TransitionProps & { children: ReactElement<unknown, ElementType> }, props: TransitionProps & { children: ReactElement<unknown, ElementType> },
@@ -30,57 +31,58 @@ export default function SocialMediaDialog({
emailInput, emailInput,
setEmailInput, setEmailInput,
fullScreen, fullScreen,
computedMaxWidth,
verificationCode, verificationCode,
setVerificationCode, setVerificationCode,
apiError,
isLoading, isLoading,
dialogStep, dialogStep,
onSendCode, onSendCode,
onConfirmEmail, onConfirmEmail,
}: SocialMediaDialogProps) { }: SocialMediaDialogProps) {
const [emailError, setEmailError] = useState<string | undefined>();
const [touched, setTouched] = useState(false);
const validateEmail = (value: string) => {
if (!value) {
setEmailError(t('settingForm.thisFieldIsRequired'));
return false;
}
if (!isEmail(value)) {
setEmailError(t('settingForm.emailIsInvalid'));
return false;
}
setEmailError(undefined);
return true;
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
fullWidth fullWidth
fullScreen={fullScreen} fullScreen={fullScreen}
maxWidth={computedMaxWidth} maxWidth="xs"
scroll="paper" scroll="body"
keepMounted keepMounted
TransitionComponent={fullScreen ? MobileSlide : undefined} TransitionComponent={fullScreen ? MobileSlide : undefined}
PaperProps={{ PaperProps={{ sx: { mx: 1 } }}
sx: {
borderRadius: { xs: 0, sm: 2 },
width: { xs: '100%', sm: '30%' },
height: 'auto',
},
}}
> >
<DialogTitle <DialogTitle sx={{ p: 2 }}>
sx={{ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
display: 'flex', <IconButton onClick={onClose} disabled={isLoading}>
alignItems: 'center', <Icon Component={CloseCircle} size="large" color="primary.main" />
fontWeight: 'bold', </IconButton>
fontSize: '16px', <Box component="span" fontWeight="bold" fontSize={18}>
gap: 1, {t('settingForm.addEmailButton')}
p: { xs: 1.5, sm: 2 }, </Box>
bgcolor: 'background.default', </Box>
}}
>
<IconButton onClick={onClose} aria-label="Close" disabled={isLoading}>
<Icon Component={CloseCircle} size="medium" color="primary.main" />
</IconButton>
{t('settingForm.addEmailButton')}
</DialogTitle> </DialogTitle>
<DialogContent <DialogContent
dividers
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 2, gap: 2,
py: { xs: 1.5, sm: 2 }, px: { xs: 2, sm: 3 },
}} }}
> >
<Box> <Box>
@@ -94,43 +96,46 @@ export default function SocialMediaDialog({
fullWidth fullWidth
type="email" type="email"
value={emailInput} value={emailInput}
onChange={(e) => setEmailInput(e.target.value)} onChange={(e) => {
setEmailInput(e.target.value);
if (touched) validateEmail(e.target.value);
}}
onBlur={() => {
setTouched(true);
validateEmail(emailInput);
}}
label={t('settingForm.email')} label={t('settingForm.email')}
placeholder="abc@email.com" placeholder="abc@email.com"
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
autoFocus autoFocus
disabled={isLoading || dialogStep === 'enterCode'} disabled={isLoading || dialogStep === 'enterCode'}
error={touched && !!emailError}
helperText={touched && emailError ? emailError : ' '}
/> />
{dialogStep === 'enterCode' && ( {dialogStep === 'enterCode' && (
<> <TextField
<TextField fullWidth
fullWidth type="text"
type="text" value={verificationCode}
value={verificationCode} onChange={(e) => setVerificationCode(e.target.value)}
onChange={(e) => setVerificationCode(e.target.value)} label={t('settingForm.verificationCode')}
label={t('settingForm.verificationCode')} autoComplete="one-time-code"
autoComplete="one-time-code" inputMode="numeric"
inputMode="numeric" sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} autoFocus
autoFocus disabled={isLoading}
disabled={isLoading} />
/>
</>
)}
{apiError && (
<Typography color="error" variant="caption" sx={{ mt: 1 }}>
{apiError}
</Typography>
)} )}
</DialogContent>
<Box sx={{ px: 3, pb: 2 }}>
<Button <Button
variant="contained"
fullWidth fullWidth
sx={{ textTransform: 'none', borderRadius: 2, mt: 1 }} sx={{ height: 48, textTransform: 'none', borderRadius: 2 }}
variant="contained"
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)} disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}
onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail} onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail}
> >
@@ -142,7 +147,7 @@ export default function SocialMediaDialog({
t('settingForm.confirmAndSave') t('settingForm.confirmAndSave')
)} )}
</Button> </Button>
</DialogContent> </Box>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Box, Typography, IconButton } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { Google, Sms, Trash } from 'iconsax-react'; import { Google, Sms } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui'; import { Icon } from '@rkheftan/harmony-ui';
import { type SocialMediaListProps } from '@/features/profile/types/settingsType'; import { type SocialMediaListProps } from '@/features/profile/types/settingsType';
@@ -64,10 +64,6 @@ export default function SocialMediaList({ emailList }: SocialMediaListProps) {
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<IconButton aria-label="Delete">
<Icon Component={Trash} size="medium" variant="Outline" />
</IconButton>
</Box> </Box>
))} ))}
</Box> </Box>

View File

@@ -74,8 +74,8 @@ export default function SocialMediaMenu({
onClose={handleCloseMenu} onClose={handleCloseMenu}
PaperProps={{ PaperProps={{
sx: { sx: {
minWidth: 187, width: anchor ? anchor.offsetWidth : 'auto',
maxWidth: '90vw', // maxWidth: '90vw',
}, },
}} }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}

View File

@@ -49,7 +49,14 @@ export function ActiveDevicesPage() {
ip: session.ipAddress, ip: session.ipAddress,
current: session.key === currentKey, current: session.key === currentKey,
})); }));
setDevices(formattedDevices);
const sortedDevices = formattedDevices.sort((a, b) => {
if (a.current && !b.current) return -1;
if (!a.current && b.current) return 1;
return 0;
});
setDevices(sortedDevices);
} }
}, [profileData, i18n.language, t]); }, [profileData, i18n.language, t]);
@@ -66,6 +73,7 @@ export function ActiveDevicesPage() {
const { data } = await deleteSessions({ keys: [id] }); const { data } = await deleteSessions({ keys: [id] });
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' });
} else { } else {
showToast({ showToast({
message: data.message || t('active.deleteFailed'), message: data.message || t('active.deleteFailed'),
@@ -73,7 +81,6 @@ export function ActiveDevicesPage() {
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
// console.error('Delete error:', error);
showToast({ showToast({
message: t('active.deleteFailed'), message: t('active.deleteFailed'),
severity: 'error', severity: 'error',

View File

@@ -59,7 +59,6 @@ export function SettingPage() {
} = useApi(fetchProfile); } = useApi(fetchProfile);
const { const {
data: saveData,
loading: isSaving, loading: isSaving,
error: saveError, error: saveError,
execute: executeSaveSettings, execute: executeSaveSettings,
@@ -87,15 +86,6 @@ export function SettingPage() {
} }
}, [profileData, setMode, i18n]); }, [profileData, setMode, i18n]);
useEffect(() => {
if (saveData?.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
}
}, [saveData, draftSettings, setMode, i18n]);
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
const resolvedMode = mode === 'light' || mode === 'dark' ? mode : 'light'; const resolvedMode = mode === 'light' || mode === 'dark' ? mode : 'light';
@@ -109,7 +99,7 @@ export function SettingPage() {
setMode(savedSettings.theme); setMode(savedSettings.theme);
}; };
const handleEditToggle = () => { const handleEditToggle = async () => {
if (isEditing) { if (isEditing) {
const languageObj = languageOptions.find( const languageObj = languageOptions.find(
(o) => o.code === draftSettings.language, (o) => o.code === draftSettings.language,
@@ -119,11 +109,18 @@ export function SettingPage() {
); );
if (languageObj && calendarObj) { if (languageObj && calendarObj) {
executeSaveSettings({ const result = await executeSaveSettings({
theme: themeApiMap[draftSettings.theme], theme: themeApiMap[draftSettings.theme],
calendarType: calendarObj.apiValue, calendarType: calendarObj.apiValue,
language: languageObj.apiValue, language: languageObj.apiValue,
}); });
if (result?.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
}
} }
} else { } else {
setIsEditing(true); setIsEditing(true);
@@ -164,11 +161,13 @@ export function SettingPage() {
variant={isEditing ? 'contained' : 'outlined'} variant={isEditing ? 'contained' : 'outlined'}
disabled={isSaving || isFetching} disabled={isSaving || isFetching}
> >
{isSaving {isSaving ? (
? t('settings.saving') <CircularProgress size={24} />
: isEditing ) : isEditing ? (
? t('settings.saveButton') t('settings.saveButton')
: t('settings.editButton')} ) : (
t('settings.editButton')
)}
</Button> </Button>
</Box> </Box>
} }
@@ -211,40 +210,54 @@ export function SettingPage() {
display: 'flex', display: 'flex',
flexDirection: { xs: 'column', sm: 'row' }, flexDirection: { xs: 'column', sm: 'row' },
gap: 4, gap: 4,
mt: 2,
}} }}
> >
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
{isEditing ? ( {isEditing ? (
<ThemeToggleButton <Box
value={draftSettings.theme} sx={{
onChange={(newTheme) => { display: 'flex',
setDraftSettings((prev) => ({ alignItems: 'center',
...prev, gap: 2,
theme: newTheme,
}));
}} }}
/> >
) : ( <Typography variant="body1" color="text.primary">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {t('settings.theme')}
<Icon
Component={savedSettings.theme === 'light' ? Sun1 : Moon}
size="medium"
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography> </Typography>
<ThemeToggleButton
value={draftSettings.theme}
onChange={(newTheme) => {
if (newTheme) {
setMode(newTheme);
setDraftSettings((prev) => ({
...prev,
theme: newTheme,
}));
}
}}
/>
</Box> </Box>
) : (
<>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon
Component={
savedSettings.theme === 'light' ? Sun1 : Moon
}
size="medium"
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
</>
)} )}
</Box> </Box>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
{isEditing ? ( {isEditing ? (
<Autocomplete <Autocomplete
options={languageOptions} options={languageOptions}
@@ -259,26 +272,30 @@ export function SettingPage() {
language: v.code, language: v.code,
})) }))
} }
renderInput={(p) => <TextField {...p} />} renderInput={(p) => (
<TextField {...p} label={t('settings.language')} />
)}
size="medium" size="medium"
fullWidth fullWidth
disableClearable disableClearable
/> />
) : ( ) : (
<Typography variant="body1"> <>
{ <Typography variant="caption" color="text.secondary">
languageOptions.find( {t('settings.language')}
(o) => o.code === savedSettings.language, </Typography>
)?.label <Typography variant="body1">
} {
</Typography> languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
</>
)} )}
</Box> </Box>
</Box> </Box>
<Box sx={{ mt: 2, width: { xs: '100%', sm: 'calc(50% - 16px)' } }}> <Box sx={{ mt: 2, width: { xs: '100%', sm: 'calc(50% - 16px)' } }}>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
{isEditing ? ( {isEditing ? (
<Autocomplete <Autocomplete
options={calendarOptions.map((c) => c.key)} options={calendarOptions.map((c) => c.key)}
@@ -287,18 +304,25 @@ export function SettingPage() {
onChange={(_, v) => onChange={(_, v) =>
v && setDraftSettings((prev) => ({ ...prev, calendar: v })) v && setDraftSettings((prev) => ({ ...prev, calendar: v }))
} }
renderInput={(params) => <TextField {...params} />} renderInput={(params) => (
<TextField {...params} label={t('settings.calendar')} />
)}
size="medium" size="medium"
fullWidth fullWidth
disableClearable disableClearable
/> />
) : ( ) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <>
<Icon Component={Calendar1} size="medium" variant="Bold" /> <Typography variant="caption" color="text.secondary">
<Typography variant="body1"> {t('settings.calendar')}
{t(`settings.${savedSettings.calendar}`)}
</Typography> </Typography>
</Box> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={Calendar1} size="medium" variant="Bold" />
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
</>
)} )}
</Box> </Box>
</Box> </Box>