From 7f45df04e048d020d42a7a376fe7b9c4eee8ba80 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 17 Aug 2026 09:15:39 +0000 Subject: [PATCH] feat(seafarer-registration): remove standalone registration page and redirect to licensing flow; update profile handling and eligibility checks --- .../pages/OperationsOnboardingPage.tsx | 9 +- .../features/profile/pages/ProfilePage.tsx | 22 +- .../src/app/features/profile/types/address.ts | 4 + .../pages/SeafarerRegistrationPage.tsx | 642 ------------------ apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + apps/portal/src/app/router.tsx | 15 +- libs/auth/src/lib/types/auth.types.ts | 3 + 8 files changed, 41 insertions(+), 656 deletions(-) delete mode 100644 apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx diff --git a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx index 3f2abd624..5c4d858a9 100644 --- a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx +++ b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx @@ -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 = { - SEAFARER_REGISTRATION: '/seafarer-registration', + SEAFARER_REGISTRATION: '/profile', VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply', }; diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 3dd6ae467..067ba3340 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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(); 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(null); const [loadedAddress, setLoadedAddress] = useState(null); const [dataLoading, setDataLoading] = useState(true); @@ -466,6 +480,12 @@ export function ProfilePage() { + {showSeafarerBanner && ( + }> + {t('profileGate.seafarerBanner')} + + )} + {/* Profile summary */} diff --git a/apps/portal/src/app/features/profile/types/address.ts b/apps/portal/src/app/features/profile/types/address.ts index 4a65f2d58..a7e27152e 100644 --- a/apps/portal/src/app/features/profile/types/address.ts +++ b/apps/portal/src/app/features/profile/types/address.ts @@ -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; diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx deleted file mode 100644 index cd0e7bee4..000000000 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx +++ /dev/null @@ -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 ( - - - {STEPS.map((step, i) => { - const isDone = completed.includes(i); - const isCurrent = active === i; - return ( - - - - {isDone ? ( - - ) : ( - - {i + 1} - - )} - - - {isDone ? `${step.label} ✓` : step.label} - - - - {i < STEPS.length - 1 && ( - - )} - - ); - })} - - - ); -} - -// --------------------------------------------------------------------------- -// Section heading -// --------------------------------------------------------------------------- -function SectionHead({ title }: { title: string }) { - return ( - <> - - {title} - - ); -} - -// --------------------------------------------------------------------------- -// Review row -// --------------------------------------------------------------------------- -function ReviewRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value || '—'} -
- ); -} - -// --------------------------------------------------------------------------- -// 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 ( - - - - - -
- - {slot.label} - {slot.required && *} - - {slot.description} -
-
- - {file ? ( - - - {file.name} - - - ) : ( - - {(props) => ( - - )} - - )} -
- ); -} - -// --------------------------------------------------------------------------- -// 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([]); - const [submitting, setSubmitting] = useState(false); - const [updateProfile] = useUpdateMyProfileMutation(); - const [saveAddress] = useSaveMyAddressMutation(); - - // Step 1 — Personal Information - const [firstName, setFirstName] = useState({ en: '', am: '' }); - const [middleName, setMiddleName] = useState({ en: '', am: '' }); - const [lastName, setLastName] = useState({ en: '', am: '' }); - const [gender, setGender] = useState(null); - const [dob, setDob] = useState(null); - const [placeOfBirth, setPlaceOfBirth] = useState(''); - const [nationality, setNationality] = useState('Ethiopian'); - const [maritalStatus, setMaritalStatus] = useState(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(null); - const [permanentAddress, setPermanentAddress] = useState(''); - const [currentAddress, setCurrentAddress] = useState(''); - const [emergencyName, setEmergencyName] = useState(''); - const [emergencyRel, setEmergencyRel] = useState(null); - const [emergencyPhone, setEmergencyPhone] = useState(''); - - // Step 3 — Documents - const [files, setFiles] = useState>({ - 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 = ( - - - Personal Information - - - - - - - - - - - - - - - - - Contact Details - - - - - - - - {emergencyName && ( - <> - - Emergency Contact - - - - - - - )} - - - - Documents - - {DOC_SLOTS.map((slot) => ( - - {files[slot.key] ? ( - - ) : ( - - )} -
- - {slot.label} - {slot.required && !files[slot.key] && *} - - {files[slot.key] && ( - {files[slot.key]!.name} - )} -
-
- ))} -
-
-
- ); - - const stepLabel = STEPS[active]?.label ?? ''; - const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck]; - const StepIcon = stepIcons[active]; - - if (registered && !editing) { - const status = profile.seafarerStatus ?? 'PENDING'; - return ( - - -
- Seafarer Registration - - {profile.seafarerNumber - ? `Seafarer ID ${profile.seafarerNumber}` - : 'Submitted — a Seafarer ID is issued once EMA approves your registration.'} - -
- - - {status} - - - -
- {review} -
- ); - } - - return ( - - {/* Page header */} -
- New Seafarer Registration - Register a new seafarer profile — Step {active + 1} of {STEPS.length} -
- - {/* Step indicator */} - - - {/* Card */} - - {/* Card header */} - - - - {stepLabel} - - Step {active + 1} of {STEPS.length} - - - {/* ── Step 1: Personal Information ───────────────────────────── */} - {active === 0 && ( - - - - - - - -