refactor: modularize profile setup components and update profile completion logic commit

This commit is contained in:
mengstabketemaw
2026-06-26 16:12:25 +03:00
parent 08ade42157
commit ac8df4cdff
6 changed files with 624 additions and 310 deletions

View File

@@ -1,12 +1,17 @@
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { authStorage } from '@ema-platform/auth'; import { authStorage } from '@ema-platform/auth';
export function ProfileGuard() { interface ProfileGuardProps {
children?: ReactNode;
}
export function ProfileGuard({ children }: ProfileGuardProps) {
const profileId = authStorage.getProfileId(); const profileId = authStorage.getProfileId();
if (!profileId) { if (!profileId) {
return <Navigate to="/profile-setup" replace />; return <Navigate to="/profile-setup" replace />;
} }
return <Outlet />; return <>{children}</>;
} }

View File

@@ -4,13 +4,9 @@ import {
Button, Button,
Center, Center,
Group, Group,
Loader,
Paper, Paper,
Select,
SimpleGrid,
Stack, Stack,
Text, Text,
TextInput,
Title, Title,
rem, rem,
} from '@mantine/core'; } from '@mantine/core';
@@ -25,56 +21,27 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { authStorage, setUser, logout } from '@ema-platform/auth'; import { authStorage, setUser, logout } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
const GENDERS = ['MALE', 'FEMALE']; ProfileFormContent,
const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED']; profileSchema,
const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE']; type ProfileValues,
} from '../../profile/components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../../profile/components/AddressFormContent';
const STEPS = [ const STEPS = [
{ label: 'Profile', icon: IconUser }, { label: 'Profile', icon: IconUser },
{ label: 'Address', icon: IconMapPin }, { label: 'Address', icon: IconMapPin },
]; ];
const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'),
firstName: z.string().min(3, 'First name must be at least 3 characters'),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
gender: z.string().min(1, 'Select your gender'),
dob: z.string().min(1, 'Select your date of birth'),
pob: z.string().optional(),
maritalStatus: z.string().min(1, 'Select your marital status'),
});
type ProfileValues = z.infer<typeof profileSchema>;
const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
idNumber: z.string().min(1, 'Enter ID number'),
nationality: z.string().min(1, 'Enter nationality'),
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
secondaryPhoneNumber: z.string().optional(),
email: z.string().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subcityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().optional(),
postalAddress: z.string().optional(),
emergencyContactName: z.string().optional(),
emergencyContactPhone: z.string().optional(),
emergencyContactRelation: z.string().optional(),
});
type AddressValues = z.infer<typeof addressSchema>;
function StepIndicator({ active, completed }: { active: number; completed: number[] }) { function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return ( return (
<Box mb={32}> <Box mb={32}>
@@ -160,7 +127,7 @@ export function ProfileSetupPage() {
const [profileTrigger] = useApiMutation<{ id: string }>(); const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>(); const [addressTrigger] = useApiMutation<unknown>();
const [meTrigger] = useApiMutation<{ id: string }>(); const [meTrigger] = useApiMutation<{ id: string }>();
const [profileCheckTrigger] = useApiMutation<{ count: number; items: Array<{ id: string; userId: string }> }>(); const [profileCheckTrigger] = useApiMutation<{ total: number; items: Array<{ id: string; user: { id: string }; isComplete: boolean }> }>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>(); const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const fetched = useRef(false); const fetched = useRef(false);
@@ -179,11 +146,11 @@ export function ProfileSetupPage() {
useEffect(() => { useEffect(() => {
if (!user || checkedExistingProfile.current) return; if (!user || checkedExistingProfile.current) return;
checkedExistingProfile.current = true; checkedExistingProfile.current = true;
profileCheckTrigger({ url: '/profiles?take=1&skip=0', method: 'GET' }) profileCheckTrigger({ url: `/profiles?q=${encodeURIComponent('i=user')}`, method: 'GET' })
.unwrap() .unwrap()
.then((data) => { .then((data) => {
const existing = data.items?.find((p) => p.userId === user.id); const existing = data.items?.find((p) => p.user?.id === user.id);
if (existing) { if (existing?.isComplete) {
authStorage.setProfileId(existing.id); authStorage.setProfileId(existing.id);
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
} }
@@ -292,10 +259,16 @@ export function ProfileSetupPage() {
maritalStatus: pv.maritalStatus, maritalStatus: pv.maritalStatus,
}, },
}).unwrap(); }).unwrap();
await profileTrigger({
url: `/profiles/${profileResult.id}`,
method: 'PUT',
body: { isComplete: true },
}).unwrap();
authStorage.setProfileId(profileResult.id); authStorage.setProfileId(profileResult.id);
await addressTrigger({ await addressTrigger({
url: `/addresses/profile/${profileResult.id}`, url: `/addresss/profile/${profileResult.id}`,
method: 'POST', method: 'POST',
body: { body: {
idType: av.idType, idType: av.idType,
@@ -355,78 +328,15 @@ export function ProfileSetupPage() {
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm"> <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information Personal Information
</Text> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <ProfileFormContent
<Select register={profileRegister}
label="Profession" errors={profileErrors}
placeholder={professionsLoading ? 'Loading...' : 'Select'} setValue={profileSetValue}
required watch={profileWatch}
data={professionOptions} trigger={profileTriggerValidation}
error={profileErrors.professionId?.message} professionsLoading={professionsLoading}
value={profileWatch('professionId')} professionOptions={professionOptions}
onChange={(val) => profileSetValue('professionId', val || '', { shouldValidate: true })} />
onBlur={() => profileTriggerValidation('professionId')}
name="professionId"
searchable
disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
/>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...profileRegister('firstName')}
error={profileErrors.firstName?.message}
/>
<TextInput
label="Middle Name"
placeholder="Enter middle name"
required
{...profileRegister('middleName')}
error={profileErrors.middleName?.message}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...profileRegister('lastName')}
error={profileErrors.lastName?.message}
/>
<Select
label="Gender"
placeholder="Select"
required
data={GENDERS}
error={profileErrors.gender?.message}
value={profileWatch('gender')}
onChange={(val) => profileSetValue('gender', val || '', { shouldValidate: true })}
onBlur={() => profileTriggerValidation('gender')}
name="gender"
/>
<TextInput
label="Date of Birth"
type="date"
required
{...profileRegister('dob')}
error={profileErrors.dob?.message}
/>
<TextInput
label="Place of Birth"
placeholder="City, Region"
{...profileRegister('pob')}
error={profileErrors.pob?.message}
/>
<Select
label="Marital Status"
placeholder="Select"
required
data={MARITAL_STATUSES}
error={profileErrors.maritalStatus?.message}
value={profileWatch('maritalStatus')}
onChange={(val) => profileSetValue('maritalStatus', val || '', { shouldValidate: true })}
onBlur={() => profileTriggerValidation('maritalStatus')}
name="maritalStatus"
/>
</SimpleGrid>
</> </>
)} )}
@@ -435,119 +345,13 @@ export function ProfileSetupPage() {
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm"> <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact Identity & Contact
</Text> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <AddressFormContent
<Select register={addressRegister}
label="ID Type" errors={addressErrors}
placeholder="Select" setValue={addressSetValue}
required watch={addressWatch}
data={ID_TYPES} trigger={addressTriggerValidation}
error={addressErrors.idType?.message} />
value={addressWatch('idType')}
onChange={(val) => addressSetValue('idType', val || '', { shouldValidate: true })}
onBlur={() => addressTriggerValidation('idType')}
name="idType"
/>
<TextInput
label="ID Number"
placeholder="Enter ID number"
required
{...addressRegister('idNumber')}
error={addressErrors.idNumber?.message}
/>
<TextInput
label="Nationality"
placeholder="e.g. Ethiopian"
required
{...addressRegister('nationality')}
error={addressErrors.nationality?.message}
/>
<TextInput
label="Primary Phone"
placeholder="+251 9XX XXX XXX"
required
{...addressRegister('primaryPhoneNumber')}
error={addressErrors.primaryPhoneNumber?.message}
/>
<TextInput
label="Secondary Phone"
placeholder="+251 9XX XXX XXX"
{...addressRegister('secondaryPhoneNumber')}
error={addressErrors.secondaryPhoneNumber?.message}
/>
<TextInput
label="Email"
type="email"
placeholder="email@example.com"
{...addressRegister('email')}
error={addressErrors.email?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Region ID"
placeholder="Region UUID (optional)"
{...addressRegister('regionId')}
error={addressErrors.regionId?.message}
/>
<TextInput
label="City ID"
placeholder="City UUID (optional)"
{...addressRegister('cityId')}
error={addressErrors.cityId?.message}
/>
<TextInput
label="Subcity ID"
placeholder="Subcity UUID (optional)"
{...addressRegister('subcityId')}
error={addressErrors.subcityId?.message}
/>
<TextInput
label="Woreda ID"
placeholder="Woreda UUID (optional)"
{...addressRegister('woredaId')}
error={addressErrors.woredaId?.message}
/>
<TextInput
label="Kebele ID"
placeholder="Kebele UUID (optional)"
{...addressRegister('kebeleId')}
error={addressErrors.kebeleId?.message}
/>
<TextInput
label="Street Address"
placeholder="Street name, house number"
{...addressRegister('streetAddress')}
error={addressErrors.streetAddress?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Contact Name"
placeholder="Full name"
{...addressRegister('emergencyContactName')}
error={addressErrors.emergencyContactName?.message}
/>
<TextInput
label="Contact Phone"
placeholder="+251 9XX XXX XXX"
{...addressRegister('emergencyContactPhone')}
error={addressErrors.emergencyContactPhone?.message}
/>
<TextInput
label="Relationship"
placeholder="Spouse, Parent, etc."
{...addressRegister('emergencyContactRelation')}
error={addressErrors.emergencyContactRelation?.message}
/>
</SimpleGrid>
</> </>
)} )}

View File

@@ -0,0 +1,160 @@
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
export const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
idNumber: z.string().min(1, 'Enter ID number'),
nationality: z.string().min(1, 'Enter nationality'),
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
secondaryPhoneNumber: z.string().optional(),
email: z.string().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subcityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().optional(),
postalAddress: z.string().optional(),
emergencyContactName: z.string().optional(),
emergencyContactPhone: z.string().optional(),
emergencyContactRelation: z.string().optional(),
});
export type AddressValues = z.infer<typeof addressSchema>;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
interface AddressFormContentProps {
register: UseFormRegister<AddressValues>;
errors: FieldErrors<AddressValues>;
setValue: UseFormSetValue<AddressValues>;
watch: UseFormWatch<AddressValues>;
trigger: UseFormTrigger<AddressValues>;
}
export function AddressFormContent({
register,
errors,
setValue,
watch,
trigger,
}: AddressFormContentProps) {
return (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="ID Type"
placeholder="Select"
required
data={[...ID_TYPES]}
error={errors.idType?.message}
value={watch('idType')}
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
onBlur={() => trigger('idType')}
name="idType"
/>
<TextInput
label="ID Number"
placeholder="Enter ID number"
required
{...register('idNumber')}
error={errors.idNumber?.message}
/>
<TextInput
label="Nationality"
placeholder="e.g. Ethiopian"
required
{...register('nationality')}
error={errors.nationality?.message}
/>
<TextInput
label="Primary Phone"
placeholder="+251 9XX XXX XXX"
required
{...register('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<TextInput
label="Secondary Phone"
placeholder="+251 9XX XXX XXX"
{...register('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
label="Email"
type="email"
placeholder="email@example.com"
{...register('email')}
error={errors.email?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Region ID"
placeholder="Region UUID (optional)"
{...register('regionId')}
error={errors.regionId?.message}
/>
<TextInput
label="City ID"
placeholder="City UUID (optional)"
{...register('cityId')}
error={errors.cityId?.message}
/>
<TextInput
label="Subcity ID"
placeholder="Subcity UUID (optional)"
{...register('subcityId')}
error={errors.subcityId?.message}
/>
<TextInput
label="Woreda ID"
placeholder="Woreda UUID (optional)"
{...register('woredaId')}
error={errors.woredaId?.message}
/>
<TextInput
label="Kebele ID"
placeholder="Kebele UUID (optional)"
{...register('kebeleId')}
error={errors.kebeleId?.message}
/>
<TextInput
label="Street Address"
placeholder="Street name, house number"
{...register('streetAddress')}
error={errors.streetAddress?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Contact Name"
placeholder="Full name"
{...register('emergencyContactName')}
error={errors.emergencyContactName?.message}
/>
<TextInput
label="Contact Phone"
placeholder="+251 9XX XXX XXX"
{...register('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput
label="Relationship"
placeholder="Spouse, Parent, etc."
{...register('emergencyContactRelation')}
error={errors.emergencyContactRelation?.message}
/>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,115 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
export const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'),
firstName: z.string().min(3, 'First name must be at least 3 characters'),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
gender: z.string().min(1, 'Select your gender'),
dob: z.string().min(1, 'Select your date of birth'),
pob: z.string().optional(),
maritalStatus: z.string().min(1, 'Select your marital status'),
});
export type ProfileValues = z.infer<typeof profileSchema>;
export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
interface ProfileFormContentProps {
register: UseFormRegister<ProfileValues>;
errors: FieldErrors<ProfileValues>;
setValue: UseFormSetValue<ProfileValues>;
watch: UseFormWatch<ProfileValues>;
trigger: UseFormTrigger<ProfileValues>;
professionsLoading: boolean;
professionOptions: Array<{ value: string; label: string }>;
}
export function ProfileFormContent({
register,
errors,
setValue,
watch,
trigger,
professionsLoading,
professionOptions,
}: ProfileFormContentProps) {
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="Profession"
placeholder={professionsLoading ? 'Loading...' : 'Select'}
required
data={professionOptions}
error={errors.professionId?.message}
value={watch('professionId')}
onChange={(val) => setValue('professionId', val || '', { shouldValidate: true })}
onBlur={() => trigger('professionId')}
name="professionId"
searchable
disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
/>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...register('firstName')}
error={errors.firstName?.message}
/>
<TextInput
label="Middle Name"
placeholder="Enter middle name"
required
{...register('middleName')}
error={errors.middleName?.message}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...register('lastName')}
error={errors.lastName?.message}
/>
<Select
label="Gender"
placeholder="Select"
required
data={[...GENDERS]}
error={errors.gender?.message}
value={watch('gender')}
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
onBlur={() => trigger('gender')}
name="gender"
/>
<TextInput
label="Date of Birth"
type="date"
required
{...register('dob')}
error={errors.dob?.message}
/>
<TextInput
label="Place of Birth"
placeholder="City, Region"
{...register('pob')}
error={errors.pob?.message}
/>
<Select
label="Marital Status"
placeholder="Select"
required
data={[...MARITAL_STATUSES]}
error={errors.maritalStatus?.message}
value={watch('maritalStatus')}
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}
onBlur={() => trigger('maritalStatus')}
name="maritalStatus"
/>
</SimpleGrid>
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Badge, Badge,
Box, Box,
Button, Button,
Center,
Divider, Divider,
Group, Group,
Loader,
Paper, Paper,
PasswordInput, PasswordInput,
SimpleGrid, SimpleGrid,
@@ -28,6 +30,7 @@ import {
IconDeviceFloppy, IconDeviceFloppy,
IconLock, IconLock,
IconMail, IconMail,
IconMapPin,
IconMoon, IconMoon,
IconPhone, IconPhone,
IconSettings, IconSettings,
@@ -42,10 +45,20 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui'; import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { authStorage, setUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth';
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../components/AddressFormContent';
import classes from './ProfilePage.module.css'; import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) { function getInitials(name: string, fallback: string) {
@@ -56,7 +69,6 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase(); return letters.toUpperCase();
} }
/** 04 rough strength score used by the meter on the security tab. */
function passwordScore(pw: string) { function passwordScore(pw: string) {
if (!pw) return 0; if (!pw) return 0;
let score = 0; let score = 0;
@@ -76,16 +88,102 @@ export function ProfilePage() {
const [updateTrigger] = useApiMutation<AuthUser>(); const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>(); const [passwordTrigger] = useApiMutation<unknown>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const [isSavingProfile, setIsSavingProfile] = useState(false); const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
const [isSavingAddress, setIsSavingAddress] = useState(false);
// UI-only preferences (no backend wiring yet).
const [twoStepEnabled, setTwoStepEnabled] = useState(false); const [twoStepEnabled, setTwoStepEnabled] = useState(false);
const [emailNotifications, setEmailNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true);
// Load the latest profile from the server on mount so the form always // ---- Profession list (for Profile tab) ----
// reflects the current account information (the cached user may be stale). const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const professionsFetched = useRef(false);
useEffect(() => {
if (professionsFetched.current) return;
professionsFetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
);
const professionNameMap = useMemo(() => {
const map: Record<string, string> = {};
professions.forEach((p) => { map[p.id] = p.name.en; });
return map;
}, [professions]);
// ---- Fetch profile data (for Profile & Address tabs) ----
const [fetchProfile] = useApiMutation<Record<string, any>>();
const [updateProfile] = useApiMutation<unknown>();
const [updateAddress] = useApiMutation<unknown>();
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
const [addressId, setAddressId] = useState<string | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const profileId = authStorage.getProfileId();
useEffect(() => {
if (!profileId) {
setDataLoading(false);
return;
}
fetchProfile({ url: `/profiles/${profileId}?i=address,profession`, method: 'GET' })
.unwrap()
.then((data) => {
setLoadedProfile({
professionId: data.profession?.id || '',
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
gender: data.gender || '',
dob: data.dob ? data.dob.split('T')[0] : '',
pob: data.pob || '',
maritalStatus: data.maritalStatus || '',
});
if (data.address) {
setAddressId(data.address.id);
setLoadedAddress({
idType: data.address.idType || '',
idNumber: data.address.idNumber || '',
nationality: data.address.nationality || '',
primaryPhoneNumber: data.address.primaryPhoneNumber || '',
secondaryPhoneNumber: data.address.secondaryPhoneNumber || '',
email: data.address.email || '',
regionId: data.address.regionId || '',
cityId: data.address.cityId || '',
subcityId: data.address.subCityId || '',
woredaId: data.address.woredaId || '',
kebeleId: data.address.kebeleId || '',
streetAddress: data.address.streetAddress || '',
postalAddress: data.address.postalAddress || '',
emergencyContactName: data.address.emergencyContactName || '',
emergencyContactPhone: data.address.emergencyContactPhone || '',
emergencyContactRelation: data.address.emergencycontactRelation || '',
});
}
setDataLoading(false);
})
.catch(() => {
setDataLoading(false);
});
}, [profileId, fetchProfile]);
// Load the latest user from the server on mount
useEffect(() => { useEffect(() => {
let active = true; let active = true;
meTrigger({ url: '/auth/me', method: 'GET' }) meTrigger({ url: '/auth/me', method: 'GET' })
@@ -93,37 +191,28 @@ export function ProfilePage() {
.then((me) => { .then((me) => {
if (active) dispatch(setUser(me)); if (active) dispatch(setUser(me));
}) })
.catch(() => { .catch(() => {});
/* fall back to the cached user already in the store */ return () => { active = false; };
});
return () => {
active = false;
};
// meTrigger/dispatch are stable; run once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// ---- Profile form ---- // ---- Personal form (auth user data) ----
const profileSchema = z.object({ const personalSchema = z.object({
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
.string()
.min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }), email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
}); });
type ProfileValues = z.infer<typeof profileSchema>; type PersonalValues = z.infer<typeof personalSchema>;
const { const {
register: registerProfile, register: registerPersonal,
handleSubmit: handleProfileSubmit, handleSubmit: handlePersonalSubmit,
reset: resetProfile, reset: resetPersonal,
formState: { errors: profileErrors }, formState: { errors: personalErrors },
} = useForm<ProfileValues>({ } = useForm<PersonalValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(personalSchema),
values: { values: {
nameEn: user?.name?.en ?? '', nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '', nameAm: user?.name?.am ?? '',
@@ -133,7 +222,7 @@ export function ProfilePage() {
}, },
}); });
const onSaveProfile = async (values: ProfileValues) => { const onSavePersonal = async (values: PersonalValues) => {
setIsSavingProfile(true); setIsSavingProfile(true);
try { try {
await updateTrigger({ await updateTrigger({
@@ -147,7 +236,6 @@ export function ProfilePage() {
}, },
}).unwrap(); }).unwrap();
// Refresh the cached user so the rest of the app stays in sync.
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap(); const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me)); dispatch(setUser(me));
@@ -159,18 +247,77 @@ export function ProfilePage() {
} }
}; };
// ---- Maritime Profile form ----
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
values: loadedProfile ?? undefined,
});
const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return;
setIsSavingMaritime(true);
try {
await updateProfile({
url: `/profiles/${profileId}`,
method: 'PUT',
body: values,
}).unwrap();
notify.success('Profile updated');
} catch {
notify.error('Failed to update profile');
} finally {
setIsSavingMaritime(false);
}
};
// ---- Address form ----
const {
register: registerAddress,
handleSubmit: handleAddressSubmit,
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
formState: { errors: addressErrors },
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
values: loadedAddress ?? undefined,
});
const onSaveAddress = async (values: AddressValues) => {
if (!addressId) return;
setIsSavingAddress(true);
try {
await updateAddress({
url: `/addresss/${addressId}`,
method: 'PUT',
body: {
...values,
postalAddess: values.postalAddress,
},
}).unwrap();
notify.success('Address updated');
} catch {
notify.error('Failed to update address');
} finally {
setIsSavingAddress(false);
}
};
// ---- Password form ---- // ---- Password form ----
const passwordSchema = z const passwordSchema = z
.object({ .object({
oldPassword: z oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
.string() newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
.min(1, { message: t('profile.validation.passwordMin') }), confirmPassword: z.string().min(8, { 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, { .refine((data) => data.newPassword === data.confirmPassword, {
message: t('profile.validation.passwordMismatch'), message: t('profile.validation.passwordMismatch'),
@@ -214,21 +361,13 @@ export function ProfilePage() {
const displayName = user?.name?.en || user?.username || ''; const displayName = user?.name?.en || user?.username || '';
const score = passwordScore(watchPassword('newPassword')); const score = passwordScore(watchPassword('newPassword'));
const strengthLabels = [ const strengthLabels = [
'', '', t('profile.strength.weak'), t('profile.strength.fair'),
t('profile.strength.weak'), t('profile.strength.good'), t('profile.strength.strong'),
t('profile.strength.fair'),
t('profile.strength.good'),
t('profile.strength.strong'),
]; ];
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal']; const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' }; const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
// Mantine uses 'auto' for the system option. const appearanceOptions: { value: MantineColorScheme; label: string; icon: typeof IconSun }[] = [
const appearanceOptions: {
value: MantineColorScheme;
label: string;
icon: typeof IconSun;
}[] = [
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun }, { value: 'light', label: t('profile.appearance.light'), icon: IconSun },
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon }, { value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop }, { value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
@@ -297,13 +436,19 @@ export function ProfilePage() {
{/* Tabs */} {/* Tabs */}
<Tabs <Tabs
defaultValue="profile" defaultValue="personal"
variant="pills" variant="pills"
classNames={{ list: classes.list, tab: classes.tab }} classNames={{ list: classes.list, tab: classes.tab }}
> >
<Tabs.List> <Tabs.List>
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}> <Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')} Personal
</Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
Profile
</Tabs.Tab>
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}> <Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')} {t('profile.tabs.security')}
@@ -313,10 +458,10 @@ export function ProfilePage() {
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
{/* ---- Profile ---- */} {/* ---- Personal (auth user data) ---- */}
<Tabs.Panel value="profile" pt="md"> <Tabs.Panel value="personal" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder> <Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}> <form onSubmit={handlePersonalSubmit(onSavePersonal)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>{t('profile.personal')}</Title> <Title order={5}>{t('profile.personal')}</Title>
@@ -327,14 +472,14 @@ export function ProfilePage() {
<TextInput <TextInput
label={t('profile.fields.fullNameEn')} label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />} leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message} error={personalErrors.nameEn?.message}
{...registerProfile('nameEn')} {...registerPersonal('nameEn')}
/> />
<TextInput <TextInput
label={t('profile.fields.fullNameAm')} label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />} leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message} error={personalErrors.nameAm?.message}
{...registerProfile('nameAm')} {...registerPersonal('nameAm')}
/> />
<TextInput <TextInput
label={t('profile.fields.username')} label={t('profile.fields.username')}
@@ -342,8 +487,8 @@ export function ProfilePage() {
readOnly readOnly
variant="filled" variant="filled"
leftSection={<IconAt size={18} />} leftSection={<IconAt size={18} />}
error={profileErrors.username?.message} error={personalErrors.username?.message}
{...registerProfile('username')} {...registerPersonal('username')}
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>
@@ -358,14 +503,14 @@ export function ProfilePage() {
<TextInput <TextInput
label={t('profile.fields.email')} label={t('profile.fields.email')}
leftSection={<IconMail size={18} />} leftSection={<IconMail size={18} />}
error={profileErrors.email?.message} error={personalErrors.email?.message}
{...registerProfile('email')} {...registerPersonal('email')}
/> />
<TextInput <TextInput
label={t('profile.fields.phone')} label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />} leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message} error={personalErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')} {...registerPersonal('phoneNumber')}
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>
@@ -374,7 +519,7 @@ export function ProfilePage() {
<Button <Button
type="button" type="button"
variant="default" variant="default"
onClick={() => resetProfile()} onClick={() => resetPersonal()}
> >
{t('profile.cancel')} {t('profile.cancel')}
</Button> </Button>
@@ -391,6 +536,90 @@ export function ProfilePage() {
</Paper> </Paper>
</Tabs.Panel> </Tabs.Panel>
{/* ---- Maritime Profile ---- */}
<Tabs.Panel value="profile" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
{dataLoading ? (
<Center py="xl"><Loader /></Center>
) : !loadedProfile ? (
<Text c="dimmed" ta="center" py="xl">
No profile found. Complete your profile setup first.
</Text>
) : (
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>Maritime Profile</Title>
<Text size="sm" c="dimmed" mb="md">
Your professional maritime details
</Text>
<ProfileFormContent
register={registerProfile}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</div>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Profile
</Button>
</Group>
</Stack>
</form>
)}
</Paper>
</Tabs.Panel>
{/* ---- Address ---- */}
<Tabs.Panel value="address" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
{dataLoading ? (
<Center py="xl"><Loader /></Center>
) : !loadedAddress ? (
<Text c="dimmed" ta="center" py="xl">
No address found. Complete your profile setup first.
</Text>
) : (
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
<Stack gap="xl">
<div>
<Title order={5}>Address & Contact</Title>
<Text size="sm" c="dimmed" mb="md">
Your identity documents, contact details and emergency contact
</Text>
<AddressFormContent
register={registerAddress}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</div>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingAddress}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Address
</Button>
</Group>
</Stack>
</form>
)}
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */} {/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md"> <Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder> <Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -46,7 +46,7 @@ export function LoginPage() {
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>(); const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ count: number; items: Array<{ userId: string }> }>(); const [profileCheckTrigger] = useApiMutation<{ total: number; items: Array<{ id: string; user: { id: string }; isComplete: boolean }> }>();
const { const {
register, register,
@@ -74,19 +74,20 @@ export function LoginPage() {
try { try {
const profiles = await profileCheckTrigger({ const profiles = await profileCheckTrigger({
url: '/profiles?take=10000&skip=0', url: `/profiles?q=${encodeURIComponent('i=user')}`,
method: 'GET', method: 'GET',
}).unwrap(); }).unwrap();
const userProfile = profiles.items?.find((p) => p.userId === me.id); const userProfile = profiles.items?.find((p) => p.user?.id === me.id);
if (!userProfile) { if (userProfile?.isComplete) {
authStorage.setProfileId(userProfile.id);
} else {
navigate('/profile-setup'); navigate('/profile-setup');
return; return;
} }
authStorage.setProfileId(userProfile.id);
} catch { } catch {
// profile check failed — proceed to dashboard anyway navigate('/profile-setup');
return;
} }
if (me.isPhoneNumberVerified) { if (me.isPhoneNumberVerified) {