diff --git a/sdk/build.ts b/sdk/build.ts index aae813a..ede358c 100755 --- a/sdk/build.ts +++ b/sdk/build.ts @@ -107,7 +107,10 @@ function rollupConfig( inlineDynamicImports: false, banner: bannerComment, entryFileNames: "[name].mjs", - chunkFileNames: "[name].mjs", + chunkFileNames: (chunk) => + chunk.facadeModuleId?.includes("libphonenumber-js") + ? "libphonenumber.mjs" + : "[name].mjs", }, ] : [ diff --git a/sdk/src/components/field-country.tsx b/sdk/src/components/field-country.tsx index 56324f1..7dacd26 100644 --- a/sdk/src/components/field-country.tsx +++ b/sdk/src/components/field-country.tsx @@ -6,8 +6,9 @@ import { useState, } from "preact/hooks"; import { FieldProps } from "./field"; -import { CountryCode, getCountries } from "libphonenumber-js"; +import type { CountryCode } from "libphonenumber-js"; import { Dropdown, DropdownOption } from "./core/dropdown"; +import { getLoadedLibphonenumber } from "../libphonenumber-loader"; import { formFieldId, formFieldName, usePrevious } from "../utils"; import { FunctionComponent, TargetedEvent } from "preact"; import { useChannelComponentData } from "./channel-root"; @@ -35,16 +36,40 @@ const FlagIcon: FunctionComponent = ({ ); }; +// the country list never changes +export function useCountriesAsDropdownOptions(): DropdownOption[] { + return useMemo( + () => + getLoadedLibphonenumber() + .getCountries() + .map((countryCode) => { + const country = new Intl.DisplayNames(["en"], { + type: "region", + }).of(countryCode); + + return { + title: country, + value: countryCode, + leadingAsset: , + } as DropdownOption; + }) + .sort((a, b) => a.title.localeCompare(b.title)), + [], + ); +} + export const CountryField: FunctionComponent = (props) => { const { field, onChange } = props; const id = formFieldId(field); const name = formFieldName(field); + const countriesAsDropdownOptions = useCountriesAsDropdownOptions(); + const [selectedCountry, setSelectedCountry] = useState< CountryCode | undefined >(field.initial_value as CountryCode | undefined); - const selectedCountryIndex = COUNTRIES_AS_DROPDOWN_OPTIONS.findIndex( + const selectedCountryIndex = countriesAsDropdownOptions.findIndex( (option) => option.value === selectedCountry, ); @@ -52,7 +77,7 @@ export const CountryField: FunctionComponent = (props) => { useOnCardCountryChange((newCountry: CountryCode) => { if (hiddenFieldRef.current) { - const newOption = COUNTRIES_AS_DROPDOWN_OPTIONS.find((option) => { + const newOption = countriesAsDropdownOptions.find((option) => { return option.value === newCountry; }); if (newOption) onChangeWrapper(newOption); @@ -92,7 +117,7 @@ export const CountryField: FunctionComponent = (props) => { return; } - const option = COUNTRIES_AS_DROPDOWN_OPTIONS.find( + const option = countriesAsDropdownOptions.find( (o) => o.value === filledValue, ); if (option) { @@ -102,18 +127,17 @@ export const CountryField: FunctionComponent = (props) => { hiddenFieldRef.current.value = selectedCountry ?? ""; } }, - [onChange, onChangeWrapper, selectedCountry], + [onChange, onChangeWrapper, selectedCountry, countriesAsDropdownOptions], ); - // the country list never changes const selectOptions = useMemo( () => - COUNTRIES_AS_DROPDOWN_OPTIONS.map((option) => ( + countriesAsDropdownOptions.map((option) => ( )), - [], + [countriesAsDropdownOptions], ); return ( @@ -132,7 +156,7 @@ export const CountryField: FunctionComponent = (props) => { { - const country = new Intl.DisplayNames(["en"], { - type: "region", - }).of(countryCode); - - return { - title: country, - value: countryCode, - leadingAsset: , - } as DropdownOption; - }) - .sort((a, b) => a.title.localeCompare(b.title)); - export function useOnCardCountryChange(fn: (newCountry: CountryCode) => void) { const cardDetails = useChannelComponentData()?.cardDetails; const cardDetailsCountry = cardDetails?.details?.country_codes[0]; diff --git a/sdk/src/components/field-phone-number.tsx b/sdk/src/components/field-phone-number.tsx index a62fa36..d97733c 100644 --- a/sdk/src/components/field-phone-number.tsx +++ b/sdk/src/components/field-phone-number.tsx @@ -1,14 +1,12 @@ import { FieldProps } from "./field"; import { Dropdown, DropdownOption } from "./core/dropdown"; -import { CountryCode, getCountryCallingCode } from "libphonenumber-js/min"; +import type { CountryCode } from "libphonenumber-js"; +import type { PhoneNumber } from "libphonenumber-js"; import { - COUNTRIES_AS_DROPDOWN_OPTIONS, + useCountriesAsDropdownOptions, useOnCardCountryChange, } from "./field-country"; -import parsePhoneNumberFromString, { - getExampleNumber, - PhoneNumber, -} from "libphonenumber-js"; +import { getLoadedLibphonenumber } from "../libphonenumber-loader"; import examples from "libphonenumber-js/mobile/examples"; import { useSession } from "./session-provider"; import { formFieldId, formFieldName } from "../utils"; @@ -22,6 +20,21 @@ import { import { FunctionComponent, TargetedEvent, TargetedFocusEvent } from "preact"; import { InternalSetFieldTouchedEvent } from "../private-event-types"; +type DropdownOptionWithDial = DropdownOption & { dial: string }; + +const sanitizePhoneNumber = ( + country: DropdownOptionWithDial, + phoneNumber: string, +): PhoneNumber | null => { + const lib = getLoadedLibphonenumber(); + const parsed = lib.parsePhoneNumberFromString( + phoneNumber, + country.value as CountryCode, + ); + if (parsed && parsed.isPossible()) return parsed; + return null; +}; + export const PhoneNumberField: FunctionComponent = (props) => { const { field, onChange } = props; const id = formFieldId(field); @@ -31,6 +44,49 @@ export const PhoneNumberField: FunctionComponent = (props) => { const hiddenFieldRef = useRef(null); + const countriesAsDropdownOptions = useCountriesAsDropdownOptions(); + + const countriesWithDialCodesAsDropdownOptions = useMemo(() => { + const lib = getLoadedLibphonenumber(); + return countriesAsDropdownOptions + .map((country) => { + const dial = lib.getCountryCallingCode(country.value as CountryCode); + if (!dial) return null; + return { + ...country, + shortTitle: `+${dial}`, + title: `${country.title} (+${dial})`, + dial, + }; + }) + .filter((c): c is DropdownOptionWithDial => Boolean(c)); + }, [countriesAsDropdownOptions]); + + function initialValues(initial: string | undefined, sessionCountry: string) { + const defaultInitial = { + country: sessionCountry, + localNumber: "", + }; + if (!initial) return defaultInitial; + const lib = getLoadedLibphonenumber(); + const parsed = lib.parsePhoneNumberFromString(initial); + if (!parsed) return defaultInitial; + const countryOption = countriesWithDialCodesAsDropdownOptions.find( + (option) => option.value === parsed.country, + ); + if (!countryOption) return defaultInitial; + const sanitized = sanitizePhoneNumber(countryOption, parsed.nationalNumber); + if (!sanitized) return defaultInitial; + const international = parsed.formatInternational(); + const countryCode = lib.getCountryCallingCode( + countryOption.value as CountryCode, + ); + return { + country: countryOption.value as string, + localNumber: international.replace(`+${countryCode} `, ""), + }; + } + const initial = useMemo( () => initialValues(field.initial_value, session.country), [field.initial_value, session.country], @@ -38,14 +94,13 @@ export const PhoneNumberField: FunctionComponent = (props) => { const [countryCode, setCountryCode] = useState(initial.country); const countryCodeIndex = useMemo(() => { - const index = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.findIndex( + const index = countriesWithDialCodesAsDropdownOptions.findIndex( (r) => r.value === countryCode, ); if (index === -1) return 0; return index; - }, [countryCode]); - const country = - COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS[countryCodeIndex]; + }, [countryCode, countriesWithDialCodesAsDropdownOptions]); + const country = countriesWithDialCodesAsDropdownOptions[countryCodeIndex]; const [localNumber, setLocalNumber] = useState(initial.localNumber); const inputRef = useRef(null); @@ -54,10 +109,8 @@ export const PhoneNumberField: FunctionComponent = (props) => { (country: DropdownOptionWithDial, localNumber: string) => { const phoneNumber = sanitizePhoneNumber(country, localNumber); if (phoneNumber) { - // use parsed format if parsing was successful return phoneNumber.number; } else { - // else just concat the dial code and local number return `+${country.dial}${localNumber}`; } }, @@ -96,7 +149,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { // when the user inputs a card number, update the phone number field to match useOnCardCountryChange((newCountry: CountryCode) => { - const newOption = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( + const newOption = countriesWithDialCodesAsDropdownOptions.find( (option) => option.value === newCountry, ); if (newOption && newOption.value !== countryCode && !localNumber) { @@ -105,25 +158,27 @@ export const PhoneNumberField: FunctionComponent = (props) => { }); function getExampleLocalNumber() { + const lib = getLoadedLibphonenumber(); return ( - getExampleNumber(country.value as CountryCode, examples) + lib + .getExampleNumber(country.value as CountryCode, examples) ?.formatInternational() ?.replace( - `+${getCountryCallingCode(country.value as CountryCode)} `, + `+${lib.getCountryCallingCode(country.value as CountryCode)} `, "", ) || "" ); } function formatForUser(_country = country, _localNumber = localNumber) { + const lib = getLoadedLibphonenumber(); const phoneNumber = sanitizePhoneNumber(_country, _localNumber); if (phoneNumber) { // sync the dropdown if the number is from a different country if (phoneNumber.country && phoneNumber.country !== _country.value) { - const matchedCountry = - COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( - (option) => option.value === phoneNumber.country, - ); + const matchedCountry = countriesWithDialCodesAsDropdownOptions.find( + (option) => option.value === phoneNumber.country, + ); if (matchedCountry) { setCountryCode(matchedCountry.value as string); _country = matchedCountry; @@ -133,7 +188,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { // remove country dial code from displayed local number setLocalNumber( international.replace( - `+${getCountryCallingCode(_country.value as CountryCode)} `, + `+${lib.getCountryCallingCode(_country.value as CountryCode)} `, "", ), ); @@ -154,7 +209,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { return (
= (props) => {
); }; - -type DropdownOptionWithDial = DropdownOption & { dial: string }; -const COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS = - COUNTRIES_AS_DROPDOWN_OPTIONS.map( - (country) => { - const dial = getCountryCallingCode(country.value as CountryCode); - if (!dial) return null; - return { - ...country, - shortTitle: `+${dial}`, - title: `${country.title} (+${dial})`, - dial, - }; - }, - ).filter((country): country is DropdownOptionWithDial => { - return Boolean(country); - }); - -const sanitizePhoneNumber = ( - country: DropdownOptionWithDial, - phoneNumber: string, -): PhoneNumber | null => { - const parsed = parsePhoneNumberFromString( - phoneNumber, - country.value as CountryCode, - ); - if (parsed && parsed.isPossible()) return parsed; - - return null; -}; - -function initialValues(initial: string | undefined, sessionCountry: string) { - const defaultInitial = { - country: sessionCountry, - localNumber: "", - }; - if (!initial) return defaultInitial; - const parsed = parsePhoneNumberFromString(initial); - if (!parsed) return defaultInitial; - const countryOption = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( - (option) => option.value === parsed.country, - ); - if (!countryOption) return defaultInitial; - const sanitized = sanitizePhoneNumber(countryOption, parsed.nationalNumber); - if (!sanitized) return defaultInitial; - const international = parsed.formatInternational(); - const countryCode = getCountryCallingCode(countryOption.value as CountryCode); - return { - country: countryOption.value as string, - localNumber: international.replace(`+${countryCode} `, ""), - }; -} diff --git a/sdk/src/libphonenumber-loader.test.ts b/sdk/src/libphonenumber-loader.test.ts new file mode 100644 index 0000000..0ea967e --- /dev/null +++ b/sdk/src/libphonenumber-loader.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("libphonenumber-loader", () => { + // module-level state must be reset between tests to avoid leaking state. + beforeEach(() => { + vi.resetModules(); + }); + + it("should not expose the module before loading finishes", async () => { + const { preloadLibphonenumber, getLoadedLibphonenumber } = + await import("./libphonenumber-loader"); + expect(() => getLoadedLibphonenumber()).toThrowError(); + + preloadLibphonenumber(); + expect(() => getLoadedLibphonenumber()).toThrowError(); + }); + + it("should resolve to the real module and expose it synchronously afterwards", async () => { + const { getLibphonenumber, getLoadedLibphonenumber } = + await import("./libphonenumber-loader"); + const mod = await getLibphonenumber(); + expect(typeof mod.parsePhoneNumberFromString).toBe("function"); + expect(typeof mod.getCountries).toBe("function"); + expect(getLoadedLibphonenumber()).toBe(mod); + }); + + it("should auto-trigger loading even if preload was never called", async () => { + const { getLibphonenumber } = await import("./libphonenumber-loader"); + const mod = await getLibphonenumber(); + expect(typeof mod.parsePhoneNumberFromString).toBe("function"); + }); +}); diff --git a/sdk/src/libphonenumber-loader.ts b/sdk/src/libphonenumber-loader.ts new file mode 100644 index 0000000..1cb059c --- /dev/null +++ b/sdk/src/libphonenumber-loader.ts @@ -0,0 +1,31 @@ +import { assert } from "./utils"; + +// The in-flight/resolved Promise, null means loading hasn't started yet. +let libphonenumberPromise: Promise | null = + null; + +// The resolved module, null until loading finishes. +let libphonenumberModule: typeof import("libphonenumber-js") | null = null; + +// Starts loading without waiting. +export function preloadLibphonenumber(): void { + if (libphonenumberPromise !== null) return; + libphonenumberPromise = import("libphonenumber-js").then((mod) => { + libphonenumberModule = mod; + return mod; + }); +} + +// Waits for the module, triggering the load first if it hasn't started. +export async function getLibphonenumber(): Promise< + typeof import("libphonenumber-js") +> { + preloadLibphonenumber(); + return libphonenumberPromise!; +} + +// Synchronous check: returns the module once loaded, otherwise null. +export function getLoadedLibphonenumber(): typeof import("libphonenumber-js") { + assert(libphonenumberModule); + return libphonenumberModule; +} diff --git a/sdk/src/public-sdk.ts b/sdk/src/public-sdk.ts index 25d3f9c..524149b 100644 --- a/sdk/src/public-sdk.ts +++ b/sdk/src/public-sdk.ts @@ -115,6 +115,10 @@ import { ChannelValidBehavior } from "./lifecycle/behaviors/channel-valid"; import { CustomerDetailsFormHandle } from "./components/customer-form"; import { getTelemetry, SessionTelemetry } from "./telemetry"; import { TelemetryEvents } from "./telemetry-events"; +import { + getLibphonenumber, + preloadLibphonenumber, +} from "./libphonenumber-loader"; /** * @internal @@ -388,6 +392,8 @@ export class XenditComponents extends EventTarget { * Initialize session data asynchronously */ protected async initializeAsync(): Promise { + // Start loading libphonenumber-js in the background + preloadLibphonenumber(); let bff: BffResponse; try { // Fetch session data from the server @@ -464,6 +470,20 @@ export class XenditComponents extends EventTarget { } } + // Wait for libphonenumber-js + try { + await getLibphonenumber(); + } catch (error) { + this[internal].behaviorTree.bb.sdkStatus = "FATAL_ERROR"; + this[internal].behaviorTree.bb.sdkFatalErrorMessage = + errorToString(error); + + getTelemetry(this).append(TelemetryEvents.Loaded(false)); + + this.behaviorTreeUpdate(); + return; + } + // telemetry for successful load if (resumeSession) { getTelemetry(this).appendAndPushScope(TelemetryEvents.Resume(true)); @@ -1942,12 +1962,18 @@ export class XenditComponentsTest extends XenditComponents { * Override to use test data instead of making API calls */ protected async initializeAsync(): Promise { + // Start loading libphonenumber-js in the background (same as the real initializeAsync) + preloadLibphonenumber(); + // Simulate network delay and prevent firing the init event before the constructor returns await sleep(MOCK_NETWORK_DELAY_MS); // Always use test data for this class const bff = (await import("./data/test-data")).makeTestBffData(); + // Wait for libphonenumber-js so fields can assume it's already loaded + await getLibphonenumber(); + // Update internal data this.dispatchEvent( new InternalUpdateWorldState({ diff --git a/sdk/src/styles.css b/sdk/src/styles.css index 1bc5458..84ab7aa 100644 --- a/sdk/src/styles.css +++ b/sdk/src/styles.css @@ -935,68 +935,6 @@ xendit-channel-picker[inert] { /* Iframe field load failure ends */ -/* Loading shimmer */ -@keyframes xendit-shimmer-sweep { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - -.xendit-shimmer { - background: linear-gradient( - 90deg, - var(--xendit-color-disabled) 25%, - color-mix(in srgb, var(--xendit-color-disabled), white 40%) 50%, - var(--xendit-color-disabled) 75% - ); - background-size: 200% 100%; - animation: xendit-shimmer-sweep 1.5s ease-in-out infinite; -} - -@media (prefers-reduced-motion: reduce) { - .xendit-shimmer { - animation: none; - } -} - -.xendit-channel-form-field .xendit-iframe-shimmer { - position: absolute; - margin: 6px 12px; - inset: 0; - z-index: 1; - border-radius: 4px; -} - -.xendit-channel-form-field .xendit-iframe-hidden { - opacity: 0; -} - -/* Loading shimmer ends */ - -/* Iframe field load failure */ -.xendit-channel-form-field .xendit-iframe-field-error { - position: absolute; - margin: 6px 12px; - inset: 0; - z-index: 1; - display: flex; - align-items: center; - font-family: monospace; - font-size: 10px; - box-sizing: border-box; -} - -.xendit-channel-form-field .xendit-iframe-field-error > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Iframe field load failure ends */ - /* Form Simulation Helper */ .xendit-form-simulation-trigger { background: transparent; diff --git a/sdk/src/validation.test.ts b/sdk/src/validation.test.ts index 3741a7a..71f13d1 100644 --- a/sdk/src/validation.test.ts +++ b/sdk/src/validation.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as libphonenumber from "libphonenumber-js"; import { BffChannel, ChannelFormField } from "./backend-types/channel"; import { channelPropertiesAreValid, @@ -6,6 +7,11 @@ import { } from "./validation"; import { ChannelComponentData } from "./public-sdk"; +// Make the loader return the real libphonenumber-js module synchronously. +vi.mock("./libphonenumber-loader", () => ({ + getLoadedLibphonenumber: () => libphonenumber, +})); + function makeField( channelProperty: string, fieldType: ChannelFormField["type"], diff --git a/sdk/src/validation.ts b/sdk/src/validation.ts index 7c28362..724f2fb 100644 --- a/sdk/src/validation.ts +++ b/sdk/src/validation.ts @@ -4,7 +4,7 @@ import { ChannelProperties, ChannelPropertyPrimative, } from "./backend-types/channel"; -import parsePhoneNumberFromString from "libphonenumber-js/min"; +import { getLoadedLibphonenumber } from "./libphonenumber-loader"; import { filterFormFields } from "./components/channel-form"; import { BffSessionType } from "./backend-types/session"; import { LocaleKey, LocalizedString } from "./localization"; @@ -47,7 +47,8 @@ export const validateEmail = (value: string): LocaleKey | undefined => { }; export const validatePhoneNumber = (value: string): LocaleKey | undefined => { - const phone = parsePhoneNumberFromString(value); + const lib = getLoadedLibphonenumber(); + const phone = lib.parsePhoneNumberFromString(value); if (!phone || !phone.isValid()) { return { localeKey: "validation.generic_invalid",