diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index 9ee8006b..aeed4e44 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -19,7 +19,7 @@ import { initializeApp, getApps } from "firebase/app"; import { firebaseConfig } from "./config"; import { connectAuthEmulator, getAuth } from "firebase/auth"; -import { autoAnonymousLogin, initializeUI, oneTapSignIn } from "@firebase-ui/core"; +import { autoAnonymousLogin, initializeUI, oneTapSignIn, countryCodes } from "@firebase-ui/core"; export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; @@ -32,6 +32,10 @@ export const ui = initializeUI({ oneTapSignIn({ clientId: "200312857118-lscdui98fkaq7ffr81446blafjn5o6r0.apps.googleusercontent.com", }), + countryCodes({ + allowedCountries: ["US", "CA", "GB"], + defaultCountry: "GB", + }), ], }); diff --git a/packages/core/package.json b/packages/core/package.json index 63bf9a48..f46b174d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@firebase-ui/translations": "workspace:*", + "libphonenumber-js": "^1.12.23", "nanostores": "catalog:", "qrcode-generator": "^2.0.4", "zod": "catalog:" diff --git a/packages/core/src/behaviors/country-codes.test.ts b/packages/core/src/behaviors/country-codes.test.ts new file mode 100644 index 00000000..b348b055 --- /dev/null +++ b/packages/core/src/behaviors/country-codes.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from "vitest"; +import { countryCodesHandler, CountryCodesOptions } from "./country-codes"; +import { countryData } from "../country-data"; + +describe("countryCodesHandler", () => { + describe("default behavior", () => { + it("should return all countries when no options provided", () => { + const result = countryCodesHandler(); + + expect(result.allowedCountries).toEqual(countryData); + expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US")); + }); + + it("should return all countries when empty options provided", () => { + const result = countryCodesHandler({}); + + expect(result.allowedCountries).toEqual(countryData); + expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US")); + }); + }); + + describe("allowedCountries filtering", () => { + it("should filter countries based on allowedCountries", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toHaveLength(3); + // Order is preserved from original countryData array, not from allowedCountries + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should handle single allowed country", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toHaveLength(1); + expect(result.allowedCountries[0]!.code).toBe("US"); + }); + + it("should handle empty allowedCountries array", () => { + const options: CountryCodesOptions = { + allowedCountries: [], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toEqual(countryData); + }); + }); + + describe("defaultCountry setting", () => { + it("should set default country when provided", () => { + const options: CountryCodesOptions = { + defaultCountry: "GB", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("GB"); + expect(result.defaultCountry.name).toBe("United Kingdom"); + }); + + it("should default to US when no defaultCountry provided", () => { + const result = countryCodesHandler(); + + expect(result.defaultCountry.code).toBe("US"); + }); + + it("should default to US when defaultCountry is undefined", () => { + const options: CountryCodesOptions = { + defaultCountry: undefined, + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("US"); + }); + }); + + describe("defaultCountry validation with allowedCountries", () => { + it("should keep defaultCountry when it's in allowedCountries", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "GB", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("GB"); + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should override defaultCountry when it's not in allowedCountries", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "FR", // France is not in allowed countries + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("CA"); // Should default to first allowed country (CA comes first in original array) + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + expect(consoleSpy).toHaveBeenCalledWith( + 'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to CA' + ); + + consoleSpy.mockRestore(); + }); + + it("should override defaultCountry to first allowed country when not in list", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["GB", "CA", "AU"], // US is not in this list + defaultCountry: "US", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("AU"); // Should default to first allowed country (AU comes first in original array) + expect(consoleSpy).toHaveBeenCalledWith( + 'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to AU' + ); + + consoleSpy.mockRestore(); + }); + + it("should not warn when defaultCountry is in allowedCountries", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "CA", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("CA"); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe("edge cases", () => { + it("should handle invalid country codes gracefully", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "INVALID", "GB"] as any, + }; + + const result = countryCodesHandler(options); + + // Should only include valid countries + expect(result.allowedCountries).toHaveLength(2); + expect(result.allowedCountries.map((c) => c.code)).toEqual(["GB", "US"]); + }); + + it("should handle case sensitivity", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["us", "gb"] as any, // lowercase + defaultCountry: "US", // This will trigger the validation logic + }; + + const result = countryCodesHandler(options); + + // Should fall back to all countries when no matches found + expect(result.allowedCountries).toEqual(countryData); + expect(consoleSpy).toHaveBeenCalledWith( + 'No countries matched the "allowedCountries" list, falling back to all countries' + ); + + consoleSpy.mockRestore(); + }); + + it("should handle special country codes like Kosovo", () => { + const options: CountryCodesOptions = { + allowedCountries: ["XK", "US", "GB"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries.length).toBeGreaterThan(2); // Kosovo has multiple entries + expect(result.allowedCountries.some((c) => c.code === "XK")).toBe(true); + expect(result.allowedCountries.some((c) => c.code === "US")).toBe(true); + expect(result.allowedCountries.some((c) => c.code === "GB")).toBe(true); + }); + }); + + describe("return type validation", () => { + it("should return objects with correct structure", () => { + const result = countryCodesHandler(); + + expect(result).toHaveProperty("allowedCountries"); + expect(result).toHaveProperty("defaultCountry"); + expect(Array.isArray(result.allowedCountries)).toBe(true); + expect(typeof result.defaultCountry).toBe("object"); + + // Check structure of country objects + result.allowedCountries.forEach((country) => { + expect(country).toHaveProperty("name"); + expect(country).toHaveProperty("dialCode"); + expect(country).toHaveProperty("code"); + expect(country).toHaveProperty("emoji"); + }); + + expect(result.defaultCountry).toHaveProperty("name"); + expect(result.defaultCountry).toHaveProperty("dialCode"); + expect(result.defaultCountry).toHaveProperty("code"); + expect(result.defaultCountry).toHaveProperty("emoji"); + }); + }); +}); diff --git a/packages/core/src/behaviors/country-codes.ts b/packages/core/src/behaviors/country-codes.ts new file mode 100644 index 00000000..6f5ec791 --- /dev/null +++ b/packages/core/src/behaviors/country-codes.ts @@ -0,0 +1,41 @@ +import { CountryCode, countryData } from "../country-data"; + +export type CountryCodesOptions = { + // The allowed countries are the countries that will be shown in the country selector + // or `getCountries` is called. + allowedCountries?: CountryCode[]; + // The default country is the country that will be selected by default when + // the country selector is rendered, or `getDefaultCountry` is called. + defaultCountry?: CountryCode; +}; + +export const countryCodesHandler = (options?: CountryCodesOptions) => { + // Determine allowed countries + let allowedCountries = options?.allowedCountries?.length + ? countryData.filter((country) => options.allowedCountries!.includes(country.code)) + : countryData; + + // If no countries match, fall back to all countries + if (options?.allowedCountries?.length && allowedCountries.length === 0) { + console.warn(`No countries matched the "allowedCountries" list, falling back to all countries`); + allowedCountries = countryData; + } + + // Determine default country + let defaultCountry = options?.defaultCountry + ? countryData.find((country) => country.code === options.defaultCountry)! + : countryData.find((country) => country.code === "US")!; + + // If default country is not in allowed countries, use first allowed country + if (!allowedCountries.some((country) => country.code === defaultCountry.code)) { + defaultCountry = allowedCountries[0]!; + console.warn( + `The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to ${defaultCountry.code}` + ); + } + + return { + allowedCountries, + defaultCountry, + }; +}; diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts index f81c23d6..d043547c 100644 --- a/packages/core/src/behaviors/index.test.ts +++ b/packages/core/src/behaviors/index.test.ts @@ -246,8 +246,9 @@ describe("requireDisplayName", () => { describe("defaultBehaviors", () => { it("should include recaptchaVerification by default", () => { expect(defaultBehaviors).toHaveProperty("recaptchaVerification"); - expect(defaultBehaviors.recaptchaVerification).toHaveProperty("type", "callable"); - expect(typeof defaultBehaviors.recaptchaVerification.handler).toBe("function"); + expect(defaultBehaviors).toHaveProperty("providerSignInStrategy"); + expect(defaultBehaviors).toHaveProperty("providerLinkStrategy"); + expect(defaultBehaviors).toHaveProperty("countryCodes"); }); it("should not include other behaviors by default", () => { diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 1b1ddd21..022af593 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -6,6 +6,7 @@ import * as recaptchaHandlers from "./recaptcha"; import * as providerStrategyHandlers from "./provider-strategy"; import * as oneTapSignInHandlers from "./one-tap"; import * as requireDisplayNameHandlers from "./require-display-name"; +import * as countryCodesHandlers from "./country-codes"; import { callableBehavior, initBehavior, @@ -35,6 +36,7 @@ type Registry = { (ui: FirebaseUIConfiguration) => ReturnType >; requireDisplayName: CallableBehavior; + countryCodes: CallableBehavior; }; export type Behavior = Pick; @@ -106,6 +108,12 @@ export function requireDisplayName(): Behavior<"requireDisplayName"> { }; } +export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions): Behavior<"countryCodes"> { + return { + countryCodes: callableBehavior(() => countryCodesHandlers.countryCodesHandler(options)), + }; +} + export function hasBehavior(ui: FirebaseUIConfiguration, key: T): boolean { return !!ui.behaviors[key]; } @@ -121,4 +129,5 @@ export function getBehavior(ui: FirebaseUIConfiguratio export const defaultBehaviors: Behavior<"recaptchaVerification"> = { ...recaptchaVerification(), ...providerRedirectStrategy(), + ...countryCodes(), }; diff --git a/packages/core/src/country-data.test.ts b/packages/core/src/country-data.test.ts index 209c4fe2..d13bc7c8 100644 --- a/packages/core/src/country-data.test.ts +++ b/packages/core/src/country-data.test.ts @@ -1,28 +1,37 @@ import { describe, it, expect } from "vitest"; -import { countryData, getCountryByDialCode, getCountryByCode, formatPhoneNumberWithCountry } from "./country-data"; +import { countryData, formatPhoneNumber, CountryData, CountryCode } from "./country-data"; describe("CountryData", () => { - describe("CountryData interface", () => { - it("should have correct structure for all countries", () => { - countryData.forEach((country) => { - expect(country).toHaveProperty("name"); - expect(country).toHaveProperty("dialCode"); - expect(country).toHaveProperty("code"); - expect(country).toHaveProperty("emoji"); - - expect(typeof country.name).toBe("string"); - expect(typeof country.dialCode).toBe("string"); - expect(typeof country.code).toBe("string"); - expect(typeof country.emoji).toBe("string"); - - expect(country.name.length).toBeGreaterThan(0); - expect(country.dialCode).toMatch(/^\+\d+$/); - expect(country.code).toMatch(/^[A-Z]{2}$/); - expect(country.emoji.length).toBeGreaterThan(0); - }); + it("should have correct structure for all countries", () => { + countryData.forEach((country) => { + expect(country).toHaveProperty("name"); + expect(country).toHaveProperty("dialCode"); + expect(country).toHaveProperty("code"); + expect(country).toHaveProperty("emoji"); + + expect(typeof country.name).toBe("string"); + expect(typeof country.dialCode).toBe("string"); + expect(typeof country.code).toBe("string"); + expect(typeof country.emoji).toBe("string"); + + expect(country.name.length).toBeGreaterThan(0); + expect(country.dialCode).toMatch(/^\+\d+$/); + expect(country.code).toMatch(/^[A-Z]{2}$/); + expect(country.emoji.length).toBeGreaterThan(0); }); }); + it("should handle countries with multiple dial codes", () => { + const kosovoCountries = countryData.filter((country) => country.code === "XK"); + expect(kosovoCountries.length).toBeGreaterThan(1); + + // Test that Kosovo has multiple entries with different dial codes + const dialCodes = kosovoCountries.map((country) => country.dialCode); + expect(dialCodes).toContain("+377"); + expect(dialCodes).toContain("+381"); + expect(dialCodes).toContain("+386"); + }); + describe("countryData array", () => { it("should have valid dial codes", () => { countryData.forEach((country) => { @@ -48,137 +57,123 @@ describe("CountryData", () => { }); }); - describe("getCountryByDialCode", () => { - it("should return correct country for valid dial code", () => { - const usCountry = getCountryByDialCode("+1"); - expect(usCountry).toBeDefined(); - expect(usCountry?.code).toBe("US"); - expect(usCountry?.name).toBe("United States"); + describe("CountryCode type", () => { + it("should have proper literal types", () => { + // These should be valid CountryCode values + const validCodes: CountryCode[] = ["US", "GB", "CA", "AU", "DE", "FR"]; + expect(validCodes).toBeDefined(); - const ukCountry = getCountryByDialCode("+44"); - expect(ukCountry).toBeDefined(); - expect(ukCountry?.code).toBe("GB"); - expect(ukCountry?.name).toBe("United Kingdom"); - - const japanCountry = getCountryByDialCode("+81"); - expect(japanCountry).toBeDefined(); - expect(japanCountry?.code).toBe("JP"); - expect(japanCountry?.name).toBe("Japan"); - }); + // Test that we can find countries by their codes + const usCountry = countryData.find((country) => country.code === "US"); + const gbCountry = countryData.find((country) => country.code === "GB"); - it("should return undefined for invalid dial code", () => { - expect(getCountryByDialCode("+999")).toBeUndefined(); - expect(getCountryByDialCode("invalid")).toBeUndefined(); - expect(getCountryByDialCode("")).toBeUndefined(); + expect(usCountry).toBeDefined(); + expect(gbCountry).toBeDefined(); + expect(usCountry?.code).toBe("US"); + expect(gbCountry?.code).toBe("GB"); }); + }); - it("should handle dial codes with multiple countries", () => { - const countries = countryData.filter((country) => country.dialCode === "+1"); - expect(countries.length).toBeGreaterThan(1); + describe("formatPhoneNumber", () => { + const ukCountry: CountryData = { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "πŸ‡¬πŸ‡§" }; + const usCountry: CountryData = { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }; + const kzCountry: CountryData = { name: "Kazakhstan", dialCode: "+7", code: "KZ", emoji: "πŸ‡°πŸ‡Ώ" }; - // Should return the first match (US) - const result = getCountryByDialCode("+1"); - expect(result?.code).toBe("US"); - }); - }); + describe("basic formatting", () => { + it("should format phone number with country dial code", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("2125551234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("7012345678", kzCountry)).toBe("+77012345678"); + }); - describe("getCountryByCode", () => { - it("should return correct country for valid country code", () => { - const usCountry = getCountryByCode("US"); - expect(usCountry).toBeDefined(); - expect(usCountry?.code).toBe("US"); - expect(usCountry?.name).toBe("United States"); - expect(usCountry?.dialCode).toBe("+1"); - - const ukCountry = getCountryByCode("GB"); - expect(ukCountry).toBeDefined(); - expect(ukCountry?.code).toBe("GB"); - expect(ukCountry?.name).toBe("United Kingdom"); - expect(ukCountry?.dialCode).toBe("+44"); + it("should handle phone numbers with spaces and special characters", () => { + expect(formatPhoneNumber("07480 842 372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("(212) 555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("701-234-5678", kzCountry)).toBe("+77012345678"); + }); }); - it("should handle case insensitive country codes", () => { - // @ts-expect-error - we want to test case insensitivity - expect(getCountryByCode("us")).toBeDefined(); - // @ts-expect-error - we want to test case insensitivity - expect(getCountryByCode("Us")).toBeDefined(); - // @ts-expect-error - we want to test case insensitivity - expect(getCountryByCode("uS")).toBeDefined(); - - expect(getCountryByCode("US")).toBeDefined(); - // @ts-expect-error - we want to test case insensitivity - const result = getCountryByCode("us"); - expect(result?.code).toBe("US"); - }); + describe("handling numbers with existing country codes", () => { + it("should preserve correct country code", () => { + expect(formatPhoneNumber("+441234567890", ukCountry)).toBe("+441234567890"); + expect(formatPhoneNumber("+11234567890", usCountry)).toBe("+11234567890"); + expect(formatPhoneNumber("+71234567890", kzCountry)).toBe("+71234567890"); + }); - it("should return undefined for invalid country code", () => { - // @ts-expect-error - we want to test invalid country code - expect(getCountryByCode("XX")).toBeUndefined(); - // @ts-expect-error - we want to test invalid country code - expect(getCountryByCode("INVALID")).toBeUndefined(); - // @ts-expect-error - we want to test case insensitivity - expect(getCountryByCode("")).toBeUndefined(); - // @ts-expect-error - we want to test invalid country code - expect(getCountryByCode("U")).toBeUndefined(); - // @ts-expect-error - we want to test invalid country code - expect(getCountryByCode("USA")).toBeUndefined(); - }); + it("should preserve existing country code even if different from context", () => { + expect(formatPhoneNumber("+12125551234", ukCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("+447480842372", usCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+447480842372", kzCountry)).toBe("+447480842372"); + }); - it("should handle special characters in country codes", () => { - expect(getCountryByCode("XK")).toBeDefined(); // Kosovo + it("should handle numbers with different country codes", () => { + expect(formatPhoneNumber("+77012345678", ukCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("+77012345678", usCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("+447480842372", kzCountry)).toBe("+447480842372"); + }); }); - }); - describe("formatPhoneNumberWithCountry", () => { - it("should format phone number with country dial code", () => { - expect(formatPhoneNumberWithCountry("1234567890", "US")).toBe("+11234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "GB")).toBe("+441234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "JP")).toBe("+811234567890"); - }); + describe("handling numbers starting with 0", () => { + it("should remove leading 0 and add country code", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("02125551234", usCountry)).toBe("02125551234"); + expect(formatPhoneNumber("07012345678", kzCountry)).toBe("07012345678"); + }); - it("should handle phone numbers with spaces", () => { - expect(formatPhoneNumberWithCountry("123 456 7890", "US")).toBe("+1123 456 7890"); - expect(formatPhoneNumberWithCountry(" 1234567890 ", "US")).toBe("+11234567890"); + it("should handle numbers with 0 and existing country code", () => { + expect(formatPhoneNumber("+4407480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+102125551234", usCountry)).toBe("+102125551234"); + }); }); - it("should handle empty phone numbers", () => { - expect(formatPhoneNumberWithCountry("", "US")).toBe("+1"); - expect(formatPhoneNumberWithCountry(" ", "US")).toBe("+1"); + describe("handling numbers with country dial code without +", () => { + it("should add + to numbers starting with country dial code", () => { + expect(formatPhoneNumber("447480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("12125551234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("77012345678", kzCountry)).toBe("+77012345678"); + }); }); - it("should handle phone numbers with dashes and parentheses", () => { - expect(formatPhoneNumberWithCountry("(123) 456-7890", "US")).toBe("+1(123) 456-7890"); - expect(formatPhoneNumberWithCountry("123-456-7890", "US")).toBe("+1123-456-7890"); - }); + describe("edge cases", () => { + it("should handle empty phone numbers", () => { + expect(formatPhoneNumber("", ukCountry)).toBe(""); + expect(formatPhoneNumber(" ", ukCountry)).toBe(""); + }); - it("should handle international numbers with existing dial codes", () => { - expect(formatPhoneNumberWithCountry("+44 20 7946 0958", "US")).toBe("+120 7946 0958"); - expect(formatPhoneNumberWithCountry("+81 3 1234 5678", "GB")).toBe("+443 1234 5678"); - }); + it("should handle very long phone numbers", () => { + const longNumber = "12345678901234567890"; + expect(formatPhoneNumber(longNumber, ukCountry)).toBe("12345678901234567890"); + }); - it("should handle edge cases", () => { - expect(formatPhoneNumberWithCountry("1234567890", "MC")).toBe("+3771234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "RU")).toBe("+71234567890"); - }); - }); + it("should handle numbers with multiple + signs", () => { + expect(formatPhoneNumber("++447480842372", ukCountry)).toBe("+"); + expect(formatPhoneNumber("+44+7480842372", ukCountry)).toBe("+44"); + }); - describe("Edge cases and error handling", () => { - it("should handle very long phone numbers", () => { - const longNumber = "12345678901234567890"; - expect(formatPhoneNumberWithCountry(longNumber, "US")).toBe("+112345678901234567890"); + it("should handle numbers with mixed formatting", () => { + expect(formatPhoneNumber("+44 (0) 7480 842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+1-800-123-4567", usCountry)).toBe("+18001234567"); + }); }); - it("should handle countries with multiple dial codes", () => { - const kosovoCountries = countryData.filter((country) => country.code === "XK"); - expect(kosovoCountries.length).toBeGreaterThan(1); + describe("real-world examples", () => { + it("should handle UK mobile numbers", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+447480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("447480842372", ukCountry)).toBe("+447480842372"); + }); - const result1 = getCountryByDialCode("+377"); - const result2 = getCountryByDialCode("+381"); - const result3 = getCountryByDialCode("+386"); + it("should handle US phone numbers", () => { + expect(formatPhoneNumber("(212) 555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("212-555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("+12125551234", usCountry)).toBe("+12125551234"); + }); - expect(result1?.code).toBe("XK"); - expect(result2?.code).toBe("XK"); - expect(result3?.code).toBe("XK"); + it("should handle Kazakhstan numbers", () => { + expect(formatPhoneNumber("+77012345678", kzCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("7012345678", kzCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("07012345678", kzCountry)).toBe("07012345678"); + }); }); }); }); diff --git a/packages/core/src/country-data.ts b/packages/core/src/country-data.ts index 65e623ae..20a56347 100644 --- a/packages/core/src/country-data.ts +++ b/packages/core/src/country-data.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { formatIncompletePhoneNumber, parsePhoneNumberWithError, type CountryCode } from "libphonenumber-js"; + export const countryData = [ - { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, - { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "πŸ‡¬πŸ‡§" }, { name: "Afghanistan", dialCode: "+93", code: "AF", emoji: "πŸ‡¦πŸ‡«" }, { name: "Albania", dialCode: "+355", code: "AL", emoji: "πŸ‡¦πŸ‡±" }, { name: "Algeria", dialCode: "+213", code: "DZ", emoji: "πŸ‡©πŸ‡Ώ" }, @@ -112,7 +112,6 @@ export const countryData = [ { name: "Guinea-Bissau", dialCode: "+245", code: "GW", emoji: "πŸ‡¬πŸ‡Ό" }, { name: "Guyana", dialCode: "+592", code: "GY", emoji: "πŸ‡¬πŸ‡Ύ" }, { name: "Haiti", dialCode: "+509", code: "HT", emoji: "πŸ‡­πŸ‡Ή" }, - { name: "Heard Island and McDonald Islands", dialCode: "+672", code: "HM", emoji: "πŸ‡­πŸ‡²" }, { name: "Honduras", dialCode: "+504", code: "HN", emoji: "πŸ‡­πŸ‡³" }, { name: "Hong Kong", dialCode: "+852", code: "HK", emoji: "πŸ‡­πŸ‡°" }, { name: "Hungary", dialCode: "+36", code: "HU", emoji: "πŸ‡­πŸ‡Ί" }, @@ -222,7 +221,6 @@ export const countryData = [ { name: "Solomon Islands", dialCode: "+677", code: "SB", emoji: "πŸ‡ΈπŸ‡§" }, { name: "Somalia", dialCode: "+252", code: "SO", emoji: "πŸ‡ΈπŸ‡΄" }, { name: "South Africa", dialCode: "+27", code: "ZA", emoji: "πŸ‡ΏπŸ‡¦" }, - { name: "South Georgia and the South Sandwich Islands", dialCode: "+500", code: "GS", emoji: "πŸ‡¬πŸ‡Έ" }, { name: "South Korea", dialCode: "+82", code: "KR", emoji: "πŸ‡°πŸ‡·" }, { name: "South Sudan", dialCode: "+211", code: "SS", emoji: "πŸ‡ΈπŸ‡Έ" }, { name: "Spain", dialCode: "+34", code: "ES", emoji: "πŸ‡ͺπŸ‡Έ" }, @@ -251,6 +249,8 @@ export const countryData = [ { name: "Uganda", dialCode: "+256", code: "UG", emoji: "πŸ‡ΊπŸ‡¬" }, { name: "Ukraine", dialCode: "+380", code: "UA", emoji: "πŸ‡ΊπŸ‡¦" }, { name: "United Arab Emirates", dialCode: "+971", code: "AE", emoji: "πŸ‡¦πŸ‡ͺ" }, + { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "πŸ‡¬πŸ‡§" }, + { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, { name: "Uruguay", dialCode: "+598", code: "UY", emoji: "πŸ‡ΊπŸ‡Ύ" }, { name: "Uzbekistan", dialCode: "+998", code: "UZ", emoji: "πŸ‡ΊπŸ‡Ώ" }, { name: "Vanuatu", dialCode: "+678", code: "VU", emoji: "πŸ‡»πŸ‡Ί" }, @@ -263,27 +263,41 @@ export const countryData = [ { name: "Zambia", dialCode: "+260", code: "ZM", emoji: "πŸ‡ΏπŸ‡²" }, { name: "Zimbabwe", dialCode: "+263", code: "ZW", emoji: "πŸ‡ΏπŸ‡Ό" }, { name: "Γ…land Islands", dialCode: "+358", code: "AX", emoji: "πŸ‡¦πŸ‡½" }, -] as const; +] as const satisfies CountryData[]; -export type CountryData = (typeof countryData)[number]; +export type CountryData = { + name: string; + dialCode: string; + code: CountryCode; + emoji: string; +}; -export type CountryCode = CountryData["code"]; +export type { CountryCode }; -export function getCountryByDialCode(dialCode: string): CountryData | undefined { - return countryData.find((country) => country.dialCode === dialCode); -} +export function formatPhoneNumber(phoneNumber: string, countryData: CountryData): string { + try { + const parsedNumber = parsePhoneNumberWithError(phoneNumber, countryData.code); -export function getCountryByCode(code: CountryCode): CountryData | undefined { - return countryData.find((country) => country.code === code.toUpperCase()); -} + if (parsedNumber && parsedNumber.isValid()) { + // Return the E164 format. + return parsedNumber.number; + } + } catch { + // If parsing fails, try to format as incomplete number + } + + try { + // Try to format as incomplete number with country + const formatted = formatIncompletePhoneNumber(phoneNumber, countryData.code); + // Remove spaces from the formatted result. + return formatted.replace(/\s/g, ""); + } catch { + // If all else fails, just clean the number and prepend country code + const cleaned = phoneNumber.replace(/[^\d+]/g, "").trim(); + if (cleaned.startsWith("+")) { + return cleaned; + } -export function formatPhoneNumberWithCountry(phoneNumber: string, countryCode: CountryCode): string { - const countryData = getCountryByCode(countryCode); - if (!countryData) { - return phoneNumber; + return `${countryData.dialCode}${cleaned}`; } - const countryDialCode = countryData.dialCode; - // Remove any existing dial code if present - const cleanNumber = phoneNumber.replace(/^\+\d+/, "").trim(); - return `${countryDialCode}${cleanNumber}`; } diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx index e6613ee4..4b7ae4c1 100644 --- a/packages/react/src/auth/forms/phone-auth-form.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -17,10 +17,8 @@ "use client"; import { - CountryCode, - countryData, FirebaseUIError, - formatPhoneNumberWithCountry, + formatPhoneNumber, getTranslation, verifyPhoneNumber, confirmPhoneNumber, @@ -30,7 +28,7 @@ import { useCallback, useRef, useState } from "react"; import { usePhoneAuthNumberFormSchema, usePhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; import { form } from "~/components/form"; import { Policies } from "~/components/policies"; -import { CountrySelector } from "~/components/country-selector"; +import { CountrySelector, CountrySelectorRef } from "~/components/country-selector"; export function usePhoneNumberFormAction() { const ui = useUI(); @@ -81,14 +79,13 @@ export function PhoneNumberForm(props: PhoneNumberFormProps) { const ui = useUI(); const recaptchaContainerRef = useRef(null); const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); const form = usePhoneNumberForm({ recaptchaVerifier: recaptchaVerifier!, onSuccess: props.onSubmit, - formatPhoneNumber: (phoneNumber) => formatPhoneNumberWithCountry(phoneNumber, selectedCountry), + formatPhoneNumber: (phoneNumber) => formatPhoneNumber(phoneNumber, countrySelector.current!.getCountry()), }); - const [selectedCountry, setSelectedCountry] = useState(countryData[0].code); - return (
setSelectedCountry(code as CountryCode)} - className="fui-phone-input__country-selector" - /> - } + before={} /> )} diff --git a/packages/react/src/components/country-selector.test.tsx b/packages/react/src/components/country-selector.test.tsx index dffe2ae3..d1b1fc17 100644 --- a/packages/react/src/components/country-selector.test.tsx +++ b/packages/react/src/components/country-selector.test.tsx @@ -15,11 +15,71 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; -import { countryData } from "@firebase-ui/core"; -import { CountrySelector } from "./country-selector"; +import { render, screen, fireEvent, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { countryData, countryCodes } from "@firebase-ui/core"; +import { CountrySelector, CountrySelectorRef, useCountries, useDefaultCountry } from "./country-selector"; +import { createMockUI, createFirebaseUIProvider } from "~/tests/utils"; +import { RefObject } from "react"; + +describe("useCountries", () => { + it("should return allowed countries from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toHaveLength(3); + expect(result.current.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should return all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toEqual(countryData); + }); +}); + +describe("useDefaultCountry", () => { + it("should return default country from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + expect(result.current.name).toBe("United States"); + }); + + it("should return US when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + }); +}); describe("", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + beforeEach(() => { vi.clearAllMocks(); }); @@ -28,56 +88,122 @@ describe("", () => { cleanup(); }); - it("renders with the selected country", () => { - const country = countryData[0]; + it("renders with the default country", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); - render( {}} />); - - expect(screen.getByText(country.emoji)).toBeInTheDocument(); - expect(screen.getByText(country.dialCode)).toBeInTheDocument(); + expect(screen.getByText("πŸ‡ΊπŸ‡Έ")).toBeInTheDocument(); + expect(screen.getByText("+1")).toBeInTheDocument(); const select = screen.getByRole("combobox"); - expect(select).toHaveValue(country.code); + expect(select).toHaveValue("US"); }); it("applies custom className", () => { - const country = countryData[0]; - render( {}} className="custom-class" />); + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); const rootDiv = screen.getByRole("combobox").closest("div.fui-country-selector"); expect(rootDiv).toHaveClass("custom-class"); }); - it("calls onChange when a different country is selected", () => { - const country = countryData[0]; - const onChangeMock = vi.fn(); + it("changes selection when a different country is selected", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + const select = screen.getByRole("combobox"); - render(); + // Change to GB + fireEvent.change(select, { target: { value: "GB" } }); + + expect(screen.getByText("πŸ‡¬πŸ‡§")).toBeInTheDocument(); + expect(screen.getByText("+44")).toBeInTheDocument(); + expect(select).toHaveValue("GB"); + }); + + it("renders only allowed countries in the dropdown", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); const select = screen.getByRole("combobox"); + const options = select.querySelectorAll("option"); + + expect(options).toHaveLength(3); + expect(Array.from(options).map((option) => option.value)).toEqual(["CA", "GB", "US"]); + }); - // Find a different country to select - const newCountry = countryData.find(($) => $.code !== country.code); + it("displays country information correctly", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); - if (!newCountry) { - expect.fail("No different country found in countryData. Test cannot proceed."); - } + // Check that all countries show dial code and name + const options = screen.getAllByRole("option"); + options.forEach((option) => { + const text = option.textContent; + expect(text).toMatch(/^\+\d+ \([^)]+\)$/); // Format: +123 (Country Name) + }); + }); +}); - // Change the selection - fireEvent.change(select, { target: { value: newCountry.code } }); +describe("CountrySelector ref", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); - // Check if onChange was called with the new country - expect(onChangeMock).toHaveBeenCalledTimes(1); - expect(onChangeMock).toHaveBeenCalledWith(newCountry.code); + beforeEach(() => { + vi.clearAllMocks(); }); - it("renders all countries in the dropdown", () => { - const country = countryData[0]; - render( {}} />); + afterEach(() => { + cleanup(); + }); - const select = screen.getByRole("combobox"); - const options = select.querySelectorAll("option"); + it("should expose getCountry and setCountry methods", () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + expect(ref.current).toBeDefined(); + expect(typeof ref.current?.getCountry).toBe("function"); + expect(typeof ref.current?.setCountry).toBe("function"); + }); + + it("should return current selected country via getCountry", () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("US"); + expect(currentCountry?.name).toBe("United States"); + }); + + it("should set country via setCountry", async () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + ref.current?.setCountry("GB"); + + await waitFor(() => { + const select = screen.getByRole("combobox"); + expect(select).toHaveValue("GB"); + }); + }); + + it("should update getCountry after setCountry", async () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + ref.current?.setCountry("CA"); + + await waitFor(() => { + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("CA"); + }); - expect(options.length).toBe(countryData.length); + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.name).toBe("Canada"); }); }); diff --git a/packages/react/src/components/country-selector.tsx b/packages/react/src/components/country-selector.tsx index cd4b6ce9..fae76842 100644 --- a/packages/react/src/components/country-selector.tsx +++ b/packages/react/src/components/country-selector.tsx @@ -16,37 +16,64 @@ "use client"; -import { CountryCode, countryData, getCountryByCode } from "@firebase-ui/core"; -import { ComponentProps } from "react"; +import { CountryCode, type CountryData, getBehavior } from "@firebase-ui/core"; +import { ComponentProps, forwardRef, useImperativeHandle, useState, useCallback } from "react"; +import { useUI } from "~/hooks"; import { cn } from "~/utils/cn"; -export type CountrySelectorProps = ComponentProps<"div"> & { - value: CountryCode; - onChange: (code: CountryCode) => void; - allowedCountries?: CountryCode[]; -}; +export interface CountrySelectorRef { + getCountry: () => CountryData; + setCountry: (code: CountryCode) => void; +} + +export type CountrySelectorProps = ComponentProps<"div">; + +export function useCountries() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().allowedCountries; +} + +export function useDefaultCountry() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().defaultCountry; +} -export function CountrySelector({ value, onChange, allowedCountries, className, ...props }: CountrySelectorProps) { - const country = getCountryByCode(value); - const countries = allowedCountries ? countryData.filter((c) => allowedCountries.includes(c.code)) : countryData; +export const CountrySelector = forwardRef(({ className, ...props }, ref) => { + const countries = useCountries(); + const defaultCountry = useDefaultCountry(); + const [selected, setSelected] = useState(defaultCountry); - if (!country) { - return null; - } + const setCountry = useCallback( + (code: CountryCode) => { + const foundCountry = countries.find((country) => country.code === code); + setSelected(foundCountry!); + }, + [countries] + ); + + useImperativeHandle( + ref, + () => ({ + getCountry: () => selected, + setCountry, + }), + [selected, setCountry] + ); return (
- {country.emoji} + {selected.emoji}
- {country.dialCode} + {selected.dialCode}