feat(seafarer-registration): remove standalone registration page and redirect to licensing flow; update profile handling and eligibility checks

This commit is contained in:
Nati
2026-08-17 09:15:39 +00:00
parent 3e3d20174c
commit 7f45df04e0
8 changed files with 41 additions and 656 deletions

View File

@@ -4,12 +4,15 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
/**
* Where a fresh applicant lands after declaring themselves. Someone who says
* "I am a seafarer" or "I own a vessel" came here to register, so they are
* taken straight to that form instead of a dashboard that only links to it.
* "I own a vessel" came here to register, so they are taken straight to that
* form instead of a dashboard that only links to it. A seafarer goes to
* `/profile` instead — registration is built from the profile
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so
* sending them straight to the wizard would only bounce them back here.
* Seafarer wins when both are ticked; the other form is one nav click away.
*/
const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/seafarer-registration',
SEAFARER_REGISTRATION: '/profile',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
@@ -30,6 +31,7 @@ import {
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconInfoCircle,
IconLock,
IconMail,
IconBuildingWarehouse,
@@ -49,7 +51,7 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { setUser, useCurrentProfile } from '@ema-platform/auth';
import { 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';
@@ -66,6 +68,7 @@ import {
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. */
@@ -160,11 +163,22 @@ export function ProfilePage() {
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);
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
const [dataLoading, setDataLoading] = useState(true);
@@ -466,6 +480,12 @@ export function ProfilePage() {
<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">

View File

@@ -5,6 +5,8 @@ import type { AddressValues } from '../components/AddressFormContent';
export interface AddressPayload {
idType: string;
idNumber: string;
passportNumber?: string;
passportExpiry?: string;
nationality: string;
regionId?: string;
cityId?: string;
@@ -14,6 +16,8 @@ export interface AddressPayload {
woredaId?: string;
kebeleId?: string;
streetAddress?: string;
/** Where the seafarer currently lives, when different from the address above. */
currentAddress?: string;
primaryPhoneNumber: string;
secondaryPhoneNumber?: string;
email?: string;

View File

@@ -1,642 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useCurrentProfile, useUpdateMyProfileMutation } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { useSaveMyAddressMutation } from '../../profile/api/address-api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAddressBook,
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconSchool,
IconUser,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NATIONALITIES = [
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
];
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
const GENDERS = ['Male', 'Female'];
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
const STEPS = [
{ label: 'Personal Information' },
{ label: 'Contact Details' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
];
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
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' }}
>
{isDone ? `${step.label}` : 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>
);
}
// ---------------------------------------------------------------------------
// Section heading
// ---------------------------------------------------------------------------
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
// ---------------------------------------------------------------------------
// Review row
// ---------------------------------------------------------------------------
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// Document upload card
// ---------------------------------------------------------------------------
function DocCard({
slot,
file,
onFile,
}: {
slot: DocSlot;
file: File | null;
onFile: (f: File | null) => void;
}) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: 'dashed',
borderColor: file
? 'var(--mantine-color-teal-5)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box
style={{
width: rem(44),
height: rem(44),
borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">
{slot.label}
{slot.required && <Text span c="red" ml={3}>*</Text>}
</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { onFile(null); resetRef.current?.(); }}
>
Remove
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="default" {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistrationPage() {
const navigate = useNavigate();
// Every signed-in user already has a profile (`/profiles/me` provisions
// one), so registering fills that row in rather than creating a second —
// POST /profiles trips the unique user_id constraint.
const { profileId, profile } = useCurrentProfile();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [updateProfile] = useUpdateMyProfileMutation();
const [saveAddress] = useSaveMyAddressMutation();
// Step 1 — Personal Information
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
const [gender, setGender] = useState<string | null>(null);
const [dob, setDob] = useState<Date | null>(null);
const [placeOfBirth, setPlaceOfBirth] = useState('');
const [nationality, setNationality] = useState<string | null>('Ethiopian');
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
const [nationalIdNumber, setNationalIdNumber] = useState('');
const [passportNumber, setPassportNumber] = useState('');
const [passportExpiry, setPassportExpiry] = useState('');
// Step 2 — Contact Details
const [mobile, setMobile] = useState('');
const [email, setEmail] = useState('');
const [locationId, setLocationId] = useState<string | null>(null);
const [permanentAddress, setPermanentAddress] = useState('');
const [currentAddress, setCurrentAddress] = useState('');
const [emergencyName, setEmergencyName] = useState('');
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
const [emergencyPhone, setEmergencyPhone] = useState('');
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
nationalId: null, passport: null, graduation: null, photo: null,
});
const setFile = (key: string) => (f: File | null) =>
setFiles((prev) => ({ ...prev, [key]: f }));
// Signup already asked for name, email and phone — start from those (and
// whatever is on the profile) instead of making the applicant retype them.
// Only blank fields are filled, so nothing typed here is overwritten.
useEffect(() => {
const [enFirst = '', ...enRest] = (user?.name.en ?? '').trim().split(/\s+/);
const [amFirst = '', ...amRest] = (user?.name.am ?? '').trim().split(/\s+/);
const orEmpty = (v?: string | null) => v ?? '';
// Profile stores MALE / SINGLE; the selects here list Male / Single.
const title = (v: string) => v.charAt(0) + v.slice(1).toLowerCase();
setFirstName((c) => c.en ? c : { en: profile?.firstName || enFirst, am: amFirst });
setMiddleName((c) => c.en ? c : { en: profile?.middleName || enRest.slice(0, -1).join(' '), am: amRest.slice(0, -1).join(' ') });
setLastName((c) => c.en ? c : { en: profile?.lastName || orEmpty(enRest.at(-1)), am: orEmpty(amRest.at(-1)) });
if (profile?.gender) setGender((c) => c ?? title(profile.gender));
if (profile?.dob) setDob((c) => c ?? new Date(profile.dob));
if (profile?.pob) setPlaceOfBirth((c) => c || profile.pob);
if (profile?.maritalStatus) setMaritalStatus((c) => c ?? title(profile.maritalStatus));
setEmail((c) => c || profile?.address?.email || user?.email || '');
setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || '');
if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber);
if (profile?.address?.nationality) setNationality((c) => c ?? profile.address.nationality);
if (profile?.address?.streetAddress) setPermanentAddress((c) => c || orEmpty(profile.address.streetAddress));
if (profile?.address?.emergencyContactName) setEmergencyName((c) => c || orEmpty(profile.address.emergencyContactName));
if (profile?.address?.emergencyContactPhone) setEmergencyPhone((c) => c || orEmpty(profile.address.emergencyContactPhone));
if (profile?.address?.emergencyContactRelation) setEmergencyRel((c) => c ?? profile.address.emergencyContactRelation);
}, [user, profile]);
// Registration writes the profile as SEAFARER with personal details, so a
// profile in that state is a submitted registration: show it read-only
// instead of an empty wizard. "Edit" reopens the wizard on the same data.
const registered = profile?.type === 'SEAFARER' && !!profile.firstName && !!profile.dob;
const [editing, setEditing] = useState(false);
const canNext = () => {
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
if (active === 2) return !!files.nationalId && !!files.photo;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
if (!profileId) return;
setSubmitting(true);
try {
await updateProfile({
id: profileId,
body: {
type: 'SEAFARER',
firstName: firstName.en,
middleName: middleName.en || undefined,
lastName: lastName.en,
gender: gender?.toUpperCase() ?? 'MALE',
dob: dob?.toISOString().split('T')[0] ?? '',
pob: placeOfBirth || undefined,
maritalStatus: maritalStatus?.toUpperCase() ?? 'SINGLE',
},
}).unwrap();
await saveAddress({
profileId,
body: {
idType: 'NID',
idNumber: nationalIdNumber,
nationality: nationality ?? 'Ethiopian',
primaryPhoneNumber: mobile,
email: email || undefined,
website: null,
streetAddress: permanentAddress || undefined,
emergencyContactName: emergencyName || undefined,
emergencyContactPhone: emergencyPhone || undefined,
emergencyContactRelation: emergencyRel || undefined,
},
}).unwrap();
notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`);
setEditing(false);
setActive(0);
setCompleted([]);
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
const review = (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
<ReviewRow label="National ID No." value={nationalIdNumber} />
<ReviewRow label="Passport No." value={passportNumber} />
<ReviewRow label="Passport Expiry" value={passportExpiry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>
{emergencyName && (
<>
<Divider mt="md" mb="sm" />
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Name" value={emergencyName} />
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
<ReviewRow label="Phone" value={emergencyPhone} />
</SimpleGrid>
</>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs" align="center">
{files[slot.key] ? (
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
)}
<div>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label}
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
</Text>
{files[slot.key] && (
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
)}
</div>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
);
const stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active];
if (registered && !editing) {
const status = profile.seafarerStatus ?? 'PENDING';
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registration</Title>
<Text fz="sm" c="dimmed">
{profile.seafarerNumber
? `Seafarer ID ${profile.seafarerNumber}`
: 'Submitted — a Seafarer ID is issued once EMA approves your registration.'}
</Text>
</div>
<Group gap="sm">
<Badge variant="light" color={status === 'ACTIVE' ? 'teal' : status === 'PENDING' ? 'yellow' : 'red'}>
{status}
</Badge>
<Button variant="default" onClick={() => setEditing(true)}>Edit details</Button>
</Group>
</Group>
{review}
</Stack>
);
}
return (
<Stack gap="md">
{/* Page header */}
<div>
<Title order={3}>New Seafarer Registration</Title>
<Text fz="sm" c="dimmed">Register a new seafarer profile Step {active + 1} of {STEPS.length}</Text>
</div>
{/* Step indicator */}
<StepIndicator active={active} completed={completed} />
{/* Card */}
<Paper withBorder radius="lg" p="xl">
{/* Card header */}
<Group justify="space-between" mb="lg">
<Group gap="xs">
<StepIcon size={20} stroke={1.6} />
<Text fw={700} fz="lg">{stepLabel}</Text>
</Group>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: Personal Information ───────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<SectionHead title="Identity Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
</SimpleGrid>
<SectionHead title="Identity Documents" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
A unique Seafarer ID will be automatically generated upon approval of this registration.
</Alert>
</Stack>
)}
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<SectionHead title="Contact Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Location" />
<LocationPicker
value={locationId ?? undefined}
onChange={setLocationId}
required
/>
<SectionHead title="Address" />
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
<Textarea
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
placeholder="Full current address"
autosize
minRows={2}
value={currentAddress}
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
/>
<SectionHead title="Emergency Contact" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard
key={slot.key}
slot={slot}
file={files[slot.key]}
onFile={setFile(slot.key)}
/>
))}
</SimpleGrid>
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap={6} align="center">
{files[slot.key] ? (
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
</Text>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
{active === 3 && review}
{/* Navigation buttons */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/applications')}>
Cancel
</Button>
<Group gap="sm">
{active > 0 && (
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
onClick={next}
disabled={!canNext()}
>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={handleSubmit}
loading={submitting}
>
Submit Registration
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -235,6 +235,7 @@ export const am: Translations = {
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
seafarerBanner: 'የባህረኛ ምዝገባ ለማድረግ የመገለጫ መረጃ ያስፈልጋል።',
},
profileSections: {

View File

@@ -235,6 +235,7 @@ export const en = {
seafarerReason:
'Seafarer registration is built from your profile — these details fill it in for you.',
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
seafarerBanner: 'Profile details are needed for seafarer registration.',
},
profileSections: {

View File

@@ -27,7 +27,6 @@ import { RequireSeafarerProfile } from "./features/profile/components/RequireSea
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
@@ -153,17 +152,13 @@ export const router = createBrowserRouter([
},
// Seafarer
// The standalone wizard is gone — registration is the config-driven
// licensing flow like every other licence type, gated by
// RequireSeafarerProfile + RequirePermission the same way
// /licensing/:typeCode/apply already is.
{
path: "/seafarer-registration",
// No profile gate: this wizard asks for the personal, identity and
// contact details itself across its four steps, so sending the
// applicant to /profile first made the approved form unreachable —
// they were bounced out before ever seeing it.
element: (
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
),
element: <Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />,
},
{
path: "/seafarer/records",

View File

@@ -35,6 +35,8 @@ export interface CurrentProfileAddress {
id: string;
idType: string;
idNumber: string;
passportNumber: string | null;
passportExpiry: string | null;
nationality: string;
regionId: string | null;
cityId: string | null;
@@ -42,6 +44,7 @@ export interface CurrentProfileAddress {
woredaId: string | null;
kebeleId: string | null;
streetAddress: string | null;
currentAddress: string | null;
houseNumber: string | null;
primaryPhoneNumber: string;
secondaryPhoneNumber: string | null;