feat: update seafarer registration flow and localization; remove profile gating and enhance user experience

This commit is contained in:
fitse-yotor
2026-08-18 16:44:39 +03:00
parent 1931e69594
commit 03e2b2b8a4
6 changed files with 56 additions and 71 deletions

View File

@@ -23,7 +23,12 @@ import { approveRegistration, runWorkflow } from './support/workflow';
*/
/**
* Fills the fields `RequireSeafarerProfile` refuses to open the wizard without.
* Fills the profile the seafarer wizard prefills its Identity Details step
* from.
*
* No longer a precondition for reaching the wizard — that redirect is gone and
* the step collects these itself — but a populated profile is the returning
* applicant's case, and it is the prefill that keeps them from retyping.
*
* They are split across two tabs, and every tab's panel is in the DOM whether
* or not it is showing — so each one has to be selected before its inputs can

View File

@@ -227,12 +227,17 @@ export function LicenseApplicationPage() {
detail?.application?.formData,
]);
// Generic fill for every field the config marks `readOnly` with a
// `source` — e.g. seafarer registration's read-only Identity Details step,
// which shows what's already on the profile instead of asking again.
// `readOnly` fields are never sent by the applicant and the server skips
// them at validation, so this is display-only; the profile itself is what
// an edit has to go through.
// Generic fill for every field the config gives a `source` — the profile
// value the applicant would otherwise retype. Seafarer registration's
// Identity Details step is the case that drives this: it collects name,
// gender, DOB and national ID *in the wizard* rather than sending the
// applicant to `/profile` first, so those fields are editable and this is a
// prefill, not a display.
//
// Editable sourced fields are filled only while still blank. Re-running
// this effect (a refetched profile, a saved draft) must not overwrite what
// the applicant has since typed — for a `readOnly` field the profile stays
// authoritative, so those keep tracking it.
useEffect(() => {
if (!profile || !config) return;
const context = { user: profile.user, profile };
@@ -242,10 +247,14 @@ export function LicenseApplicationPage() {
const next = { ...prev };
for (const section of config.licenseType.formSchema.sections) {
for (const field of section.fields) {
if (!field.readOnly || !field.source) continue;
if (!field.source) continue;
const current = next[section.key]?.[field.key];
const untouched =
current === undefined || current === null || current === "";
if (!field.readOnly && !untouched) continue;
const value = readSourcePath(context, field.source);
if (value === undefined || value === null || value === "") continue;
if (next[section.key]?.[field.key] === value) continue;
if (current === value) continue;
next[section.key] = { ...next[section.key], [field.key]: value };
changed = true;
}

View File

@@ -1,24 +1,14 @@
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';
import { Navigate, useParams } from 'react-router-dom';
import { 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.
* The identity the seafarer wizard needs before it can produce a registration.
*
* 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.
* No longer a gate on opening the wizard: the Identity Details step collects
* these itself, so an applicant with an empty profile starts in registration
* rather than being sent to `/profile` to prepare for it. Kept because
* `ProfilePage` still reads it to show what a seafarer registration will need.
*/
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
fields: [
@@ -42,35 +32,26 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
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.
* Guards the seafarer wizard against the one case it cannot serve: an
* applicant who already holds a seafarer number.
*
* 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).
* It deliberately does *not* gate on profile completeness any more. Selecting
* Seafarer Registration now opens the wizard, and the Identity Details step
* collects name, gender, DOB, marital status, nationality and national ID
* itself — an empty profile is a thing the wizard fills, not a reason to be
* sent away from it. Those answers reach the profile when a reviewer approves
* the registration (`CompletionEffectService.registerSeafarer`).
*
* 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 a deep link skips this.
*/
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { typeCode } = useParams();
const { pathname } = useLocation();
const { isLoading, isFetching, error, gapsFor, profile } = useCurrentProfile();
const { isLoading, error, profile } = useCurrentProfile();
// Shared wizard route — only the seafarer type is gated here.
// Shared wizard route — only the seafarer type is checked 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}</>;
@@ -91,24 +72,10 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
return <Navigate to="/seaman-book" replace />;
}
// A failed lookup must not lock anyone out — the server still refuses the
// application for a profile it can't fill in from.
// A failed lookup must not lock anyone out — an unreadable profile says
// nothing about whether this applicant is already registered, and the
// server refuses a duplicate registration regardless.
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 />;
return <>{children}</>;
}

View File

@@ -234,8 +234,8 @@ export const am: Translations = {
addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
seafarerBanner: 'የባህረኛ ምዝገባ ለማድረግ የመገለጫ መረጃ ያስፈልጋል።',
seafarerBanner:
'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
},
profileSections: {

View File

@@ -234,8 +234,8 @@ export const en = {
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}}',
seafarerBanner: 'Profile details are needed for seafarer registration.',
seafarerBanner:
'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.',
},
profileSections: {

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
allowBuilds:
core-js: set this to true or false
esbuild: set this to true or false
nx: set this to true or false