mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
refactor: modularize profile setup components and update profile completion logic commit
This commit is contained in:
@@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0–4 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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user