Implement seafarer profile requirement gate to ensure complete profile before registration

This commit is contained in:
estifanos
2026-08-08 08:35:40 +00:00
parent 1116e57a8d
commit fbf948b3d3
7 changed files with 175 additions and 14 deletions

View File

@@ -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<string | undefined>(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 ?? [];

View File

@@ -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 (
<Center h={200}>
<Loader />
</Center>
);
}
// 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 (
<Center h={200}>
<Loader />
</Center>
);
}
const target = PROFILE_FIELD_SECTION[gaps[0]];
return <Navigate to={`/profile#${target}`} replace />;
}

View File

@@ -146,6 +146,7 @@ export function ProfilePage() {
isLoading: profileResolving,
completeness,
missing,
refetch: refetchProfile,
} = useCurrentProfile();
const [updateProfile] = useApiMutation<unknown>();
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);

View File

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

View File

@@ -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: {

View File

@@ -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: <PaymentFailurePage /> },
{
path: "/licensing/:typeCode/apply",
element: <LicenseApplicationPage />,
element: (
<RequireSeafarerProfile>
<LicenseApplicationPage />
</RequireSeafarerProfile>
),
},
{
path: "/licensing/:typeCode/applications/:applicationId",
@@ -128,7 +133,14 @@ export const router = createBrowserRouter([
},
// Seafarer
{ path: "/seafarer-registration", element: <SeafarerRegistrationPage /> },
{
path: "/seafarer-registration",
element: (
<RequireSeafarerProfile>
<SeafarerRegistrationPage />
</RequireSeafarerProfile>
),
},
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
{ path: "/exams", element: <ExamsPage /> },
// The public-facing registry was a hardcoded mock and does not belong in

View File

@@ -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<ProfileField, 'personal' | 'address' | 'emergency'> = {
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',