Files
emaui/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx

401 lines
13 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Center,
Group,
Paper,
Stack,
Text,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconLogout2,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/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';
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 },
];
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
function inferUserType(professionName: string): string {
const name = professionName.toLowerCase();
if (name.includes('seafarer')) return 'SEAFARER';
return 'EMPLOYEE';
}
export function ProfileSetupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
const [meTrigger] = useApiMutation<{ id: 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);
const checkedExistingProfile = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
useEffect(() => {
if (!user || checkedExistingProfile.current) return;
checkedExistingProfile.current = true;
profileCheckTrigger({ url: `/profiles?q=${encodeURIComponent('i=user')}`, method: 'GET' })
.unwrap()
.then((data) => {
const existing = data.items?.find((p) => p.user?.id === user.id);
if (existing?.isComplete) {
authStorage.setProfileId(existing.id);
navigate('/dashboard', { replace: true });
}
})
.catch(() => {});
}, [user, navigate, profileCheckTrigger]);
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]);
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
const profileDefaults: ProfileValues = useMemo(() => ({
professionId: '',
firstName: nameParts[0] || '',
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
gender: '',
dob: '',
pob: '',
maritalStatus: '',
}), [nameParts]);
const addressDefaults: AddressValues = useMemo(() => ({
idType: '',
idNumber: '',
nationality: '',
primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: '',
email: user?.email || '',
regionId: '',
cityId: '',
subcityId: '',
woredaId: '',
kebeleId: '',
streetAddress: '',
postalAddress: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
}), [user]);
const {
register: profileRegister,
handleSubmit: profileHandleSubmit,
formState: { errors: profileErrors },
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: profileDefaults,
});
const {
register: addressRegister,
handleSubmit: addressHandleSubmit,
formState: { errors: addressErrors },
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
defaultValues: addressDefaults,
});
const onNext = async () => {
const valid = await profileTriggerValidation();
if (!valid) return;
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
setActive((c) => c + 1);
};
const onSubmitAddress = async () => {
const valid = await addressTriggerValidation();
if (!valid) return;
setSubmitting(true);
try {
const pv = profileWatch();
const av = addressWatch();
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: inferUserType(selectedProfessionName),
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
},
}).unwrap();
await profileTrigger({
url: `/profiles/${profileResult.id}`,
method: 'PUT',
body: { isComplete: true },
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: av.idType,
idNumber: av.idNumber,
nationality: av.nationality,
primaryPhoneNumber: av.primaryPhoneNumber,
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
email: av.email || undefined,
regionId: av.regionId || undefined,
cityId: av.cityId || undefined,
subcityId: av.subcityId || undefined,
woredaId: av.woredaId || undefined,
kebeleId: av.kebeleId || undefined,
streetAddress: av.streetAddress || undefined,
postalAddess: av.postalAddress || undefined,
emergencyContactName: av.emergencyContactName || undefined,
emergencyContactPhone: av.emergencyContactPhone || undefined,
emergencyContactRelation: av.emergencyContactRelation || undefined,
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
} catch {
notify.error('Failed to save profile. Please try again.');
} finally {
setSubmitting(false);
}
};
if (!user) {
return (
<Center mih="100vh">
<Text c="dimmed">Please log in first.</Text>
</Center>
);
}
return (
<Center mih="100vh" bg="gray.0">
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
<Stack gap="md">
<div>
<Title order={3}>Complete Your Profile</Title>
<Text fz="sm" c="dimmed">
Set up your profile and address to get started
</Text>
</div>
<StepIndicator active={active} completed={completed} />
{active === 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<ProfileFormContent
register={profileRegister}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</>
)}
{active === 1 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<AddressFormContent
register={addressRegister}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
color="gray"
leftSection={<IconLogout2 size={16} />}
onClick={() => {
dispatch(logout());
navigate('/login');
}}
>
Sign out
</Button>
<Group gap="sm">
{active > 0 && (
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActive((c) => c - 1)}
>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={onSubmitAddress}
loading={submitting}
>
Complete Setup
</Button>
)}
</Group>
</Group>
</Stack>
</Paper>
</Center>
);
}