mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
680 lines
24 KiB
TypeScript
680 lines
24 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Divider,
|
||
Group,
|
||
Paper,
|
||
PasswordInput,
|
||
SimpleGrid,
|
||
Stack,
|
||
Switch,
|
||
Tabs,
|
||
Text,
|
||
TextInput,
|
||
Title,
|
||
UnstyledButton,
|
||
useMantineColorScheme,
|
||
type MantineColorScheme,
|
||
} from '@mantine/core';
|
||
import {
|
||
IconAt,
|
||
IconBell,
|
||
IconCheck,
|
||
IconCircle,
|
||
IconCircleCheckFilled,
|
||
IconDeviceDesktop,
|
||
IconDeviceFloppy,
|
||
IconLayoutNavbar,
|
||
IconLayoutSidebar,
|
||
IconLock,
|
||
IconMail,
|
||
IconMoon,
|
||
IconSettings,
|
||
IconShieldLock,
|
||
IconSun,
|
||
IconUser,
|
||
IconUserCircle,
|
||
} from '@tabler/icons-react';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||
import { useApiMutation } from '@ema-platform/api';
|
||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||
import type { AuthUser } from '@ema-platform/auth';
|
||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||
import { setLayoutMode } from '../../../store/preferences.slice';
|
||
import type { LayoutMode } from '../../../store/preferences.slice';
|
||
import classes from './ProfilePage.module.css';
|
||
|
||
function getInitials(name: string, fallback: string) {
|
||
const source = name?.trim() || fallback?.trim() || '';
|
||
if (!source) return '?';
|
||
const parts = source.split(/\s+/);
|
||
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2);
|
||
return letters.toUpperCase();
|
||
}
|
||
|
||
/** 0–4 rough strength score used by the meter on the security tab. */
|
||
function passwordScore(pw: string) {
|
||
if (!pw) return 0;
|
||
let score = 0;
|
||
if (pw.length >= 8) score++;
|
||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
||
if (/\d/.test(pw)) score++;
|
||
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||
return score;
|
||
}
|
||
|
||
export function ProfilePage() {
|
||
const { t, i18n } = useTranslation();
|
||
const dispatch = useAppDispatch();
|
||
const user = useAppSelector((state) => state.auth.user);
|
||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||
const { handleError } = useErrorHandler();
|
||
|
||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||
const [meTrigger] = useApiMutation<AuthUser>();
|
||
const [passwordTrigger] = useApiMutation<unknown>();
|
||
|
||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||
|
||
// UI-only preferences (no backend wiring yet).
|
||
// Two-step verification is wired but parked for the testing phase: turning it
|
||
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
|
||
// to re-enable it (the login/OTP side already handles `mfaRequired`).
|
||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||
// const {
|
||
// enabled: twoStepEnabled,
|
||
// isLoading: twoStepLoading,
|
||
// isSaving: twoStepSaving,
|
||
// setEnabled: setTwoStepEnabled,
|
||
// } = useTwoFactor();
|
||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||
|
||
// Load the latest profile from the server on mount so the form always
|
||
// reflects the current account information (the cached user may be stale).
|
||
useEffect(() => {
|
||
let active = true;
|
||
meTrigger({ url: '/auth/me', method: 'GET' })
|
||
.unwrap()
|
||
.then((me) => {
|
||
if (active) dispatch(setUser(me));
|
||
})
|
||
.catch(() => {
|
||
/* fall back to the cached user already in the store */
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
// meTrigger/dispatch are stable; run once on mount.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// ---- Profile form ----
|
||
const profileSchema = z.object({
|
||
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||
username: z
|
||
.string()
|
||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
|
||
// other E.164 number is accepted as typed.
|
||
phoneNumber,
|
||
});
|
||
type ProfileValues = z.infer<typeof profileSchema>;
|
||
|
||
const {
|
||
register: registerProfile,
|
||
handleSubmit: handleProfileSubmit,
|
||
reset: resetProfile,
|
||
watch: watchProfile,
|
||
setValue: setValueProfile,
|
||
trigger: triggerProfile,
|
||
formState: { errors: profileErrors },
|
||
} = useForm<ProfileValues>({
|
||
resolver: zodResolver(profileSchema),
|
||
values: {
|
||
nameEn: user?.name?.en ?? '',
|
||
nameAm: user?.name?.am ?? '',
|
||
username: user?.username ?? '',
|
||
email: user?.email ?? '',
|
||
phoneNumber: user?.phoneNumber ?? '',
|
||
},
|
||
});
|
||
|
||
const onSaveProfile = async (values: ProfileValues) => {
|
||
setIsSavingProfile(true);
|
||
try {
|
||
await updateTrigger({
|
||
url: '/auth/update-profile',
|
||
method: 'PATCH',
|
||
body: {
|
||
email: values.email,
|
||
username: values.username,
|
||
phoneNumber: values.phoneNumber,
|
||
name: { am: values.nameAm, en: values.nameEn },
|
||
},
|
||
}).unwrap();
|
||
|
||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||
dispatch(setUser(me));
|
||
|
||
notify.success(t('profile.profileUpdated'));
|
||
} catch (e) {
|
||
handleError(e);
|
||
} finally {
|
||
setIsSavingProfile(false);
|
||
}
|
||
};
|
||
|
||
// ---- Password form ----
|
||
const passwordSchema = z
|
||
.object({
|
||
oldPassword: z
|
||
.string()
|
||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||
newPassword: strongPasswordSchema(12),
|
||
confirmPassword: z
|
||
.string()
|
||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||
})
|
||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||
message: t('profile.validation.passwordMismatch'),
|
||
path: ['confirmPassword'],
|
||
});
|
||
type PasswordValues = z.infer<typeof passwordSchema>;
|
||
|
||
const {
|
||
register: registerPassword,
|
||
handleSubmit: handlePasswordSubmit,
|
||
reset: resetPassword,
|
||
watch: watchPassword,
|
||
formState: { errors: passwordErrors },
|
||
} = useForm<PasswordValues>({
|
||
resolver: zodResolver(passwordSchema),
|
||
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
||
});
|
||
|
||
const onChangePassword = async (values: PasswordValues) => {
|
||
setIsSavingPassword(true);
|
||
try {
|
||
await passwordTrigger({
|
||
url: '/auth/change-password',
|
||
method: 'PATCH',
|
||
body: {
|
||
oldPassword: values.oldPassword,
|
||
newPassword: values.newPassword,
|
||
confirmPassword: values.confirmPassword,
|
||
},
|
||
}).unwrap();
|
||
|
||
notify.success(t('profile.passwordChanged'));
|
||
resetPassword();
|
||
} catch (e) {
|
||
handleError(e);
|
||
} finally {
|
||
setIsSavingPassword(false);
|
||
}
|
||
};
|
||
|
||
const displayName = user?.name?.en || user?.username || '';
|
||
const score = passwordScore(watchPassword('newPassword'));
|
||
const strengthLabels = [
|
||
'',
|
||
t('profile.strength.weak'),
|
||
t('profile.strength.fair'),
|
||
t('profile.strength.good'),
|
||
t('profile.strength.strong'),
|
||
];
|
||
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
||
|
||
const flags: Record<AppLanguage, string> = { en: '\uD83C\uDDEC\uD83C\uDDE7', am: '\uD83C\uDDEA\uD83C\uDDF9' };
|
||
const appearanceOptions: {
|
||
value: MantineColorScheme;
|
||
label: string;
|
||
icon: typeof IconSun;
|
||
}[] = [
|
||
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun },
|
||
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
|
||
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
|
||
];
|
||
|
||
return (
|
||
<Stack gap="lg" maw={900}>
|
||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
|
||
|
||
{/* Profile summary */}
|
||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||
<Group align="center" wrap="nowrap">
|
||
<Box
|
||
w={64}
|
||
h={64}
|
||
style={{
|
||
flexShrink: 0,
|
||
borderRadius: '50%',
|
||
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Text fw={700} size="xl" c="white">
|
||
{getInitials(displayName, user?.email ?? '')}
|
||
</Text>
|
||
</Box>
|
||
|
||
<div>
|
||
<Group gap="xs" align="center">
|
||
<Title order={4}>{displayName || '\u2014'}</Title>
|
||
<Badge
|
||
variant="light"
|
||
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
|
||
size="sm"
|
||
>
|
||
{user?.isPhoneNumberVerified
|
||
? t('profile.verified')
|
||
: t('profile.unverified')}
|
||
</Badge>
|
||
</Group>
|
||
<Group gap={6} mt={2} c="dimmed">
|
||
<IconMail size={14} />
|
||
<Text size="sm" c="dimmed">
|
||
{user?.email}
|
||
</Text>
|
||
</Group>
|
||
</div>
|
||
|
||
<Box style={{ flex: 1 }} />
|
||
|
||
{user?.username && (
|
||
<Badge
|
||
visibleFrom="xs"
|
||
variant="default"
|
||
size="lg"
|
||
radius="xl"
|
||
leftSection={<IconAt size={13} />}
|
||
>
|
||
{user.username}
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
</Paper>
|
||
|
||
{/* Tabs */}
|
||
<Tabs
|
||
defaultValue="profile"
|
||
variant="pills"
|
||
classNames={{ list: classes.list, tab: classes.tab }}
|
||
>
|
||
<Tabs.List>
|
||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||
{t('profile.tabs.profile')}
|
||
</Tabs.Tab>
|
||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||
{t('profile.tabs.security')}
|
||
</Tabs.Tab>
|
||
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
|
||
{t('profile.tabs.preferences')}
|
||
</Tabs.Tab>
|
||
</Tabs.List>
|
||
|
||
{/* ---- Profile ---- */}
|
||
<Tabs.Panel value="profile" pt="md">
|
||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
||
<Stack gap="xl">
|
||
<div>
|
||
<Title order={5}>{t('profile.personal')}</Title>
|
||
<Text size="sm" c="dimmed" mb="md">
|
||
{t('profile.personalHint')}
|
||
</Text>
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||
<TextInput
|
||
label={t('profile.fields.fullNameEn')}
|
||
leftSection={<IconUser size={18} />}
|
||
error={profileErrors.nameEn?.message}
|
||
{...registerProfile('nameEn')}
|
||
/>
|
||
<TextInput
|
||
label={t('profile.fields.fullNameAm')}
|
||
leftSection={<IconUser size={18} />}
|
||
error={profileErrors.nameAm?.message}
|
||
{...registerProfile('nameAm')}
|
||
/>
|
||
<TextInput
|
||
label={t('profile.fields.username')}
|
||
description={t('profile.fields.usernameHint')}
|
||
readOnly
|
||
variant="filled"
|
||
leftSection={<IconAt size={18} />}
|
||
error={profileErrors.username?.message}
|
||
{...registerProfile('username')}
|
||
/>
|
||
</SimpleGrid>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<div>
|
||
<Title order={5} mb="md">
|
||
{t('profile.contact')}
|
||
</Title>
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||
<TextInput
|
||
label={t('profile.fields.email')}
|
||
leftSection={<IconMail size={18} />}
|
||
error={profileErrors.email?.message}
|
||
{...registerProfile('email')}
|
||
/>
|
||
<PhoneInput
|
||
label={t('profile.fields.phone')}
|
||
value={watchProfile('phoneNumber') || ''}
|
||
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
|
||
onBlur={() => triggerProfile('phoneNumber')}
|
||
error={profileErrors.phoneNumber?.message}
|
||
/>
|
||
</SimpleGrid>
|
||
</div>
|
||
|
||
<Group justify="flex-end">
|
||
<Button
|
||
type="button"
|
||
variant="default"
|
||
onClick={() => resetProfile()}
|
||
>
|
||
{t('profile.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
loading={isSavingProfile}
|
||
leftSection={<IconDeviceFloppy size={18} />}
|
||
>
|
||
{t('profile.updateProfile')}
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</form>
|
||
</Paper>
|
||
</Tabs.Panel>
|
||
|
||
{/* ---- Security ---- */}
|
||
<Tabs.Panel value="security" pt="md">
|
||
<Stack gap="lg">
|
||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||
<Stack gap="xl">
|
||
<div>
|
||
<Title order={5}>{t('profile.security')}</Title>
|
||
<Text size="sm" c="dimmed" mb="md">
|
||
{t('profile.securityHint')}
|
||
</Text>
|
||
<Stack gap="md">
|
||
<PasswordInput
|
||
maw={360}
|
||
label={t('profile.fields.currentPassword')}
|
||
leftSection={<IconLock size={18} />}
|
||
error={passwordErrors.oldPassword?.message}
|
||
{...registerPassword('oldPassword')}
|
||
/>
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||
<div>
|
||
<PasswordInput
|
||
label={t('profile.fields.newPassword')}
|
||
leftSection={<IconLock size={18} />}
|
||
error={passwordErrors.newPassword?.message}
|
||
{...registerPassword('newPassword')}
|
||
/>
|
||
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
|
||
</div>
|
||
<PasswordInput
|
||
label={t('profile.fields.confirmPassword')}
|
||
leftSection={<IconLock size={18} />}
|
||
error={passwordErrors.confirmPassword?.message}
|
||
{...registerPassword('confirmPassword')}
|
||
/>
|
||
</SimpleGrid>
|
||
|
||
{score > 0 && (
|
||
<Stack gap={6}>
|
||
<Group justify="space-between">
|
||
<Text size="xs" c="dimmed" fw={600}>
|
||
{t('profile.strength.label')}
|
||
</Text>
|
||
<Text size="xs" fw={600} c={strengthColors[score]}>
|
||
{strengthLabels[score]}
|
||
</Text>
|
||
</Group>
|
||
<Group gap={6} grow>
|
||
{[1, 2, 3, 4].map((i) => (
|
||
<Box
|
||
key={i}
|
||
h={6}
|
||
style={{
|
||
borderRadius: 999,
|
||
backgroundColor:
|
||
i <= score
|
||
? `var(--mantine-color-${strengthColors[score]}-6)`
|
||
: 'var(--mantine-color-default-border)',
|
||
}}
|
||
/>
|
||
))}
|
||
</Group>
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||
<div>
|
||
<Text fw={600}>{t('profile.twoStep.title')}</Text>
|
||
<Text size="sm" c="dimmed">
|
||
{t('profile.twoStep.desc')}
|
||
</Text>
|
||
</div>
|
||
<Switch
|
||
checked={twoStepEnabled}
|
||
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
||
// disabled={twoStepLoading || twoStepSaving}
|
||
// onChange={async (e) => {
|
||
// try {
|
||
// await setTwoStepEnabled(e.currentTarget.checked);
|
||
// notify.success(t('profile.twoStep.saved'));
|
||
// } catch (err) {
|
||
// handleError(err);
|
||
// }
|
||
// }}
|
||
/>
|
||
</Group>
|
||
|
||
<Group justify="flex-end">
|
||
<Button
|
||
type="submit"
|
||
loading={isSavingPassword}
|
||
leftSection={<IconShieldLock size={18} />}
|
||
>
|
||
{t('profile.updatePassword')}
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</form>
|
||
</Paper>
|
||
|
||
<ActiveSessions />
|
||
</Stack>
|
||
</Tabs.Panel>
|
||
|
||
{/* ---- Preferences ---- */}
|
||
<Tabs.Panel value="preferences" pt="md">
|
||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||
<Stack gap="xl">
|
||
<div>
|
||
<Title order={5}>{t('profile.languageTitle')}</Title>
|
||
<Text size="sm" c="dimmed" mb="md">
|
||
{t('profile.languageHint')}
|
||
</Text>
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||
{SUPPORTED_LANGUAGES.map((lng) => {
|
||
const active = i18n.language === lng;
|
||
return (
|
||
<UnstyledButton
|
||
key={lng}
|
||
onClick={() => i18n.changeLanguage(lng)}
|
||
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
||
p="md"
|
||
>
|
||
<Group wrap="nowrap">
|
||
<Text fz={22}>{flags[lng as AppLanguage]}</Text>
|
||
<div style={{ flex: 1 }}>
|
||
<Text fw={600} size="sm">
|
||
{t(`language.${lng}`)}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{lng === 'en' ? 'English (United States)' : 'Amharic'}
|
||
</Text>
|
||
</div>
|
||
{active ? (
|
||
<IconCircleCheckFilled
|
||
size={20}
|
||
color="var(--mantine-color-emaPrimary-6)"
|
||
/>
|
||
) : (
|
||
<IconCircle
|
||
size={20}
|
||
color="var(--mantine-color-dimmed)"
|
||
/>
|
||
)}
|
||
</Group>
|
||
</UnstyledButton>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<div>
|
||
<Title order={5}>{t('profile.appearance.title')}</Title>
|
||
<Text size="sm" c="dimmed" mb="md">
|
||
{t('profile.appearance.subtitle')}
|
||
</Text>
|
||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||
{appearanceOptions.map(({ value, label, icon: Icon }) => {
|
||
const active = colorScheme === value;
|
||
return (
|
||
<UnstyledButton
|
||
key={value}
|
||
onClick={() => setColorScheme(value)}
|
||
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
||
p="md"
|
||
>
|
||
<Group wrap="nowrap">
|
||
<Icon
|
||
size={20}
|
||
color={
|
||
active
|
||
? 'var(--mantine-color-emaPrimary-6)'
|
||
: 'var(--mantine-color-dimmed)'
|
||
}
|
||
/>
|
||
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
||
{label}
|
||
</Text>
|
||
{active && (
|
||
<IconCircleCheckFilled
|
||
size={18}
|
||
color="var(--mantine-color-emaPrimary-6)"
|
||
/>
|
||
)}
|
||
</Group>
|
||
</UnstyledButton>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<div>
|
||
<Title order={5}>{t('profile.layout.title')}</Title>
|
||
<Text size="sm" c="dimmed" mb="md">
|
||
{t('profile.layout.subtitle')}
|
||
</Text>
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||
{([{ value: 'top', label: t('profile.layout.top'), icon: IconLayoutNavbar }, { value: 'sidebar', label: t('profile.layout.sidebar'), icon: IconLayoutSidebar }] as const).map(({ value, label, icon: Icon }) => {
|
||
const active = layoutMode === value;
|
||
return (
|
||
<UnstyledButton
|
||
key={value}
|
||
onClick={() => dispatch(setLayoutMode(value))}
|
||
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
||
p="md"
|
||
>
|
||
<Group wrap="nowrap">
|
||
<Icon
|
||
size={20}
|
||
color={
|
||
active
|
||
? 'var(--mantine-color-emaPrimary-6)'
|
||
: 'var(--mantine-color-dimmed)'
|
||
}
|
||
/>
|
||
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
||
{label}
|
||
</Text>
|
||
{active && (
|
||
<IconCircleCheckFilled
|
||
size={18}
|
||
color="var(--mantine-color-emaPrimary-6)"
|
||
/>
|
||
)}
|
||
</Group>
|
||
</UnstyledButton>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||
<Group gap="sm" wrap="nowrap">
|
||
<IconBell size={20} color="var(--mantine-color-dimmed)" />
|
||
<div>
|
||
<Text fw={600}>{t('profile.notifications.title')}</Text>
|
||
<Text size="sm" c="dimmed">
|
||
{t('profile.notifications.desc')}
|
||
</Text>
|
||
</div>
|
||
</Group>
|
||
<Switch
|
||
checked={emailNotifications}
|
||
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
|
||
/>
|
||
</Group>
|
||
|
||
<Group justify="flex-end">
|
||
<Button
|
||
leftSection={<IconCheck size={18} />}
|
||
onClick={() => notify.success(t('profile.profileUpdated'))}
|
||
>
|
||
{t('profile.savePreferences')}
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Paper>
|
||
</Tabs.Panel>
|
||
</Tabs>
|
||
</Stack>
|
||
);
|
||
}
|