mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
@@ -55,6 +56,9 @@ interface CertificatesOverview {
|
||||
type: string;
|
||||
submitted: string;
|
||||
status: string;
|
||||
/** The fee owed at the current status, or null when nothing is due. */
|
||||
feeAmount: number | null;
|
||||
feeCurrency: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -72,7 +76,8 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'grape',
|
||||
INSPECTION_COMPLETED: 'grape',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||
ELIGIBILITY_PAID: 'blue',
|
||||
EXAM_PAYMENT_PENDING: 'orange',
|
||||
EXAM_PAID: 'blue',
|
||||
EXAM_SCHEDULED: 'indigo',
|
||||
@@ -140,6 +145,7 @@ export function CertificatesPage() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
|
||||
const { data } = useApiQuery<CertificatesOverview>({
|
||||
url: '/certificates/my',
|
||||
@@ -220,14 +226,25 @@ export function CertificatesPage() {
|
||||
{/* Tooltip needs a hoverable child even while the button itself is
|
||||
disabled, so the reason still shows on hover. */}
|
||||
<span>
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/certificates/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoC / CoP
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoP
|
||||
</Button>
|
||||
</Group>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -269,7 +286,7 @@ export function CertificatesPage() {
|
||||
<Text fw={700} mb="md">My Applications</Text>
|
||||
{applications.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
||||
No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
@@ -301,14 +318,28 @@ export function CertificatesPage() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/applications/${app.applicationId}`)}
|
||||
>
|
||||
Details
|
||||
</Text>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* feeAmount is non-null exactly when the server would
|
||||
accept a payment, so the button and the API agree. */}
|
||||
{app.feeAmount !== null && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="yellow"
|
||||
loading={isPaying}
|
||||
onClick={() => pay(app.applicationId)}
|
||||
>
|
||||
Pay {app.feeAmount.toLocaleString()} {app.feeCurrency}
|
||||
</Button>
|
||||
)}
|
||||
<Text
|
||||
fz="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/applications/${app.applicationId}`)}
|
||||
>
|
||||
Details
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import {
|
||||
type FormSectionConfig,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'PHONE' ? (
|
||||
<PhoneInput
|
||||
{...common}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'TEXTAREA' ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
|
||||
@@ -144,6 +144,11 @@ export function DocumentSlots({
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{requirement.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{localized(requirement.description)}
|
||||
</Text>
|
||||
)}
|
||||
{existing?.files?.[0] && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{existing.files[0].originalName} ·{' '}
|
||||
|
||||
@@ -7,12 +7,13 @@ interface Props {
|
||||
t: TFunction;
|
||||
requesting: boolean;
|
||||
paying: boolean;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the candidate can do while an examined certificate is in its exam leg.
|
||||
* What the candidate can do while an examined certificate is in its
|
||||
* eligibility or exam leg.
|
||||
*
|
||||
* Kept apart from the general actions column because these statuses only ever
|
||||
* occur on types that examine — folding them into that column would put five
|
||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
||||
t,
|
||||
requesting,
|
||||
paying,
|
||||
onRequestExamFee,
|
||||
onRetakeExam,
|
||||
onPay,
|
||||
}: Props) {
|
||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
||||
// the same decision — ask for the fee that buys a sitting.
|
||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
||||
const retake = app.status === 'EXAM_FAILED';
|
||||
// The eligibility fee is invoiced the moment the application is submitted —
|
||||
// there is no separate "request" step, so this is a pay button, exactly
|
||||
// like EXAM_PAYMENT_PENDING below.
|
||||
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color={retake ? 'orange' : 'teal'}
|
||||
loading={requesting}
|
||||
onClick={() => onRequestExamFee(app)}
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{retake
|
||||
? t('applications.actions.bookRetake', 'Book a resit')
|
||||
: t('applications.actions.bookExam', 'Book exam')}
|
||||
{t('applications.actions.payEligibilityFee', {
|
||||
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Paid — queued for backoffice review. Nothing for the candidate to do.
|
||||
if (app.status === 'ELIGIBILITY_PAID') {
|
||||
return (
|
||||
<Button size="xs" variant="subtle" disabled>
|
||||
{t('applications.actions.eligibilityUnderReview', 'Under review')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Failed a sitting: the only decision left is whether to pay for another.
|
||||
if (app.status === 'EXAM_FAILED') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
loading={requesting}
|
||||
onClick={() => onRetakeExam(app)}
|
||||
>
|
||||
{t('applications.actions.bookRetake', 'Book a resit')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +93,25 @@ export function ExamStageActions({
|
||||
);
|
||||
}
|
||||
|
||||
// Passed: the certificate itself is the last fee on the application.
|
||||
if (app.status === 'EXAM_PASSED') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{t('applications.actions.payCertificateFee', {
|
||||
defaultValue: 'Pay certificate fee ({{amount}} {{currency}})',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Paid and scheduled are both waiting states — nothing for the candidate to
|
||||
// do, so say so rather than offering a button that does nothing.
|
||||
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
|
||||
|
||||
@@ -270,13 +270,18 @@ export function LicenseApplicationPage() {
|
||||
const nameFallback = accountName?.en
|
||||
? splitPersonName(accountName.en)
|
||||
: null;
|
||||
const firstName = profile.firstName || nameFallback?.firstName || "";
|
||||
const middleName = profile.middleName || nameFallback?.middleName || "";
|
||||
const lastName = profile.lastName || nameFallback?.lastName || "";
|
||||
const context = {
|
||||
user: accountUser ?? profile.user,
|
||||
profile: {
|
||||
...profile,
|
||||
firstName: profile.firstName || nameFallback?.firstName || "",
|
||||
middleName: profile.middleName || nameFallback?.middleName || "",
|
||||
lastName: profile.lastName || nameFallback?.lastName || "",
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
// The profile has no single "full name" column — it's first/middle/last.
|
||||
fullName: [firstName, middleName, lastName].filter(Boolean).join(" "),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,7 +296,12 @@ export function LicenseApplicationPage() {
|
||||
const untouched =
|
||||
current === undefined || current === null || current === "";
|
||||
if (!field.readOnly && !untouched) continue;
|
||||
const value = readSourcePath(context, source);
|
||||
const raw = readSourcePath(context, source);
|
||||
// Profile dates arrive as ISO datetimes; a DATE field's picker wants
|
||||
// yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the
|
||||
// same mismatch today — fixed once here rather than per config.
|
||||
const value =
|
||||
field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw;
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (current === value) continue;
|
||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||
@@ -727,9 +737,14 @@ export function LicenseApplicationPage() {
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb={section.description ? 4 : "sm"}>
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
{section.description && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
{locked && (
|
||||
<Alert
|
||||
color="gray"
|
||||
|
||||
@@ -8,11 +8,13 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
||||
|
||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||
const EXAM_STAGE_STATUSES = [
|
||||
'ELIGIBILITY_APPROVED',
|
||||
'ELIGIBILITY_PAYMENT_PENDING',
|
||||
'ELIGIBILITY_PAID',
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_PAID',
|
||||
'EXAM_SCHEDULED',
|
||||
'EXAM_FAILED',
|
||||
'EXAM_PASSED',
|
||||
];
|
||||
|
||||
export function applicationActionsColumn(
|
||||
@@ -23,12 +25,12 @@ export function applicationActionsColumn(
|
||||
bypassEnabled: boolean;
|
||||
bypassing: boolean;
|
||||
isPaying: boolean;
|
||||
/** True while the exam fee is being raised for a booking or a resit. */
|
||||
/** True while a resit is being requested. */
|
||||
requestingExamFee: boolean;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
onCertificate: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
@@ -46,14 +48,17 @@ export function applicationActionsColumn(
|
||||
t={t}
|
||||
requesting={deps.requestingExamFee}
|
||||
paying={deps.isPaying}
|
||||
onRequestExamFee={deps.onRequestExamFee}
|
||||
onRetakeExam={deps.onRetakeExam}
|
||||
onPay={deps.onPay}
|
||||
/>
|
||||
{/* Both fee stops are bypassable — an examined certificate is
|
||||
{/* Every fee stop is bypassable — an examined certificate charges
|
||||
three separate fees (eligibility, exam, certificate) and is
|
||||
otherwise untestable without a live gateway. */}
|
||||
{deps.bypassEnabled &&
|
||||
(app.status === 'PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
||||
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PASSED') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useRequestExamPaymentMutation,
|
||||
useRetakeExamMutation,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
||||
useRequestExamPaymentMutation();
|
||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
@@ -135,15 +134,15 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the examination fee, for a first sitting or a resit.
|
||||
* Re-opens the examination fee for a failed candidate.
|
||||
*
|
||||
* Payment is a separate step: this only moves the application to
|
||||
* Payment is a separate step: this only moves the application back to
|
||||
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||
* the provider the same way every other fee does.
|
||||
*/
|
||||
async function requestExamFee(applicationId: string) {
|
||||
async function retakeExamFee(applicationId: string) {
|
||||
try {
|
||||
await requestExamPayment(applicationId).unwrap();
|
||||
await retakeExam(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||
onPay: (app) => pay(app.id),
|
||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
||||
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFo
|
||||
import { z } from 'zod';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
|
||||
import { phoneNumber, optionalPhoneNumber, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||
import type { Location, LocationType } from '../../location/types/location';
|
||||
@@ -17,8 +17,8 @@ export function addressSchema(t: TFunction) {
|
||||
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
|
||||
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
|
||||
primaryPhoneNumber: ethiopianPhone,
|
||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||
primaryPhoneNumber: phoneNumber,
|
||||
secondaryPhoneNumber: optionalPhoneNumber,
|
||||
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
@@ -30,7 +30,7 @@ export function addressSchema(t: TFunction) {
|
||||
// Emergency contact is collected but never required — leaving it blank must
|
||||
// not stop an applicant moving on.
|
||||
emergencyContactName: z.string().trim().optional(),
|
||||
emergencyContactPhone: optionalEthiopianPhone,
|
||||
emergencyContactPhone: optionalPhoneNumber,
|
||||
emergencyContactRelation: z.string().trim().optional(),
|
||||
});
|
||||
}
|
||||
@@ -147,18 +147,22 @@ export function AddressFormContent({
|
||||
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||
error={errors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileFields.primaryPhoneNumber')}
|
||||
description={t('profileAddress.accountManagedHint')}
|
||||
required
|
||||
readOnly
|
||||
{...register('primaryPhoneNumber')}
|
||||
value={watch('primaryPhoneNumber') || ''}
|
||||
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
|
||||
onBlur={() => trigger('primaryPhoneNumber')}
|
||||
error={errors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileAddress.secondaryPhoneNumber')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('secondaryPhoneNumber')}
|
||||
value={watch('secondaryPhoneNumber') || ''}
|
||||
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
|
||||
onBlur={() => trigger('secondaryPhoneNumber')}
|
||||
error={errors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -206,10 +210,12 @@ export function AddressFormContent({
|
||||
{...register('emergencyContactName')}
|
||||
error={errors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileAddress.contactPhone')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('emergencyContactPhone')}
|
||||
value={watch('emergencyContactPhone') || ''}
|
||||
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
|
||||
onBlur={() => trigger('emergencyContactPhone')}
|
||||
error={errors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
|
||||
@@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean {
|
||||
return birth <= cutoff;
|
||||
}
|
||||
|
||||
export const profileSchema = (t: TFunction) =>
|
||||
// Printed on the Seaman Book, so a seafarer account can't leave them blank —
|
||||
// every other account type may. Blood type offers UNKNOWN, so requiring an
|
||||
// answer never forces a claim. Mirrors seafarer-registration.seed-data.ts's
|
||||
// `required: true` on the same fields, and the backend's own check in
|
||||
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
|
||||
export const profileSchema = (t: TFunction, isSeafarer: boolean) =>
|
||||
z.object({
|
||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
||||
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
|
||||
@@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) =>
|
||||
.refine((value) => isAtLeast18(value), {
|
||||
message: t('profileForm.validation.dobMinAge'),
|
||||
}),
|
||||
pob: z.string().optional(),
|
||||
pob: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.pobRequired'))
|
||||
: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
||||
bloodType: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.bloodTypeRequired'))
|
||||
: z.string().optional(),
|
||||
hairColor: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.hairColorRequired'))
|
||||
: z.string().optional(),
|
||||
eyeColor: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.eyeColorRequired'))
|
||||
: z.string().optional(),
|
||||
heightCm: isSeafarer
|
||||
? z
|
||||
.string()
|
||||
.min(1, t('profileForm.validation.heightRequired'))
|
||||
.refine((v) => Number(v) >= 100 && Number(v) <= 250, {
|
||||
message: t('profileForm.validation.heightRange'),
|
||||
})
|
||||
: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || (Number(v) >= 100 && Number(v) <= 250), {
|
||||
message: t('profileForm.validation.heightRange'),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
|
||||
@@ -37,6 +66,13 @@ export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
|
||||
export const GENDERS = ['MALE', 'FEMALE'] as const;
|
||||
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
// Must match EBloodType/EHairColor/EEyeColor on the backend (common/enums/user.enum.ts).
|
||||
export const BLOOD_TYPES = [
|
||||
'A_POSITIVE', 'A_NEGATIVE', 'B_POSITIVE', 'B_NEGATIVE',
|
||||
'AB_POSITIVE', 'AB_NEGATIVE', 'O_POSITIVE', 'O_NEGATIVE', 'UNKNOWN',
|
||||
] as const;
|
||||
export const HAIR_COLORS = ['BLACK', 'BROWN', 'BLONDE', 'RED', 'GREY', 'WHITE', 'BALD', 'OTHER'] as const;
|
||||
export const EYE_COLORS = ['BROWN', 'BLACK', 'BLUE', 'GREEN', 'HAZEL', 'GREY', 'OTHER'] as const;
|
||||
|
||||
interface ProfileFormContentProps {
|
||||
register: UseFormRegister<ProfileValues>;
|
||||
@@ -46,6 +82,8 @@ interface ProfileFormContentProps {
|
||||
trigger: UseFormTrigger<ProfileValues>;
|
||||
professionsLoading: boolean;
|
||||
professionOptions: Array<{ value: string; label: string }>;
|
||||
/** Place of birth, hair/eye colour and height become required for these accounts. */
|
||||
isSeafarer: boolean;
|
||||
}
|
||||
|
||||
export function ProfileFormContent({
|
||||
@@ -56,6 +94,7 @@ export function ProfileFormContent({
|
||||
trigger,
|
||||
professionsLoading,
|
||||
professionOptions,
|
||||
isSeafarer,
|
||||
}: ProfileFormContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -119,6 +158,7 @@ export function ProfileFormContent({
|
||||
<TextInput
|
||||
label={t('profileFields.pob')}
|
||||
placeholder={t('profileForm.placeholders.pob')}
|
||||
required={isSeafarer}
|
||||
{...register('pob')}
|
||||
error={errors.pob?.message}
|
||||
/>
|
||||
@@ -133,6 +173,50 @@ export function ProfileFormContent({
|
||||
onBlur={() => trigger('maritalStatus')}
|
||||
name="maritalStatus"
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.bloodType')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={BLOOD_TYPES.map((b) => ({ value: b, label: t(`profileForm.bloodTypes.${b}`) }))}
|
||||
error={errors.bloodType?.message}
|
||||
value={watch('bloodType') || null}
|
||||
onChange={(val) => setValue('bloodType', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('bloodType')}
|
||||
name="bloodType"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profileFields.heightCm')}
|
||||
placeholder={t('profileForm.placeholders.heightCm')}
|
||||
type="number"
|
||||
required={isSeafarer}
|
||||
{...register('heightCm')}
|
||||
error={errors.heightCm?.message}
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.hairColor')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={HAIR_COLORS.map((h) => ({ value: h, label: t(`profileForm.hairColors.${h}`) }))}
|
||||
error={errors.hairColor?.message}
|
||||
value={watch('hairColor') || null}
|
||||
onChange={(val) => setValue('hairColor', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('hairColor')}
|
||||
name="hairColor"
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.eyeColor')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={EYE_COLORS.map((e) => ({ value: e, label: t(`profileForm.eyeColors.${e}`) }))}
|
||||
error={errors.eyeColor?.message}
|
||||
value={watch('eyeColor') || null}
|
||||
onChange={(val) => setValue('eyeColor', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('eyeColor')}
|
||||
name="eyeColor"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
IconBuildingWarehouse,
|
||||
IconMapPin,
|
||||
IconMoon,
|
||||
IconPhone,
|
||||
IconSettings,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
@@ -49,7 +48,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
@@ -187,6 +186,12 @@ export function ProfilePage() {
|
||||
const showSeafarerBanner =
|
||||
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
|
||||
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
|
||||
// Place of birth, blood type, hair/eye colour and height print on the
|
||||
// Seaman Book, so a seafarer account can't leave them blank — every other
|
||||
// account type may. Mirrors the seafarer-registration wizard's
|
||||
// `required: true` on the same fields and the backend check in
|
||||
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
|
||||
const isSeafarer = (resolvedProfile ?? storedProfile)?.type === 'SEAFARER';
|
||||
|
||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||
@@ -224,6 +229,10 @@ export function ProfilePage() {
|
||||
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
||||
pob: currentProfile.pob || '',
|
||||
maritalStatus: currentProfile.maritalStatus || '',
|
||||
bloodType: currentProfile.bloodType || '',
|
||||
hairColor: currentProfile.hairColor || '',
|
||||
eyeColor: currentProfile.eyeColor || '',
|
||||
heightCm: currentProfile.heightCm != null ? String(currentProfile.heightCm) : '',
|
||||
});
|
||||
|
||||
// Primary phone and email are the account's contact details (same
|
||||
@@ -270,7 +279,7 @@ export function ProfilePage() {
|
||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
phoneNumber,
|
||||
});
|
||||
type PersonalValues = z.infer<typeof personalSchema>;
|
||||
|
||||
@@ -278,6 +287,9 @@ export function ProfilePage() {
|
||||
register: registerPersonal,
|
||||
handleSubmit: handlePersonalSubmit,
|
||||
reset: resetPersonal,
|
||||
watch: watchPersonal,
|
||||
setValue: setValuePersonal,
|
||||
trigger: triggerPersonal,
|
||||
formState: { errors: personalErrors },
|
||||
} = useForm<PersonalValues>({
|
||||
resolver: zodResolver(personalSchema),
|
||||
@@ -365,7 +377,7 @@ export function ProfilePage() {
|
||||
trigger: profileTriggerValidation,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema(t)),
|
||||
resolver: zodResolver(profileSchema(t, isSeafarer)),
|
||||
values: loadedProfile ?? undefined,
|
||||
});
|
||||
|
||||
@@ -383,7 +395,15 @@ export function ProfilePage() {
|
||||
await updateProfile({
|
||||
url: `/profiles/${profileId}`,
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
// Empty string is not a valid enum value on the backend — an
|
||||
// untouched Select must clear the column, not fail validation.
|
||||
body: {
|
||||
...values,
|
||||
heightCm: values.heightCm ? Number(values.heightCm) : null,
|
||||
bloodType: values.bloodType || null,
|
||||
hairColor: values.hairColor || null,
|
||||
eyeColor: values.eyeColor || null,
|
||||
},
|
||||
}).unwrap();
|
||||
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
||||
|
||||
@@ -669,11 +689,12 @@ export function ProfilePage() {
|
||||
error={personalErrors.email?.message}
|
||||
{...registerPersonal('email')}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profile.fields.phone')}
|
||||
leftSection={<IconPhone size={18} />}
|
||||
value={watchPersonal('phoneNumber') || ''}
|
||||
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
|
||||
onBlur={() => triggerPersonal('phoneNumber')}
|
||||
error={personalErrors.phoneNumber?.message}
|
||||
{...registerPersonal('phoneNumber')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
@@ -724,6 +745,7 @@ export function ProfilePage() {
|
||||
trigger={profileTriggerValidation}
|
||||
professionsLoading={professionsLoading}
|
||||
professionOptions={professionOptions}
|
||||
isSeafarer={isSeafarer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
IconCheck,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
IconShip,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
const OWNER_TYPES = [
|
||||
'Individual (Private Owner)',
|
||||
@@ -51,7 +51,7 @@ export function VesselOwnerRegisterPage() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [registerTrigger] = useApiMutation<{ id: string }>();
|
||||
|
||||
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
|
||||
const canSubmit = !!fullName.trim() && !!email.trim() && isValidPhoneNumber(phone) && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -143,13 +143,11 @@ export function VesselOwnerRegisterPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label="Phone Number"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
leftSection={<IconPhone size={16} />}
|
||||
required
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
onChange={setPhone}
|
||||
/>
|
||||
<TextInput
|
||||
label="National ID / TIN"
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
IconTransferIn,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
// Minimal vessel type for the approved vessel list
|
||||
interface ApprovedVessel {
|
||||
@@ -208,7 +209,7 @@ export function OwnershipTransferPage() {
|
||||
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
||||
|
||||
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
||||
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
|
||||
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedVesselId(null);
|
||||
@@ -393,7 +394,7 @@ export function OwnershipTransferPage() {
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
|
||||
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
|
||||
<PhoneInput label="Phone Number" required value={newOwnerPhone} onChange={setNewOwnerPhone} />
|
||||
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
|
||||
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
|
||||
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
IconShip,
|
||||
IconWaveSine,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -311,7 +312,7 @@ export function VesselRegistrationApplicationPage() {
|
||||
if (active === 2) return (
|
||||
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
|
||||
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
|
||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
|
||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && isValidPhoneNumber(ownerPhone)
|
||||
);
|
||||
if (active === 3) return category === 'Inland Waterway Vessel'
|
||||
? !!files.vesselPhotos
|
||||
@@ -551,12 +552,11 @@ export function VesselRegistrationApplicationPage() {
|
||||
value={ownerNationalIdOrTin}
|
||||
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label="Owner Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
value={ownerPhone}
|
||||
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
|
||||
onChange={setOwnerPhone}
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner Address"
|
||||
|
||||
@@ -258,6 +258,10 @@ export const am: Translations = {
|
||||
dob: 'የትውልድ ቀን',
|
||||
pob: 'የትውልድ ቦታ',
|
||||
maritalStatus: 'የጋብቻ ሁኔታ',
|
||||
bloodType: 'የደም አይነት',
|
||||
hairColor: 'የፀጉር ቀለም',
|
||||
eyeColor: 'የአይን ቀለም',
|
||||
heightCm: 'ቁመት (ሴ.ሜ)',
|
||||
professionId: 'ሙያ',
|
||||
idType: 'የመታወቂያ ዓይነት',
|
||||
idNumber: 'የመታወቂያ ቁጥር',
|
||||
@@ -436,6 +440,7 @@ export const am: Translations = {
|
||||
middleName: 'የአባት ስም ያስገቡ',
|
||||
lastName: 'የአያት ስም ያስገቡ',
|
||||
pob: 'ከተማ፣ ክልል',
|
||||
heightCm: 'ለምሳሌ 175',
|
||||
},
|
||||
genders: {
|
||||
MALE: 'ወንድ',
|
||||
@@ -447,6 +452,36 @@ export const am: Translations = {
|
||||
DIVORCED: 'የፈታ/ች',
|
||||
WIDOWED: 'የሞተበት/ባት',
|
||||
},
|
||||
bloodTypes: {
|
||||
A_POSITIVE: 'A+',
|
||||
A_NEGATIVE: 'A-',
|
||||
B_POSITIVE: 'B+',
|
||||
B_NEGATIVE: 'B-',
|
||||
AB_POSITIVE: 'AB+',
|
||||
AB_NEGATIVE: 'AB-',
|
||||
O_POSITIVE: 'O+',
|
||||
O_NEGATIVE: 'O-',
|
||||
UNKNOWN: 'የማይታወቅ',
|
||||
},
|
||||
hairColors: {
|
||||
BLACK: 'ጥቁር',
|
||||
BROWN: 'ቡናማ',
|
||||
BLONDE: 'ወርቃማ',
|
||||
RED: 'ቀይ',
|
||||
GREY: 'ግራጫ',
|
||||
WHITE: 'ነጭ',
|
||||
BALD: 'ራሰ በራ',
|
||||
OTHER: 'ሌላ',
|
||||
},
|
||||
eyeColors: {
|
||||
BROWN: 'ቡናማ',
|
||||
BLACK: 'ጥቁር',
|
||||
BLUE: 'ሰማያዊ',
|
||||
GREEN: 'አረንጓዴ',
|
||||
HAZEL: 'ኮክ ቡናማ',
|
||||
GREY: 'ግራጫ',
|
||||
OTHER: 'ሌላ',
|
||||
},
|
||||
validation: {
|
||||
professionRequired: 'ሙያዎን ይምረጡ',
|
||||
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
||||
@@ -457,6 +492,12 @@ export const am: Translations = {
|
||||
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
||||
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
||||
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
||||
heightRange: 'ቁመት ከ100 እስከ 250 ሴ.ሜ መሆን አለበት',
|
||||
pobRequired: 'የትውልድ ቦታዎን ያስገቡ',
|
||||
bloodTypeRequired: 'የደም አይነትዎን ይምረጡ — ካልተመረመሩ የማይታወቅ ይምረጡ',
|
||||
hairColorRequired: 'የፀጉር ቀለምዎን ይምረጡ',
|
||||
eyeColorRequired: 'የአይን ቀለምዎን ይምረጡ',
|
||||
heightRequired: 'ቁመትዎን ያስገቡ',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -542,7 +583,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
@@ -577,7 +618,7 @@ export const am: Translations = {
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
phonePlaceholder: "9XX XXX XXX",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
@@ -692,7 +733,7 @@ export const am: Translations = {
|
||||
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
|
||||
idTypePlaceholder: 'ይምረጡ',
|
||||
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
|
||||
phonePlaceholder: '+251 9XX XXX XXX',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
|
||||
postalAddressPlaceholder: 'ፖስታ ሳጥን',
|
||||
contactNamePlaceholder: 'ሙሉ ስም',
|
||||
|
||||
@@ -257,6 +257,10 @@ export const en = {
|
||||
dob: 'Date of birth',
|
||||
pob: 'Place of birth',
|
||||
maritalStatus: 'Marital status',
|
||||
bloodType: 'Blood type',
|
||||
hairColor: 'Hair color',
|
||||
eyeColor: 'Eye color',
|
||||
heightCm: 'Height (cm)',
|
||||
professionId: 'Profession',
|
||||
idType: 'ID type',
|
||||
idNumber: 'ID number',
|
||||
@@ -435,6 +439,7 @@ export const en = {
|
||||
middleName: 'Enter middle name',
|
||||
lastName: 'Enter last name',
|
||||
pob: 'City, Region',
|
||||
heightCm: 'e.g. 175',
|
||||
},
|
||||
genders: {
|
||||
MALE: 'Male',
|
||||
@@ -446,6 +451,36 @@ export const en = {
|
||||
DIVORCED: 'Divorced',
|
||||
WIDOWED: 'Widowed',
|
||||
},
|
||||
bloodTypes: {
|
||||
A_POSITIVE: 'A+',
|
||||
A_NEGATIVE: 'A-',
|
||||
B_POSITIVE: 'B+',
|
||||
B_NEGATIVE: 'B-',
|
||||
AB_POSITIVE: 'AB+',
|
||||
AB_NEGATIVE: 'AB-',
|
||||
O_POSITIVE: 'O+',
|
||||
O_NEGATIVE: 'O-',
|
||||
UNKNOWN: 'Unknown',
|
||||
},
|
||||
hairColors: {
|
||||
BLACK: 'Black',
|
||||
BROWN: 'Brown',
|
||||
BLONDE: 'Blonde',
|
||||
RED: 'Red',
|
||||
GREY: 'Grey',
|
||||
WHITE: 'White',
|
||||
BALD: 'Bald',
|
||||
OTHER: 'Other',
|
||||
},
|
||||
eyeColors: {
|
||||
BROWN: 'Brown',
|
||||
BLACK: 'Black',
|
||||
BLUE: 'Blue',
|
||||
GREEN: 'Green',
|
||||
HAZEL: 'Hazel',
|
||||
GREY: 'Grey',
|
||||
OTHER: 'Other',
|
||||
},
|
||||
validation: {
|
||||
professionRequired: 'Select your profession',
|
||||
firstNameMin: 'First name must be at least 3 characters',
|
||||
@@ -456,6 +491,12 @@ export const en = {
|
||||
dobMinAge: 'You must be at least 18 years old',
|
||||
maritalStatusRequired: 'Select your marital status',
|
||||
nameParts: 'Enter your first, middle, and last name',
|
||||
heightRange: 'Height must be between 100 and 250 cm',
|
||||
pobRequired: 'Enter your place of birth',
|
||||
bloodTypeRequired: 'Select your blood type — choose Unknown if untested',
|
||||
hairColorRequired: 'Select your hair color',
|
||||
eyeColorRequired: 'Select your eye color',
|
||||
heightRequired: 'Enter your height',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -541,7 +582,7 @@ export const en = {
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
@@ -576,7 +617,7 @@ export const en = {
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
@@ -691,7 +732,7 @@ export const en = {
|
||||
accountManagedHint: 'From your account, edit it in the Personal tab',
|
||||
idTypePlaceholder: 'Select',
|
||||
idNumberPlaceholder: 'Enter ID number',
|
||||
phonePlaceholder: '+251 9XX XXX XXX',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
streetAddressPlaceholder: 'Street name, house number',
|
||||
postalAddressPlaceholder: 'P.O. Box',
|
||||
contactNamePlaceholder: 'Full name',
|
||||
|
||||
@@ -39,7 +39,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||
@@ -69,6 +68,10 @@ export const router = createBrowserRouter([
|
||||
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||
{ path: "/reset-password", element: <SetPasswordPage /> },
|
||||
|
||||
// Reached from the login page by a logged-out user, so it must stay
|
||||
// public — ProtectedRoute would bounce them straight to the landing page.
|
||||
{ path: "/forgot-password", element: <ForgotPasswordPage /> },
|
||||
|
||||
// Protected auth pages
|
||||
{
|
||||
element: (
|
||||
@@ -78,14 +81,6 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
path: "/otp-verify",
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ForgotPasswordPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
path: "/forgot-password",
|
||||
},
|
||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
||||
// profile details are collected where they are actually needed: on /profile,
|
||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
||||
@@ -275,14 +270,9 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/certificates/apply",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||
<CoCApplicationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// CoC/CoP applications go through the generic license wizard —
|
||||
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user