diff --git a/.claude/settings.json b/.claude/settings.json index 96553853b..c8f10648e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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.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\\)$\")" ] } } diff --git a/apps/portal/public/brand/logo-mark-white.svg b/apps/portal/public/brand/logo-mark-white.svg index 2b942ff39..fb40d3754 100644 --- a/apps/portal/public/brand/logo-mark-white.svg +++ b/apps/portal/public/brand/logo-mark-white.svg @@ -1,10 +1,12 @@ - - - - - - - - + + + + + + + ema diff --git a/apps/portal/public/brand/logo-mark.svg b/apps/portal/public/brand/logo-mark.svg index 24db43408..605061117 100644 --- a/apps/portal/public/brand/logo-mark.svg +++ b/apps/portal/public/brand/logo-mark.svg @@ -1,17 +1,14 @@ - - - - - - - - - - - - - - + + + + + + + + + ema diff --git a/apps/portal/public/favicon.svg b/apps/portal/public/favicon.svg index af58d0ae0..9b9394073 100644 --- a/apps/portal/public/favicon.svg +++ b/apps/portal/public/favicon.svg @@ -1,15 +1,7 @@ - + - - - - - - - - - - - - + + + + diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 0817e8dea..d3955d4e7 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -1,108 +1,316 @@ +import { useState } from 'react'; import { Avatar, + Badge, Button, + Divider, Group, Paper, + PasswordInput, Select, SimpleGrid, Stack, + Text, TextInput, Title, } 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 { notify } from '@ema-platform/ui'; +import { useApiMutation } from '@ema-platform/api'; import { PageHeader } from '../../../components/PageHeader'; 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() { const { t, i18n } = useTranslation(); + const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.auth.user); - const handleSave = () => notify.success(t('common.saved')); + const [updateTrigger] = useApiMutation(); + const [meTrigger] = useApiMutation(); + const [passwordTrigger] = useApiMutation(); + + 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; + + const { + register: registerProfile, + handleSubmit: handleProfileSubmit, + formState: { errors: profileErrors }, + } = useForm({ + 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; + + const { + register: registerPassword, + handleSubmit: handlePasswordSubmit, + reset: resetPassword, + formState: { errors: passwordErrors }, + } = useForm({ + 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 ( + {/* Profile details */} - + - BN + {getInitials(displayName, user?.email ?? '')}
- Blue Nile Shipping PLC - + + {displayName || '—'} + + {user?.isPhoneNumberVerified + ? t('profile.verified') + : t('profile.unverified')} + + + + {user?.email} +
- -
- - {t('profile.personal')} - - - - - -
+
+ +
+ + {t('profile.personal')} + + + } + error={profileErrors.nameEn?.message} + {...registerProfile('nameEn')} + /> + } + error={profileErrors.nameAm?.message} + {...registerProfile('nameAm')} + /> + + +
-
- - {t('profile.contact')} - - - - - - -
+
+ + {t('profile.contact')} + + + } + error={profileErrors.email?.message} + {...registerProfile('email')} + /> + } + error={profileErrors.phoneNumber?.message} + {...registerProfile('phoneNumber')} + /> + +
-
- - {t('profile.preferences')} - - ({ + value: lng, + label: t(`language.${lng}`), + }))} + value={i18n.language} + onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)} + allowDeselect={false} + /> ); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index f811107dd..8c582d69b 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -229,13 +229,37 @@ export const am: Translations = { personal: 'የግል መረጃ', contact: 'መገናኛ', preferences: 'ምርጫዎች', + security: 'ደህንነት', + securityHint: 'በሌላ ቦታ የማይጠቀሙበትን ጠንካራ የይለፍ ቃል ይምረጡ።', + changePassword: 'የይለፍ ቃል ይቀይሩ', + updateProfile: 'ለውጦችን አስቀምጥ', + verified: 'ተረጋግጧል', + unverified: 'አልተረጋገጠም', + profileUpdated: 'መገለጫ በተሳካ ሁኔታ ተዘምኗል', + passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል', + updateFailed: 'መገለጫን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።', + passwordFailed: + 'የይለፍ ቃል መቀየር አልተቻለም። የአሁኑን የይለፍ ቃል ያረጋግጡና እንደገና ይሞክሩ።', fields: { - fullName: 'ሙሉ ስም', + fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)', + fullNameAm: 'ሙሉ ስም (አማርኛ)', + username: 'የተጠቃሚ ስም', organization: 'ድርጅት', email: 'የኢሜይል አድራሻ', phone: 'ስልክ ቁጥር', address: 'አድራሻ', language: 'የሚመረጥ ቋንቋ', + currentPassword: 'የአሁኑ የይለፍ ቃል', + newPassword: 'አዲስ የይለፍ ቃል', + confirmPassword: 'አዲሱን የይለፍ ቃል ያረጋግጡ', + }, + validation: { + nameRequired: 'ስም ያስፈልጋል', + emailInvalid: 'ትክክለኛ ኢሜይል ያስገቡ', + usernameRequired: 'የተጠቃሚ ስም ያስፈልጋል', + phoneRequired: 'ስልክ ቁጥር ያስፈልጋል', + passwordMin: 'የይለፍ ቃል ቢያንስ 8 ቁምፊዎች መሆን አለበት', + passwordMismatch: 'የይለፍ ቃላት አይዛመዱም', }, }, diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 08a9e1a73..f6c6387d8 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -228,13 +228,37 @@ export const en = { personal: 'Personal information', contact: 'Contact', 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: { - fullName: 'Full name', + fullNameEn: 'Full name (English)', + fullNameAm: 'Full name (Amharic)', + username: 'Username', organization: 'Organization', email: 'Email address', phone: 'Phone number', address: 'Address', 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', }, },