diff --git a/demo/Collect.tsx b/demo/Collect.tsx
index c6e65ec..c5781de 100644
--- a/demo/Collect.tsx
+++ b/demo/Collect.tsx
@@ -24,6 +24,8 @@ import type {
import { styles } from './styles';
import type { ElementEvents } from '../App';
import { EncryptedToken, EncryptToken } from '../src/model/EncryptTokenData';
+import { BasisTheoryProvider } from '../src/BasisTheoryProvider';
+import { CoBadgedSupport } from '../src/CardElementTypes';
const Divider = () => ;
@@ -184,126 +186,130 @@ export const Collect = () => {
-
-
-
-
-
-
-
-
- {'Create token'}
-
-
-
- {'Update Token'}
-
-
-
- {'Delete Token'}
-
-
-
-
-
- {'Tokenize Data'}
-
-
-
- {'Encrypt Token'}
-
-
-
-
-
- {'Clear'}
-
-
- {token && (
- <>
-
- TOKEN:
-
-
- {JSON.stringify(token, undefined, 2)}
-
- >
- )}
-
- {tokenizedData && (
- <>
-
- TOKENIZED DATA:
-
- {JSON.stringify(tokenizedData, undefined, 2)}
-
- >
- )}
-
- {encryptedToken && (
- <>
-
- ENCRYPTED TOKEN:
-
- {JSON.stringify(encryptedToken, undefined, 2)}
-
- >
- )}
-
+
+
+
+
+
+
+
+
+
+ {'Create token'}
+
+
+
+ {'Update Token'}
+
+
+
+ {'Delete Token'}
+
+
+
+
+
+ {'Tokenize Data'}
+
+
+
+ {'Encrypt Token'}
+
+
+
+
+
+ {'Clear'}
+
+
+ {token && (
+ <>
+
+ TOKEN:
+
+
+ {JSON.stringify(token, undefined, 2)}
+
+ >
+ )}
+
+ {tokenizedData && (
+ <>
+
+ TOKENIZED DATA:
+
+ {JSON.stringify(tokenizedData, undefined, 2)}
+
+ >
+ )}
+
+ {encryptedToken && (
+ <>
+
+ ENCRYPTED TOKEN:
+
+ {JSON.stringify(encryptedToken, undefined, 2)}
+
+ >
+ )}
+
+
);
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0993b31..fb827f1 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -8,7 +8,6 @@ PODS:
- hermes-engine (0.79.5):
- hermes-engine/Pre-built (= 0.79.5)
- hermes-engine/Pre-built (0.79.5)
- - OpenSSL-Universal (3.3.3001)
- RCT-Folly (2024.11.18.00):
- boost
- DoubleConversion
@@ -1335,32 +1334,6 @@ PODS:
- ReactCommon/turbomodule/core
- react-native-get-random-values (1.11.0):
- React-Core
- - react-native-quick-crypto (0.7.14):
- - DoubleConversion
- - glog
- - hermes-engine
- - OpenSSL-Universal
- - RCT-Folly (= 2024.11.18.00)
- - RCTRequired
- - RCTTypeSafety
- - React
- - React-Core
- - React-debug
- - React-Fabric
- - React-featureflags
- - React-graphics
- - React-hermes
- - React-ImageManager
- - React-jsi
- - React-NativeModulesApple
- - React-RCTFabric
- - React-renderercss
- - React-rendererdebug
- - React-utils
- - ReactCodegen
- - ReactCommon/turbomodule/bridging
- - ReactCommon/turbomodule/core
- - Yoga
- react-native-safe-area-context (5.5.1):
- DoubleConversion
- glog
@@ -1854,7 +1827,6 @@ DEPENDENCIES:
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
@@ -1892,7 +1864,6 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- - OpenSSL-Universal
- SocketRocket
EXTERNAL SOURCES:
@@ -1975,8 +1946,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
- react-native-quick-crypto:
- :path: "../node_modules/react-native-quick-crypto"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple:
@@ -2054,7 +2023,6 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: f03b0e06d3882d71e67e45b073bb827da1a21aae
- OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5
RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8
@@ -2087,7 +2055,6 @@ SPEC CHECKSUMS:
React-Mapbuffer: 96a2f2a176268581733be182fa6eebab1c0193be
React-microtasksnativemodule: bda561d2648e1e52bd9e5a87f8889836bdbde2e2
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
- react-native-quick-crypto: dcf29d2a08af7b16fb0ee9ef2327585743300ed5
react-native-safe-area-context: 091add53da15b67c76b176724725581b29a1cde3
React-NativeModulesApple: 1ecb83880dd11baf2228f8dd89d8419c387e03ad
React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd
diff --git a/src/BaseElementTypes.ts b/src/BaseElementTypes.ts
index 19709a3..87d6605 100644
--- a/src/BaseElementTypes.ts
+++ b/src/BaseElementTypes.ts
@@ -41,11 +41,11 @@ enum ElementType {
type PrimitiveType = boolean | number | string | null | undefined;
-type ValidationResult = 'incomplete' | 'invalid' | undefined;
+type ValidationResult = 'incomplete' | 'invalid' | 'network_not_selected' | undefined;
type FieldError = {
targetId: string;
- type: 'incomplete' | 'invalid';
+ type: 'incomplete' | 'invalid' | 'network_not_selected';
};
type ElementEvent = {
diff --git a/src/BasisTheoryProvider.tsx b/src/BasisTheoryProvider.tsx
index ecbb27a..2f4a9cc 100644
--- a/src/BasisTheoryProvider.tsx
+++ b/src/BasisTheoryProvider.tsx
@@ -2,12 +2,48 @@ import type { PropsWithChildren } from 'react';
import React, { createContext, useContext, useMemo } from 'react';
import type { BasisTheoryElements } from './useBasisTheory';
+type BasisTheoryConfig = {
+ apiKey?: string;
+ baseUrl?: string;
+};
+
type BasisTheoryProviderType = {
bt?: BasisTheoryElements;
+ config?: BasisTheoryConfig;
};
const BasisTheoryContext = createContext({});
+const ConfigManager = (() => {
+ let internalConfig: BasisTheoryConfig = {};
+
+ const updateConfig = (updates: Partial): void => {
+ internalConfig = { ...internalConfig, ...updates };
+ };
+
+ const setConfig = (config: BasisTheoryConfig): void => {
+ internalConfig = config;
+ };
+
+ const getConfig = (): BasisTheoryConfig => internalConfig;
+
+ const updateApiKey = (apiKey: string): void => {
+ internalConfig = { ...internalConfig, apiKey };
+ };
+
+ const updateBaseUrl = (baseUrl: string): void => {
+ internalConfig = { ...internalConfig, baseUrl };
+ };
+
+ return {
+ updateConfig,
+ setConfig,
+ getConfig,
+ updateApiKey,
+ updateBaseUrl,
+ };
+})();
+
const BasisTheoryProvider = ({
bt,
children,
@@ -15,6 +51,7 @@ const BasisTheoryProvider = ({
const value = useMemo(
() => ({
bt,
+ config: ConfigManager.getConfig(),
}),
[bt]
);
@@ -29,4 +66,13 @@ const BasisTheoryProvider = ({
const useBasisTheoryFromContext = (): BasisTheoryProviderType =>
useContext(BasisTheoryContext);
-export { BasisTheoryProvider, useBasisTheoryFromContext };
+const useBasisTheoryConfig = () => useContext(BasisTheoryContext).config;
+
+const useConfigManager = () => ConfigManager;
+
+
+export { BasisTheoryProvider, useBasisTheoryFromContext, useBasisTheoryConfig };
+
+export { useConfigManager as _useConfigManager };
+
+
diff --git a/src/CardElementTypes.ts b/src/CardElementTypes.ts
new file mode 100644
index 0000000..96a5c12
--- /dev/null
+++ b/src/CardElementTypes.ts
@@ -0,0 +1,55 @@
+export const CARD_BRANDS = [
+ 'accel',
+ 'bancontact',
+ 'cartes-bancaires',
+ 'culiance',
+ 'dankort',
+ 'ebt',
+ 'eftpos-australia',
+ 'nyce',
+ 'private-label',
+ 'prop',
+ 'pulse',
+ 'rupay',
+ 'star',
+ 'uatp',
+ 'korean-local',
+ 'visa',
+ 'mastercard',
+ 'american-express',
+ 'discover',
+ 'diners-club',
+ 'jcb',
+ 'unionpay',
+ 'maestro',
+ 'elo',
+ 'hiper',
+ 'hipercard',
+ 'mir',
+ 'unknown',
+] as const;
+
+export type CardBrand = (typeof CARD_BRANDS)[number];
+
+export enum CoBadgedSupport {
+ CartesBancaires = 'cartes-bancaires',
+}
+
+interface CardIssuerDetails {
+ country: string;
+ name: string;
+ }
+ interface CardInfo {
+ brand: string;
+ funding: string;
+ issuer: CardIssuerDetails;
+ }
+
+ export interface BinInfo {
+ brand: string;
+ funding: string;
+ issuer: CardIssuerDetails;
+ segment: string;
+ additional?: CardInfo[];
+ }
+
\ No newline at end of file
diff --git a/src/ElementValues.ts b/src/ElementValues.ts
index 5170306..ae18076 100644
--- a/src/ElementValues.ts
+++ b/src/ElementValues.ts
@@ -1,9 +1,18 @@
import { type PrimitiveType } from './BaseElementTypes';
+import type { BinInfo } from './CardElementTypes';
+import type { CardBrand } from './CardElementTypes';
/**
- * If `_elementValues` requires any modification, we should start looking for a better state management solution.
+ * Unified element state store. Everything related to a given element lives under the same key.
*/
-const _elementValues: Record = {};
+const _elementState: Record = {};
/**
* `_elementErrors` are used to validate the payload before it's sent to the API. If not empty the request won't be made.
@@ -11,4 +20,92 @@ const _elementValues: Record = {};
*/
const _elementErrors: Record = {};
-export { _elementErrors, _elementValues };
+// Legacy exports for backward compatibility - these now access the unified store
+const _elementValues: Record = new Proxy({}, {
+ get: (_, id: string) => _elementState[id]?.value,
+ set: (_, id: string, value: PrimitiveType) => {
+ if (!_elementState[id]) {
+ _elementState[id] = { value: '', rawValue: '', metadata: {} };
+ }
+ _elementState[id].value = value;
+ return true;
+ },
+ deleteProperty: (_, id: string) => {
+ if (_elementState[id]) {
+ delete _elementState[id];
+ }
+ return true;
+ },
+ has: (_, id: string) => id in _elementState,
+ ownKeys: () => Object.keys(_elementState),
+ getOwnPropertyDescriptor: (_, id: string) => {
+ if (id in _elementState) {
+ return {
+ enumerable: true,
+ configurable: true,
+ };
+ }
+ return undefined;
+ },
+});
+
+const _elementRawValues: Record = new Proxy({}, {
+ get: (_, id: string) => _elementState[id]?.rawValue,
+ set: (_, id: string, value: PrimitiveType) => {
+ if (!_elementState[id]) {
+ _elementState[id] = { value: '', rawValue: '', metadata: {} };
+ }
+ _elementState[id].rawValue = value;
+ return true;
+ },
+ deleteProperty: (_, id: string) => {
+ if (_elementState[id]) {
+ delete _elementState[id];
+ }
+ return true;
+ },
+ has: (_, id: string) => id in _elementState,
+ ownKeys: () => Object.keys(_elementState),
+ getOwnPropertyDescriptor: (_, id: string) => {
+ if (id in _elementState) {
+ return {
+ enumerable: true,
+ configurable: true,
+ };
+ }
+ return undefined;
+ },
+});
+
+const _elementMetadata: Record = new Proxy({}, {
+ get: (_, id: string) => _elementState[id]?.metadata,
+ set: (_, id: string, metadata: { binInfo?: BinInfo; selectedNetwork?: CardBrand }) => {
+ if (!_elementState[id]) {
+ _elementState[id] = { value: '', rawValue: '', metadata: {} };
+ }
+ _elementState[id].metadata = metadata;
+ return true;
+ },
+ deleteProperty: (_, id: string) => {
+ if (_elementState[id]) {
+ delete _elementState[id];
+ }
+ return true;
+ },
+ has: (_, id: string) => id in _elementState,
+ ownKeys: () => Object.keys(_elementState),
+ getOwnPropertyDescriptor: (_, id: string) => {
+ if (id in _elementState) {
+ return {
+ enumerable: true,
+ configurable: true,
+ };
+ }
+ return undefined;
+ },
+});
+
+export { _elementErrors, _elementValues, _elementMetadata, _elementRawValues, _elementState };
diff --git a/src/components/BrandPicker.tsx b/src/components/BrandPicker.tsx
new file mode 100644
index 0000000..ed7927d
--- /dev/null
+++ b/src/components/BrandPicker.tsx
@@ -0,0 +1,160 @@
+import React, { useState, useMemo, useCallback } from 'react';
+import { Text, View, TouchableOpacity, Modal, ScrollView, ViewStyle, TextStyle } from 'react-native';
+import { CardBrand } from '../CardElementTypes';
+import { labelizeCardBrand } from '../utils/shared';
+
+interface BrandPickerProps {
+ brands: CardBrand[];
+ selectedBrand: CardBrand | undefined;
+ onBrandSelect: (brand: CardBrand | undefined) => void;
+ style?: ViewStyle;
+}
+
+const defaultStyles = {
+ container: {
+ marginBottom: 10,
+ },
+ buttonText: {
+ color: '#374151',
+ fontSize: 16,
+ },
+ placeholderText: {
+ color: '#9ca3af',
+ fontSize: 16,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ },
+ modalContainer: {
+ backgroundColor: '#ffffff',
+ borderRadius: 12,
+ minWidth: 250,
+ maxWidth: 300,
+ borderWidth: 1,
+ borderColor: '#d1d5db',
+ },
+ modalTitle: {
+ color: '#374151',
+ fontSize: 18,
+ fontWeight: '600' as const,
+ textAlign: 'center' as const,
+ paddingVertical: 15,
+ borderBottomWidth: 1,
+ borderBottomColor: '#d1d5db',
+ },
+ scrollView: {
+ maxHeight: 200,
+ },
+ optionButton: {
+ paddingVertical: 15,
+ paddingHorizontal: 20,
+ borderBottomColor: '#d1d5db',
+ },
+ selectedOption: {
+ backgroundColor: '#f3f4f6',
+ },
+ optionText: {
+ color: '#374151',
+ fontSize: 16,
+ textAlign: 'center' as const,
+ },
+};
+
+const isTextStyle = (style: ViewStyle | undefined): style is TextStyle & ViewStyle => {
+ return style !== undefined &&
+ typeof style === 'object' &&
+ 'color' in style;
+};
+
+export const BrandPicker: React.FC = ({
+ brands,
+ selectedBrand,
+ onBrandSelect,
+ style,
+}) => {
+ const [pickerVisible, setPickerVisible] = useState(false);
+
+ const buttonTextStyle = useMemo(() => {
+ if (isTextStyle(style) && style.color) {
+ return { color: style.color };
+ }
+ return defaultStyles.buttonText;
+ }, [style]);
+
+ const displayText = useMemo(() => {
+ return selectedBrand ? labelizeCardBrand(selectedBrand as CardBrand) : 'Select card brand';
+ }, [selectedBrand]);
+
+ const shouldRender = useMemo(() => brands.length > 0, [brands]);
+
+ const handleShowPicker = useCallback(() => {
+ setPickerVisible(true);
+ }, []);
+
+ const handleHidePicker = useCallback(() => {
+ setPickerVisible(false);
+ }, []);
+
+ const handleBrandSelect = useCallback((brand: CardBrand) => {
+ onBrandSelect(brand);
+ setPickerVisible(false);
+ }, [onBrandSelect]);
+
+
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+
+
+ {displayText}
+
+
+
+
+
+
+
+ Select Card Brand
+
+
+ {brands.map((brand, index) => (
+ handleBrandSelect(brand)}
+ style={[
+ defaultStyles.optionButton,
+ {
+ borderBottomWidth: index === brands.length - 1 ? 0 : 1,
+ },
+ selectedBrand === brand && defaultStyles.selectedOption,
+ ]}
+ >
+
+ {labelizeCardBrand(brand)}
+
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/src/components/CardNumberElement.hook.ts b/src/components/CardNumberElement.hook.ts
index 4d9d78d..31b06a0 100644
--- a/src/components/CardNumberElement.hook.ts
+++ b/src/components/CardNumberElement.hook.ts
@@ -12,12 +12,17 @@ import { useBtRefUnmount } from './shared/useBtRefUnmount';
import { useMask } from './shared/useMask';
import { useUserEventHandlers } from './shared/useUserEventHandlers';
import { useCustomBin } from './useCustomBin.hook';
+import { useBinLookup } from './useBinLookup';
import { useCleanupStateBeforeUnmount } from './shared/useCleanStateOnUnmount';
+import { CardBrand, CoBadgedSupport } from '../CardElementTypes';
+import { useBrandSelector } from './shared/useBrandSelector';
type UseCardNumberElementProps = {
btRef?: ForwardedRef;
cardTypes?: CreditCardType[];
skipLuhnValidation?: boolean;
+ binLookup?: boolean;
+ coBadgedSupport?: CoBadgedSupport[];
} & EventConsumers;
export const useCardNumberElement = ({
@@ -27,12 +32,43 @@ export const useCardNumberElement = ({
onFocus,
cardTypes,
skipLuhnValidation,
+ binLookup,
+ coBadgedSupport,
}: UseCardNumberElementProps) => {
+
+ const hasCoBadgedSupport = (coBadgedSupport?.length ?? 0) > 0;
+
+ if (hasCoBadgedSupport && coBadgedSupport) {
+ const validValues = Object.values(CoBadgedSupport);
+ const invalidValues = coBadgedSupport.filter(value => !validValues.includes(value));
+
+ if (invalidValues.length > 0) {
+ throw new Error(
+ `Invalid coBadgedSupport values: ${invalidValues.join(', ')}. ` +
+ `Valid values are: ${validValues.join(', ')}`
+ );
+ }
+ }
+
const id = useId();
const type = ElementType.CARD_NUMBER;
const elementRef = useRef(null);
const [elementValue, setElementValue] = useState('');
+ const [selectedNetwork, setSelectedNetwork] = useState(undefined);
+
+ const binEnabled = binLookup || hasCoBadgedSupport;
+ const { binInfo } = useBinLookup(binEnabled, elementValue.replaceAll(' ', '').slice(0, 6));
+
+ // Get brand options from useBrandSelector hook
+ const { brandSelectorOptions } = useBrandSelector({
+ binInfo,
+ coBadgedSupport,
+ selectedNetwork,
+ setSelectedNetwork,
+ });
+
+ const brandOptionsCount = brandSelectorOptions.length;
useCleanupStateBeforeUnmount(id);
@@ -57,8 +93,13 @@ export const useCardNumberElement = ({
transform: [' ', ''],
element: {
id,
- validatorOptions: { mask, skipLuhnValidation },
+ validatorOptions: { mask, skipLuhnValidation, coBadgedSupport },
type,
+ binLookup,
+ coBadgedSupport,
+ binInfo,
+ selectedNetwork,
+ brandOptionsCount
},
onChange,
onBlur,
@@ -68,6 +109,10 @@ export const useCardNumberElement = ({
return {
elementRef,
elementValue,
+ selectedNetwork,
+ setSelectedNetwork,
+ binInfo,
+ brandSelectorOptions,
_onChange,
_onBlur,
_onFocus,
diff --git a/src/components/CardNumberElement.tsx b/src/components/CardNumberElement.tsx
index 226dcb6..1d57b28 100644
--- a/src/components/CardNumberElement.tsx
+++ b/src/components/CardNumberElement.tsx
@@ -1,8 +1,9 @@
import React from 'react';
-import type { TextInputProps } from 'react-native';
+import { View, type TextInputProps, type ViewStyle } from 'react-native';
import MaskInput from 'react-native-mask-input';
import type { UseCardNumberElementProps } from './CardNumberElement.hook';
import { useCardNumberElement } from './CardNumberElement.hook';
+import { BrandPicker } from './BrandPicker';
type TextInputSupportedProps =
| 'editable'
@@ -17,40 +18,63 @@ type CardNumberProps = UseCardNumberElementProps &
export const CardNumberElement = ({
btRef,
cardTypes,
- editable,
- keyboardType,
onBlur,
onChange,
onFocus,
+ keyboardType = 'numeric',
placeholder,
placeholderTextColor,
+ editable = true,
skipLuhnValidation,
+ binLookup,
+ coBadgedSupport,
style,
}: CardNumberProps) => {
- const { elementRef, _onChange, _onBlur, _onFocus, elementValue, mask } =
- useCardNumberElement({
- btRef,
- onBlur,
- onChange,
- onFocus,
- cardTypes,
- skipLuhnValidation,
- });
+ const {
+ elementRef,
+ _onChange,
+ _onBlur,
+ _onFocus,
+ elementValue,
+ mask,
+ selectedNetwork,
+ setSelectedNetwork,
+ brandSelectorOptions
+ } = useCardNumberElement({
+ btRef,
+ onBlur,
+ onChange,
+ onFocus,
+ cardTypes,
+ skipLuhnValidation,
+ binLookup,
+ coBadgedSupport,
+ });
return (
-
+
+ {brandSelectorOptions.length > 1 && (
+
+ )}
+
+
);
};
diff --git a/src/components/shared/useBrandSelector.ts b/src/components/shared/useBrandSelector.ts
new file mode 100644
index 0000000..27b12cd
--- /dev/null
+++ b/src/components/shared/useBrandSelector.ts
@@ -0,0 +1,58 @@
+import { useEffect, useMemo } from 'react';
+import { CardBrand, CoBadgedSupport } from '../../CardElementTypes';
+import { convertApiBrandToBrand } from '../../utils/shared';
+import { isNilOrEmpty } from '../../utils/shared';
+import type { BinInfo } from '../../CardElementTypes';
+
+interface UseBrandSelectorProps {
+ binInfo?: BinInfo;
+ coBadgedSupport?: CoBadgedSupport[];
+ selectedNetwork?: CardBrand;
+ setSelectedNetwork: (brand: CardBrand | undefined) => void;
+}
+
+interface UseBrandSelectorReturn {
+ brandSelectorOptions: CardBrand[];
+}
+
+export const useBrandSelector = ({
+ binInfo,
+ coBadgedSupport,
+ selectedNetwork,
+ setSelectedNetwork,
+}: UseBrandSelectorProps): UseBrandSelectorReturn => {
+ const hasCoBadgedSupport = useMemo(() =>
+ !isNilOrEmpty(coBadgedSupport),
+ [coBadgedSupport]
+ );
+
+ const brandSelectorOptions = useMemo(() => {
+ if (!binInfo) return [];
+
+ const { brand, additional } = binInfo;
+ const brandOptions = new Set();
+
+ brandOptions.add(convertApiBrandToBrand(brand));
+
+ additional?.forEach((a) => {
+ if (!a.brand) return;
+ const brand = convertApiBrandToBrand(a.brand);
+ if (hasCoBadgedSupport && coBadgedSupport?.includes(brand as CoBadgedSupport)) {
+ brandOptions.add(brand);
+ }
+ });
+
+ return Array.from(brandOptions);
+ }, [binInfo, coBadgedSupport]);
+
+ // Reset selected network when binInfo is cleared
+ useEffect(() => {
+ if (!binInfo && selectedNetwork) {
+ setSelectedNetwork(undefined);
+ }
+ }, [binInfo, selectedNetwork, setSelectedNetwork]);
+
+ return {
+ brandSelectorOptions,
+ };
+};
\ No newline at end of file
diff --git a/src/components/shared/useElementEvent.ts b/src/components/shared/useElementEvent.ts
index 299c68d..18924d1 100644
--- a/src/components/shared/useElementEvent.ts
+++ b/src/components/shared/useElementEvent.ts
@@ -7,17 +7,29 @@ import { useCardMetadata } from './useCardMetadata';
import { extractDigits, isNilOrEmpty } from '../../utils/shared';
import { _elementErrors } from '../../ElementValues';
import { ValidatorOptions } from '../../utils/validation';
+import { BinInfo } from '../../CardElementTypes';
+import { CardBrand } from '../../CardElementTypes';
type UseElementEventProps = {
type: ElementType;
id: string;
validatorOptions?: ValidatorOptions;
+ binInfo?: BinInfo;
+ selectedNetwork?: CardBrand;
+ binLookup?: boolean;
+ coBadgedSupport?: CardBrand[];
+ brandOptionsCount?: number;
};
export const useElementEvent = ({
type,
id,
validatorOptions,
+ binInfo,
+ selectedNetwork,
+ binLookup,
+ coBadgedSupport,
+ brandOptionsCount,
}: UseElementEventProps): CreateEvent => {
const { getValidationStrategy } = useElementValidation();
const { getMetadataFromCardNumber: _getMetadataFromCardNumber } =
@@ -62,8 +74,22 @@ export const useElementEvent = ({
return (value: string) => {
const metadata = getMetadataFromCardNumber(value);
const empty = isEmpty(value);
- const errors = validate(value);
- const valid = !empty && !errors;
+ let errors = validate(value);
+
+ // Check if selectedNetwork is required but not set
+ const requiresNetworkSelection = !isNilOrEmpty(coBadgedSupport) && (brandOptionsCount ?? 0) > 1;
+ const networkNotSelected = requiresNetworkSelection && !selectedNetwork;
+
+ // Add network selection error if required but not selected
+ if (networkNotSelected && !empty) {
+ const networkError = {
+ targetId: type,
+ type: 'network_not_selected' as const,
+ };
+ errors = errors ? [...errors, networkError] : [networkError];
+ }
+
+ const valid = !empty && !errors && !networkNotSelected;
const mask = validatorOptions?.mask ?? [];
const maskSatisfied = mask
@@ -71,7 +97,7 @@ export const useElementEvent = ({
mask.length === value.length)
: true;
- const complete = !errors && maskSatisfied;
+ const complete = !errors && maskSatisfied && !networkNotSelected;
return {
empty,
@@ -80,6 +106,8 @@ export const useElementEvent = ({
maskSatisfied,
complete,
...metadata?.card,
+ ...(binLookup ? { binInfo } : {}),
+ ...(requiresNetworkSelection ? { selectedNetwork } : {}),
};
};
};
diff --git a/src/components/shared/useUserEventHandlers.ts b/src/components/shared/useUserEventHandlers.ts
index 7c66d54..331ff2b 100644
--- a/src/components/shared/useUserEventHandlers.ts
+++ b/src/components/shared/useUserEventHandlers.ts
@@ -1,5 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
-import { _elementValues } from '../../ElementValues';
+import { useEffect } from 'react';
+import { _elementValues, _elementMetadata, _elementRawValues } from '../../ElementValues';
import { useElementEvent } from './useElementEvent';
import type { ElementType, EventConsumers } from '../../BaseElementTypes';
import type { TransformType } from './useTransform';
@@ -7,6 +8,7 @@ import { useTransform } from './useTransform';
import { ValidatorOptions } from '../../utils/validation';
import { NativeSyntheticEvent, TextInputFocusEventData } from 'react-native';
import { isString } from '../../utils/shared';
+import { BinInfo, CardBrand } from '../../CardElementTypes';
type UseUserEventHandlers = {
setElementValue: Dispatch>;
@@ -14,6 +16,11 @@ type UseUserEventHandlers = {
id: string;
type: ElementType;
validatorOptions?: ValidatorOptions;
+ binLookup?: boolean;
+ coBadgedSupport?: CardBrand[];
+ binInfo?: BinInfo;
+ selectedNetwork?: CardBrand;
+ brandOptionsCount?: number;
};
transform?: TransformType;
} & EventConsumers;
@@ -30,8 +37,31 @@ export const useUserEventHandlers = ({
const transformation = useTransform(transform);
+ useEffect(() => {
+ const currentState = _elementMetadata[element.id];
+ const newMetadata = {
+ binInfo: element.binInfo,
+ selectedNetwork: element.selectedNetwork,
+ };
+
+ const hasChanged =
+ currentState?.binInfo !== newMetadata.binInfo ||
+ currentState?.selectedNetwork !== newMetadata.selectedNetwork;
+
+ if (hasChanged && onChange) {
+ const event = createEvent(_elementRawValues[element.id]?.toString() || '');
+ onChange(event);
+ }
+
+ _elementMetadata[element.id] = {
+ ...currentState,
+ ...newMetadata,
+ };
+ }, [element.binInfo, element.selectedNetwork, onChange, createEvent, element.id]);
+
return {
_onChange: (_elementValue: string) => {
+ _elementRawValues[element.id] = _elementValue;
_elementValues[element.id] = transformation.apply(_elementValue);
setElementValue(() => {
diff --git a/src/components/useBinLookup.ts b/src/components/useBinLookup.ts
new file mode 100644
index 0000000..d99564d
--- /dev/null
+++ b/src/components/useBinLookup.ts
@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ _useConfigManager,
+} from '../BasisTheoryProvider';
+import type { BinInfo } from '../CardElementTypes';
+
+export const getBinInfo = async (
+ bin: string
+): Promise => {
+ const { getConfig } = _useConfigManager();
+ const { apiKey, baseUrl } = getConfig();
+ const url = `${baseUrl}/enrichments/card-details?bin=${bin}`;
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'BT-API-KEY': apiKey || '',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return (data as BinInfo) || undefined;
+};
+
+export const useBinLookup = (enabled: boolean, bin: string) => {
+ const [binInfo, setBinInfo] = useState(undefined);
+ const lastBinRef = useRef(undefined);
+ const cache = useRef