diff --git a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx index 46d73dd4e..bc2fbedb8 100644 --- a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx @@ -153,14 +153,16 @@ export function AddressFormContent({ required readOnly value={watch('primaryPhoneNumber') || ''} - onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: true })} + onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })} + onBlur={() => trigger('primaryPhoneNumber')} error={errors.primaryPhoneNumber?.message} /> setValue('secondaryPhoneNumber', val, { shouldValidate: true })} + onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })} + onBlur={() => trigger('secondaryPhoneNumber')} error={errors.secondaryPhoneNumber?.message} /> setValue('emergencyContactPhone', val, { shouldValidate: true })} + onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })} + onBlur={() => trigger('emergencyContactPhone')} error={errors.emergencyContactPhone?.message} /> ({ resolver: zodResolver(personalSchema), @@ -673,7 +674,8 @@ export function ProfilePage() { setValuePersonal('phoneNumber', val, { shouldValidate: true })} + onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })} + onBlur={() => triggerPersonal('phoneNumber')} error={personalErrors.phoneNumber?.message} /> diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index a8e0e9a83..13b7bb132 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -111,6 +111,7 @@ export function SignupPage() { handleSubmit, watch, setValue, + trigger, formState: { errors }, } = useForm({ resolver: zodResolver(schema), @@ -251,7 +252,8 @@ export function SignupPage() { label={t('signup.phoneLabel', 'Phone number')} placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')} value={watch('phoneNumber') || ''} - onChange={(val) => setValue('phoneNumber', val, { shouldValidate: true })} + onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })} + onBlur={() => trigger('phoneNumber')} error={errors.phoneNumber?.message} /> diff --git a/libs/ui/src/lib/input/PhoneInput.tsx b/libs/ui/src/lib/input/PhoneInput.tsx index fd0951221..0454f166d 100644 --- a/libs/ui/src/lib/input/PhoneInput.tsx +++ b/libs/ui/src/lib/input/PhoneInput.tsx @@ -46,6 +46,8 @@ export interface PhoneInputProps { /** E.164 (`+14155552671`), or '' when empty. */ value: string; onChange: (value: string) => void; + /** Fired when the number box loses focus — wire to the form's `trigger`. */ + onBlur?: () => void; label?: React.ReactNode; placeholder?: string; description?: React.ReactNode; @@ -69,6 +71,7 @@ export interface PhoneInputProps { export function PhoneInput({ value, onChange, + onBlur, label, placeholder, description, @@ -119,8 +122,12 @@ export function PhoneInput({ } function applyDigits(digits: string, forCountry: CountryCode) { - setNational(new AsYouType(forCountry).input(digits)); - push(toE164(digits, forCountry)); + // Once the digits parse, show the true national number: someone who + // types a trunk prefix (0911111111) or the country code (251911111111) + // shouldn't end up with it doubled beside the "+251" selector. + const parsed = parsePhoneNumberFromString(digits, forCountry); + setNational(new AsYouType(forCountry).input(parsed?.nationalNumber ?? digits)); + push(parsed?.number ?? toE164(digits, forCountry)); } function handleText(text: string) { @@ -162,6 +169,7 @@ export function PhoneInput({ placeholder={placeholder} value={national} onChange={(e) => handleText(e.currentTarget.value)} + onBlur={onBlur} leftSectionWidth={92} leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'} leftSection={ diff --git a/libs/ui/src/lib/input/phone.spec.ts b/libs/ui/src/lib/input/phone.spec.ts index 6cbf30ff5..f46f39712 100644 --- a/libs/ui/src/lib/input/phone.spec.ts +++ b/libs/ui/src/lib/input/phone.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone'; -import { AsYouType } from 'libphonenumber-js'; +import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js'; describe('phoneNumber', () => { it('normalizes a legacy Ethiopian national number to E.164', () => { @@ -69,3 +69,25 @@ describe('typing helpers', () => { 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'); + }); +});