mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
1041 lines
38 KiB
TypeScript
1041 lines
38 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
PasswordInput,
|
|
RingProgress,
|
|
SimpleGrid,
|
|
Stack,
|
|
Switch,
|
|
Tabs,
|
|
Text,
|
|
TextInput,
|
|
Title,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
useMantineColorScheme,
|
|
type MantineColorScheme,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAt,
|
|
IconBell,
|
|
IconCheck,
|
|
IconCircle,
|
|
IconCircleCheckFilled,
|
|
IconDeviceDesktop,
|
|
IconDeviceFloppy,
|
|
IconInfoCircle,
|
|
IconLock,
|
|
IconMail,
|
|
IconBuildingWarehouse,
|
|
IconMapPin,
|
|
IconMoon,
|
|
IconSettings,
|
|
IconShieldLock,
|
|
IconSun,
|
|
IconUser,
|
|
IconUserCircle,
|
|
} from '@tabler/icons-react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
|
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
|
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
|
import type { AuthUser } from '@ema-platform/auth';
|
|
import {
|
|
ProfileFormContent,
|
|
profileSchema,
|
|
type ProfileValues,
|
|
} from '../components/ProfileFormContent';
|
|
import {
|
|
AddressFormContent,
|
|
addressSchema,
|
|
type AddressValues,
|
|
} from '../components/AddressFormContent';
|
|
import { useSaveMyAddressMutation } from '../api/address-api';
|
|
import { toAddressPayload } from '../types/address';
|
|
import { OperationsFormContent } from '../components/OperationsFormContent';
|
|
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
|
|
import classes from './ProfilePage.module.css';
|
|
|
|
/** Tab keys addressable via the URL hash. */
|
|
const VALID_TABS = [
|
|
'personal',
|
|
'profile',
|
|
'address',
|
|
'operations',
|
|
'security',
|
|
'preferences',
|
|
];
|
|
|
|
function getInitials(name: string, fallback: string) {
|
|
const source = name?.trim() || fallback?.trim() || '';
|
|
if (!source) return '?';
|
|
const parts = source.split(/\s+/);
|
|
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2);
|
|
return letters.toUpperCase();
|
|
}
|
|
|
|
function splitProfileName(fullName: string) {
|
|
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
|
|
return { firstName, middleName, lastName: lastName.join(' ') };
|
|
}
|
|
|
|
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
|
|
return [firstName, middleName, lastName].filter(Boolean).join(' ');
|
|
}
|
|
|
|
function normalizeName(name: string) {
|
|
return name.trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function passwordScore(pw: string) {
|
|
if (!pw) return 0;
|
|
let score = 0;
|
|
if (pw.length >= 8) score++;
|
|
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
|
if (/\d/.test(pw)) score++;
|
|
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
|
return score;
|
|
}
|
|
|
|
export function ProfilePage() {
|
|
const { t, i18n } = useTranslation();
|
|
const dispatch = useAppDispatch();
|
|
const user = useAppSelector((state) => state.auth.user);
|
|
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
|
const { handleError } = useErrorHandler();
|
|
|
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
|
const [passwordTrigger] = useApiMutation<unknown>();
|
|
const localized = useLocalized();
|
|
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string; am?: string } }> }>();
|
|
|
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
|
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
|
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
|
|
|
// Two-step verification is wired but parked for the testing phase: turning it
|
|
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
|
|
// to re-enable it (the login/OTP side already handles `mfaRequired`).
|
|
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
|
// const {
|
|
// enabled: twoStepEnabled,
|
|
// isLoading: twoStepLoading,
|
|
// isSaving: twoStepSaving,
|
|
// setEnabled: setTwoStepEnabled,
|
|
// } = useTwoFactor();
|
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
|
|
|
// ---- Profession list (for Profile tab) ----
|
|
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string; am?: 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: localized(p.name) })),
|
|
[professions, localized],
|
|
);
|
|
|
|
// ---- Profile data ----
|
|
// Resolved through `useCurrentProfile`, which provisions a profile if the
|
|
// user has none. The page used to read an id out of local storage that only
|
|
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
|
// for anyone who signed up after the wizard was removed.
|
|
const {
|
|
profileId,
|
|
profile: resolvedProfile,
|
|
isLoading: profileResolving,
|
|
completeness,
|
|
missing,
|
|
isReadyFor,
|
|
refetch: refetchProfile,
|
|
} = useCurrentProfile();
|
|
const [updateProfile] = useApiMutation<unknown>();
|
|
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
|
|
|
// Only shown to applicants who can actually register as a seafarer, and
|
|
// only while the profile is still missing what that registration is built
|
|
// from — same `SEAFARER_PROFILE_REQUIREMENT` the RequireSeafarerProfile
|
|
// gate and its redirect toast use, so all three surfaces agree on what
|
|
// "ready" means. Disappears on its own once the gaps close.
|
|
const { can } = usePermissions();
|
|
const showSeafarerBanner =
|
|
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
|
|
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
|
|
// Place of birth, blood type, hair/eye colour and height print on the
|
|
// Seaman Book, so a seafarer account can't leave them blank — every other
|
|
// account type may. Mirrors the seafarer-registration wizard's
|
|
// `required: true` on the same fields and the backend check in
|
|
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
|
|
const isSeafarer = (resolvedProfile ?? storedProfile)?.type === 'SEAFARER';
|
|
|
|
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
|
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
|
const [dataLoading, setDataLoading] = useState(true);
|
|
|
|
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
|
// requirement gates link straight at them (/profile#address), so the hash
|
|
// has to select a tab rather than being ignored. Emergency-contact fields
|
|
// live inside the address form, so both anchors land on that tab.
|
|
const tabFromHash = useCallback((hash: string) => {
|
|
const key = hash.replace('#', '');
|
|
if (key === 'emergency') return 'address';
|
|
return VALID_TABS.includes(key) ? key : 'personal';
|
|
}, []);
|
|
const [activeTab, setActiveTab] = useState(() =>
|
|
tabFromHash(typeof window === 'undefined' ? '' : window.location.hash),
|
|
);
|
|
const { hash } = useLocation();
|
|
useEffect(() => {
|
|
setActiveTab(tabFromHash(hash));
|
|
}, [hash, tabFromHash]);
|
|
|
|
useEffect(() => {
|
|
// Prefer the freshly resolved profile; fall back to whatever the store
|
|
// already holds so the form does not flash empty on a refetch.
|
|
const currentProfile = resolvedProfile ?? storedProfile;
|
|
if (currentProfile) {
|
|
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
|
|
setLoadedProfile({
|
|
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
|
firstName: accountName?.firstName || currentProfile.firstName || '',
|
|
middleName: accountName?.middleName || currentProfile.middleName || '',
|
|
lastName: accountName?.lastName || currentProfile.lastName || '',
|
|
gender: currentProfile.gender || '',
|
|
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
|
pob: currentProfile.pob || '',
|
|
maritalStatus: currentProfile.maritalStatus || '',
|
|
bloodType: currentProfile.bloodType || '',
|
|
hairColor: currentProfile.hairColor || '',
|
|
eyeColor: currentProfile.eyeColor || '',
|
|
heightCm: currentProfile.heightCm != null ? String(currentProfile.heightCm) : '',
|
|
});
|
|
|
|
// Primary phone and email are the account's contact details (same
|
|
// source as the Personal tab), not the address record — always
|
|
// populated even before an address exists, and locked in the form.
|
|
setLoadedAddress({
|
|
idType: currentProfile.address?.idType || '',
|
|
idNumber: currentProfile.address?.idNumber || '',
|
|
// Stored as a country name; the select works in alpha-2 codes.
|
|
// Default to Ethiopian when no nationality is on record yet.
|
|
nationality: getCountryCode(currentProfile.address?.nationality) || 'ET',
|
|
primaryPhoneNumber: user?.phoneNumber || '',
|
|
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
|
email: user?.email || '',
|
|
regionId: currentProfile.address?.regionId || '',
|
|
cityId: currentProfile.address?.cityId || currentProfile.address?.regionId || '',
|
|
subCityId: currentProfile.address?.subCityId || '',
|
|
woredaId: currentProfile.address?.woredaId || '',
|
|
streetAddress: currentProfile.address?.streetAddress || '',
|
|
postalAddress: currentProfile.address?.postalAddress || '',
|
|
emergencyContactName: currentProfile.address?.emergencyContactName || '',
|
|
emergencyContactPhone: currentProfile.address?.emergencyContactPhone || '',
|
|
// Previously read `emergencycontactRelation` (lower-case c), so the
|
|
// saved relationship never appeared when reopening the profile.
|
|
emergencyContactRelation:
|
|
currentProfile.address?.emergencyContactRelation || '',
|
|
});
|
|
setDataLoading(false);
|
|
} else if (!profileResolving) {
|
|
// Resolver finished and there is still nothing — render the empty form
|
|
// rather than an indefinite spinner.
|
|
setDataLoading(false);
|
|
}
|
|
}, [resolvedProfile, storedProfile, profileResolving, user]);
|
|
|
|
// ---- Personal form (auth user data) ----
|
|
const personalSchema = z.object({
|
|
nameEn: z
|
|
.string()
|
|
.refine(
|
|
(name) => Object.values(splitProfileName(name)).every(Boolean),
|
|
{ message: t('profileForm.validation.nameParts') },
|
|
),
|
|
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
|
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
|
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
|
phoneNumber,
|
|
});
|
|
type PersonalValues = z.infer<typeof personalSchema>;
|
|
|
|
const {
|
|
register: registerPersonal,
|
|
handleSubmit: handlePersonalSubmit,
|
|
reset: resetPersonal,
|
|
watch: watchPersonal,
|
|
setValue: setValuePersonal,
|
|
trigger: triggerPersonal,
|
|
formState: { errors: personalErrors },
|
|
} = useForm<PersonalValues>({
|
|
resolver: zodResolver(personalSchema),
|
|
values: {
|
|
nameEn: user?.name?.en ?? '',
|
|
nameAm: user?.name?.am ?? '',
|
|
username: user?.username ?? '',
|
|
email: user?.email ?? '',
|
|
phoneNumber: user?.phoneNumber ?? '',
|
|
},
|
|
});
|
|
|
|
const onSavePersonal = async (values: PersonalValues) => {
|
|
if (!user) return;
|
|
|
|
setIsSavingProfile(true);
|
|
try {
|
|
const profileName = splitProfileName(values.nameEn);
|
|
const saves: Promise<unknown>[] = [
|
|
updateTrigger({
|
|
url: '/auth/update-profile',
|
|
method: 'PATCH',
|
|
body: {
|
|
email: values.email,
|
|
username: values.username,
|
|
phoneNumber: values.phoneNumber,
|
|
name: { am: values.nameAm, en: values.nameEn },
|
|
},
|
|
}).unwrap(),
|
|
];
|
|
|
|
// The Profile tab stores names separately as first/middle/last.
|
|
// Save those fields alongside the account's display name so either tab
|
|
// always describes the same person.
|
|
if (profileId) {
|
|
saves.push(
|
|
updateProfile({
|
|
url: `/profiles/${profileId}`,
|
|
method: 'PUT',
|
|
body: profileName,
|
|
}).unwrap(),
|
|
);
|
|
}
|
|
await Promise.all(saves);
|
|
|
|
// Update the session from the values that were just accepted. The
|
|
// endpoint is allowed to return no body (or a response wrapper), so
|
|
// treating its response as an AuthUser can blank or retain stale UI
|
|
// state until the next login.
|
|
const updatedUser: AuthUser = {
|
|
...user,
|
|
email: values.email,
|
|
username: values.username,
|
|
phoneNumber: values.phoneNumber,
|
|
name: { am: values.nameAm, en: values.nameEn },
|
|
};
|
|
dispatch(setUser(updatedUser));
|
|
setLoadedProfile((current) =>
|
|
current ? { ...current, ...profileName } : current,
|
|
);
|
|
resetPersonal({
|
|
nameEn: updatedUser.name.en,
|
|
nameAm: updatedUser.name.am,
|
|
username: updatedUser.username,
|
|
email: updatedUser.email,
|
|
phoneNumber: updatedUser.phoneNumber,
|
|
});
|
|
// Email/phone feed the profile's completeness check too — without this
|
|
// `missing` and every requirement gate stay stale until a reload.
|
|
refetchProfile();
|
|
notify.success(t('profile.profileUpdated'));
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setIsSavingProfile(false);
|
|
}
|
|
};
|
|
|
|
// ---- Maritime Profile form ----
|
|
const {
|
|
register: registerProfile,
|
|
handleSubmit: handleProfileSubmit,
|
|
setValue: profileSetValue,
|
|
watch: profileWatch,
|
|
trigger: profileTriggerValidation,
|
|
formState: { errors: profileErrors },
|
|
} = useForm<ProfileValues>({
|
|
resolver: zodResolver(profileSchema(t, isSeafarer)),
|
|
values: loadedProfile ?? undefined,
|
|
});
|
|
|
|
const onSaveProfile = async (values: ProfileValues) => {
|
|
if (!profileId) return;
|
|
|
|
const fullName = formatProfileName(values);
|
|
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
|
|
notify.error(t('profile.nameMismatch'));
|
|
return;
|
|
}
|
|
|
|
setIsSavingMaritime(true);
|
|
try {
|
|
await updateProfile({
|
|
url: `/profiles/${profileId}`,
|
|
method: 'PUT',
|
|
// Empty string is not a valid enum value on the backend — an
|
|
// untouched Select must clear the column, not fail validation.
|
|
body: {
|
|
...values,
|
|
heightCm: values.heightCm ? Number(values.heightCm) : null,
|
|
bloodType: values.bloodType || null,
|
|
hairColor: values.hairColor || null,
|
|
eyeColor: values.eyeColor || null,
|
|
},
|
|
}).unwrap();
|
|
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
|
|
|
// This endpoint doesn't invalidate the `CurrentProfile` tag (unlike
|
|
// the address save below) — without this, `missing` stays stale until
|
|
// a reload.
|
|
refetchProfile();
|
|
notify.success(t('profile.profileUpdated'));
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setIsSavingMaritime(false);
|
|
}
|
|
};
|
|
|
|
// ---- Address form ----
|
|
const {
|
|
register: registerAddress,
|
|
handleSubmit: handleAddressSubmit,
|
|
setValue: addressSetValue,
|
|
watch: addressWatch,
|
|
trigger: addressTriggerValidation,
|
|
formState: { errors: addressErrors },
|
|
} = useForm<AddressValues>({
|
|
resolver: zodResolver(addressSchema(t)),
|
|
values: loadedAddress ?? undefined,
|
|
});
|
|
|
|
const onSaveAddress = async (values: AddressValues) => {
|
|
if (!profileId) return;
|
|
try {
|
|
await saveMyAddress({
|
|
profileId,
|
|
body: toAddressPayload(values),
|
|
}).unwrap();
|
|
notify.success(t('profile.addressSaved'));
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
};
|
|
|
|
// ---- Password form ----
|
|
const passwordSchema = z
|
|
.object({
|
|
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
|
newPassword: strongPasswordSchema(8),
|
|
confirmPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
|
})
|
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
message: t('profile.validation.passwordMismatch'),
|
|
path: ['confirmPassword'],
|
|
});
|
|
type PasswordValues = z.infer<typeof passwordSchema>;
|
|
|
|
const {
|
|
register: registerPassword,
|
|
handleSubmit: handlePasswordSubmit,
|
|
reset: resetPassword,
|
|
watch: watchPassword,
|
|
formState: { errors: passwordErrors },
|
|
} = useForm<PasswordValues>({
|
|
resolver: zodResolver(passwordSchema),
|
|
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
|
});
|
|
|
|
const onChangePassword = async (values: PasswordValues) => {
|
|
setIsSavingPassword(true);
|
|
try {
|
|
await passwordTrigger({
|
|
url: '/auth/change-password',
|
|
method: 'PATCH',
|
|
body: {
|
|
oldPassword: values.oldPassword,
|
|
newPassword: values.newPassword,
|
|
confirmPassword: values.confirmPassword,
|
|
},
|
|
}).unwrap();
|
|
|
|
notify.success(t('profile.passwordChanged'));
|
|
resetPassword();
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setIsSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
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'),
|
|
];
|
|
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
|
|
|
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
|
|
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 },
|
|
];
|
|
|
|
return (
|
|
<Stack gap="lg" maw={900}>
|
|
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
|
|
|
{showSeafarerBanner && (
|
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={18} />}>
|
|
{t('profileGate.seafarerBanner')}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Profile summary */}
|
|
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
|
<Group align="center" wrap="nowrap">
|
|
<Box
|
|
w={64}
|
|
h={64}
|
|
style={{
|
|
flexShrink: 0,
|
|
borderRadius: '50%',
|
|
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text fw={700} size="xl" c="white">
|
|
{getInitials(displayName, user?.email ?? '')}
|
|
</Text>
|
|
</Box>
|
|
|
|
<div>
|
|
<Group gap="xs" align="center">
|
|
<Title order={4}>{displayName || '—'}</Title>
|
|
<Badge
|
|
variant="light"
|
|
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
|
|
size="sm"
|
|
>
|
|
{user?.isPhoneNumberVerified
|
|
? t('profile.verified')
|
|
: t('profile.unverified')}
|
|
</Badge>
|
|
</Group>
|
|
<Group gap={6} mt={2} c="dimmed">
|
|
<IconMail size={14} />
|
|
<Text size="sm" c="dimmed">
|
|
{user?.email}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
|
|
<Box style={{ flex: 1 }} />
|
|
|
|
{user?.username && (
|
|
<Badge
|
|
visibleFrom="xs"
|
|
variant="default"
|
|
size="lg"
|
|
radius="xl"
|
|
leftSection={<IconAt size={13} />}
|
|
>
|
|
{user.username}
|
|
</Badge>
|
|
)}
|
|
|
|
{/* Completeness. Informational only — nothing here blocks the user,
|
|
it just makes visible what the nudge and the in-flow gates are
|
|
reacting to. Computed by the API so all three agree. */}
|
|
<Tooltip
|
|
label={
|
|
missing.length
|
|
? missing
|
|
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
|
|
.join(', ')
|
|
: t('profileSections.sectionSaved', 'Saved')
|
|
}
|
|
multiline
|
|
w={260}
|
|
withArrow
|
|
>
|
|
<RingProgress
|
|
size={64}
|
|
thickness={6}
|
|
roundCaps
|
|
sections={[{ value: completeness, color: 'emaPrimary' }]}
|
|
aria-label={t('profileSections.completeness', {
|
|
value: completeness,
|
|
defaultValue: '{{value}}% complete',
|
|
})}
|
|
label={
|
|
<Text ta="center" fw={700} size="xs">
|
|
{completeness}%
|
|
</Text>
|
|
}
|
|
/>
|
|
</Tooltip>
|
|
</Group>
|
|
</Paper>
|
|
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={(value) => {
|
|
const next = value ?? 'personal';
|
|
setActiveTab(next);
|
|
// Keep the URL shareable without pushing a history entry per tab.
|
|
window.history.replaceState(null, '', `#${next}`);
|
|
}}
|
|
variant="pills"
|
|
classNames={{ list: classes.list, tab: classes.tab }}
|
|
>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
|
|
{t('profile.tabs.personal')}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
|
|
{t('profile.tabs.profile')}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
|
|
{t('profile.tabs.address')}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab
|
|
value="operations"
|
|
leftSection={<IconBuildingWarehouse size={18} />}
|
|
>
|
|
{t('profile.tabs.operations')}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
|
{t('profile.tabs.security')}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
|
|
{t('profile.tabs.preferences')}
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
{/* ---- Personal (auth user data) ---- */}
|
|
<Tabs.Panel value="personal" pt="md">
|
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
|
<form onSubmit={handlePersonalSubmit(onSavePersonal)}>
|
|
<Stack gap="xl">
|
|
<div>
|
|
<Title order={5}>{t('profile.personal')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.personalHint')}
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
label={t('profile.fields.fullNameEn')}
|
|
leftSection={<IconUser size={18} />}
|
|
error={personalErrors.nameEn?.message}
|
|
{...registerPersonal('nameEn')}
|
|
/>
|
|
<TextInput
|
|
label={t('profile.fields.fullNameAm')}
|
|
leftSection={<IconUser size={18} />}
|
|
error={personalErrors.nameAm?.message}
|
|
{...registerPersonal('nameAm')}
|
|
/>
|
|
<TextInput
|
|
label={t('profile.fields.username')}
|
|
description={t('profile.fields.usernameHint')}
|
|
readOnly
|
|
variant="filled"
|
|
leftSection={<IconAt size={18} />}
|
|
error={personalErrors.username?.message}
|
|
{...registerPersonal('username')}
|
|
/>
|
|
</SimpleGrid>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div>
|
|
<Title order={5} mb="md">
|
|
{t('profile.contact')}
|
|
</Title>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
label={t('profile.fields.email')}
|
|
leftSection={<IconMail size={18} />}
|
|
error={personalErrors.email?.message}
|
|
{...registerPersonal('email')}
|
|
/>
|
|
<PhoneInput
|
|
label={t('profile.fields.phone')}
|
|
value={watchPersonal('phoneNumber') || ''}
|
|
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
|
|
onBlur={() => triggerPersonal('phoneNumber')}
|
|
error={personalErrors.phoneNumber?.message}
|
|
/>
|
|
</SimpleGrid>
|
|
</div>
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
onClick={() => resetPersonal()}
|
|
>
|
|
{t('profile.cancel')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
loading={isSavingProfile}
|
|
leftSection={<IconDeviceFloppy size={18} />}
|
|
>
|
|
{t('profile.updateProfile')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</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">
|
|
{t('profile.maritimeSection.noProfile')}
|
|
</Text>
|
|
) : (
|
|
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
|
<Stack gap="xl">
|
|
<div>
|
|
<Title order={5}>{t('profile.maritimeSection.title')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.maritimeSection.subtitle')}
|
|
</Text>
|
|
<ProfileFormContent
|
|
register={registerProfile}
|
|
errors={profileErrors}
|
|
setValue={profileSetValue}
|
|
watch={profileWatch}
|
|
trigger={profileTriggerValidation}
|
|
professionsLoading={professionsLoading}
|
|
professionOptions={professionOptions}
|
|
isSeafarer={isSeafarer}
|
|
/>
|
|
</div>
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
type="submit"
|
|
loading={isSavingMaritime}
|
|
leftSection={<IconDeviceFloppy size={18} />}
|
|
>
|
|
{t('profile.maritimeSection.save')}
|
|
</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>
|
|
) : (
|
|
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
|
|
<Stack gap="xl">
|
|
<div>
|
|
<Title order={5}>{t('profile.addressSection.title')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.addressSection.subtitle')}
|
|
</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>
|
|
|
|
{/* ---- Operations (what the applicant may apply for) ---- */}
|
|
<Tabs.Panel value="operations" pt="md">
|
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
|
<OperationsFormContent />
|
|
</Paper>
|
|
</Tabs.Panel>
|
|
|
|
{/* ---- Security ---- */}
|
|
<Tabs.Panel value="security" pt="md">
|
|
<Stack gap="lg">
|
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
|
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
|
<Stack gap="xl">
|
|
<div>
|
|
<Title order={5}>{t('profile.security')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.securityHint')}
|
|
</Text>
|
|
<Stack gap="md">
|
|
<PasswordInput
|
|
maw={360}
|
|
label={t('profile.fields.currentPassword')}
|
|
leftSection={<IconLock size={18} />}
|
|
error={passwordErrors.oldPassword?.message}
|
|
{...registerPassword('oldPassword')}
|
|
/>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<div>
|
|
<PasswordInput
|
|
label={t('profile.fields.newPassword')}
|
|
leftSection={<IconLock size={18} />}
|
|
error={passwordErrors.newPassword?.message}
|
|
{...registerPassword('newPassword')}
|
|
/>
|
|
<PasswordRequirements password={watchPassword('newPassword')} minLength={8} />
|
|
</div>
|
|
<PasswordInput
|
|
label={t('profile.fields.confirmPassword')}
|
|
leftSection={<IconLock size={18} />}
|
|
error={passwordErrors.confirmPassword?.message}
|
|
{...registerPassword('confirmPassword')}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
{score > 0 && (
|
|
<Stack gap={6}>
|
|
<Group justify="space-between">
|
|
<Text size="xs" c="dimmed" fw={600}>
|
|
{t('profile.strength.label')}
|
|
</Text>
|
|
<Text size="xs" fw={600} c={strengthColors[score]}>
|
|
{strengthLabels[score]}
|
|
</Text>
|
|
</Group>
|
|
<Group gap={6} grow>
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<Box
|
|
key={i}
|
|
h={6}
|
|
style={{
|
|
borderRadius: 999,
|
|
backgroundColor:
|
|
i <= score
|
|
? `var(--mantine-color-${strengthColors[score]}-6)`
|
|
: 'var(--mantine-color-gray-light)',
|
|
}}
|
|
/>
|
|
))}
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
|
<div>
|
|
<Text fw={600}>{t('profile.twoStep.title')}</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t('profile.twoStep.desc')}
|
|
</Text>
|
|
</div>
|
|
<Switch
|
|
checked={twoStepEnabled}
|
|
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
|
// disabled={twoStepLoading || twoStepSaving}
|
|
// onChange={async (e) => {
|
|
// try {
|
|
// await setTwoStepEnabled(e.currentTarget.checked);
|
|
// notify.success(t('profile.twoStep.saved'));
|
|
// } catch (err) {
|
|
// handleError(err);
|
|
// }
|
|
// }}
|
|
/>
|
|
</Group>
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
type="submit"
|
|
loading={isSavingPassword}
|
|
leftSection={<IconShieldLock size={18} />}
|
|
>
|
|
{t('profile.updatePassword')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
|
|
<ActiveSessions />
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
{/* ---- Preferences ---- */}
|
|
<Tabs.Panel value="preferences" pt="md">
|
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
|
<Stack gap="xl">
|
|
<div>
|
|
<Title order={5}>{t('profile.languageTitle')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.languageHint')}
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
{SUPPORTED_LANGUAGES.map((lng) => {
|
|
const active = i18n.language === lng;
|
|
return (
|
|
<UnstyledButton
|
|
key={lng}
|
|
onClick={() => i18n.changeLanguage(lng)}
|
|
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
|
p="md"
|
|
>
|
|
<Group wrap="nowrap">
|
|
<Text fz={22}>{flags[lng]}</Text>
|
|
<div style={{ flex: 1 }}>
|
|
<Text fw={600} size="sm">
|
|
{t(`language.${lng}`)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{t(`profile.languageFull.${lng}`)}
|
|
</Text>
|
|
</div>
|
|
{active ? (
|
|
<IconCircleCheckFilled
|
|
size={20}
|
|
color="var(--mantine-color-emaPrimary-6)"
|
|
/>
|
|
) : (
|
|
<IconCircle
|
|
size={20}
|
|
color="var(--mantine-color-gray-4)"
|
|
/>
|
|
)}
|
|
</Group>
|
|
</UnstyledButton>
|
|
);
|
|
})}
|
|
</SimpleGrid>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div>
|
|
<Title order={5}>{t('profile.appearance.title')}</Title>
|
|
<Text size="sm" c="dimmed" mb="md">
|
|
{t('profile.appearance.subtitle')}
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
|
{appearanceOptions.map(({ value, label, icon: Icon }) => {
|
|
const active = colorScheme === value;
|
|
return (
|
|
<UnstyledButton
|
|
key={value}
|
|
onClick={() => setColorScheme(value)}
|
|
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
|
p="md"
|
|
>
|
|
<Group wrap="nowrap">
|
|
<Icon
|
|
size={20}
|
|
color={
|
|
active
|
|
? 'var(--mantine-color-emaPrimary-6)'
|
|
: 'var(--mantine-color-gray-6)'
|
|
}
|
|
/>
|
|
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
|
{label}
|
|
</Text>
|
|
{active && (
|
|
<IconCircleCheckFilled
|
|
size={18}
|
|
color="var(--mantine-color-emaPrimary-6)"
|
|
/>
|
|
)}
|
|
</Group>
|
|
</UnstyledButton>
|
|
);
|
|
})}
|
|
</SimpleGrid>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
|
<Group gap="sm" wrap="nowrap">
|
|
<IconBell size={20} color="var(--mantine-color-gray-6)" />
|
|
<div>
|
|
<Text fw={600}>{t('profile.notifications.title')}</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t('profile.notifications.desc')}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
<Switch
|
|
checked={emailNotifications}
|
|
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
|
|
/>
|
|
</Group>
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
leftSection={<IconCheck size={18} />}
|
|
onClick={() => notify.success(t('profile.profileUpdated'))}
|
|
>
|
|
{t('profile.savePreferences')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
);
|
|
}
|