From ac8df4cdff170fd8a63cf8d31948a86d3e82e64f Mon Sep 17 00:00:00 2001 From: mengstabketemaw Date: Fri, 26 Jun 2026 16:12:25 +0300 Subject: [PATCH] refactor: modularize profile setup components and update profile completion logic commit --- .../src/app/components/ProfileGuard.tsx | 11 +- .../profile-setup/pages/ProfileSetupPage.tsx | 270 ++----------- .../profile/components/AddressFormContent.tsx | 160 ++++++++ .../profile/components/ProfileFormContent.tsx | 115 ++++++ .../features/profile/pages/ProfilePage.tsx | 363 ++++++++++++++---- libs/auth/src/lib/pages/LoginPage.tsx | 15 +- 6 files changed, 624 insertions(+), 310 deletions(-) create mode 100644 apps/portal/src/app/features/profile/components/AddressFormContent.tsx create mode 100644 apps/portal/src/app/features/profile/components/ProfileFormContent.tsx diff --git a/apps/portal/src/app/components/ProfileGuard.tsx b/apps/portal/src/app/components/ProfileGuard.tsx index c9328918a..b7f38216a 100644 --- a/apps/portal/src/app/components/ProfileGuard.tsx +++ b/apps/portal/src/app/components/ProfileGuard.tsx @@ -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 ; } - return ; + return <>{children}; } diff --git a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx index 5d9c5360c..bed449c23 100644 --- a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx +++ b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx @@ -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; - -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; - function StepIndicator({ active, completed }: { active: number; completed: number[] }) { return ( @@ -160,7 +127,7 @@ export function ProfileSetupPage() { const [profileTrigger] = useApiMutation<{ id: string }>(); const [addressTrigger] = useApiMutation(); 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() { Personal Information - - profileSetValue('gender', val || '', { shouldValidate: true })} - onBlur={() => profileTriggerValidation('gender')} - name="gender" - /> - - - addressSetValue('idType', val || '', { shouldValidate: true })} - onBlur={() => addressTriggerValidation('idType')} - name="idType" - /> - - - - - - - - - Address - - - - - - - - - - - - Emergency Contact - - - - - - + )} diff --git a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx new file mode 100644 index 000000000..04cb08d26 --- /dev/null +++ b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx @@ -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; + +export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const; + +interface AddressFormContentProps { + register: UseFormRegister; + errors: FieldErrors; + setValue: UseFormSetValue; + watch: UseFormWatch; + trigger: UseFormTrigger; +} + +export function AddressFormContent({ + register, + errors, + setValue, + watch, + trigger, +}: AddressFormContentProps) { + return ( + <> + + setValue('professionId', val || '', { shouldValidate: true })} + onBlur={() => trigger('professionId')} + name="professionId" + searchable + disabled={professionsLoading} + rightSection={professionsLoading ? : undefined} + /> + + + + setValue('maritalStatus', val || '', { shouldValidate: true })} + onBlur={() => trigger('maritalStatus')} + name="maritalStatus" + /> + + ); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 2c4493fa9..b2905384f 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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(); } -/** 0–4 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(); const [meTrigger] = useApiMutation(); const [passwordTrigger] = useApiMutation(); + 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>([]); + 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 = {}; + professions.forEach((p) => { map[p.id] = p.name.en; }); + return map; + }, [professions]); + + // ---- Fetch profile data (for Profile & Address tabs) ---- + const [fetchProfile] = useApiMutation>(); + const [updateProfile] = useApiMutation(); + const [updateAddress] = useApiMutation(); + + const [loadedProfile, setLoadedProfile] = useState(null); + const [loadedAddress, setLoadedAddress] = useState(null); + const [addressId, setAddressId] = useState(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; + type PersonalValues = z.infer; const { - register: registerProfile, - handleSubmit: handleProfileSubmit, - reset: resetProfile, - formState: { errors: profileErrors }, - } = useForm({ - resolver: zodResolver(profileSchema), + register: registerPersonal, + handleSubmit: handlePersonalSubmit, + reset: resetPersonal, + formState: { errors: personalErrors }, + } = useForm({ + 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({ + 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({ + 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 = { 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 */} - }> - {t('profile.tabs.profile')} + }> + Personal + + }> + Profile + + }> + Address }> {t('profile.tabs.security')} @@ -313,10 +458,10 @@ export function ProfilePage() { - {/* ---- Profile ---- */} - + {/* ---- Personal (auth user data) ---- */} + -
+
{t('profile.personal')} @@ -327,14 +472,14 @@ export function ProfilePage() { } - error={profileErrors.nameEn?.message} - {...registerProfile('nameEn')} + error={personalErrors.nameEn?.message} + {...registerPersonal('nameEn')} /> } - error={profileErrors.nameAm?.message} - {...registerProfile('nameAm')} + error={personalErrors.nameAm?.message} + {...registerPersonal('nameAm')} /> } - error={profileErrors.username?.message} - {...registerProfile('username')} + error={personalErrors.username?.message} + {...registerPersonal('username')} />
@@ -358,14 +503,14 @@ export function ProfilePage() { } - error={profileErrors.email?.message} - {...registerProfile('email')} + error={personalErrors.email?.message} + {...registerPersonal('email')} /> } - error={profileErrors.phoneNumber?.message} - {...registerProfile('phoneNumber')} + error={personalErrors.phoneNumber?.message} + {...registerPersonal('phoneNumber')} /> @@ -374,7 +519,7 @@ export function ProfilePage() { @@ -391,6 +536,90 @@ export function ProfilePage() { + {/* ---- Maritime Profile ---- */} + + + {dataLoading ? ( +
+ ) : !loadedProfile ? ( + + No profile found. Complete your profile setup first. + + ) : ( + + +
+ Maritime Profile + + Your professional maritime details + + +
+ + + + +
+ + )} +
+
+ + {/* ---- Address ---- */} + + + {dataLoading ? ( +
+ ) : !loadedAddress ? ( + + No address found. Complete your profile setup first. + + ) : ( +
+ +
+ Address & Contact + + Your identity documents, contact details and emergency contact + + +
+ + + + +
+
+ )} +
+
+ {/* ---- Security ---- */} diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 169dde10f..7f998bb4c 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -46,7 +46,7 @@ export function LoginPage() { const [rememberMe, setRememberMe] = useState(true); const [loginTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); - 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) {