Files
emaui/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx
2026-06-17 13:50:20 +03:00

606 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
IconLock,
IconMail,
IconMoon,
IconPhone,
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 } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { 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 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();
}
/** 04 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 [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).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
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') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
});
type ProfileValues = z.infer<typeof profileSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
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 {
notify.error(t('profile.updateFailed'));
} finally {
setIsSavingProfile(false);
}
};
// ---- Password form ----
const passwordSchema = z
.object({
oldPassword: z
.string()
.min(1, { message: t('profile.validation.passwordMin') }),
newPassword: z
.string()
.min(8, { message: t('profile.validation.passwordMin') }),
confirmPassword: z
.string()
.min(8, { 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 {
notify.error(t('profile.passwordFailed'));
} 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')} />
{/* 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')}
/>
<TextInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/>
</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">
<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">
<PasswordInput
label={t('profile.fields.newPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.newPassword?.message}
{...registerPassword('newPassword')}
/>
<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-gray-2)',
}}
/>
))}
</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)}
/>
</Group>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconShieldLock size={18} />}
>
{t('profile.updatePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</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-gray-4)"
/>
)}
</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-gray-6)'
}
/>
<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-gray-6)" />
<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>
);
}