From fbf948b3d38dfee41b070c3bad3478f2ca8a20e6 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 8 Aug 2026 08:35:40 +0000 Subject: [PATCH] Implement seafarer profile requirement gate to ensure complete profile before registration --- .../pages/LicenseApplicationPage.tsx | 20 ++++ .../components/RequireSeafarerProfile.tsx | 105 ++++++++++++++++++ .../features/profile/pages/ProfilePage.tsx | 8 ++ apps/portal/src/app/i18n/locales/am.ts | 2 + apps/portal/src/app/i18n/locales/en.ts | 3 + apps/portal/src/app/router.tsx | 16 ++- libs/auth/src/lib/hooks/useCurrentProfile.ts | 35 ++++-- 7 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 apps/portal/src/app/features/profile/components/RequireSeafarerProfile.tsx diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 341a04b31..a19609d51 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -50,6 +50,7 @@ import { type ValidationIssue, } from '@ema-platform/api'; import { ModalFooter } from '@ema-platform/ui'; +import { useCurrentProfile } from '@ema-platform/auth'; import { ConfigDrivenSection } from '../components/ConfigDrivenSection'; import { DocumentSlots } from '../components/DocumentSlots'; import { StaffEvidence } from '../components/StaffEvidence'; @@ -65,6 +66,7 @@ export function LicenseApplicationPage() { const { data: config, isLoading: loadingConfig } = useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode }); + const { profile } = useCurrentProfile(); const [createApplication] = useCreateApplicationMutation(); const [appId, setAppId] = useState(applicationId); @@ -112,6 +114,24 @@ export function LicenseApplicationPage() { if (detail?.application?.formData) setDraft(detail.application.formData); }, [detail?.application?.id, detail?.application?.adjustmentRound]); + // Nationality is already on file from the profile's Address tab — carry it + // into whichever section the form config puts a `nationality` field in, + // rather than asking again. Only fills a blank; a value already on the + // draft (the applicant's own edit, or one the server saved) is left alone. + useEffect(() => { + const nationality = profile?.address?.nationality; + if (!nationality || !config) return; + const section = config.licenseType.formSchema.sections.find((s) => + s.fields.some((f) => f.key === 'nationality'), + ); + if (!section) return; + setDraft((prev) => + prev[section.key]?.nationality + ? prev + : { ...prev, [section.key]: { ...prev[section.key], nationality } }, + ); + }, [profile?.address?.nationality, config]); + const application = detail?.application; const isAdjusting = application?.status === 'RESUBMIT_REQUIRED'; const openRemarks = detail?.openRemarks ?? []; diff --git a/apps/portal/src/app/features/profile/components/RequireSeafarerProfile.tsx b/apps/portal/src/app/features/profile/components/RequireSeafarerProfile.tsx new file mode 100644 index 000000000..350b0b014 --- /dev/null +++ b/apps/portal/src/app/features/profile/components/RequireSeafarerProfile.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react'; +import { Center, Loader } from '@mantine/core'; +import { Navigate, useLocation, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { notify } from '@ema-platform/ui'; +import { + PROFILE_FIELD_SECTION, + useCurrentProfile, + type ProfileRequirement, +} from '@ema-platform/auth'; + +/** + * Seafarer registration is filled in from the profile (nationality, ID, + * names, contact details) — the server refuses an application missing them, + * so they're asked for up front instead of at submit time. + * + * Only the fields the Personal, Maritime Profile and Address tabs actually + * mark required — matches `profileSchema` / `addressSchema`, so the gate is + * always satisfiable by finishing those tabs and never blocks on an optional + * field (place of birth, region/city/woreda, emergency contact) the forms + * don't star. + */ +export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = { + fields: [ + 'firstName', + 'middleName', + 'lastName', + 'gender', + 'dob', + 'maritalStatus', + 'professionId', + 'idType', + 'idNumber', + 'nationality', + 'primaryPhoneNumber', + 'email', + ], + reason: + 'Seafarer registration is built from your profile — these details fill it in for you.', +}; + +const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION'; + +/** + * Sends an applicant with an incomplete profile to `/profile` before they can + * reach seafarer registration. Wraps `/seafarer-registration` directly and + * `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the + * latter is the shared wizard route every licence type renders through, so + * without it the gate is a decoration a deep link skips. + * + * Fires before the wizard starts, not mid-application, so nothing is lost — + * unlike the case `ProfileRequirementGate`'s doc comment warns against + * (mid-flow redirects on the old, deleted setup wizard). + */ +export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(); + const { typeCode } = useParams(); + const { pathname } = useLocation(); + const { isLoading, isFetching, error, gapsFor } = useCurrentProfile(); + + // Shared wizard route — only the seafarer type is gated here. + const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY; + const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : []; + const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0; + + useEffect(() => { + if (!redirecting) return; + const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', '); + notify.info(t('profileGate.seafarerRedirect', { fields })); + // Fire once per redirect, not on every render while gaps/gapsFor are + // recreated — the toast content is captured at the moment it fires. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [redirecting, pathname]); + + if (!gated) return <>{children}; + + if (isLoading) { + return ( +
+ +
+ ); + } + + // A failed lookup must not lock anyone out — the server still refuses the + // application for a profile it can't fill in from. + if (error) return <>{children}; + + if (gaps.length === 0) return <>{children}; + + // Gaps while a save is still landing are not an answer yet. Saving a + // profile tab invalidates this query and the applicant may already be + // headed back here in the same tick — deciding on the pre-save cache would + // bounce them off the screen they just finished. + if (isFetching) { + return ( +
+ +
+ ); + } + + const target = PROFILE_FIELD_SECTION[gaps[0]]; + return ; +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 28a38695a..cc638ed40 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -146,6 +146,7 @@ export function ProfilePage() { isLoading: profileResolving, completeness, missing, + refetch: refetchProfile, } = useCurrentProfile(); const [updateProfile] = useApiMutation(); const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation(); @@ -280,6 +281,9 @@ export function ProfilePage() { 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); @@ -311,6 +315,10 @@ export function ProfilePage() { body: values, }).unwrap(); + // This endpoint doesn't invalidate the `CurrentProfile` tag (unlike + // the address save below) — without this, `missing` stays stale until + // a reload. + refetchProfile(); notify.success('Profile updated'); } catch (e) { handleError(e); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 36669d8c6..1fe2d9a39 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -149,6 +149,8 @@ export const am: Translations = { title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን', addDetails: 'እነዚህን መረጃዎች ጨምር', viewProfile: 'ሙሉ መገለጫ ይመልከቱ', + seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።', + seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}', }, profileSections: { diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 634e60d87..c23ee7ccb 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -147,6 +147,9 @@ export const en = { title_other: 'We need {{count}} more details before you continue', addDetails: 'Add these details', viewProfile: 'View full profile', + 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}}', }, profileSections: { diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 1952936c4..d8bef245a 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -16,6 +16,7 @@ import { // Portal feature pages import { DashboardPage } from "./features/dashboard/pages/DashboardPage"; import { RequireOperations } from "./features/onboarding/components/RequireOperations"; +import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile"; import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage"; import { ProfilePage } from "./features/profile/pages/ProfilePage"; import { SupportPage } from "./features/support/pages/SupportPage"; @@ -114,7 +115,11 @@ export const router = createBrowserRouter([ { path: "/payments/failure", element: }, { path: "/licensing/:typeCode/apply", - element: , + element: ( + + + + ), }, { path: "/licensing/:typeCode/applications/:applicationId", @@ -128,7 +133,14 @@ export const router = createBrowserRouter([ }, // Seafarer - { path: "/seafarer-registration", element: }, + { + path: "/seafarer-registration", + element: ( + + + + ), + }, { path: "/seafarer/records", element: }, { path: "/exams", element: }, // The public-facing registry was a hardcoded mock and does not belong in diff --git a/libs/auth/src/lib/hooks/useCurrentProfile.ts b/libs/auth/src/lib/hooks/useCurrentProfile.ts index 948da185e..b4b9c8e5a 100644 --- a/libs/auth/src/lib/hooks/useCurrentProfile.ts +++ b/libs/auth/src/lib/hooks/useCurrentProfile.ts @@ -38,21 +38,32 @@ export const PROFILE_FIELDS = [ export type ProfileField = (typeof PROFILE_FIELDS)[number]; -/** Which `/profile` tab collects a field — used to build deep links. */ -export const PROFILE_FIELD_SECTION: Record = { - firstName: 'personal', - middleName: 'personal', - lastName: 'personal', - gender: 'personal', - dob: 'personal', - pob: 'personal', - maritalStatus: 'personal', - professionId: 'personal', +/** + * Which `/profile` tab collects a field — used to build deep links. + * + * `personal` here means the Personal tab (account details: name, username, + * email, phone), which is `/profile`'s `'personal'` panel. Name/DOB/gender/ + * profession live on the *Maritime Profile* panel instead, whose tab key is + * `'profile'` — easy to conflate with the field-section name of the same + * word, so it's called out here rather than left implicit. + */ +export const PROFILE_FIELD_SECTION: Record< + ProfileField, + 'personal' | 'profile' | 'address' | 'emergency' +> = { + firstName: 'profile', + middleName: 'profile', + lastName: 'profile', + gender: 'profile', + dob: 'profile', + pob: 'profile', + maritalStatus: 'profile', + professionId: 'profile', idType: 'address', idNumber: 'address', nationality: 'address', - primaryPhoneNumber: 'address', - email: 'address', + primaryPhoneNumber: 'personal', + email: 'personal', regionId: 'address', cityId: 'address', subCityId: 'address',