chore: change styles and add Api for user profile

This commit is contained in:
Koosha Lahouti
2025-08-12 20:20:28 +03:30
parent ed57858c2e
commit 782ef2a2de
35 changed files with 2785 additions and 1843 deletions

View File

@@ -10,7 +10,6 @@ export function PageWrapper({ children }: PageWrapperProps) {
<Box
sx={{
mx: 'auto',
width: { xs: '100%', sm: '754px' },
backgroundColor: 'background.paper',
display: 'flex',
flexDirection: 'column',

View File

@@ -1,53 +1,205 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
useTheme,
useMediaQuery,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { DeviceMessage, Logout } from 'iconsax-react';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import React from 'react';
import { Icon } from '@rkheftan/harmony-ui';
import apiClient from '@/lib/apiClient';
function formatSessionDate(
isoDate: string,
lang: string,
t: TFunction,
): string {
const date = new Date(isoDate);
const now = new Date();
const diffInMinutes = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 1) {
return t('active.justNow');
}
if (diffInMinutes < 60) {
return t('active.minutesAgo', { count: diffInMinutes });
}
let displayLocale: string;
let options: Intl.DateTimeFormatOptions;
if (lang.startsWith('fa')) {
displayLocale = 'fa-IR';
options = {
calendar: 'persian',
numberingSystem: 'arab',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
} else {
displayLocale = 'en-US';
options = {
calendar: 'gregory',
numberingSystem: 'latn',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
}
return new Intl.DateTimeFormat(displayLocale, options).format(date);
}
interface Device {
id: string;
timeAndDate: string;
deviceModel: string;
ip: string;
current: boolean;
}
interface ApiSession {
key: string;
created: string;
deviceOs: string;
deviceName: string;
ipAddress: string;
}
interface ActiveSessionsData {
sessions: ApiSession[];
currentKey: string;
}
interface ProfileApiResponse {
success: boolean;
message?: string;
activeSessions?: ActiveSessionsData;
}
export function ActiveDevices() {
const { t } = useTranslation('activeDevices');
const { t, i18n } = useTranslation('setting');
const token = localStorage.getItem('authToken');
const [devices, setDevices] = useState<Device[]>([]);
const [loadingDeleteIds, setLoadingDeleteIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const devices = [
{
id: 0,
timeAndDate: 'دقایقی پیش',
deviceModel: 'asus i5 24i',
ip: '192.168.1.1',
current: true,
},
{
id: 1,
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
deviceModel: 'Dell XPS 15',
ip: '89.165.23.12',
current: false,
},
{
id: 2,
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
deviceModel: 'Samsung Galaxy S22',
ip: '10.0.0.5',
current: false,
},
{
id: 3,
timeAndDate: '۲۲:۱۳ - ۱۴۰۴/۰۹/۰۹',
deviceModel: 'MacBook Pro 14-inch',
ip: '172.16.0.101',
current: false,
},
];
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
useEffect(() => {
const fetchActiveSessions = async () => {
setIsLoading(true);
setFetchError(null);
if (!token) {
setIsLoading(false);
setFetchError(t('active.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<ProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && res.data.activeSessions) {
const { sessions, currentKey } = res.data.activeSessions;
const formattedDevices = sessions.map((session: ApiSession) => ({
id: session.key,
timeAndDate: formatSessionDate(session.created, i18n.language, t),
deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
setDevices(formattedDevices);
} else {
throw new Error(
res.data.message || t('active.failFetchActiveSessions'),
);
}
} catch (err: unknown) {
console.error(t('active.failFetchActiveSessions'), err);
let message = t('active.errorFetch');
if (err instanceof Error) {
message = err.message;
}
setFetchError(message);
} finally {
setIsLoading(false);
}
};
fetchActiveSessions();
}, [token, i18n.language, t]);
const handleDeleteDevice = async (id: string) => {
if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]);
try {
const res = await apiClient.post(
'/Profile/DeleteSessions',
{
keys: [id],
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
} else {
console.error('Delete failed:', res.data.message);
}
} catch (error: unknown) {
// console.error('Delete error:', error);
} finally {
setLoadingDeleteIds((prev) =>
prev.filter((loadingId) => loadingId !== id),
);
}
};
const handleTerminateAllOtherSessions = async () => {
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
if (otherSessionKeys.length === 0) return;
try {
const res = await apiClient.post(
'/Profile/DeleteSessions',
{
keys: otherSessionKeys,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setDevices((prev) => prev.filter((d) => d.current));
} else {
console.error('Failed to terminate other sessions:', res.data.message);
}
} catch (error: unknown) {
console.error('Error terminating sessions:', error);
}
};
return (
<PageWrapper>
<CardContainer
@@ -57,151 +209,181 @@ export function ActiveDevices() {
<Button
size="medium"
variant="outlined"
onClick={handleTerminateAllOtherSessions}
sx={{
borderRadius: 1,
borderColor: 'error.main',
color: 'error.main',
}}
disabled={isLoading}
>
{t('active.deletDevicesButton')}
{t('active.deleteDevicesButton')}
</Button>
}
>
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
// gap: 2,
}}
>
{devices.map((device) => (
<React.Fragment key={device.id}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
minHeight: 50,
}}
>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 1, sm: 1 },
}}
>
{device.timeAndDate}
</Typography>
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
}}
>
{devices.map((device) => (
<React.Fragment key={device.id}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 2, sm: 2 },
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
minHeight: 50,
py: 1.5,
}}
>
<Icon
Component={DeviceMessage}
size="medium"
color="primary.main"
/>
<Typography variant="body2" noWrap>
{device.deviceModel}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 3, sm: 3 },
}}
>
{device.ip}
</Typography>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 4, sm: 4 },
alignItems: 'center',
justifyContent: 'center',
}}
>
{device.current && (
<Button
variant="outlined"
size="medium"
sx={{
borderRadius: '100px',
border: '1px solid',
borderColor: 'success.main',
whiteSpace: 'nowrap',
color: 'success.main',
}}
>
{t('active.currentDevice')}
</Button>
)}
</Box>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
textAlign: { xs: 'left', sm: 'center' },
minWidth: { sm: '138px' },
order: { xs: 5, sm: 5 },
}}
>
<Button
size="small"
variant="outlined"
startIcon={
<Icon
Component={Logout}
size="small"
color="error.main"
/>
}
disabled={device.current}
<Typography
variant="body2"
sx={{
color: 'error.main',
borderRadius: 1,
borderColor: 'error.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
'& .MuiButton-startIcon': {
marginRight: '4px',
marginLeft: 0,
},
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
// order: { xs: 1, sm: 1 },
}}
>
{t('active.deleteDevice')}
</Button>
{device.timeAndDate}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 2, sm: 2 },
}}
>
<Icon
Component={DeviceMessage}
size="medium"
color="primary.main"
/>
<Typography variant="body2" noWrap>
{device.deviceModel}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 3, sm: 3 },
}}
>
{device.ip}
</Typography>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '138px' },
order: { xs: 4, sm: 4 },
alignItems: 'center',
justifyContent: 'center',
}}
>
{device.current && (
<Button
variant="outlined"
size="medium"
sx={{
borderRadius: 12.5,
border: '1px solid',
borderColor: 'success.main',
whiteSpace: 'nowrap',
color: 'success.main',
'&.Mui-disabled': {
color: 'success.main',
borderColor: 'success.main',
},
}}
disabled
>
{t('active.currentDevice')}
</Button>
)}
</Box>
<Box
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
textAlign: { xs: 'left', sm: 'center' },
minWidth: { sm: '138px' },
order: { xs: 5, sm: 5 },
}}
>
<Button
size="small"
variant="outlined"
startIcon={
<Icon
Component={Logout}
size="small"
color="error.main"
/>
}
disabled={
device.current || loadingDeleteIds.includes(device.id)
}
onClick={() => handleDeleteDevice(device.id)}
sx={{
color: 'error.main',
borderRadius: 1,
borderColor: 'error.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
'& .MuiButton-startIcon': {
marginRight: 0.5,
marginLeft: 0,
},
}}
>
{loadingDeleteIds.includes(device.id)
? t('active.deleting...')
: t('active.deleteDevice')}
</Button>
</Box>
</Box>
</Box>
{isXsup && (
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
)}
</React.Fragment>
))}
</Box>
{isXsup && (
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
)}
</React.Fragment>
))}
</Box>
)}
</CardContainer>
</PageWrapper>
);

View File

@@ -11,11 +11,12 @@ import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { PasswordDialog } from './PasswordDialog';
import { Toast } from '@/components/Toast';
import { regex } from '@/utils/regex';
export function PasswordSecurity() {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation('security');
const { t } = useTranslation('setting');
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');
@@ -26,12 +27,14 @@ export function PasswordSecurity() {
const [changePassword, setChangePassword] = useState(false);
const [loading, setLoading] = useState(false);
const hasNumber = /\d/.test(password);
const hasMinLength = password.length >= 8;
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
const hasSpecialChar = /[!@#$%^&*]/.test(password);
const validPassword =
hasNumber && hasMinLength && hasUpperAndLower && hasSpecialChar;
const {
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
} = regex(password);
const matchPassword = password === confirmPassword;
useEffect(() => {

View File

@@ -5,7 +5,7 @@ import { PageWrapper } from '../PageWrapper';
import React from 'react';
export function RecentLogins() {
const { t } = useTranslation('security');
const { t } = useTranslation('setting');
const data = [
{
id: 0,
@@ -31,7 +31,6 @@ export function RecentLogins() {
>
<Box
sx={{
// px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
@@ -102,7 +101,7 @@ export function RecentLogins() {
<Button
variant="outlined"
sx={{
borderRadius: '15px',
borderRadius: 2,
border: '2px solid',
borderColor: 'success.main',
height: '30px',

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
@@ -6,6 +6,7 @@ import {
useColorScheme,
Autocomplete,
TextField,
CircularProgress,
} from '@mui/material';
import { CardContainer } from '@/components/CardContainer';
import { useTranslation } from 'react-i18next';
@@ -13,69 +14,188 @@ import { ThemeToggleButton } from '@/components/ThemToggle';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
import apiClient from '@/lib/apiClient';
type ThemeMode = 'light' | 'dark';
type CalendarType = 'christian' | 'solar' | 'lunar';
interface SettingsState {
language: string;
calendar: CalendarType;
theme: ThemeMode;
}
interface UserSettingsFromApi {
theme: number;
calendarType: number;
language: number;
}
interface GetProfileApiResponse {
success: boolean;
message?: string;
userSettings?: UserSettingsFromApi;
}
interface SaveSettingApiResponse {
success: boolean;
message?: string;
}
const languageOptions = [
{ code: 'en', label: 'English', apiValue: 1 },
{ code: 'fa', label: 'فارسی', apiValue: 2 },
];
const calendarOptions: { key: CalendarType; apiValue: number }[] = [
{ key: 'christian', apiValue: 1 },
{ key: 'solar', apiValue: 2 },
{ key: 'lunar', apiValue: 3 },
];
const themeApiMap: Record<ThemeMode, number> = { light: 1, dark: 2 };
export function Setting() {
const { t, i18n } = useTranslation(['setting']);
const { mode } = useColorScheme();
const { mode, setMode } = useColorScheme();
const token = localStorage.getItem('authToken');
const [savedLanguage, setSavedLanguage] = useState<string>(
i18n.language || 'en',
);
const [draftLanguage, setDraftLanguage] = useState<string>(savedLanguage);
const [savedSettings, setSavedSettings] = useState<SettingsState>({
language: i18n.language || 'en',
calendar: 'solar',
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
const [draftSettings, setDraftSettings] =
useState<SettingsState>(savedSettings);
const [isEditing, setIsEditing] = useState(false);
const [selectedCalendar, setSelectedCalendar] = useState<
'christian' | 'solar' | 'lunar'
>('solar');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const languageOptions = [
{ code: 'en', label: 'English' },
{ code: 'fa', label: 'فارسی' },
];
const calendarOptions: ('christian' | 'solar' | 'lunar')[] = [
'christian',
'solar',
'lunar',
];
const [isFetching, setIsFetching] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const handleDraftLanguageChange = (
_: React.SyntheticEvent,
v: { code: string; label: string } | null,
) => v && setDraftLanguage(v.code);
useEffect(() => {
const fetchUserSettings = async () => {
setIsFetching(true);
setFetchError(null);
if (!token) {
setIsFetching(false);
setFetchError(t('settings.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && res.data.userSettings) {
const { theme, calendarType, language } = res.data.userSettings;
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light',
2: 'dark',
};
const themeMode = themeReverseMap[theme] || 'light';
const calendarSetting = calendarOptions.find(
(c) => c.apiValue === calendarType,
);
const calendarKey = calendarSetting ? calendarSetting.key : 'solar';
const languageSetting = languageOptions.find(
(l) => l.apiValue === language,
);
const languageCode = languageSetting ? languageSetting.code : 'en';
const newSettings: SettingsState = {
theme: themeMode,
calendar: calendarKey,
language: languageCode,
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(themeMode);
i18n.changeLanguage(languageCode);
} else {
throw new Error(res.data.message || t('settings.failRetrieve'));
}
} catch (e: unknown) {
let message = t('settings.errorFetch');
if (e instanceof Error) {
message = e.message;
}
setFetchError(message);
} finally {
setIsFetching(false);
}
};
fetchUserSettings();
}, [token, setMode, i18n, t]);
useEffect(() => {
if (isEditing) {
setDraftSettings({
...savedSettings,
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
}
}, [isEditing, savedSettings, mode]);
const handleCancel = () => {
setDraftLanguage(savedLanguage);
setIsEditing(false);
setError(null);
};
const handleSave = () => {
if (draftLanguage !== savedLanguage) {
i18n.changeLanguage(draftLanguage);
setSavedLanguage(draftLanguage);
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const languageObj = languageOptions.find(
(o) => o.code === draftSettings.language,
);
const calendarObj = calendarOptions.find(
(c) => c.key === draftSettings.calendar,
);
const apiThemeValue = themeApiMap[draftSettings.theme];
if (!languageObj || !calendarObj) {
setError(t('settings.invalidSelection'));
setLoading(false);
return;
}
const res = await apiClient.post<SaveSettingApiResponse>(
'/Profile/SaveSetting',
{
theme: apiThemeValue,
calendarType: calendarObj.apiValue,
language: languageObj.apiValue,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
await i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
} else {
setError(res.data.message || t('settings.saveFailed'));
}
} catch (e: unknown) {
setError(t('settings.saveFailed'));
} finally {
setLoading(false);
}
setIsEditing(false);
};
const handleEditToggle = () =>
const handleEditToggle = () => {
isEditing ? handleSave() : setIsEditing(true);
// useEffect(() => {
// setSelectedCalendar(t('settings.solar'));
// }, [i18n.language, t]);
};
return (
<PageWrapper>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: { xs: 0, sm: 3 },
mx: 0,
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: { xs: 0, sm: 3 }, mx: 0 }}>
<CardContainer
title={t('settings.title')}
subtitle={t('settings.description')}
@@ -90,9 +210,9 @@ export function Setting() {
sx={{
color: 'primary.main',
textTransform: 'none',
// width: { xs: '100%', sm: 'auto' },
fontSize: { xs: '0.85rem', sm: '1rem' },
}}
disabled={loading || isFetching}
>
{t('settings.rejectButton')}
</Button>
@@ -108,80 +228,153 @@ export function Setting() {
bgcolor: isEditing ? 'primary.main' : 'background.default',
color: isEditing ? 'primary.contrastText' : 'primary.main',
}}
disabled={loading || isFetching}
>
{isEditing
? t('settings.saveButton')
: t('settings.editButton')}
{loading
? t('settings.saving...')
: isEditing
? t('settings.saveButton')
: t('settings.editButton')}
</Button>
</Box>
}
>
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 2,
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">
{t('settings.theme')}
</Typography>
<ThemeToggleButton />
</Box>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{error && (
<Typography color="error.main" variant="body2" mb={2}>
{error}
</Typography>
)}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 2,
}}
>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Icon
Component={mode === 'light' ? Sun1 : Moon}
size="medium"
variant="Bold"
color={mode === 'light' ? 'black' : 'primary.main'}
<Typography variant="body1" sx={{ mb: 1 }}>
{t('settings.theme')}
</Typography>
<ThemeToggleButton
value={draftSettings.theme}
onChange={(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"
color={
savedSettings.theme === 'light'
? 'action.hover'
: 'primary.main'
}
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
</Box>
)}
</Box>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find(
(o) => o.code === draftSettings.language,
) || null
}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({
...prev,
language: v.code,
}))
}
renderInput={(p) => (
<TextField {...p} label={t('settings.language')} />
)}
size="medium"
fullWidth
/>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
<Typography variant="body1">
{mode === 'light'
? t('settings.light')
: t('settings.dark')}
{
languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
</Box>
</Box>
)}
)}
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ mt: 2, width: { xs: '100%', md: '50%' } }}>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find((o) => o.code === draftLanguage) ||
null
options={calendarOptions.map((c) => c.key)}
getOptionLabel={(key) => t(`settings.${key}`)}
value={draftSettings.calendar}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({ ...prev, calendar: v }))
}
onChange={handleDraftLanguageChange}
renderInput={(p) => (
<TextField {...p} label={t('settings.language')} />
renderInput={(params) => (
<TextField {...params} label={t('settings.calendar')} />
)}
size="medium"
fullWidth
@@ -189,51 +382,24 @@ export function Setting() {
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
<Typography variant="body1">
{
languageOptions.find((o) => o.code === savedLanguage)
?.label
}
{t('settings.calendar')}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Icon
Component={Calendar1}
size="medium"
color={mode === 'light' ? 'black' : 'primary.main'}
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
</Box>
)}
</Box>
</Box>
<Box sx={{ mt: 2, width: { xs: '100%', md: '50%' } }}>
{isEditing ? (
<Autocomplete
options={calendarOptions}
getOptionLabel={(key) => t(`settings.${key}`)}
value={selectedCalendar}
onChange={(_, v) => v && setSelectedCalendar(v)}
renderInput={(params) => (
<TextField {...params} label={t('settings.calendar')} />
)}
size="medium"
fullWidth
/>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Icon
Component={Calendar1}
size="medium"
color={mode === 'light' ? 'black' : 'primary.main'}
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${selectedCalendar}`)}
</Typography>
</Box>
</Box>
)}
</Box>
</Box>
)}
</CardContainer>
</Box>
</Box>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Box, Button } from '@mui/material';
import { Box, Button, Typography, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { ProfileImage } from './personalInformation/ProfileImage';
@@ -7,43 +7,193 @@ import { InfoRowDisplay } from './personalInformation/InfoRowDisplay';
import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper';
import { Gender, type InfoRowData } from '../../types';
import axios, { isAxiosError } from 'axios';
import apiClient from '@/lib/apiClient';
interface GetProfileApiResponse {
success: boolean;
message?: string;
firstName?: string;
lastName?: string;
nationalCode?: string;
gender?: Gender;
countryCode?: string;
profileImageUrl?: string;
}
interface SaveProfileApiResponse {
success: boolean;
message?: string;
}
interface TokenApiResponse {
access_token: string;
}
export function PersonalInformation() {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const initialData: InfoRowData = {
firstName: 'محمد حسین',
lastName: 'برزه‌گر',
country: 'قطر',
const [data, setData] = useState<InfoRowData>({
firstName: '',
lastName: '',
nationalCode: '',
gender: Gender.None,
};
country: '',
});
const [originalData, setOriginalData] = useState<InfoRowData | null>(null);
// const [token, setToken] = useState<string | null>(null);
const [tokenError, setTokenError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const storedToken = localStorage.getItem('authToken');
const [data, setData] = useState<InfoRowData>(initialData);
const [gender, setGender] = useState<Gender>(Gender.None);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
useEffect(() => {
if (Object.values(Gender).includes(data.gender)) {
setGender(data.gender);
}
}, [data.gender]);
const fetchProfile = async () => {
setIsLoading(true);
setFetchError(null);
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{
headers: {
Authorization: `Bearer ${storedToken}`,
},
},
);
if (res.data?.success) {
const profile = res.data;
const fetchedData = {
firstName: profile.firstName ?? '',
lastName: profile.lastName ?? '',
nationalCode: profile.nationalCode ?? '',
gender: Object.values(Gender).includes(profile.gender as Gender)
? (profile.gender as Gender)
: Gender.None,
country: profile.countryCode ?? '',
};
setData(fetchedData);
setOriginalData(fetchedData);
setUploadedImageUrl(profile.profileImageUrl || null);
} else {
throw new Error(res.data.message || t('settingForm.failRetrieve'));
}
} catch (error: unknown) {
let message = t('settingForm.errorFetch');
if (error instanceof Error) {
message = error.message;
}
setFetchError(message);
} finally {
setIsLoading(false);
}
};
const initials = `${data.firstName?.trim()[0] || ''}${data.lastName?.trim()[0] || ''}`;
const toggleEdit = () => {
if (isEditing) {
setData((prev) => ({
...prev,
gender: gender,
}));
if (storedToken) {
fetchProfile();
} else {
setGender(
Object.values(Gender).includes(data.gender) ? data.gender : Gender.None,
);
setIsLoading(false);
setFetchError(t('settingForm.notLoggedIn'));
}
}, [storedToken, t]);
const initials = `${data?.firstName?.trim()[0] || ''}${
data?.lastName?.trim()[0] || ''
}`;
const handleEditClick = () => {
setIsEditing(true);
setSaveError(null);
setOriginalData(data);
};
const handleCancelClick = () => {
setIsEditing(false);
if (originalData) {
setData(originalData);
}
setSaveError(null);
};
const handleSaveClick = async () => {
if (!data) return;
setSaveError(null);
try {
const formData = new FormData();
formData.append('FirstName', data.firstName || '');
formData.append('LastName', data.lastName || '');
formData.append('NationalCode', data.nationalCode || '');
formData.append('Gender', String(data.gender ?? Gender.None));
formData.append('CountryCode', data.country || '');
if (uploadedImageUrl && uploadedImageUrl.startsWith('data:')) {
const response = await fetch(uploadedImageUrl);
const blob = await response.blob();
formData.append('Image', blob, 'profile.jpg');
}
const res = await apiClient.post<SaveProfileApiResponse>(
'Profile/SaveProfilePersonalInforamtion',
formData,
{
headers: {
Authorization: `Bearer ${storedToken}`,
},
},
);
if (res.data.success) {
setIsEditing(false);
setOriginalData(data);
} else {
throw new Error(res.data.message || t('settingForm.unknownError'));
}
} catch (error: unknown) {
let message = t('settingForm.checkConnection');
if (error instanceof Error) {
message = error.message;
}
setSaveError(message);
}
};
const apiUrl = 'https://accounts.business-harmony.com';
const tokenEndpoint = `${apiUrl}/connect/token`;
const getToken = async () => {
setTokenError(null);
try {
const body = new URLSearchParams();
body.set('grant_type', 'password');
body.set('username', 'zareian.1381@gmail.com');
body.set('password', '123@Qweasd');
body.set('client_id', 'harmony_identity');
body.set('scope', 'openid harmony_identity profile offline_access');
const response = await axios.post<TokenApiResponse>(
tokenEndpoint,
body.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (response.data?.access_token) {
localStorage.setItem('authToken', response.data.access_token);
} else {
throw new Error('No access token in response');
}
} catch (error: unknown) {
let message = 'Failed to get token';
if (isAxiosError(error) && error.response) {
message = `Request failed with status ${error.response.status}`;
} else if (error instanceof Error) {
message = error.message;
}
setTokenError(message);
}
setIsEditing(!isEditing);
};
return (
@@ -53,78 +203,140 @@ export function PersonalInformation() {
subtitle={t('settingForm.descriptionPersonalInfo')}
highlighted={isEditing}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isEditing && (
<Button
variant="text"
onClick={() => setIsEditing(false)}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
// fontSize: { xs: '0.8 5rem', sm: '1rem' },
}}
>
{t('settingForm.rejectButton')}
</Button>
)}
<Button
onClick={toggleEdit}
size="large"
variant="outlined"
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
width: '100%',
}}
>
<Box
sx={{
borderRadius: 1,
bgcolor: isEditing ? 'primary.main' : 'background.default',
color: isEditing ? 'primary.contrastText' : 'primary.main',
display: 'flex',
gap: 1,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
{isEditing
? t('settingForm.saveButton')
: t('settingForm.editButton')}
</Button>
<Button
variant="contained"
color="secondary"
onClick={getToken}
size="large"
sx={{ textTransform: 'none' }}
disabled={isLoading}
>
Get Token
</Button>
{isEditing ? (
<>
<Button
variant="text"
onClick={handleCancelClick}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
>
{t('settingForm.rejectButton')}
</Button>
<Button
onClick={handleSaveClick}
size="large"
variant="contained"
sx={{
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
>
{t('settingForm.saveButton')}
</Button>
</>
) : (
<Button
onClick={handleEditClick}
size="large"
variant="outlined"
sx={{
borderRadius: 1,
}}
disabled={isLoading}
>
{t('settingForm.editButton')}
</Button>
)}
</Box>
{saveError && (
<Typography
color="error"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
{saveError}
</Typography>
)}
</Box>
}
>
<Box
sx={{
mx: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{isEditing && (
<ProfileImage
initials={initials}
uploadedImageUrl={uploadedImageUrl}
onImageChange={(file) => {
const reader = new FileReader();
reader.onload = () =>
setUploadedImageUrl(reader.result as string);
reader.readAsDataURL(file);
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">{fetchError}</Typography>
</Box>
) : (
<>
{tokenError && (
<Box sx={{ mt: 2, color: 'red' }}>Error: {tokenError}</Box>
)}
<Box
sx={{
mx: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
onRemoveImage={() => setUploadedImageUrl(null)}
/>
)}
{isEditing ? (
<InfoRowEdit
data={data}
setData={setData}
gender={gender}
setGender={setGender}
/>
) : (
<InfoRowDisplay
data={data}
uploadedImageUrl={uploadedImageUrl}
initials={initials}
/>
)}
</Box>
>
{isEditing && (
<ProfileImage
initials={initials}
uploadedImageUrl={uploadedImageUrl}
onImageChange={(file) => {
const reader = new FileReader();
reader.onload = () =>
setUploadedImageUrl(reader.result as string);
reader.readAsDataURL(file);
}}
onRemoveImage={() => setUploadedImageUrl(null)}
/>
)}
{data &&
(isEditing ? (
<InfoRowEdit data={data} setData={setData} />
) : (
<InfoRowDisplay
data={data}
uploadedImageUrl={uploadedImageUrl}
initials={initials}
/>
))}
</Box>
</>
)}
</CardContainer>
</PageWrapper>
);

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { PageWrapper } from '../PageWrapper';
@@ -6,9 +6,32 @@ import { CardContainer } from '@/components/CardContainer';
import PhoneDisplay from './phoneNumber/PhoneDisplay';
import PhoneEditForm from './phoneNumber/PhoneEditForm';
import PhoneActionButtons from './phoneNumber/PhoneActionButtons';
import apiClient from '@/lib/apiClient';
import { CircularProgress, Box, Typography } from '@mui/material';
interface Phone {
phone: string;
time: string;
withCode: string;
}
interface GetProfileApiResponse {
success: boolean;
message?: string;
phoneNumber?: string;
}
interface ApiResponse {
success: boolean;
message?: string;
}
interface ConfirmApiResponse extends ApiResponse {
confirm?: boolean;
}
export function PhoneNumber() {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const [isEditing, setIsEditing] = useState(false);
const [phoneNumber, setPhoneNumber] = useState('');
const [verificationCode, setVerificationCode] = useState('');
@@ -18,15 +41,60 @@ export function PhoneNumber() {
);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [phones, setPhone] = useState([
{ phone: '09123456789', time: '۱ ماه پیش', withCode: '+989123456789' },
]);
const [phones, setPhone] = useState<Phone[]>([]);
const [countryCode, setCountryCode] = useState('+98');
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const inputError: boolean = touched && !!error;
const token = localStorage.getItem('authToken');
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
useEffect(() => {
const fetchPhoneNumber = async () => {
setIsLoading(true);
setFetchError(null);
if (!token) {
setIsLoading(false);
setFetchError(t('settingForm.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && res.data.phoneNumber) {
setPhone([
{
phone: res.data.phoneNumber,
time: '',
withCode: res.data.phoneNumber,
},
]);
} else if (!res.data.success) {
throw new Error(
res.data.message || t('settingForm.failFetchPhoneNumber'),
);
}
} catch (err: unknown) {
let message = t('settingForm.errorFetchPhoneNumber');
if (err instanceof Error) {
message = err.message;
}
setFetchError(message);
} finally {
setIsLoading(false);
}
};
fetchPhoneNumber();
}, [token, t]);
const isPhoneValid = (code: string, phone: string) => {
const phoneNum = parsePhoneNumberFromString(code + phone);
@@ -37,6 +105,7 @@ export function PhoneNumber() {
setTouched(true);
if (!phoneNumber) {
setError(t('settingForm.thisFieldIsRequired'));
return;
}
if (!isPhoneValid(countryCode, phoneNumber)) {
setError(t('settingForm.phoneNumberIsInvalid'));
@@ -54,34 +123,100 @@ export function PhoneNumber() {
setIsVerified(false);
setButtonState('default');
setShowToast(false);
setError(undefined);
setTouched(false);
}
return enteringEditMode;
});
};
const handleSendCode = () => {
const handleSendCode = async () => {
if (!phoneNumber) return;
setButtonState('counting');
setIsVerified(false);
if (!isPhoneValid(countryCode, phoneNumber)) {
setError(t('settingForm.phoneNumberIsInvalid'));
return;
}
setError(undefined);
try {
const res = await apiClient.post<ApiResponse>(
'/Profile/SendVerfiyPhoneNumberCode',
{
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setButtonState('counting');
setIsVerified(false);
} else {
setError(res.data.message || t('settingForm.sendCodeFailed'));
}
} catch (error: unknown) {
setError(t('settingForm.sendCodeFailed'));
}
};
const handleVerifyCode = () => {
const handleVerifyCode = async () => {
if (!verificationCode) {
setError(t('settingForm.verificationCodeRequired'));
return;
}
setIsVerifying(true);
setTimeout(() => {
setError(undefined);
try {
const res = await apiClient.post<ConfirmApiResponse>(
'/Profile/ConfirmPhoneNumberChangeCode',
{
phoneNumber: countryCode + phoneNumber.replace(/^0/, ''),
verifyCode: verificationCode,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && res.data.confirm) {
setIsVerified(true);
setShowToast(true);
await handleChangePhoneNumber();
} else {
setError(res.data.message || t('settingForm.verifyCodeFailed'));
setIsVerified(false);
}
} catch (error: unknown) {
setError(t('settingForm.verifyCodeFailed'));
setIsVerified(false);
} finally {
setIsVerifying(false);
setIsVerified(true);
setShowToast(true);
const newPhone = '+98' + phoneNumber.slice(1);
setPhone([
{ phone: phoneNumber, time: 'چند ثانیه پیش', withCode: newPhone },
]);
}, 1500);
}
};
const handleVerifyClick = () => {
if (!verificationCode || isVerifying) return;
handleVerifyCode();
setTimeout(() => setShowToast(true), 1600);
const handleChangePhoneNumber = async () => {
try {
const fullPhoneNumber = countryCode + phoneNumber.replace(/^0/, '');
const res = await apiClient.post<ApiResponse>(
'/Profile/ChangePhoneNumber',
{
phoneNumber: fullPhoneNumber,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setPhone([
{
phone: phoneNumber,
time: t('settingForm.justNow'),
withCode: fullPhoneNumber,
},
]);
setIsEditing(false);
} else {
setError(res.data.message || t('settingForm.changePhoneFailed'));
}
} catch (error: unknown) {
setError(t('settingForm.changePhoneFailed'));
}
};
return (
@@ -98,7 +233,23 @@ export function PhoneNumber() {
/>
}
>
{isEditing ? (
{isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '150px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error">{fetchError}</Typography>
</Box>
) : isEditing ? (
<PhoneEditForm
phoneNumber={phoneNumber}
setPhoneNumber={setPhoneNumber}
@@ -111,7 +262,7 @@ export function PhoneNumber() {
buttonState={buttonState}
setButtonState={setButtonState}
handleSendCode={handleSendCode}
handleVerifyClick={handleVerifyClick}
handleVerifyClick={handleVerifyCode}
error={error}
inputError={inputError}
handleBlur={handleBlur}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
@@ -7,13 +7,53 @@ import SocialMediaMenu from './socialMedia/SocialMediaMenu';
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
import useMediaQuery from '@mui/material/useMediaQuery';
import type { Theme } from '@mui/material/styles';
import apiClient from '@/lib/apiClient';
import { Box, CircularProgress, Typography } from '@mui/material';
interface EmailAccount {
email: string;
provider: 'email' | 'google';
time: string;
}
interface GetProfileApiResponse {
success: boolean;
message?: string;
email?: string;
}
interface SendCodeApiResponse {
success: boolean;
message?: string;
}
interface ConfirmCodeApiResponse {
success: boolean;
confirm?: boolean;
message?: string;
}
interface ChangeEmailApiResponse {
success: boolean;
message?: string;
}
export function SocialMedia() {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const token = localStorage.getItem('authToken');
const [openDialog, setOpenDialog] = useState(false);
const [emailInput, setEmailInput] = useState('');
const [emailError, setEmailError] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>(
'enterEmail',
);
const [apiError, setApiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [emailList, setEmailList] = useState<EmailAccount[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const fullScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down('sm'),
@@ -26,10 +66,128 @@ export function SocialMedia() {
| 'lg'
| 'xl';
const emailList = [
{ email: 'emailtemp@email.com', provider: 'email', time: '1 ماه پیش' },
{ email: 'emailtemp@gmail.com', provider: 'google', time: '1 ماه پیش' },
] as const;
useEffect(() => {
const fetchInitialEmail = async () => {
setIsFetching(true);
setFetchError(null);
if (!token) {
setIsFetching(false);
setFetchError(t('settingForm.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && res.data.email) {
const userEmail = res.data.email;
const newAccount: EmailAccount = {
email: userEmail,
provider: userEmail.includes('gmail.com') ? 'google' : 'email',
time: '',
};
setEmailList([newAccount]);
} else if (!res.data.success) {
throw new Error(res.data.message || t('settingForm.failFetchEmail'));
}
} catch (err: unknown) {
let message = t('settingForm.errorFetchEmail');
if (err instanceof Error) {
message = err.message;
}
setFetchError(message);
} finally {
setIsFetching(false);
}
};
fetchInitialEmail();
}, [token, t]);
const resetDialog = () => {
setOpenDialog(false);
setEmailInput('');
setVerificationCode('');
setApiError(null);
setIsLoading(false);
setDialogStep('enterEmail');
};
const handleSendCode = async () => {
if (!/^\S+@\S+\.\S+$/.test(emailInput)) {
setApiError(t('settingForm.emailIsInvalid'));
return;
}
setIsLoading(true);
setApiError(null);
try {
const res = await apiClient.post<SendCodeApiResponse>(
'Profile/SendEmailChangeCode',
{ email: emailInput },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setDialogStep('enterCode');
} else {
setApiError(res.data.message || t('settingForm.sendCodeFailed'));
}
} catch (err: unknown) {
setApiError(t('settingForm.sendCodeFailed'));
} finally {
setIsLoading(false);
}
};
const handleConfirmAndChangeEmail = async () => {
if (verificationCode.length < 4) {
setApiError(t('settingForm.verificationCodeRequired'));
return;
}
setIsLoading(true);
setApiError(null);
try {
const confirmRes = await apiClient.post<ConfirmCodeApiResponse>(
'Profile/ConfirmEmailChangeCode',
{ email: emailInput, verifyCode: verificationCode },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (confirmRes.data.success && confirmRes.data.confirm) {
const changeRes = await apiClient.post<ChangeEmailApiResponse>(
'Profile/ChangeEmail',
{ email: emailInput },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (changeRes.data.success) {
setEmailList((prevList) => [
...prevList,
{
email: emailInput,
provider: 'email',
time: t('settingForm.justNow'),
},
]);
resetDialog();
} else {
setApiError(
changeRes.data.message || t('settingForm.changeEmailFailed'),
);
}
} else {
setApiError(
confirmRes.data.message || t('settingForm.verifyCodeFailed'),
);
}
} catch (err: unknown) {
setApiError(t('settingForm.anErrorOccurred'));
} finally {
setIsLoading(false);
}
};
return (
<PageWrapper>
@@ -40,15 +198,38 @@ export function SocialMedia() {
<SocialMediaMenu t={t} onOpenDialog={() => setOpenDialog(true)} />
}
>
<SocialMediaList t={t} emailList={emailList} />
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '100px',
}}
>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
</Box>
) : (
<SocialMediaList t={t} emailList={emailList} />
)}
<SocialMediaDialog
open={openDialog}
onClose={() => setOpenDialog(false)}
onClose={resetDialog}
t={t}
emailInput={emailInput}
setEmailInput={setEmailInput}
emailError={emailError}
setEmailError={setEmailError}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
apiError={apiError}
isLoading={isLoading}
dialogStep={dialogStep}
onSendCode={handleSendCode}
onConfirmEmail={handleConfirmAndChangeEmail}
fullScreen={fullScreen}
computedMaxWidth={computedMaxWidth}
/>

View File

@@ -7,7 +7,7 @@ interface DisplayFieldProps {
}
export function DisplayField({ label, value }: DisplayFieldProps) {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const displayValue = value?.trim() || t('settingForm.notDetermined');
return (

View File

@@ -23,7 +23,7 @@ export function InfoRowDisplay({
uploadedImageUrl,
initials,
}: InfoRowDisplayProps) {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const displayValue = (value: string) =>
value?.trim() || t('settingForm.notDetermined');

View File

@@ -2,6 +2,7 @@ import {
Box,
TextField,
FormControl,
InputLabel,
MenuItem,
Select,
Autocomplete,
@@ -15,17 +16,10 @@ import { type InfoRowData } from '@/features/profile/types';
interface InfoRowEditProps {
data: InfoRowData;
setData: React.Dispatch<React.SetStateAction<InfoRowData>>;
gender: Gender;
setGender: React.Dispatch<React.SetStateAction<Gender>>;
}
export function InfoRowEdit({
data,
setData,
gender,
setGender,
}: InfoRowEditProps) {
const { t } = useTranslation(['countries', 'profileSetting']);
export function InfoRowEdit({ data, setData }: InfoRowEditProps) {
const { t } = useTranslation(['countries', 'setting']);
const countryOptions = countries.map((c) => ({
code: c.code,
@@ -34,26 +28,27 @@ export function InfoRowEdit({
const currentCountry =
countryOptions.find((c) => c.code === data.country) || null;
const fields = [
{
name: 'firstName' as const,
label: t('settingForm.name', { ns: 'profileSetting' }),
value: data.firstName,
},
{
name: 'lastName' as const,
label: t('settingForm.familyName', { ns: 'profileSetting' }),
value: data.lastName,
},
{
name: 'nationalCode' as const,
label: t('settingForm.nationalCode', { ns: 'profileSetting' }),
value: data.nationalCode,
},
];
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{[
{
name: 'firstName' as keyof InfoRowData,
label: t('settingForm.name', { ns: 'profileSetting' }),
value: data.firstName,
},
{
name: 'lastName' as keyof InfoRowData,
label: t('settingForm.familyName', { ns: 'profileSetting' }),
value: data.lastName,
},
{
name: 'nationalCode' as keyof InfoRowData,
label: t('settingForm.nationalCode', { ns: 'profileSetting' }),
value: data.nationalCode,
},
].map(({ name, label, value }) => (
{fields.map(({ name, label, value }) => (
<Box
key={name}
sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}
@@ -75,22 +70,17 @@ export function InfoRowEdit({
<Box sx={{ width: { xs: '100%', sm: '48%', md: 'calc(50% - 8px)' } }}>
<FormControl fullWidth>
<InputLabel>
{t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
</InputLabel>
<Select
value={gender}
onChange={(e) => setGender(e.target.value as Gender)}
displayEmpty
renderValue={(selected) =>
selected ? (
selected === Gender.Male ? (
t('settingForm.man', { ns: 'profileSetting' })
) : (
t('settingForm.woman', { ns: 'profileSetting' })
)
) : (
<span>
{t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
</span>
)
value={data.gender === Gender.None ? '' : data.gender}
label={t('settingForm.genderPlaceholder', { ns: 'profileSetting' })}
onChange={(e) =>
setData((prev) => ({
...prev,
gender: e.target.value as Gender,
}))
}
>
<MenuItem value={Gender.Male}>
@@ -117,6 +107,7 @@ export function InfoRowEdit({
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<CountryFlag code={option.code} />
{option.label}
</Box>
)}
renderInput={(params) => (

View File

@@ -19,7 +19,7 @@ export function ProfileImage({
onImageChange,
onRemoveImage,
}: ProfileImageProps) {
const { t } = useTranslation('profileSetting');
const { t } = useTranslation('setting');
const [error, setError] = useState<string | null>(null);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -80,7 +80,9 @@ export function ProfileImage({
Component={Camera}
size="small"
color={
uploadedImageUrl && onRemoveImage ? 'primary.main' : 'white'
uploadedImageUrl && onRemoveImage
? 'primary.main'
: 'background.paper'
}
/>
}

View File

@@ -2,6 +2,7 @@ import React, { type ReactElement, type ElementType } from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
DialogTitle,
@@ -27,10 +28,15 @@ interface SocialMediaDialogProps {
t: (key: string) => string;
emailInput: string;
setEmailInput: (val: string) => void;
emailError: boolean;
setEmailError: (val: boolean) => void;
fullScreen: boolean;
computedMaxWidth: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
verificationCode: string;
setVerificationCode: (value: string) => void;
apiError: string | null;
isLoading: boolean;
dialogStep: 'enterEmail' | 'enterCode';
onSendCode: () => void;
onConfirmEmail: () => void;
}
export default function SocialMediaDialog({
@@ -39,17 +45,16 @@ export default function SocialMediaDialog({
t,
emailInput,
setEmailInput,
emailError,
setEmailError,
fullScreen,
computedMaxWidth,
verificationCode,
setVerificationCode,
apiError,
isLoading,
dialogStep,
onSendCode,
onConfirmEmail,
}: SocialMediaDialogProps) {
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmailInput(value);
setEmailError(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value));
};
return (
<Dialog
open={open}
@@ -64,7 +69,7 @@ export default function SocialMediaDialog({
sx: {
borderRadius: { xs: 0, sm: 2 },
width: { xs: '100%', sm: '30%' },
height: { xs: '100%', sm: '43%' },
height: 'auto',
},
}}
>
@@ -79,7 +84,7 @@ export default function SocialMediaDialog({
bgcolor: 'background.default',
}}
>
<IconButton onClick={onClose} aria-label="Close">
<IconButton onClick={onClose} aria-label="Close" disabled={isLoading}>
<Icon Component={CloseCircle} size="medium" color="primary.main" />
</IconButton>
{t('settingForm.addEmailButton')}
@@ -105,24 +110,53 @@ export default function SocialMediaDialog({
fullWidth
type="email"
value={emailInput}
onChange={handleEmailChange}
error={emailError}
helperText={emailError ? t('settingForm.emailError') : ''}
onChange={(e) => setEmailInput(e.target.value)}
label={t('settingForm.email')}
placeholder="abc@email.com"
autoComplete="email"
inputMode="email"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
autoFocus
disabled={isLoading || dialogStep === 'enterCode'}
/>
{dialogStep === 'enterCode' && (
<>
<TextField
fullWidth
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
label={t('settingForm.verificationCode')}
autoComplete="one-time-code"
inputMode="numeric"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
autoFocus
disabled={isLoading}
/>
</>
)}
{apiError && (
<Typography color="error" variant="caption" sx={{ mt: 1 }}>
{apiError}
</Typography>
)}
<Button
variant="contained"
fullWidth
sx={{ textTransform: 'none', borderRadius: 2 }}
disabled={emailError || emailInput === ''}
sx={{ textTransform: 'none', borderRadius: 2, mt: 1 }}
disabled={isLoading || (dialogStep === 'enterEmail' && !emailInput)}
onClick={dialogStep === 'enterEmail' ? onSendCode : onConfirmEmail}
>
{t('settingForm.verificationCodeButton')}
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : dialogStep === 'enterEmail' ? (
t('settingForm.verificationCodeButton')
) : (
t('settingForm.confirmAndSave')
)}
</Button>
</DialogContent>
</Dialog>

View File

@@ -13,7 +13,7 @@ interface SocialMediaListProps {
export default function SocialMediaList({ emailList }: SocialMediaListProps) {
return (
<Box sx={{ width: '100%', borderRadius: '8px', p: { xs: 1, sm: 2 } }}>
<Box sx={{ width: '100%', borderRadius: 1, p: { xs: 1, sm: 2 } }}>
{emailList.map((item, index) => (
<Box
key={index}

View File

@@ -1,7 +1,7 @@
export enum Gender {
Male = 'male',
Female = 'female',
None = '',
Male = 1,
Female = 2,
None = 0,
}
export interface InfoRowData {