implement profile page update

This commit is contained in:
Mengisteab
2026-06-11 11:11:58 +00:00
parent 7d91c88216
commit 0ce32f827a
7 changed files with 359 additions and 111 deletions

View File

@@ -8,7 +8,8 @@
"Bash(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.app.json)", "Bash(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.app.json)",
"Bash(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json)", "Bash(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json)",
"Bash(node_modules/.bin/tsc --noEmit -p tsconfig.json)", "Bash(node_modules/.bin/tsc --noEmit -p tsconfig.json)",
"Bash(../../node_modules/.bin/tsc --noEmit -p tsconfig.json)" "Bash(../../node_modules/.bin/tsc --noEmit -p tsconfig.json)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
] ]
} }
} }

View File

@@ -1,10 +1,12 @@
<!-- PLACEHOLDER app logo — white/mono mark for dark (gradient) backgrounds, <!-- EMA logo — white/mono version for dark backgrounds (auth brand panel).
e.g. the auth brand panel. Replace this file (keep the same name/path). --> Same faceted "A" + "ema" wordmark, rendered in white with subtle facet
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EMA logo"> shading so the folds still read. -->
<rect width="48" height="48" rx="11" fill="#ffffff" /> <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ema">
<g transform="translate(12 12)" stroke="#3160b7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"> <polygon points="24,4 8,33 16,33" fill="#ffffff" fill-opacity="0.95" />
<circle cx="12" cy="5" r="2" /> <polygon points="24,4 16,33 24,33" fill="#ffffff" fill-opacity="0.78" />
<line x1="12" y1="22" x2="12" y2="7" /> <polygon points="24,4 24,33 32,33" fill="#ffffff" fill-opacity="0.55" />
<path d="M5 12H2a10 10 0 0 0 20 0h-3" /> <polygon points="24,4 32,33 40,33" fill="#ffffff" fill-opacity="0.88" />
</g> <text x="24" y="46" text-anchor="middle"
font-family="Inter, 'Segoe UI', Arial, sans-serif"
font-size="13" font-weight="700" letter-spacing="-0.5" fill="#ffffff">ema</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 613 B

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -1,17 +1,14 @@
<!-- PLACEHOLDER app logo — color mark for light backgrounds. Replace this file <!-- EMA logo — colour version (faceted "A" peak + "ema" wordmark).
(keep the same name/path) with the real EMA mark. SVG or swap for PNG via Hand-built vector approximation of the brand mark; tweak the facet points
the LOGO map in src/app/components/Logo.tsx. --> or colours below to fine-tune. Replace this file to swap in official art. -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EMA logo"> <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ema">
<defs> <!-- faceted A / peak -->
<linearGradient id="emaMark" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse"> <polygon points="24,4 8,33 16,33" fill="#8db0ee" />
<stop stop-color="#3160b7" /> <polygon points="24,4 16,33 24,33" fill="#4b7fe5" />
<stop offset="1" stop-color="#1fc29d" /> <polygon points="24,4 24,33 32,33" fill="#1c2c4a" />
</linearGradient> <polygon points="24,4 32,33 40,33" fill="#3160b7" />
</defs> <!-- wordmark -->
<rect width="48" height="48" rx="11" fill="url(#emaMark)" /> <text x="24" y="46" text-anchor="middle"
<g transform="translate(12 12)" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"> font-family="Inter, 'Segoe UI', Arial, sans-serif"
<circle cx="12" cy="5" r="2" /> font-size="13" font-weight="700" letter-spacing="-0.5" fill="#3160b7">ema</text>
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 893 B

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -1,15 +1,7 @@
<!-- PLACEHOLDER favicon (browser tab). Replace with the real mark; keep name. --> <!-- EMA favicon — emblem only (no wordmark, for legibility at small sizes). -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs> <polygon points="24,3 5,45 16,45" fill="#8db0ee" />
<linearGradient id="favMark" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse"> <polygon points="24,3 16,45 24,45" fill="#4b7fe5" />
<stop stop-color="#3160b7" /> <polygon points="24,3 24,45 32,45" fill="#1c2c4a" />
<stop offset="1" stop-color="#1fc29d" /> <polygon points="24,3 32,45 43,45" fill="#3160b7" />
</linearGradient>
</defs>
<rect width="48" height="48" rx="11" fill="url(#favMark)" />
<g transform="translate(12 12)" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 728 B

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -1,45 +1,196 @@
import { useState } from 'react';
import { import {
Avatar, Avatar,
Badge,
Button, Button,
Divider,
Group, Group,
Paper, Paper,
PasswordInput,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text,
TextInput, TextInput,
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceFloppy } from '@tabler/icons-react'; import {
IconDeviceFloppy,
IconLock,
IconMail,
IconPhone,
IconUser,
} 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 { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { PageHeader } from '../../../components/PageHeader'; import { PageHeader } from '../../../components/PageHeader';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '../../auth/store/auth.slice';
import type { AuthUser } from '../../auth/types/auth.types';
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();
}
export function ProfilePage() { export function ProfilePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const handleSave = () => notify.success(t('common.saved')); const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
// ---- 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,
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();
// Refresh the cached user so the rest of the app stays in sync.
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,
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 || '';
return ( return (
<Stack gap="lg" maw={820}> <Stack gap="lg" maw={820}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} /> <PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
{/* Profile details */}
<Paper p="lg" shadow="sm" radius="md" withBorder> <Paper p="lg" shadow="sm" radius="md" withBorder>
<Group mb="lg"> <Group mb="lg" align="center">
<Avatar color="emaPrimary" radius="xl" size={64}> <Avatar color="emaPrimary" radius="xl" size={64}>
BN {getInitials(displayName, user?.email ?? '')}
</Avatar> </Avatar>
<div> <div>
<Title order={4}>Blue Nile Shipping PLC</Title> <Group gap="xs" align="center">
<TextInput <Title order={4}>{displayName || '—'}</Title>
variant="unstyled" <Badge
defaultValue="muluhabit@gmail.com" variant="light"
readOnly color={user?.isPhoneNumberVerified ? 'teal' : 'gray'}
size="sm" size="sm"
/> >
{user?.isPhoneNumberVerified
? t('profile.verified')
: t('profile.unverified')}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{user?.email}
</Text>
</div> </div>
</Group> </Group>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="lg"> <Stack gap="lg">
<div> <div>
<Title order={5} mb="sm"> <Title order={5} mb="sm">
@@ -47,12 +198,21 @@ export function ProfilePage() {
</Title> </Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput <TextInput
label={t('profile.fields.fullName')} label={t('profile.fields.fullNameEn')}
defaultValue="Dawit Bekele" leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
/> />
<TextInput <TextInput
label={t('profile.fields.organization')} label={t('profile.fields.fullNameAm')}
defaultValue="Blue Nile Shipping PLC" leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
error={profileErrors.username?.message}
{...registerProfile('username')}
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>
@@ -64,20 +224,79 @@ export function ProfilePage() {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput <TextInput
label={t('profile.fields.email')} label={t('profile.fields.email')}
defaultValue="muluhabit@gmail.com" leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
/> />
<TextInput <TextInput
label={t('profile.fields.phone')} label={t('profile.fields.phone')}
defaultValue="+251 91 234 5678" leftSection={<IconPhone size={18} />}
/> error={profileErrors.phoneNumber?.message}
<TextInput {...registerProfile('phoneNumber')}
label={t('profile.fields.address')}
defaultValue="Addis Ababa, Ethiopia"
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>
<div> <Group>
<Button
type="submit"
loading={isSavingProfile}
leftSection={<IconDeviceFloppy size={18} />}
>
{t('profile.updateProfile')}
</Button>
</Group>
</Stack>
</form>
</Paper>
{/* Change password */}
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={5}>{t('profile.security')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.securityHint')}
</Text>
<Divider mb="md" />
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<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>
<Group>
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconLock size={18} />}
>
{t('profile.changePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
{/* Preferences */}
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={5} mb="sm"> <Title order={5} mb="sm">
{t('profile.preferences')} {t('profile.preferences')}
</Title> </Title>
@@ -92,17 +311,6 @@ export function ProfilePage() {
onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)} onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)}
allowDeselect={false} allowDeselect={false}
/> />
</div>
<Group>
<Button
leftSection={<IconDeviceFloppy size={18} />}
onClick={handleSave}
>
{t('common.save')}
</Button>
</Group>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
); );

View File

@@ -229,13 +229,37 @@ export const am: Translations = {
personal: 'የግል መረጃ', personal: 'የግል መረጃ',
contact: 'መገናኛ', contact: 'መገናኛ',
preferences: 'ምርጫዎች', preferences: 'ምርጫዎች',
security: 'ደህንነት',
securityHint: 'በሌላ ቦታ የማይጠቀሙበትን ጠንካራ የይለፍ ቃል ይምረጡ።',
changePassword: 'የይለፍ ቃል ይቀይሩ',
updateProfile: 'ለውጦችን አስቀምጥ',
verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም',
profileUpdated: 'መገለጫ በተሳካ ሁኔታ ተዘምኗል',
passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል',
updateFailed: 'መገለጫን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
passwordFailed:
'የይለፍ ቃል መቀየር አልተቻለም። የአሁኑን የይለፍ ቃል ያረጋግጡና እንደገና ይሞክሩ።',
fields: { fields: {
fullName: 'ሙሉ ስም', fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)',
fullNameAm: 'ሙሉ ስም (አማርኛ)',
username: 'የተጠቃሚ ስም',
organization: 'ድርጅት', organization: 'ድርጅት',
email: 'የኢሜይል አድራሻ', email: 'የኢሜይል አድራሻ',
phone: 'ስልክ ቁጥር', phone: 'ስልክ ቁጥር',
address: 'አድራሻ', address: 'አድራሻ',
language: 'የሚመረጥ ቋንቋ', language: 'የሚመረጥ ቋንቋ',
currentPassword: 'የአሁኑ የይለፍ ቃል',
newPassword: 'አዲስ የይለፍ ቃል',
confirmPassword: 'አዲሱን የይለፍ ቃል ያረጋግጡ',
},
validation: {
nameRequired: 'ስም ያስፈልጋል',
emailInvalid: 'ትክክለኛ ኢሜይል ያስገቡ',
usernameRequired: 'የተጠቃሚ ስም ያስፈልጋል',
phoneRequired: 'ስልክ ቁጥር ያስፈልጋል',
passwordMin: 'የይለፍ ቃል ቢያንስ 8 ቁምፊዎች መሆን አለበት',
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
}, },
}, },

View File

@@ -228,13 +228,37 @@ export const en = {
personal: 'Personal information', personal: 'Personal information',
contact: 'Contact', contact: 'Contact',
preferences: 'Preferences', preferences: 'Preferences',
security: 'Security',
securityHint: 'Choose a strong password you do not use anywhere else.',
changePassword: 'Change password',
updateProfile: 'Save changes',
verified: 'Verified',
unverified: 'Unverified',
profileUpdated: 'Profile updated successfully',
passwordChanged: 'Password changed successfully',
updateFailed: 'Could not update profile. Please try again.',
passwordFailed:
'Could not change password. Check your current password and try again.',
fields: { fields: {
fullName: 'Full name', fullNameEn: 'Full name (English)',
fullNameAm: 'Full name (Amharic)',
username: 'Username',
organization: 'Organization', organization: 'Organization',
email: 'Email address', email: 'Email address',
phone: 'Phone number', phone: 'Phone number',
address: 'Address', address: 'Address',
language: 'Preferred language', language: 'Preferred language',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm new password',
},
validation: {
nameRequired: 'Name is required',
emailInvalid: 'Enter a valid email',
usernameRequired: 'Username is required',
phoneRequired: 'Phone number is required',
passwordMin: 'Password must be at least 8 characters',
passwordMismatch: 'Passwords do not match',
}, },
}, },