diff --git a/src/app/auth/components/verify/verify.component.spec.ts b/src/app/auth/components/verify/verify.component.spec.ts index 54bd849a1..81058acca 100644 --- a/src/app/auth/components/verify/verify.component.spec.ts +++ b/src/app/auth/components/verify/verify.component.spec.ts @@ -18,6 +18,7 @@ import { HttpService } from '@shared/services/http/http.service'; import { ApiService } from '@shared/services/api/api.service'; import { ActivatedRoute, Router } from '@angular/router'; import { EventService } from '@shared/services/event/event.service'; +import { MessageService } from '@shared/services/message/message.service'; const defaultAuthData = require('@root/test/responses/auth.verify.unverifiedEmail.success.json'); @@ -169,6 +170,40 @@ describe('VerifyComponent', () => { req.flush(verifyPhoneResponse); }); + it('reports a phone code that fails to send on arrival', async () => { + const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); + await init(unverifiedPhoneData, { sendSms: true }); + + const showErrorSpy = spyOn(TestBed.inject(MessageService), 'showError'); + const request = httpMock.expectOne((candidate) => + candidate.url.includes('resendTextCreatedAccount'), + ); + request.flush({ isSuccessful: false, isSystemUp: true, Results: [] }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(showErrorSpy).toHaveBeenCalled(); + }); + + it('reports a failed send that is not a response object', async () => { + const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); + await init(unverifiedPhoneData, { sendSms: true }); + + const showErrorSpy = spyOn(TestBed.inject(MessageService), 'showError'); + const request = httpMock.expectOne((candidate) => + candidate.url.includes('resendTextCreatedAccount'), + ); + request.error(new ProgressEvent('network down')); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(showErrorSpy.calls.mostRecent().args[0].message).toBe( + 'error.generic.internal', + ); + }); + it('should show CAPTCHA before verifying phone', async () => { const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); await init(unverifiedPhoneData, { sendSms: true }); diff --git a/src/app/auth/components/verify/verify.component.ts b/src/app/auth/components/verify/verify.component.ts index 605c3d822..321d10b1f 100644 --- a/src/app/auth/components/verify/verify.component.ts +++ b/src/app/auth/components/verify/verify.component.ts @@ -86,7 +86,11 @@ export class VerifyComponent implements OnInit { if (this.canSendCodes('phone')) { if (queryParams.sendSms) { - this.accountService.resendPhoneVerification(); + this.accountService + .resendPhoneVerification() + .catch((response: unknown) => { + this.showCodeSendError(response); + }); } } } @@ -233,6 +237,16 @@ export class VerifyComponent implements OnInit { }); } + private showCodeSendError(response: unknown): void { + this.message.showError({ + message: + response instanceof AuthResponse + ? response.getMessage() + : 'error.generic.internal', + translate: true, + }); + } + public resolveCaptcha(response: string): void { this.captchaPassed = true; if (this.canSendCodes('phone')) { diff --git a/src/app/core/components/account-settings/account-settings.component.html b/src/app/core/components/account-settings/account-settings.component.html index 58b19c8dc..72c860016 100644 --- a/src/app/core/components/account-settings/account-settings.component.html +++ b/src/app/core/components/account-settings/account-settings.component.html @@ -36,6 +36,9 @@
+

+ Permanent only supports US and Canada numbers. +

@@ -73,4 +78,12 @@ }
+ @if (phoneNumberWarning) { +
+ + warning + {{ phoneNumberWarning }} + +
+ } diff --git a/src/app/core/components/account-settings/account-settings.component.scss b/src/app/core/components/account-settings/account-settings.component.scss index fafd47474..e5cc617f0 100644 --- a/src/app/core/components/account-settings/account-settings.component.scss +++ b/src/app/core/components/account-settings/account-settings.component.scss @@ -75,3 +75,30 @@ p { form { @include tabbedDialogPanelForm; } + +.settings-item-note { + font-size: $font-size-sm; + padding-bottom: $grid-unit * 0.5; + + @include desktop { + padding-left: 12rem; + } +} + +.settings-group-note { + font-size: $font-size-sm; + color: $gray-dark; + padding: 0 0 $grid-unit * 0.5; + margin: 0; +} + +.phone-number-warning { + color: $PR-yellow-700; + display: flex; + align-items: center; + gap: $grid-unit * 0.25; + + i { + font-size: $mat-icon-sm; + } +} diff --git a/src/app/core/components/account-settings/account-settings.component.spec.ts b/src/app/core/components/account-settings/account-settings.component.spec.ts index 494039f6d..ea11c43f8 100644 --- a/src/app/core/components/account-settings/account-settings.component.spec.ts +++ b/src/app/core/components/account-settings/account-settings.component.spec.ts @@ -120,6 +120,147 @@ describe('AccountSettingsComponent', () => { } }); + it('warns about a number that arrived from the backend, without any editing', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + expect(instance.account).toBe(TestBed.inject(AccountService).getAccount()); + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect( + ngMocks.find('.phone-number-warning').nativeElement.textContent, + ).toContain('US and Canada'); + }); + + it('renders no warning for a stored number the provider can reach', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+1 (202) 555-0147'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + }); + + it('keeps the standing guidance separate from the warning', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '416 555 0147'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect( + ngMocks.find('.settings-group-note').nativeElement.textContent, + ).toContain('Permanent only supports US and Canada numbers.'); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + }); + + it('warns in the same place once a typed number is long enough to judge', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.phoneStatus = 'status.auth.unverified'; + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped('+40 748'); + fixture.detectChanges(); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + + instance.onPhoneNumberTyped('+40748498404'); + fixture.detectChanges(); + + expect( + ngMocks.find('.phone-number-warning').nativeElement.textContent, + ).toContain('US and Canada'); + }); + + it('stays quiet while a reachable number is being typed', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.phoneStatus = 'status.auth.unverified'; + instance.onPhoneNumberEditingChange(true); + + ['2', '202', '(202) 555', '(202) 555-014', '(202) 555-0147'].forEach( + (partial) => { + instance.onPhoneNumberTyped(partial); + + expect(instance.phoneNumberWarning).withContext(partial).toBeNull(); + }, + ); + }); + + it('falls back to the stored number once editing ends', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.unverified'; + + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped('(202) 555-0147'); + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.onPhoneNumberEditingChange(false); + + expect(instance.phoneNumberWarning).toContain('US and Canada'); + }); + + it('does not warn about a number that has already verified', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.verified'; + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.account.phoneStatus = 'status.auth.unverified'; + + expect(instance.phoneNumberWarning).toContain('US and Canada'); + }); + + it('does not warn about a number that was cleared', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = ''; + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped(''); + + expect(instance.phoneNumberWarning).toBeNull(); + }); + + it('still saves a number the provider cannot reach', async () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + const accountUpdateSpy = spyOn( + TestBed.inject(ApiService).account, + 'update', + ).and.resolveTo(new AccountVO({})); + const errorMessageSpy = spyOn(TestBed.inject(MessageService), 'showError'); + + await instance.onSaveProfileInfo('primaryPhone', '+44 20 7946 0958'); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(accountUpdateSpy).toHaveBeenCalled(); + expect(errorMessageSpy).not.toHaveBeenCalled(); + expect(instance.account.primaryPhone).toBe('+44 20 7946 0958'); + }); + it('should disable "Verify Phone Number" button if primaryPhone is empty', () => { const fixture = MockRender(AccountSettingsComponent); const instance = fixture.point.componentInstance; diff --git a/src/app/core/components/account-settings/account-settings.component.ts b/src/app/core/components/account-settings/account-settings.component.ts index 145d09de9..0fa4974ab 100644 --- a/src/app/core/components/account-settings/account-settings.component.ts +++ b/src/app/core/components/account-settings/account-settings.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; import { AccountService } from '@shared/services/account/account.service'; import { AccountVO } from '@models'; import { ApiService } from '@shared/services/api/api.service'; @@ -8,6 +8,11 @@ import { FormInputSelectOption } from '@shared/components/form-input/form-input. import { Router, ActivatedRoute } from '@angular/router'; import { EventService } from '@shared/services/event/event.service'; import { savePropertyOnAccount } from '@shared/services/account/account.service.helpers'; +import { + UNSUPPORTED_PHONE_COUNTRY_NOTICE, + storedPhoneNumberNeedsCountryWarning, + typedPhoneNumberNeedsCountryWarning, +} from '@shared/utilities/phone-number'; @Component({ selector: 'pr-account-settings', @@ -21,6 +26,8 @@ export class AccountSettingsComponent implements OnInit { public states: FormInputSelectOption[]; public waiting = false; + private readonly phoneNumberBeingEdited = signal(null); + constructor( private accountService: AccountService, private prConstants: PrConstantsService, @@ -50,6 +57,30 @@ export class AccountSettingsComponent implements OnInit { }); } + public get phoneNumberWarning(): string | null { + if (this.account.phoneStatus === 'status.auth.verified') { + return null; + } + + const beingEdited = this.phoneNumberBeingEdited(); + const needsWarning = + beingEdited === null + ? storedPhoneNumberNeedsCountryWarning(this.account.primaryPhone) + : typedPhoneNumberNeedsCountryWarning(beingEdited); + + return needsWarning ? UNSUPPORTED_PHONE_COUNTRY_NOTICE : null; + } + + onPhoneNumberTyped(value: string) { + this.phoneNumberBeingEdited.set(value ?? ''); + } + + onPhoneNumberEditingChange(isEditing: boolean) { + if (!isEditing) { + this.phoneNumberBeingEdited.set(null); + } + } + async onSaveProfileInfo(prop: keyof AccountVO, value: string) { savePropertyOnAccount( this.account, diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.html b/src/app/shared/components/inline-value-edit/inline-value-edit.component.html index 755116911..74f5496bc 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.html +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.html @@ -10,7 +10,8 @@ name="text" class="form-control" [class.is-editing]="isEditing" - [(ngModel)]="editValue" + [ngModel]="editValue" + (ngModelChange)="onEditValueChange($event)" #input #model="ngModel" [required]="required" diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts b/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts index e0587846d..9b822aaed 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts @@ -462,6 +462,48 @@ describe('InlineValueEditComponent', () => { expect(component.isEditing).toBeTrue(); }); + it('emits the edit value as it is typed', () => { + const emitted: unknown[] = []; + component.editValueChange.subscribe((value) => emitted.push(value)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + + expect(emitted[0]).toBe(TEST_TEXT); + + component.onEditValueChange('Partly typed'); + + expect(emitted[emitted.length - 1]).toBe('Partly typed'); + expect(component.editValue).toBe('Partly typed'); + }); + + it('reports when editing starts and when it ends with a save', () => { + const emitted: boolean[] = []; + component.editingChange.subscribe((isEditing) => emitted.push(isEditing)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + + expect(emitted[emitted.length - 1]).toBeTrue(); + + component.onEditValueChange('Something else'); + component.save(); + + expect(emitted[emitted.length - 1]).toBeFalse(); + }); + + it('reports when editing ends with a cancel', () => { + const emitted: boolean[] = []; + component.editingChange.subscribe((isEditing) => emitted.push(isEditing)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + component.onEditValueChange('Something else'); + component.cancel(); + + expect(emitted[emitted.length - 1]).toBeFalse(); + }); + it('should NOT start editing when clicking on a link', () => { component.canEdit = true; component.type = 'textarea'; diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts b/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts index c148088f1..8306108bc 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts @@ -71,6 +71,9 @@ export class InlineValueEditComponent implements OnInit, OnChanges { @HostBinding('class.always-show') @Input() alwaysShow = false; @Output() doneEditing: EventEmitter = new EventEmitter(); + @Output() editValueChange: EventEmitter = + new EventEmitter(); + @Output() editingChange: EventEmitter = new EventEmitter(); @Output() externalEdit: EventEmitter = new EventEmitter(); @Output() toggledDatePicker: EventEmitter = @@ -149,6 +152,8 @@ export class InlineValueEditComponent implements OnInit, OnChanges { this.editValue = this.displayValue; } this.isEditing = true; + this.editValueChange.emit(this.editValue); + this.editingChange.emit(true); this.focusInput(); if (!this.noScroll) { @@ -178,6 +183,7 @@ export class InlineValueEditComponent implements OnInit, OnChanges { this.doneEditing.emit(this.editValue); } this.isEditing = false; + this.editingChange.emit(false); if (!skipBlur) { this.blurInput(); } @@ -189,6 +195,7 @@ export class InlineValueEditComponent implements OnInit, OnChanges { } this.editValue = this.displayValue; this.isEditing = false; + this.editingChange.emit(false); this.blurInput(); } @@ -280,6 +287,11 @@ export class InlineValueEditComponent implements OnInit, OnChanges { } } + onEditValueChange(value: ValueType) { + this.editValue = value; + this.editValueChange.emit(value); + } + onTextInputBlur() { if (this.saveOnBlur) { this.save(true); diff --git a/src/app/shared/utilities/phone-number.spec.ts b/src/app/shared/utilities/phone-number.spec.ts new file mode 100644 index 000000000..e1afd58aa --- /dev/null +++ b/src/app/shared/utilities/phone-number.spec.ts @@ -0,0 +1,164 @@ +import { + storedPhoneNumberNeedsCountryWarning, + typedPhoneNumberNeedsCountryWarning, +} from './phone-number'; + +describe('storedPhoneNumberNeedsCountryWarning', () => { + it('stays quiet for a number however it is punctuated', () => { + expect( + storedPhoneNumberNeedsCountryWarning('(202) 555 - 0147'), + ).toBeFalse(); + + expect(storedPhoneNumberNeedsCountryWarning('2025550147')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning('202.555.0147')).toBeFalse(); + expect( + storedPhoneNumberNeedsCountryWarning(' 202 555 0147 '), + ).toBeFalse(); + }); + + it('stays quiet however the country code is dialled', () => { + expect( + storedPhoneNumberNeedsCountryWarning('+1 (202) 555-0147'), + ).toBeFalse(); + + expect(storedPhoneNumberNeedsCountryWarning('1-202-555-0147')).toBeFalse(); + expect( + storedPhoneNumberNeedsCountryWarning('001 202 555 0147'), + ).toBeFalse(); + + expect( + storedPhoneNumberNeedsCountryWarning('011 1 202 555 0147'), + ).toBeFalse(); + }); + + it('stays quiet for a Canadian number', () => { + expect(storedPhoneNumberNeedsCountryWarning('+1 416 555 0147')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning('604 555 0147')).toBeFalse(); + }); + + it('stays quiet for a US territory, which is a US number', () => { + expect(storedPhoneNumberNeedsCountryWarning('787 555 0147')).toBeFalse(); // Puerto Rico + expect(storedPhoneNumberNeedsCountryWarning('939 555 0147')).toBeFalse(); // Puerto Rico + expect(storedPhoneNumberNeedsCountryWarning('340 555 0147')).toBeFalse(); // US Virgin Islands + expect(storedPhoneNumberNeedsCountryWarning('671 555 0147')).toBeFalse(); // Guam + expect(storedPhoneNumberNeedsCountryWarning('684 555 0147')).toBeFalse(); // American Samoa + expect(storedPhoneNumberNeedsCountryWarning('670 555 0147')).toBeFalse(); // N. Mariana Islands + }); + + it('stays quiet for an empty field, since clearing the number is allowed', () => { + expect(storedPhoneNumberNeedsCountryWarning('')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(' ')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(null)).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(undefined)).toBeFalse(); + }); + + it('warns about numbers from outside the numbering plan', () => { + expect(storedPhoneNumberNeedsCountryWarning('+44 20 7946 0958')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+40 721 234 567')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+49 151 12345678')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+61 2 9374 4000')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('+86 138 0013 8000'), + ).toBeTrue(); + + expect(storedPhoneNumberNeedsCountryWarning('+91 98765 43210')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('0044 20 7946 0958'), + ).toBeTrue(); + }); + + it('warns about the other +1 countries, which dial exactly like a US number', () => { + expect(storedPhoneNumberNeedsCountryWarning('+1 876 555 0123')).toBeTrue(); // Jamaica + expect(storedPhoneNumberNeedsCountryWarning('+1 658 555 0123')).toBeTrue(); // Jamaica + expect(storedPhoneNumberNeedsCountryWarning('242 555 0123')).toBeTrue(); // The Bahamas + expect(storedPhoneNumberNeedsCountryWarning('246 555 0123')).toBeTrue(); // Barbados + expect(storedPhoneNumberNeedsCountryWarning('441 555 0123')).toBeTrue(); // Bermuda + expect(storedPhoneNumberNeedsCountryWarning('721 555 0123')).toBeTrue(); // Sint Maarten + expect(storedPhoneNumberNeedsCountryWarning('809 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('829 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('849 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('868 555 0123')).toBeTrue(); // Trinidad & Tobago + }); + + it('warns about a national trunk prefix that is not a country code', () => { + expect(storedPhoneNumberNeedsCountryWarning('0721 234 567')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('020 7946 0958')).toBeTrue(); + }); + + it('warns about the wrong quantity of digits, however it got stored', () => { + expect(storedPhoneNumberNeedsCountryWarning('555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 555 014')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 555 01478')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('(202) 555-0147 ext 22'), + ).toBeTrue(); + }); + + it('warns about an area code the plan never assigns', () => { + expect(storedPhoneNumberNeedsCountryWarning('102 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('002 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('911 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('411 555 0147')).toBeTrue(); + }); + + it('warns about an exchange code the plan never assigns', () => { + expect(storedPhoneNumberNeedsCountryWarning('202 155 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 055 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 411 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('555 123 4567')).toBeTrue(); + }); + + it('warns about text that holds no number at all', () => { + expect(storedPhoneNumberNeedsCountryWarning('call me')).toBeTrue(); + }); +}); + +describe('typedPhoneNumberNeedsCountryWarning', () => { + const everyPrefixOf = (wholeNumber: string): string[] => + Array.from(wholeNumber, (_, index) => wholeNumber.slice(0, index + 1)); + + [ + '(202) 555-0147', + '+1 (202) 555-0147', + '1-202-555-0147', + '001 202 555 0147', + '011 1 202 555 0147', + '416 555 0147', + '787 555 0147', + ].forEach((wholeNumber) => { + it(`stays quiet at every keystroke of ${wholeNumber}`, () => { + everyPrefixOf(wholeNumber).forEach((partial) => { + expect(typedPhoneNumberNeedsCountryWarning(partial)) + .withContext(partial) + .toBeFalse(); + }); + }); + }); + + it('warns as soon as an international number is long enough to judge', () => { + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946 09')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946 0958')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('+40 721 234 567')).toBeTrue(); + }); + + it('warns as soon as a whole-length number names another +1 country', () => { + expect(typedPhoneNumberNeedsCountryWarning('876 555 012')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning('876 555 0123')).toBeTrue(); + }); + + it('warns about a whole-length number with an unusable code', () => { + expect(typedPhoneNumberNeedsCountryWarning('202 155 0147')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('020 7946 0958')).toBeTrue(); + }); + + it('holds back on ten digits that could still be growing a country code', () => { + expect(typedPhoneNumberNeedsCountryWarning('102 555 0147')).toBeFalse(); + }); + + it('stays quiet for an empty field', () => { + expect(typedPhoneNumberNeedsCountryWarning('')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning(null)).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning(undefined)).toBeFalse(); + }); +}); diff --git a/src/app/shared/utilities/phone-number.ts b/src/app/shared/utilities/phone-number.ts new file mode 100644 index 000000000..3fe6f76e6 --- /dev/null +++ b/src/app/shared/utilities/phone-number.ts @@ -0,0 +1,105 @@ +const NON_DIGITS = /\D/g; +const NANP_COUNTRY_CODE = '1'; +const NANP_SUBSCRIBER_DIGITS = 10; +const INTERNATIONAL_CALL_PREFIXES = ['011', '00']; +const NANP_NUMBER = /^([2-9]\d\d)([2-9]\d\d)\d{4}$/; +const SERVICE_CODE = /^\d11$/; + +const AREA_CODES_OUTSIDE_US_AND_CANADA = new Set([ + '242', // The Bahamas + '246', // Barbados + '264', // Anguilla + '268', // Antigua and Barbuda + '284', // British Virgin Islands + '345', // Cayman Islands + '441', // Bermuda + '473', // Grenada + '649', // Turks and Caicos Islands + '658', // Jamaica + '664', // Montserrat + '721', // Sint Maarten + '758', // Saint Lucia + '767', // Dominica + '784', // Saint Vincent and the Grenadines + '809', // Dominican Republic + '829', // Dominican Republic + '849', // Dominican Republic + '868', // Trinidad and Tobago + '869', // Saint Kitts and Nevis + '876', // Jamaica +]); + +export const UNSUPPORTED_PHONE_COUNTRY_NOTICE = + 'Permanent can only send verification codes to US and Canada numbers.'; + +const onlyDigits = (text: string | null | undefined): string => + (text ?? '').replace(NON_DIGITS, ''); + +const internationalCallPrefixOf = (digits: string): string => + INTERNATIONAL_CALL_PREFIXES.find((candidate) => + digits.startsWith(candidate), + ) ?? ''; + +const countryCodeOf = (digits: string): string => + digits.startsWith(NANP_COUNTRY_CODE) ? NANP_COUNTRY_CODE : ''; + +const withoutDiallingPrefixes = (digits: string): string => { + const afterInternationalPrefix = digits.slice( + internationalCallPrefixOf(digits).length, + ); + const hasCountryCode = + afterInternationalPrefix.startsWith(NANP_COUNTRY_CODE) && + afterInternationalPrefix.length === + NANP_SUBSCRIBER_DIGITS + NANP_COUNTRY_CODE.length; + + return hasCountryCode + ? afterInternationalPrefix.slice(NANP_COUNTRY_CODE.length) + : afterInternationalPrefix; +}; + +const digitsNeededForAWholeNumber = (digits: string): number => { + const internationalCallPrefix = internationalCallPrefixOf(digits); + const countryCode = countryCodeOf( + digits.slice(internationalCallPrefix.length), + ); + + return ( + NANP_SUBSCRIBER_DIGITS + internationalCallPrefix.length + countryCode.length + ); +}; + +const namesAUsOrCanadaNumber = (text: string | null | undefined): boolean => { + const wholeNumber = NANP_NUMBER.exec( + withoutDiallingPrefixes(onlyDigits(text)), + ); + + if (!wholeNumber) { + return false; + } + + const [, areaCode, exchangeCode] = wholeNumber; + + return ( + !SERVICE_CODE.test(areaCode) && + !SERVICE_CODE.test(exchangeCode) && + !AREA_CODES_OUTSIDE_US_AND_CANADA.has(areaCode) + ); +}; + +const isBlank = (text: string | null | undefined): boolean => + (text ?? '').trim() === ''; + +export const storedPhoneNumberNeedsCountryWarning = ( + text: string | null | undefined, +): boolean => !isBlank(text) && !namesAUsOrCanadaNumber(text); + +export const typedPhoneNumberNeedsCountryWarning = ( + text: string | null | undefined, +): boolean => { + const digits = onlyDigits(text); + + return ( + digits.length >= digitsNeededForAWholeNumber(digits) && + !namesAUsOrCanadaNumber(text) + ); +};