mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Implement seafarer profile requirement gate to ensure complete profile before registration
This commit is contained in:
@@ -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 ?? [];
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -149,6 +149,8 @@ export const am: Translations = {
|
||||
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
||||
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
||||
},
|
||||
|
||||
profileSections: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user