Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-21 06:58:57 +00:00
53 changed files with 2950 additions and 1258 deletions

View File

@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
applicantUserId: 'user-mock-001',
kind: 'RENEWAL',
status: 'ELIGIBILITY_APPROVED',
status: 'ELIGIBILITY_PAID',
assignedOfficerId: 'officer-mock-002',
claimedAt: '2026-08-01T10:00:00.000Z',
formData: { account: { applicantName: 'Abebe Tesfaye' } },

View File

@@ -6,6 +6,9 @@ import type {
ApplicationPayment,
ApplicationStaff,
Attachment,
DocumentRequirement,
FormSchemaPalette,
FormSectionConfig,
InitiatePaymentResult,
IssuedLicense,
Inspection,
@@ -25,6 +28,7 @@ import type {
QueueFilter,
RemarkTargetType,
SavedQueueView,
SchemaIssue,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
@@ -66,6 +70,7 @@ const TAGS = [
'License',
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -178,6 +183,76 @@ export const licensingApi = baseApi
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
}),
// ------------------------------------------------ form-schema builder
/** Replaces a licence type's form schema. Server re-validates on save. */
updateFormSchema: builder.mutation<
LicenseType,
{ id: string; formSchema: { sections: FormSectionConfig[] } }
>({
query: ({ id, formSchema }) => ({
url: `/license-types/${id}/form-schema`,
method: 'PUT',
body: { formSchema },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
}),
/** Dry-run lint, for inline feedback while the schema is being edited. */
validateFormSchema: builder.mutation<
{ valid: boolean; issues: SchemaIssue[] },
{ formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string }
>({
query: (body) => ({
url: '/license-types/form-schema/validate',
method: 'POST',
body,
}),
}),
/** Field types, condition operators and prefill sources the builder may offer. */
getFormSchemaPalette: builder.query<FormSchemaPalette, void>({
query: () => ({ url: '/license-types/form-schema/palette' }),
}),
// ------------------------------------------------- document requirements
/**
* Every document requirement, for the admin editor to filter by licence
* type client-side. The collection-query `q` filter syntax (`w=column:op:
* value`) has no typed builder on this side, and the table is small
* configuration data with no pagination need — see `licenseTypeId` usage
* at the call site.
*/
getDocumentRequirements: builder.query<Paginated<DocumentRequirement>, void>({
query: () => ({ url: '/document-requirements' }),
providesTags: () => [listTag('DocumentRequirement')],
}),
createDocumentRequirement: builder.mutation<
DocumentRequirement,
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
>({
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
updateDocumentRequirement: builder.mutation<
DocumentRequirement,
{ id: string } & Partial<DocumentRequirement>
>({
query: ({ id, ...body }) => ({
url: `/document-requirements/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
deleteDocumentRequirement: builder.mutation<unknown, string>({
query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// -------------------------------------------------------- application
createApplication: builder.mutation<
LicenseApplication,
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
scheduleExam: builder.mutation<
LicenseApplication,
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
>({
query: ({ id, examDate: _examDate, ...body }) => ({
// Matches the controller's `:id/exam-scheduled` route — `examDate`
// is UI-only context for the confirmation toast, not part of
// `MarkExamScheduledDto`, so it never goes on the wire.
url: `/license-application-review/${id}/exam-scheduled`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/** Records a published examination result (pass or fail). */
recordExamOutcome: builder.mutation<
LicenseApplication,
{ id: string; passed: boolean; score?: number }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/schedule-exam`,
url: `/license-application-review/${id}/exam-outcome`,
method: 'POST',
body,
}),
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
}),
/**
* Raises the examination fee — after eligibility approval, or again when
* a failed candidate elects to resit.
* A failed candidate asks for another sitting. Re-opens the examination
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
* assessed and paid for on the first attempt.
*/
requestExamPayment: builder.mutation<LicenseApplication, string>({
retakeExam: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-applications/${id}/request-exam-payment`,
url: `/license-applications/${id}/retake`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
@@ -809,6 +902,13 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
@@ -864,7 +964,8 @@ export const {
useFinalApproveMutation,
useRejectApplicationMutation,
useScheduleExamMutation,
useRequestExamPaymentMutation,
useRecordExamOutcomeMutation,
useRetakeExamMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,

View File

@@ -1,3 +1,4 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
@@ -74,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
SCHEDULED: 'Pickup Scheduled',
CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed',
ELIGIBILITY_APPROVED: 'Eligible to Sit',
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
ELIGIBILITY_PAID: 'Eligibility Under Review',
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
EXAM_PAID: 'Awaiting Exam Date',
EXAM_SCHEDULED: 'Exam Scheduled',
@@ -100,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
SCHEDULED: 'cyan',
CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green',
ELIGIBILITY_APPROVED: 'teal',
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
ELIGIBILITY_PAID: 'lime',
EXAM_PAYMENT_PENDING: 'yellow',
EXAM_PAID: 'lime',
EXAM_SCHEDULED: 'cyan',
@@ -135,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
REJECTED: 100,
// The exam leg sits between approval and the certificate fee, so these
// interleave with PAYMENT_PENDING (80) rather than running past it.
ELIGIBILITY_APPROVED: 60,
ELIGIBILITY_PAYMENT_PENDING: 52,
ELIGIBILITY_PAID: 56,
EXAM_PAYMENT_PENDING: 64,
EXAM_PAID: 68,
EXAM_SCHEDULED: 72,
@@ -149,10 +153,15 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
'DRAFT',
'RESUBMIT_REQUIRED',
'PAYMENT_PENDING',
// Due the moment an examined application is submitted, before any officer
// looks at it.
'ELIGIBILITY_PAYMENT_PENDING',
// Both wait on the candidate: one to pay for a sitting, one to decide to
// sit again after a failure.
'EXAM_PAYMENT_PENDING',
'EXAM_FAILED',
// Passed: the certificate fee falls due, and only the candidate can pay it.
'EXAM_PASSED',
];
/** Statuses that are finished, whichever way they went. */
@@ -526,6 +535,13 @@ export function validateSections(
}
if (empty) continue;
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
// non-empty string that still has to be caught here.
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
continue;
}
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
if (field.min !== undefined && numeric < field.min) {

View File

@@ -41,9 +41,11 @@ export type LicenseStatus =
| "SCHEDULED"
| "CERTIFICATE_ISSUED"
| "COMPLETED"
// Examined certificates (CoC, some CoP): approval establishes eligibility,
// the candidate pays to sit, and the certificate fee falls due on a pass.
| "ELIGIBILITY_APPROVED"
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
// due before review starts, then the candidate pays to sit, and the
// certificate fee falls due on a pass.
| "ELIGIBILITY_PAYMENT_PENDING"
| "ELIGIBILITY_PAID"
| "EXAM_PAYMENT_PENDING"
| "EXAM_PAID"
| "EXAM_SCHEDULED"
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
label: Bilingual;
type: FormFieldType;
required?: boolean;
placeholder?: Bilingual;
helpText?: Bilingual;
options?: { value: string; label: Bilingual }[];
min?: number;
max?: number;
maxLength?: number;
showWhen?: FieldCondition;
readOnly?: boolean;
source?: string;
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
export interface DocumentRequirement {
id: string;
licenseTypeId: string;
key: string;
name: Bilingual;
description?: Bilingual;
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
allowedMimeTypes: string[];
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
sortOrder: number;
isActive: boolean;
}
/**
* What the form-schema builder may put on a form — the field types the engine
* understands, which constraints each one honours, the condition operators it
* supports, and the prefill sources a read-only field may draw from. Drives
* the builder's pickers so they never hardcode a list the server already owns.
*/
export interface FormSchemaPalette {
fieldTypes: {
type: FormFieldType;
supportsOptions: boolean;
supportsRange: boolean;
supportsMaxLength: boolean;
}[];
conditionOperators: ("equals" | "notEquals" | "in" | "isSet")[];
prefillSources: string[];
}
/** One problem the server's form-schema lint found. */
export interface SchemaIssue {
path: string;
message: string;
}
export interface StaffEvidenceRequirement {

View File

@@ -23,11 +23,25 @@ import { z } from 'zod';
import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
// Same email-or-phone rule as LoginPage: a phone-looking value normalizes to
// E.164 (bare Ethiopian national numbers default to +251) so the backend
// always gets a value it can look the account up by, under the `email` key.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
email: z
.string()
.trim()
.transform((value) => {
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, 'ET')?.number ?? value;
})
.refine((value) => emailRegex.test(value) || isValidPhoneNumber(value), {
message: 'Enter a valid email or phone number',
}),
});
type FormValues = z.infer<typeof schema>;
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
Forgot your password?
</Title>
<Text c="dimmed" mt={6}>
Enter the email linked to your account and we&apos;ll send you a link
to reset your password.
Enter the email or phone number linked to your account and
we&apos;ll send you a link to reset your password.
</Text>
</div>
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email address"
label="Email or phone"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}

View File

@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useApiMutation } from "@ema-platform/api";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type {
@@ -53,11 +54,7 @@ export function LoginPage() {
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
navigate("/");
};
// Built inside the component (not module scope) so validation messages
@@ -67,24 +64,23 @@ export function LoginPage() {
.string()
.trim()
.transform((value) => {
// Convert 09xxxxxxxx -> +2519xxxxxxxx
if (/^09\d{8}$/.test(value)) {
return `+251${value.substring(1)}`;
}
return value;
// A phone-looking value normalizes to E.164 (bare Ethiopian
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
// international check below can validate it; anything else
// (an email) passes through untouched.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
})
.refine(
(value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+2519\d{8}$/;
return emailRegex.test(value) || phoneRegex.test(value);
return emailRegex.test(value) || isValidPhoneNumber(value);
},
{
message: t(
"login.emailOrPhoneInvalid",
"Enter a valid email or phone number (+2519xxxxxxxx)",
"Enter a valid email or phone number",
),
},
),

View File

@@ -17,7 +17,6 @@ import {
IconArrowLeft,
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -88,7 +87,7 @@ export function SignupPage() {
.object({
email: z.string().email(),
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
phoneNumber,
userType: z.literal('individual'),
nameEn: z
.string()
@@ -111,6 +110,8 @@ export function SignupPage() {
register,
handleSubmit,
watch,
setValue,
trigger,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -247,12 +248,13 @@ export function SignupPage() {
/>
</SimpleGrid>
<TextInput
<PhoneInput
label={t('signup.phoneLabel', 'Phone number')}
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
leftSection={<IconDeviceMobile size={18} />}
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
value={watch('phoneNumber') || ''}
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -87,6 +87,11 @@ export interface CurrentProfile {
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;
/** Identifying particulars for the Seaman Book. Left blank by choice. */
bloodType?: string | null;
hairColor?: string | null;
eyeColor?: string | null;
heightCm?: number | null;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { currentSessionId } from './jwt';
/** Builds a JWT-shaped string whose payload is `claims`, base64url encoded. */
function token(claims: Record<string, unknown>): string {
const payload = btoa(JSON.stringify(claims))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `header.${payload}.signature`;
}
describe('currentSessionId', () => {
it('reads the sessionId claim', () => {
expect(currentSessionId(token({ sessionId: 'abc' }))).toBe('abc');
});
it('falls back to sid, then jti', () => {
expect(currentSessionId(token({ sid: 'from-sid' }))).toBe('from-sid');
expect(currentSessionId(token({ jti: 'from-jti' }))).toBe('from-jti');
});
it('decodes payloads containing base64url characters', () => {
// '>' and '?' are what force '+' and '/' in standard base64.
const id = 'a>b?c>d?e>f?';
expect(currentSessionId(token({ sessionId: id }))).toBe(id);
});
it('returns undefined for a token with no session claim', () => {
expect(currentSessionId(token({ sub: 'user-1' }))).toBeUndefined();
});
it('returns undefined rather than throwing on junk', () => {
expect(currentSessionId(undefined)).toBeUndefined();
expect(currentSessionId('')).toBeUndefined();
expect(currentSessionId('opaque-token')).toBeUndefined();
expect(currentSessionId('header.not-base64!!.sig')).toBeUndefined();
});
});

View File

@@ -21,6 +21,7 @@ export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/feedback/use-error-handler";

View File

@@ -16,9 +16,9 @@ registerLocale(en);
registerLocale(am);
registerNationalityLocale(nationalityEn);
type CountryLang = 'en' | 'am';
export type CountryLang = 'en' | 'am';
function resolveLang(lng: string): CountryLang {
export function resolveLang(lng: string): CountryLang {
return lng === 'am' ? 'am' : 'en';
}
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
return getCountryName(code, lang);
}
function CountryFlag({ code }: { code: string }) {
export function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberFromString,
type CountryCode,
} from 'libphonenumber-js';
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
type CountryOption = ComboboxItem & { name: string };
// Search by country name ("united"), dial code ("+1") or ISO prefix ("us") —
// the closed control's label alone ("+1") isn't enough to find a country.
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
const q = search.trim().toLowerCase();
if (!q) return options;
return (options as CountryOption[]).filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().startsWith(q),
);
};
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => {
const o = option as CountryOption;
return (
<Group gap="xs" wrap="nowrap" justify="space-between" flex={1}>
<Group gap="xs" wrap="nowrap">
<CountryFlag code={o.value} />
<Text fz="sm">{o.name}</Text>
</Group>
<Text fz="xs" c="dimmed">{o.label}</Text>
</Group>
);
};
export interface PhoneInputProps {
/** E.164 (`+14155552671`), or '' when empty. */
value: string;
onChange: (value: string) => void;
/**
* Fired when focus leaves a field that has digits in it — wire to the
* form's `trigger`. A blank field is left to submit-time validation, like
* the form's other inputs, so tabbing past it doesn't raise an error.
*/
onBlur?: () => void;
label?: React.ReactNode;
placeholder?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
withAsterisk?: boolean;
disabled?: boolean;
readOnly?: boolean;
}
/**
* International phone entry: a searchable country/dial-code select beside a
* national-number text box, WhatsApp/Telegram style. Controlled —
* `value`/`onChange` carry the E.164 string.
*
* The typed digits live in local state rather than being re-derived from
* `value` on every render: an incomplete number doesn't parse, so deriving
* would blank the box between keystrokes.
*/
export function PhoneInput({
value,
onChange,
onBlur,
label,
placeholder,
description,
error,
required,
withAsterisk,
disabled,
readOnly,
}: PhoneInputProps) {
const { t, i18n } = useTranslation();
const lang = resolveLang(i18n.language);
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
const [country, setCountry] = useState<CountryCode>(initialCountry);
const [national, setNational] = useState(() =>
formatNational(toNationalDigits(value, initialCountry), initialCountry),
);
// Mantine keeps the selected label ("+251") as the search text, so typing
// would search for "+251u". Cleared while the dropdown is open instead.
const [search, setSearch] = useState('');
// What this field last pushed upward, so an echo of our own value isn't
// mistaken for the parent resetting the form.
const emitted = useRef(value);
useEffect(() => {
if (value === emitted.current) return;
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
const next = parsedCountry ?? country;
if (parsedCountry) setCountry(parsedCountry);
setNational(formatNational(toNationalDigits(value, next), next));
emitted.current = value;
// Adopting an outside change only — `country` is state written here, so
// re-running on it would fight the user's typing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const countryOptions = useMemo<CountryOption[]>(
() =>
COUNTRY_CODES.map((code) => ({
value: code,
label: `+${getCountryCallingCode(code)}`,
name: getCountryName(code, lang),
})).sort((a, b) => a.name.localeCompare(b.name, lang)),
[lang],
);
function push(next: string) {
emitted.current = next;
onChange(next);
}
function applyDigits(digits: string, forCountry: CountryCode) {
// Keystroke past the longest possible number is ignored, not stored.
if (exceedsMaxLength(digits, forCountry)) return;
// Once the number is valid, show the true national part: someone who
// types a trunk prefix (0911111111) or the country code (251911111111)
// shouldn't end up with it doubled beside the "+251" selector. Not
// before: a partial number can parse too, and rewriting it mid-typing
// makes digits vanish under the caret.
const parsed = parsePhoneNumberFromString(digits, forCountry);
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
push(toE164(digits, forCountry));
}
function handleText(text: string) {
// A full `+<code><number>` arriving at once (paste, autofill, or a test
// driver's `.fill()`) is parsed standalone and switches the country,
// rather than being read under whatever country was already selected.
const trimmed = text.trim();
if (trimmed.startsWith('+')) {
const parsed = parsePhoneNumberFromString(trimmed);
if (parsed?.country) {
setCountry(parsed.country);
setNational(formatNational(parsed.nationalNumber, parsed.country));
push(parsed.number);
return;
}
}
applyDigits(nextNationalDigits(text, national), country);
}
function handleCountryChange(next: string | null) {
if (!next) return;
const code = next as CountryCode;
setCountry(code);
// A number carried over from a longer plan is cut to fit the new one.
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
}
return (
<TextInput
type="tel"
inputMode="tel"
autoComplete="tel"
label={label}
description={description}
error={error}
required={required}
withAsterisk={withAsterisk}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder}
value={national}
onChange={(e) => handleText(e.currentTarget.value)}
// Blur of the whole field, not of the number box: moving into the
// country select mustn't validate a half-typed number.
wrapperProps={{
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
},
}}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={
<Select
aria-label={t('phone.countryCode', 'Country code')}
data={countryOptions}
value={country}
onChange={handleCountryChange}
renderOption={renderCountryOption}
filter={filterCountries}
searchable
searchValue={search}
onSearchChange={setSearch}
onDropdownOpen={() => setSearch('')}
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
allowDeselect={false}
disabled={disabled || readOnly}
variant="unstyled"
size="xs"
w={92}
maxDropdownHeight={320}
comboboxProps={{ width: 260, position: 'bottom-start' }}
leftSection={<CountryFlag code={country} />}
leftSectionWidth={30}
/>
}
/>
);
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
describe('phoneNumber', () => {
it('normalizes a legacy Ethiopian national number to E.164', () => {
expect(phoneNumber.parse('0911223344')).toBe('+251911223344');
});
it('passes an Ethiopian E.164 number through unchanged', () => {
expect(phoneNumber.parse('+251911223344')).toBe('+251911223344');
});
it('accepts a valid international number', () => {
expect(phoneNumber.parse('+14155552671')).toBe('+14155552671');
});
it('rejects a too-short number', () => {
expect(() => phoneNumber.parse('+251911')).toThrow();
});
it('rejects non-numeric input', () => {
expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number');
});
it('reports a blank value as missing, not invalid', () => {
expect(() => phoneNumber.parse('')).toThrow('Phone number is required');
});
});
describe('optionalPhoneNumber', () => {
it('allows a blank value', () => {
expect(optionalPhoneNumber.parse('')).toBe('');
});
it('still validates a non-blank value', () => {
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
});
});
describe('typing helpers', () => {
// Regression: an incomplete number doesn't parse, and an earlier version
// collapsed it to '' — the box emptied on every keystroke.
it('keeps partial digits as the number is typed one character at a time', () => {
let display = '';
let value = '';
for (const ch of '911223344') {
const digits = nextNationalDigits(display + ch, display);
display = new AsYouType('ET').input(digits);
value = toE164(digits, 'ET');
}
expect(display).toBe('911223344');
expect(value).toBe('+251911223344');
});
it('drops a digit when a keystroke only removed a formatting character', () => {
// "(415)" backspaced to "(415" leaves the digits unchanged.
expect(nextNationalDigits('(415', '(415)')).toBe('41');
});
it('keeps the deleted digit count when a real digit is removed', () => {
expect(nextNationalDigits('91122334', '911223344')).toBe('91122334');
});
it('reads the national part back out of a stored E.164 value', () => {
expect(toNationalDigits('+251911223344', 'ET')).toBe('911223344');
expect(toNationalDigits('+14155552671', 'US')).toBe('4155552671');
expect(toNationalDigits('', 'ET')).toBe('');
});
it('falls back to a dial-code concatenation while the number is incomplete', () => {
expect(toE164('9', 'ET')).toBe('+2519');
expect(toE164('', 'ET')).toBe('');
});
});
describe('trunk prefix and country code entered into the number box', () => {
// The box holds the national part next to a "+251" selector, so a trunk 0
// or a typed country code must be absorbed, not shown (and doubled) there.
const cases: Array<[string, string]> = [
['0911111111', '911111111'],
['251911111111', '911111111'],
['911111111', '911111111'],
];
it.each(cases)('normalizes %s to the national number %s', (typed, national) => {
const parsed = parsePhoneNumberFromString(typed.replace(/\D/g, ''), 'ET');
expect(parsed?.nationalNumber).toBe(national);
});
it.each(cases)('yields a valid E.164 value for %s', (typed) => {
expect(phoneNumber.parse(typed)).toBe('+251911111111');
});
it('accepts a pasted +251 number', () => {
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
});
});
describe('formatNational', () => {
it('groups digits without the dial code or trunk prefix', () => {
expect(formatNational('911223344', 'ET')).toBe('91 122 3344');
expect(formatNational('4155552671', 'US')).toBe('415 555 2671');
expect(formatNational('91', 'ET')).toBe('91');
expect(formatNational('', 'ET')).toBe('');
});
it('keeps digits intact when the number does not format', () => {
expect(formatNational('0911223344', 'ET').replace(/\D/g, '')).toBe('0911223344');
});
});
describe('legacy stored values', () => {
it('shows a national record without the trunk prefix', () => {
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
});
});
describe('length limit', () => {
it('reports the longest national number per country', () => {
expect(maxNationalLength('ET')).toBe(9);
expect(maxNationalLength('US')).toBe(10);
});
it('flags digits past the limit, counting the national part only', () => {
expect(exceedsMaxLength('911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('9112233445', 'ET')).toBe(true);
expect(exceedsMaxLength('0911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('251911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('09112233445', 'ET')).toBe(true);
});
});

View File

@@ -1,13 +1,88 @@
import { z } from 'zod';
import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
export const ethiopianPhone = z
/**
* Accepts any international number in E.164 (`+<country><number>`) or a
* bare national number, which is assumed Ethiopian (`0911223344` ->
* `+251911223344`) for backward compatibility with existing records.
*/
export const phoneNumber = z
.string()
.trim()
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
.refine((v) => /^\+2519\d{8}$/.test(v), {
message: 'Enter a valid phone number (+2519xxxxxxxx)',
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
.superRefine((v, ctx) => {
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
});
/** Same rules, but blank is allowed. */
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
/**
* Digits the field should hold after an edit. A keystroke that only removed
* a formatting character (backspacing the ')' out of "(415)") leaves the
* digits unchanged, which would otherwise make the caret stick — drop a real
* digit in that case.
*/
export function nextNationalDigits(text: string, prevDisplay: string): string {
const digits = text.replace(/\D/g, '');
const deleting = text.length < prevDisplay.length;
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
return digits;
}
/** National digits of a stored value, for display in the number box. */
export function toNationalDigits(value: string, country: CountryCode): string {
if (!value) return '';
// `country` also resolves legacy national records ("0911223344").
const parsed = parsePhoneNumberFromString(value, country);
if (parsed) return parsed.nationalNumber;
if (value.startsWith('+')) {
const prefix = `+${getCountryCallingCode(country)}`;
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
}
return value.replace(/\D/g, '');
}
/**
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
* fall back to a plain dial-code concatenation rather than collapsing to ''
* — the value has to survive mid-typing for the field to be usable.
*/
export function toE164(digits: string, country: CountryCode): string {
if (!digits) return '';
return (
parsePhoneNumberFromString(digits, country)?.number ??
`+${getCountryCallingCode(country)}${digits}`
);
}
/**
* Digits grouped for display ("91 122 3344"). Formatted as an international
* number with the dial code cut off: AsYouType's national mode leaves the
* number ungrouped unless the trunk prefix was typed.
*/
export function formatNational(digits: string, country: CountryCode): string {
if (!digits) return '';
const prefix = `+${getCountryCallingCode(country)}`;
const formatted = new AsYouType().input(prefix + digits);
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
}
const metadata = new Metadata();
/** Longest national number the country's numbering plan allows. */
export function maxNationalLength(country: CountryCode): number {
metadata.selectNumberingPlan(country);
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
}
/**
* True when the typed digits exceed the country's longest number. Judged on
* the national part once it parses, so a trunk prefix (0911223344) or typed
* country code (251911223344) isn't counted against the limit.
*/
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
return national.length > maxNationalLength(country);
}

11
libs/ui/vite.config.mts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: ['default'],
},
});