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';
export function ProfileGuard() {
interface ProfileGuardProps {
children?: ReactNode;
}
export function ProfileGuard({ children }: ProfileGuardProps) {
const profileId = authStorage.getProfileId();
if (!profileId) {
return <Navigate to="/profile-setup" replace />;
}
return <Outlet />;
return <>{children}</>;
}

View File

@@ -4,13 +4,9 @@ import {
Button,
Center,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
rem,
} from '@mantine/core';
@@ -25,56 +21,27 @@ import {
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { authStorage, setUser, logout } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
const GENDERS = ['MALE', 'FEMALE'];
const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'];
const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'];
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../../profile/components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../../profile/components/AddressFormContent';
const STEPS = [
{ label: 'Profile', icon: IconUser },
{ 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[] }) {
return (
<Box mb={32}>
@@ -160,7 +127,7 @@ export function ProfileSetupPage() {
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
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 fetched = useRef(false);
@@ -179,11 +146,11 @@ export function ProfileSetupPage() {
useEffect(() => {
if (!user || checkedExistingProfile.current) return;
checkedExistingProfile.current = true;
profileCheckTrigger({ url: '/profiles?take=1&skip=0', method: 'GET' })
profileCheckTrigger({ url: `/profiles?q=${encodeURIComponent('i=user')}`, method: 'GET' })
.unwrap()
.then((data) => {
const existing = data.items?.find((p) => p.userId === user.id);
if (existing) {
const existing = data.items?.find((p) => p.user?.id === user.id);
if (existing?.isComplete) {
authStorage.setProfileId(existing.id);
navigate('/dashboard', { replace: true });
}
@@ -292,10 +259,16 @@ export function ProfileSetupPage() {
maritalStatus: pv.maritalStatus,
},
}).unwrap();
await profileTrigger({
url: `/profiles/${profileResult.id}`,
method: 'PUT',
body: { isComplete: true },
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresses/profile/${profileResult.id}`,
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: av.idType,
@@ -355,78 +328,15 @@ export function ProfileSetupPage() {
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="Profession"
placeholder={professionsLoading ? 'Loading...' : 'Select'}
required
data={professionOptions}
error={profileErrors.professionId?.message}
value={profileWatch('professionId')}
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>
<ProfileFormContent
register={profileRegister}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</>
)}
@@ -435,119 +345,13 @@ export function ProfileSetupPage() {
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="ID Type"
placeholder="Select"
required
data={ID_TYPES}
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>
<AddressFormContent
register={addressRegister}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</>
)}

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 {
Badge,
Box,
Button,
Center,
Divider,
Group,
Loader,
Paper,
PasswordInput,
SimpleGrid,
@@ -28,6 +30,7 @@ import {
IconDeviceFloppy,
IconLock,
IconMail,
IconMapPin,
IconMoon,
IconPhone,
IconSettings,
@@ -42,10 +45,20 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { authStorage, setUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } 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';
function getInitials(name: string, fallback: string) {
@@ -56,7 +69,6 @@ function getInitials(name: string, fallback: string) {
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;
@@ -76,16 +88,102 @@ export function ProfilePage() {
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const [isSavingProfile, setIsSavingProfile] = 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 [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).
// ---- Profession list (for Profile tab) ----
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(() => {
let active = true;
meTrigger({ url: '/auth/me', method: 'GET' })
@@ -93,37 +191,28 @@ export function ProfilePage() {
.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.
.catch(() => {});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Profile form ----
const profileSchema = z.object({
// ---- Personal form (auth user data) ----
const personalSchema = 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') }),
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') }),
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
});
type ProfileValues = z.infer<typeof profileSchema>;
type PersonalValues = z.infer<typeof personalSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
register: registerPersonal,
handleSubmit: handlePersonalSubmit,
reset: resetPersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
values: {
nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '',
@@ -133,7 +222,7 @@ export function ProfilePage() {
},
});
const onSaveProfile = async (values: ProfileValues) => {
const onSavePersonal = async (values: PersonalValues) => {
setIsSavingProfile(true);
try {
await updateTrigger({
@@ -147,7 +236,6 @@ export function ProfilePage() {
},
}).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));
@@ -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 ----
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') }),
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'),
@@ -214,21 +361,13 @@ export function ProfilePage() {
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'),
'', 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: '🇬🇧', 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: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
@@ -297,13 +436,19 @@ export function ProfilePage() {
{/* Tabs */}
<Tabs
defaultValue="profile"
defaultValue="personal"
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 value="personal" leftSection={<IconUserCircle size={18} />}>
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 value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
@@ -313,10 +458,10 @@ export function ProfilePage() {
</Tabs.Tab>
</Tabs.List>
{/* ---- Profile ---- */}
<Tabs.Panel value="profile" pt="md">
{/* ---- Personal (auth user data) ---- */}
<Tabs.Panel value="personal" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<form onSubmit={handlePersonalSubmit(onSavePersonal)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.personal')}</Title>
@@ -327,14 +472,14 @@ export function ProfilePage() {
<TextInput
label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
error={personalErrors.nameEn?.message}
{...registerPersonal('nameEn')}
/>
<TextInput
label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
error={personalErrors.nameAm?.message}
{...registerPersonal('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
@@ -342,8 +487,8 @@ export function ProfilePage() {
readOnly
variant="filled"
leftSection={<IconAt size={18} />}
error={profileErrors.username?.message}
{...registerProfile('username')}
error={personalErrors.username?.message}
{...registerPersonal('username')}
/>
</SimpleGrid>
</div>
@@ -358,14 +503,14 @@ export function ProfilePage() {
<TextInput
label={t('profile.fields.email')}
leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
error={personalErrors.email?.message}
{...registerPersonal('email')}
/>
<TextInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
error={personalErrors.phoneNumber?.message}
{...registerPersonal('phoneNumber')}
/>
</SimpleGrid>
</div>
@@ -374,7 +519,7 @@ export function ProfilePage() {
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
onClick={() => resetPersonal()}
>
{t('profile.cancel')}
</Button>
@@ -391,6 +536,90 @@ export function ProfilePage() {
</Paper>
</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 ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -46,7 +46,7 @@ export function LoginPage() {
const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>();
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 {
register,
@@ -74,19 +74,20 @@ export function LoginPage() {
try {
const profiles = await profileCheckTrigger({
url: '/profiles?take=10000&skip=0',
url: `/profiles?q=${encodeURIComponent('i=user')}`,
method: 'GET',
}).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');
return;
}
authStorage.setProfileId(userProfile.id);
} catch {
// profile check failed — proceed to dashboard anyway
navigate('/profile-setup');
return;
}
if (me.isPhoneNumberVerified) {