@meetelise/chat 1.51.0 → 1.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,8 @@
1
1
  /* eslint-disable @typescript-eslint/no-unused-vars */
2
2
  import { html, LitElement, PropertyValueMap, TemplateResult } from "lit";
3
3
  import { customElement, property, query, state } from "lit/decorators.js";
4
- import {
5
- shortcutKeyIsPressed,
6
- formatToPhoneInput,
7
- isPrintableCharacter,
8
- } from "../actions/formatPhoneNumber";
4
+ import "../actions/international-phone-input";
5
+ import type { InternationalPhoneChangeDetail } from "../actions/international-phone-input";
9
6
  import "./tour-type-option.ts";
10
7
  import "./date-picker.ts";
11
8
  import "./time-picker.ts";
@@ -30,12 +27,7 @@ import { MESelect } from "../me-select";
30
27
  import { TimePicker } from "./time-picker";
31
28
  import { LabeledOption } from "../../fetchBuildingInfo";
32
29
  import { LayoutOption } from "../../fetchBuildingWebchatView";
33
- import {
34
- isMobile,
35
- isValidEmail,
36
- isValidPhoneNumber,
37
- snakify,
38
- } from "../../utils";
30
+ import { isMobile, isValidEmail, snakify } from "../../utils";
39
31
  import axios, { AxiosError } from "axios";
40
32
  import mapValues from "lodash/mapValues";
41
33
  import classnames from "classnames";
@@ -155,6 +147,14 @@ export class TourScheduler extends LitElement {
155
147
  @state()
156
148
  private phoneNumber = "";
157
149
  @state()
150
+ private phoneNumberE164: string | null = null;
151
+ @state()
152
+ private phoneCountry: string | null = null;
153
+ @state()
154
+ private phoneNumberIsValid = false;
155
+ @state()
156
+ private phoneNumberHasBlurred = false;
157
+ @state()
158
158
  private availabilitiesGroupedByDay: {
159
159
  [day: string]: DateWithTimeZoneOffset[];
160
160
  } = {};
@@ -217,8 +217,6 @@ export class TourScheduler extends LitElement {
217
217
  lastNameInput!: HTMLInputElement;
218
218
  @query(".inputContainer#email input")
219
219
  emailInput!: HTMLInputElement;
220
- @query(".inputContainer#phone input")
221
- phoneInput!: HTMLInputElement;
222
220
  @query("me-select#leadSource")
223
221
  selectedLeadSource!: MESelect;
224
222
  @query("me-select#layout")
@@ -630,147 +628,13 @@ export class TourScheduler extends LitElement {
630
628
  }
631
629
  };
632
630
 
633
- handlePhoneKeydown = (e: Event): void => {
634
- // these should always be true, this is just here to help TypeScript
635
- if (
636
- !(e instanceof KeyboardEvent) ||
637
- !(e.target instanceof HTMLInputElement) ||
638
- e.target.selectionStart === null
639
- // !e.target.selectionStart
640
- )
641
- return;
642
-
643
- const cursorPosition = e.target.selectionStart;
644
-
645
- if (isPrintableCharacter(e) && !shortcutKeyIsPressed(e)) {
646
- // If e.key is a character, and no modifier key is pressed, insert it at the cursor, filter out non-numbers, and auto-format
647
- e.preventDefault();
648
- e.stopPropagation();
649
- const updated =
650
- this.phoneNumber.slice(0, cursorPosition) +
651
- e.key +
652
- this.phoneNumber.slice(cursorPosition);
653
- this.phoneNumber = formatToPhoneInput(updated.replace(/\D/g, ""));
654
- this.phoneInput.value = this.phoneNumber;
655
- } else if (e.key === "Backspace") {
656
- /*
657
- Handling backspace:
658
- - A single backspace should delete the last digit before the cursor, not just a punctuation character; the user shouldn't interact directly with the punctuation
659
- - Let the OS handle backspace combos like `Alt + Backspace`, then re-autoformat if necessary (in keyup)
660
- - If the user wants to select and backspace a range of text, let them, then auto-format the remainder
661
- */
662
-
663
- // backspace combos
664
- if (shortcutKeyIsPressed(e)) {
665
- return;
666
- }
667
-
668
- // backspace selection
669
- if (
670
- this.phoneInput.selectionEnd &&
671
- this.phoneInput.selectionStart &&
672
- this.phoneInput.selectionEnd - this.phoneInput.selectionStart > 0
673
- ) {
674
- return;
675
- }
676
-
677
- // regular backspace
678
- const originalCharacterCount = this.phoneNumber.length;
679
- const digitsBeforeCursor = this.phoneNumber
680
- .slice(0, cursorPosition)
681
- .replace(/\D/g, "");
682
- const digitsAfterCursor = this.phoneNumber
683
- .slice(cursorPosition)
684
- .replace(/\D/g, "");
685
- const updatedDigits = `${digitsBeforeCursor.slice(
686
- 0,
687
- -1
688
- )}${digitsAfterCursor}`;
689
- this.phoneNumber = formatToPhoneInput(updatedDigits);
690
- this.phoneInput.value = this.phoneNumber;
691
- const numOfCharactersDeleted =
692
- originalCharacterCount - this.phoneNumber.length;
693
- const newCursorPosition = cursorPosition - numOfCharactersDeleted;
694
- this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
695
- e.preventDefault();
696
- e.stopPropagation();
697
- return;
698
- } else if (
699
- ["ArrowLeft", "ArrowRight"].includes(e.key) &&
700
- !shortcutKeyIsPressed(e) &&
701
- !e.shiftKey
702
- ) {
703
- // when navigating with arrow keys, skip punctuation
704
- if (e.key === "ArrowLeft") {
705
- const charactersBeforeCursor = this.phoneNumber.slice(
706
- 0,
707
- cursorPosition
708
- );
709
- const numberOfNonDigitsBeforeCursor =
710
- charactersBeforeCursor.split(/\d+/).at(-1)?.length || 0;
711
- const moveLeftBy = numberOfNonDigitsBeforeCursor + 1;
712
- const newCursorPosition =
713
- cursorPosition - moveLeftBy > -1 ? cursorPosition - moveLeftBy : 0;
714
- this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
715
- }
716
- if (e.key === "ArrowRight") {
717
- const charactersAfterCursor = this.phoneNumber.slice(cursorPosition);
718
- const numberOfNonDigitsAfterCursor =
719
- charactersAfterCursor.split(/\d+/)[0].length || 0;
720
- const moveRightBy = numberOfNonDigitsAfterCursor + 1;
721
- const newCursorPosition =
722
- cursorPosition + moveRightBy < this.phoneNumber.length
723
- ? cursorPosition + moveRightBy
724
- : this.phoneNumber.length;
725
- this.phoneInput.setSelectionRange(newCursorPosition, newCursorPosition);
726
- }
727
- e.preventDefault();
728
- e.stopPropagation();
729
- } else {
730
- // Let browser/OS handle anything else. We'll handle any changes to the phone input in the `keyup` handler.
731
- // Could be a keyboard shortcut that modifies the input (like `Cmd/Ctrl + V`, which we'll handle in `keyup`),
732
- // or a keyboard shortcut that doesn't (like `Cmd + L` to jump to URL bar or `Cmd + R` to reload the page),
733
- // or Tab, an arrow key, etc.
734
- return;
735
- }
736
- };
737
-
738
- handlePhoneKeyup = (e: KeyboardEvent): void => {
739
- if (!e.key) {
740
- return;
741
- }
742
- // After formatting, place the cursor where it was before, defined as "before the digit that followed it before formatting, if any, otherwise at the end".
743
- // (We never want the cursor to be before a punctuation mark because the next digit typed will appear after the punctuation mark, not before.)
744
- // If we don't do this, the cursor automatically goes to the end when we set `this.phoneNumber`.
745
- // This is sometimes undesired: for example, if we've ended up here because a Mac user typed `Alt + Backspace` in the middle.
746
-
747
- // Arrow keys are intended to change the cursor position, so don't get in their way
748
- if (
749
- e.key.includes("Arrow") ||
750
- ["Meta", "Shift", "Control", "Alt"].includes(e.key)
751
- ) {
752
- return;
753
- }
754
-
755
- const cursorPosition = this.phoneInput.selectionStart;
756
- // find the numbers it's before and count backward from end after formatting
757
- const numbersAfterCursor = cursorPosition
758
- ? this.phoneInput.value.slice(cursorPosition).replace(/\D/g, "")
759
- : "";
760
- this.phoneNumber = formatToPhoneInput(this.phoneInput.value);
761
-
762
- // EXAMPLES: (123)| 4 numbersAfterCursor will be '4'.
763
- let cursorNegativeIndex = 0;
764
- let numbersLeft = numbersAfterCursor.length;
765
- while (numbersLeft) {
766
- if (this.phoneNumber.at(cursorNegativeIndex)?.match(/\d/)) {
767
- numbersLeft--;
768
- }
769
- cursorNegativeIndex++;
770
- }
771
- const cursorPositiveIndex =
772
- this.phoneInput.value.length - cursorNegativeIndex + 1;
773
- this.phoneInput.setSelectionRange(cursorPositiveIndex, cursorPositiveIndex);
631
+ onChangePhoneNumber = (
632
+ event: CustomEvent<InternationalPhoneChangeDetail>
633
+ ): void => {
634
+ this.phoneNumber = event.detail.formattedValue;
635
+ this.phoneNumberE164 = event.detail.e164;
636
+ this.phoneCountry = event.detail.country;
637
+ this.phoneNumberIsValid = event.detail.isValid;
774
638
  };
775
639
 
776
640
  onChangeEmail = (e: Event): void => {
@@ -788,10 +652,8 @@ export class TourScheduler extends LitElement {
788
652
  return (
789
653
  (!!this.firstNameInput?.value || !!this.lastNameInput?.value) &&
790
654
  isValidEmail(this.emailInput?.value ?? "") &&
791
- // TODO: deleting phone number doesn't cause validation to fail, at least on mobile
792
655
  !!this.phoneNumber &&
793
- this.phoneNumber.length === 14 &&
794
- isValidPhoneNumber(this.phoneNumber)
656
+ !!this.phoneNumberE164
795
657
  );
796
658
  },
797
659
  };
@@ -822,6 +684,11 @@ export class TourScheduler extends LitElement {
822
684
  if (!this.selectedDate || !this.selectedTime || this.tourType === null) {
823
685
  return;
824
686
  }
687
+ const phoneNumber = this.phoneNumberE164;
688
+ if (!phoneNumber) {
689
+ this.phoneNumberIsValid = false;
690
+ return;
691
+ }
825
692
  const queryParams = new URLSearchParams(window.location.search);
826
693
 
827
694
  let parsedLeadSource = null;
@@ -860,7 +727,7 @@ export class TourScheduler extends LitElement {
860
727
  this.selectedUnitEl?.value || this.selectedUnitValue || null;
861
728
  pushGtmEvent("scheduleTourSubmitted", {
862
729
  email: this.email,
863
- phone: `+1${this.phoneNumber.match(/\d/g)?.join("")}`,
730
+ phone: phoneNumber,
864
731
  firstName: this.firstNameInput?.value ?? this.firstNameInputValue,
865
732
  lastName: this.lastNameInput?.value ?? this.lastNameInputValue,
866
733
  tourType: tourTypeForSubmission[this.tourType],
@@ -874,7 +741,7 @@ export class TourScheduler extends LitElement {
874
741
  const data = {
875
742
  referrer: document.referrer,
876
743
  email_address: this.email,
877
- phone_number: `+1${this.phoneNumber.match(/\d/g)?.join("")}`, // e.g. +12125555555
744
+ phone_number: phoneNumber,
878
745
  building_id: this.buildingId,
879
746
  first_name: this.firstNameInput?.value ?? this.firstNameInputValue,
880
747
  last_name: this.lastNameInput?.value ?? this.lastNameInputValue,
@@ -1414,35 +1281,16 @@ export class TourScheduler extends LitElement {
1414
1281
  : ""}
1415
1282
  </div>
1416
1283
  <div class="inputContainer" id="phone">
1417
- <input
1418
- class=${classMap({
1419
- ["webchat-input"]: true,
1420
- ["webchat-font__desktop"]: !isMobile(),
1421
- ["webchat-font__mobile"]: isMobile(),
1422
- ["webchat-input__error"]:
1423
- this.phoneNumber.length === 14 &&
1424
- !isValidPhoneNumber(this.phoneNumber),
1425
- })}
1426
- type="tel"
1427
- inputmode="tel"
1428
- placeholder="Phone"
1429
- name="phone"
1430
- autocomplete="tel-national"
1431
- maxlength="14"
1284
+ <international-phone-input
1285
+ .country=${this.phoneCountry ?? this.country}
1432
1286
  .value=${this.phoneNumber}
1433
- @keydown=${this.handlePhoneKeydown}
1434
- @keyup=${this.handlePhoneKeyup}
1435
- @input=${(e: Event) => {
1436
- if (!e.target) {
1437
- return;
1438
- }
1439
- this.phoneNumber = formatToPhoneInput(
1440
- (e.target as HTMLInputElement).value
1441
- );
1287
+ .invalid=${this.phoneNumberHasBlurred && !this.phoneNumberIsValid}
1288
+ @phone-change=${this.onChangePhoneNumber}
1289
+ @phone-blur=${() => {
1290
+ this.phoneNumberHasBlurred = true;
1442
1291
  }}
1443
- />
1444
- ${this.phoneNumber.length === 14 &&
1445
- !isValidPhoneNumber(this.phoneNumber)
1292
+ ></international-phone-input>
1293
+ ${this.phoneNumberHasBlurred && !this.phoneNumberIsValid
1446
1294
  ? html`<p class="error-message">Invalid phone number</p>`
1447
1295
  : ""}
1448
1296
  </div>
@@ -1777,7 +1625,7 @@ export class TourScheduler extends LitElement {
1777
1625
  changes.
1778
1626
  ${formDisclaimer({
1779
1627
  buildingName: this.buildingName,
1780
- phoneNumberInput: this.phoneInput?.value,
1628
+ phoneNumberInput: this.phoneNumber,
1781
1629
  emailInput: this.emailInput?.value,
1782
1630
  orgLegalName: this.orgLegalName,
1783
1631
  orgSlug: this.orgSlug,
@@ -1832,7 +1680,7 @@ export class TourScheduler extends LitElement {
1832
1680
  changes.
1833
1681
  ${formDisclaimer({
1834
1682
  buildingName: this.buildingName,
1835
- phoneNumberInput: this.phoneInput?.value,
1683
+ phoneNumberInput: this.phoneNumber,
1836
1684
  emailInput: this.emailInput?.value,
1837
1685
  orgLegalName: this.orgLegalName,
1838
1686
  orgSlug: this.orgSlug,
@@ -1942,7 +1790,7 @@ export class TourScheduler extends LitElement {
1942
1790
  ? html`
1943
1791
  ${formDisclaimer({
1944
1792
  buildingName: this.buildingName,
1945
- phoneNumberInput: this.phoneInput?.value,
1793
+ phoneNumberInput: this.phoneNumber,
1946
1794
  emailInput: this.emailInput?.value,
1947
1795
  orgLegalName: this.orgLegalName,
1948
1796
  orgSlug: this.orgSlug,
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  import { getOfficeHourText } from "../OfficeHours";
19
19
  import {
20
- formatToPhoneInput,
20
+ formatToUsPhoneInput,
21
21
  isModifierKey,
22
22
  isNumericInput,
23
23
  } from "./formatPhoneNumber";
@@ -279,7 +279,7 @@ export class CallUsWindow extends LitElement {
279
279
  }
280
280
  const inputElement = e.target as HTMLInputElement;
281
281
 
282
- this.phoneNumberToText = formatToPhoneInput(inputElement.value);
282
+ this.phoneNumberToText = formatToUsPhoneInput(inputElement.value);
283
283
 
284
284
  this.phoneNumberInputRef.value.value = this.phoneNumberToText;
285
285
  };
@@ -2,16 +2,12 @@ import { css, html, LitElement, TemplateResult } from "lit";
2
2
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
3
3
  import { customElement, property, query, state } from "lit/decorators.js";
4
4
  import { classMap } from "lit/directives/class-map.js";
5
- import { createRef, ref, Ref } from "lit/directives/ref.js";
6
5
  import { installActionConfirmButton } from "./action-confirm-button";
7
6
  import { installDetailsWindow } from "./details-window";
8
7
  import "../me-select.ts";
9
8
  import { MESelect } from "../me-select";
10
- import {
11
- formatToPhoneInput,
12
- isModifierKey,
13
- isNumericInput,
14
- } from "./formatPhoneNumber";
9
+ import "./international-phone-input";
10
+ import type { InternationalPhoneChangeDetail } from "./international-phone-input";
15
11
  import { InputStyles } from "./InputStyles";
16
12
  import axios from "axios";
17
13
  import { FeatureFlagsShowDropdown } from "../../fetchFeatureFlag";
@@ -135,8 +131,6 @@ export class EmailUsWindow extends LitElement {
135
131
  @property({ attribute: true })
136
132
  featureFlagShowDropdown = "";
137
133
 
138
- phoneNumberInputRef: Ref<HTMLInputElement> = createRef();
139
-
140
134
  @property()
141
135
  privatePolicyUrl = "https://www.meetelise.com/privacy";
142
136
  @property()
@@ -151,6 +145,10 @@ export class EmailUsWindow extends LitElement {
151
145
  @state()
152
146
  phoneNumber = "";
153
147
  @state()
148
+ phoneNumberE164: string | null = null;
149
+ @state()
150
+ phoneCountry: string | null = null;
151
+ @state()
154
152
  message = "";
155
153
 
156
154
  @query("me-select#leadSource")
@@ -200,18 +198,12 @@ export class EmailUsWindow extends LitElement {
200
198
  this.email = (e.target as HTMLInputElement).value;
201
199
  };
202
200
 
203
- onChangePhoneNumber = (e: Event): void => {
204
- if (!e.target || !this.phoneNumberInputRef.value) {
205
- return;
206
- }
207
- if (isModifierKey(e as KeyboardEvent)) {
208
- return;
209
- }
210
- const inputElement = e.target as HTMLInputElement;
211
-
212
- this.phoneNumber = formatToPhoneInput(inputElement.value);
213
-
214
- this.phoneNumberInputRef.value.value = this.phoneNumber;
201
+ onChangePhoneNumber = (
202
+ e: CustomEvent<InternationalPhoneChangeDetail>
203
+ ): void => {
204
+ this.phoneNumber = e.detail.formattedValue;
205
+ this.phoneNumberE164 = e.detail.e164;
206
+ this.phoneCountry = e.detail.country;
215
207
  };
216
208
 
217
209
  onChangeMessage = (e: Event): void => {
@@ -222,12 +214,6 @@ export class EmailUsWindow extends LitElement {
222
214
  this.message = (e.target as HTMLTextAreaElement).value;
223
215
  };
224
216
 
225
- enforceFormat = (e: KeyboardEvent): void => {
226
- if (!isNumericInput(e) && !isModifierKey(e)) {
227
- e.preventDefault();
228
- }
229
- };
230
-
231
217
  validateFormFields = (): void => {
232
218
  this.hasNameError = false;
233
219
  this.hasEmailError = false;
@@ -240,7 +226,7 @@ export class EmailUsWindow extends LitElement {
240
226
  if (!this.email || !isValidEmail(this.email)) {
241
227
  this.hasEmailError = true;
242
228
  }
243
- if (!this.phoneNumber || this.phoneNumber.length !== 14) {
229
+ if (!this.phoneNumberE164) {
244
230
  this.hasPhoneNumberError = true;
245
231
  }
246
232
  this.windowHeight = 525 + 30 * this.getNumErrors();
@@ -259,6 +245,11 @@ export class EmailUsWindow extends LitElement {
259
245
  ) {
260
246
  return;
261
247
  }
248
+ const phoneNumber = this.phoneNumberE164;
249
+ if (!phoneNumber) {
250
+ this.hasPhoneNumberError = true;
251
+ return;
252
+ }
262
253
  try {
263
254
  this.isSubmitting = true;
264
255
  // Height of the button when it's in the loading state
@@ -271,7 +262,7 @@ export class EmailUsWindow extends LitElement {
271
262
  this.firstName,
272
263
  this.lastName,
273
264
  this.email,
274
- this.phoneNumber,
265
+ phoneNumber,
275
266
  this.message,
276
267
  this.buildingId,
277
268
  this.orgSlug,
@@ -427,23 +418,12 @@ export class EmailUsWindow extends LitElement {
427
418
  `
428
419
  : ""}
429
420
  <div class="email-us__vertical-spacer"></div>
430
- <input
431
- ${ref(this.phoneNumberInputRef)}
432
- type="text"
433
- placeholder="Phone"
434
- autocomplete="tel-national"
435
- inputmode="tel"
436
- class=${classMap({
437
- ["webchat-input"]: true,
438
- ["email-us__contact-input"]: true,
439
- ["webchat-font__desktop"]: !isMobile(),
440
- ["webchat-font__mobile"]: isMobile(),
441
- })}
421
+ <international-phone-input
422
+ .country=${this.phoneCountry ?? this.country}
442
423
  .value=${this.phoneNumber}
443
- maxlength="14"
444
- @keydown=${this.enforceFormat}
445
- @keyup=${this.onChangePhoneNumber}
446
- />
424
+ .invalid=${this.hasPhoneNumberError}
425
+ @phone-change=${this.onChangePhoneNumber}
426
+ ></international-phone-input>
447
427
  ${this.hasPhoneNumberError
448
428
  ? html`
449
429
  <div class="email-us__error-text">
@@ -527,7 +507,7 @@ const createEmail = async (
527
507
  firstName: string,
528
508
  lastName: string,
529
509
  email: string,
530
- rawPhoneNumber: string,
510
+ phoneNumber: string,
531
511
  message: string,
532
512
  buildingId: number,
533
513
  orgSlug: string,
@@ -536,13 +516,6 @@ const createEmail = async (
536
516
  chatId?: string | null,
537
517
  leadSourcesWithTimestamps?: LeadSourceSubmittedListItem[]
538
518
  ) => {
539
- const formattedPhoneNumber =
540
- "+1" +
541
- rawPhoneNumber
542
- .replace("(", "")
543
- .replace(")", "")
544
- .replace(" ", "")
545
- .replace("-", "");
546
519
  const queryParams = new URLSearchParams(window.location.search);
547
520
  const requestBody = {
548
521
  email_address: email,
@@ -551,7 +524,7 @@ const createEmail = async (
551
524
  first_message: message,
552
525
  first_name: firstName,
553
526
  last_name: lastName,
554
- phone_number: formattedPhoneNumber,
527
+ phone_number: phoneNumber,
555
528
  referrer: document.referrer,
556
529
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
557
530
  // @ts-ignore
@@ -565,7 +538,7 @@ const createEmail = async (
565
538
  firstName,
566
539
  lastName,
567
540
  email,
568
- phone: formattedPhoneNumber,
541
+ phone: phoneNumber,
569
542
  message,
570
543
  originatingSource:
571
544
  leadSources.find((i) => i !== "property-website") || null,
@@ -1,22 +1,156 @@
1
+ import {
2
+ getCountries,
3
+ getCountryCallingCode,
4
+ parsePhoneNumberFromString,
5
+ } from "libphonenumber-js/min";
6
+ import type { CountryCode } from "libphonenumber-js/min";
7
+
8
+ const DEFAULT_PHONE_COUNTRY: CountryCode = "US";
9
+ const supportedCountries = new Set<string>(getCountries());
10
+
11
+ export type PhoneCountryOption = {
12
+ value: CountryCode;
13
+ label: string;
14
+ compactLabel: string;
15
+ countryName: string;
16
+ };
17
+
18
+ export const getPhoneCountry = (
19
+ country: string | null | undefined
20
+ ): CountryCode => {
21
+ const normalizedCountry = country?.toUpperCase();
22
+ return normalizedCountry && supportedCountries.has(normalizedCountry)
23
+ ? (normalizedCountry as CountryCode)
24
+ : DEFAULT_PHONE_COUNTRY;
25
+ };
26
+
27
+ const getFlagEmoji = (country: CountryCode): string =>
28
+ String.fromCodePoint(
29
+ ...[...country].map((character) => character.charCodeAt(0) + 127397)
30
+ );
31
+
32
+ const getRegionDisplayNames = (): Intl.DisplayNames | null => {
33
+ try {
34
+ return typeof Intl.DisplayNames === "function"
35
+ ? new Intl.DisplayNames(["en"], { type: "region" })
36
+ : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ const PINNED_PHONE_COUNTRIES: CountryCode[] = ["US", "CA", "MX", "GB"];
43
+
44
+ export const phoneCountryOptions: PhoneCountryOption[] = (() => {
45
+ const displayNames = getRegionDisplayNames();
46
+ return getCountries()
47
+ .map((country) => {
48
+ const countryName = displayNames?.of(country) ?? country;
49
+ const flag = getFlagEmoji(country);
50
+ const callingCode = getCountryCallingCode(country);
51
+ return {
52
+ value: country,
53
+ label: `${flag} +${callingCode} – ${countryName}`,
54
+ compactLabel: `${flag} +${callingCode}`,
55
+ countryName,
56
+ };
57
+ })
58
+ .sort((left, right) => {
59
+ const leftPinnedIndex = PINNED_PHONE_COUNTRIES.indexOf(left.value);
60
+ const rightPinnedIndex = PINNED_PHONE_COUNTRIES.indexOf(right.value);
61
+ if (leftPinnedIndex !== -1 || rightPinnedIndex !== -1) {
62
+ if (leftPinnedIndex === -1) return 1;
63
+ if (rightPinnedIndex === -1) return -1;
64
+ return leftPinnedIndex - rightPinnedIndex;
65
+ }
66
+ return left.countryName.localeCompare(right.countryName, "en");
67
+ });
68
+ })();
69
+
1
70
  /**
2
- * For now, only handles the US phone number case.....
3
- * Formats into phone number as you type
71
+ * Formats a phone number as the user types. Numbers beginning with `+` infer
72
+ * their country from the calling code; national numbers use `country`.
4
73
  */
5
- export const formatToPhoneInput = (phoneNumber: string): string => {
74
+ export const formatToPhoneInput = (
75
+ phoneNumber: string,
76
+ country: string | null = DEFAULT_PHONE_COUNTRY
77
+ ): string => {
78
+ const selectedCountry = getPhoneCountry(country);
79
+ const parsedPhoneNumber = parsePhoneNumberFromString(
80
+ phoneNumber,
81
+ selectedCountry
82
+ );
83
+ if (parsedPhoneNumber?.isValid()) {
84
+ return phoneNumber.trim().startsWith("+")
85
+ ? parsedPhoneNumber.formatInternational()
86
+ : parsedPhoneNumber.formatNational();
87
+ }
88
+
89
+ if (
90
+ !phoneNumber.trim().startsWith("+") &&
91
+ (selectedCountry === "US" || selectedCountry === "CA")
92
+ ) {
93
+ return formatToUsPhoneInput(phoneNumber);
94
+ }
95
+
96
+ const digits = phoneNumber.replace(/\D/g, "").substring(0, 15);
97
+ return phoneNumber.trim().startsWith("+") ? `+${digits}` : digits;
98
+ };
99
+
100
+ export const getCountryFromPhoneInput = (
101
+ phoneNumber: string,
102
+ country: string | null = DEFAULT_PHONE_COUNTRY
103
+ ): CountryCode => {
104
+ const selectedCountry = getPhoneCountry(country);
105
+ if (!phoneNumber.trim().startsWith("+")) return selectedCountry;
106
+ const parsedPhoneNumber = parsePhoneNumberFromString(
107
+ phoneNumber,
108
+ selectedCountry
109
+ );
110
+ return parsedPhoneNumber?.country ?? selectedCountry;
111
+ };
112
+
113
+ export const getNationalPhoneNumber = (
114
+ phoneNumber: string,
115
+ country: string | null = DEFAULT_PHONE_COUNTRY
116
+ ): string => {
117
+ const parsedPhoneNumber = parsePhoneNumberFromString(
118
+ phoneNumber,
119
+ getPhoneCountry(country)
120
+ );
121
+ return parsedPhoneNumber?.nationalNumber ?? phoneNumber.replace(/\D/g, "");
122
+ };
123
+
124
+ export const getE164PhoneNumber = (
125
+ phoneNumber: string,
126
+ country: string | null = DEFAULT_PHONE_COUNTRY
127
+ ): string | null => {
128
+ const parsedPhoneNumber = parsePhoneNumberFromString(
129
+ phoneNumber,
130
+ getPhoneCountry(country)
131
+ );
132
+ return parsedPhoneNumber?.isValid() ? parsedPhoneNumber.number : null;
133
+ };
134
+
135
+ export const isValidInternationalPhoneNumber = (
136
+ phoneNumber: string,
137
+ country: string | null = DEFAULT_PHONE_COUNTRY
138
+ ): boolean => getE164PhoneNumber(phoneNumber, country) !== null;
139
+
140
+ /**
141
+ * Text Us remains limited to +1 destinations because outbound SMS currently
142
+ * skips other calling codes. Keep its ten-digit behavior isolated from the
143
+ * international Tour and Email Us input.
144
+ */
145
+ export const formatToUsPhoneInput = (phoneNumber: string): string => {
6
146
  const input = phoneNumber.replace(/\D/g, "").substring(0, 10);
7
147
  const areaCode = input.substring(0, 3);
8
148
  const middle = input.substring(3, 6);
9
149
  const last = input.substring(6, 10);
10
150
 
11
- if (input.length > 5) {
12
- return `(${areaCode}) ${middle}-${last}`;
13
- }
14
- if (input.length > 2) {
15
- return `(${areaCode}) ${middle}`;
16
- }
17
- if (input.length > 0) {
18
- return `(${areaCode}`;
19
- }
151
+ if (input.length > 5) return `(${areaCode}) ${middle}-${last}`;
152
+ if (input.length > 2) return `(${areaCode}) ${middle}`;
153
+ if (input.length > 0) return `(${areaCode}`;
20
154
  return "";
21
155
  };
22
156