From 417e20ce7d4a89060bc188afec49a6eb1bb6d6a7 Mon Sep 17 00:00:00 2001 From: Diego Diestra Date: Wed, 22 Oct 2025 06:45:58 -0500 Subject: [PATCH 1/2] feat: Add Bin Lookup and Brand Selector. --- demo/Collect.tsx | 247 ++++---- ios/Podfile.lock | 33 -- src/BaseElementTypes.ts | 4 +- src/BasisTheoryProvider.tsx | 48 +- src/CardElementTypes.ts | 55 ++ src/ElementValues.ts | 103 +++- src/components/BrandPicker.tsx | 160 +++++ src/components/CardNumberElement.hook.ts | 47 +- src/components/CardNumberElement.tsx | 76 ++- src/components/shared/useBrandSelector.ts | 58 ++ src/components/shared/useElementEvent.ts | 34 +- src/components/shared/useUserEventHandlers.ts | 32 +- src/components/useBinLookup.ts | 68 +++ src/useBasisTheory.ts | 9 +- src/utils/shared.ts | 49 ++ src/utils/validation.ts | 2 + tests/components/CardNumberElement.test.tsx | 545 ++++++++++++++++++ tests/hooks/useBrandSelector.test.tsx | 239 ++++++++ 18 files changed, 1618 insertions(+), 191 deletions(-) create mode 100644 src/CardElementTypes.ts create mode 100644 src/components/BrandPicker.tsx create mode 100644 src/components/shared/useBrandSelector.ts create mode 100644 src/components/useBinLookup.ts create mode 100644 tests/hooks/useBrandSelector.test.tsx diff --git a/demo/Collect.tsx b/demo/Collect.tsx index c6e65ec..eab1ef7 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 = () => ; @@ -60,6 +62,7 @@ export const Collect = () => { (eventSource: 'cardExpirationDate' | 'cardNumber' | 'cvc') => (event: ElementEvent) => { queueMicrotask(() => { + console.log(event); if (event.cvcLength) { setCvcLength(event.cvcLength); } @@ -184,126 +187,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>(new Map()); + + useEffect(() => { + if (!enabled || !bin || bin.length !== 6) { + setBinInfo(undefined); + lastBinRef.current = undefined; + return; + } + + if (bin === lastBinRef.current) { + return; + } + + const fetchBinInfo = async () => { + if (cache.current.has(bin)) { + setBinInfo(cache.current.get(bin)); + lastBinRef.current = bin; + return; + } + + try { + const result = await getBinInfo(bin); + setBinInfo(result); + cache.current.set(bin, result); + lastBinRef.current = bin; + } catch (err: unknown) { + if (err instanceof Error) { + console.error('BIN lookup failed:', err); + } + } + }; + + fetchBinInfo(); + }, [bin, enabled]); + + return { binInfo }; +}; diff --git a/src/useBasisTheory.ts b/src/useBasisTheory.ts index 9f669b4..5dba178 100644 --- a/src/useBasisTheory.ts +++ b/src/useBasisTheory.ts @@ -5,7 +5,10 @@ import type { } from '@basis-theory/basis-theory-js/types/sdk'; import { useEffect, useState } from 'react'; -import { useBasisTheoryFromContext } from './BasisTheoryProvider'; +import { + _useConfigManager, + useBasisTheoryFromContext, +} from './BasisTheoryProvider'; import { Proxy } from './modules/proxy'; import { Sessions } from './modules/sessions'; import { TokenIntents } from './modules/tokenIntents'; @@ -20,6 +23,10 @@ const _BasisTheoryElements = async ({ apiBaseUrl ? { apiBaseUrl } : undefined ); + const { setConfig } = _useConfigManager(); + + setConfig({ apiKey, baseUrl: apiBaseUrl ?? 'https://api.basistheory.com' }); + const proxy = Proxy(bt); const sessions = Sessions(bt); diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 97eacc8..f2dae5e 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -1,6 +1,7 @@ import type { Token } from '@basis-theory/basis-theory-js/types/models'; import { anyPass, equals, is, isEmpty, isNil, replace, type } from 'ramda'; import type { BTRef, InputBTRefWithDatepart } from '../BaseElementTypes'; +import type { CardBrand } from '../CardElementTypes'; const isString = is(String); const isBoolean = is(Boolean); @@ -31,8 +32,56 @@ const isPrimitive = anyPass([isNil, isString, isBoolean, isNumber]); const filterOutMaxOccurrences = (numbers: number[]) => numbers.filter((num) => num !== Math.max(...numbers)); + +const convertApiBrandToBrand = (apiBrandName: string): CardBrand => { + const exceptions: Record = { + AMEX: 'american-express', + 'DINERS CLUB': 'diners-club', + 'CARTES BANCAIRES': 'cartes-bancaires', + EFTPOS_AUSTRALIA: 'eftpos-australia', + 'KOREAN LOCAL': 'korean-local', + 'PRIVATE LABEL': 'private-label', + }; + + const upperCaseName = apiBrandName.toUpperCase(); + + if (exceptions[upperCaseName]) { + return exceptions[upperCaseName]; + } + + const converted = apiBrandName.toLowerCase().replace(/[\s_]+/g, '-') as CardBrand; + return converted || 'unknown'; +}; + +const labelizeCardBrand = (value: CardBrand): string => { + const exceptions: Record = { + 'american-express': 'American Express', + 'diners-club': 'Diners Club', + 'cartes-bancaires': 'Cartes Bancaires', + 'eftpos-australia': 'EFTPOS Australia', + 'private-label': 'Private Label', + 'korean-local': 'Korean Local', + jcb: 'JCB', + unionpay: 'UnionPay', + hipercard: 'Hipercard', + uapt: 'UATP', + }; + + if (exceptions[value]) { + return exceptions[value]; + } + + return value + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + + export { + convertApiBrandToBrand, extractDigits, + labelizeCardBrand, filterOutMaxOccurrences, isBoolean, isBtDateRef, diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 1510a27..6c7f5ad 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -2,6 +2,7 @@ import { cvv, expirationDate, number } from 'card-validator'; import { isEmpty, partial, split } from 'ramda'; import type { Mask, ValidationResult } from '../BaseElementTypes'; import { ElementType } from '../BaseElementTypes'; +import { CoBadgedSupport } from '../CardElementTypes'; import { extractDigits, filterOutMaxOccurrences, @@ -21,6 +22,7 @@ const _cardCvvValidator = (length = [3, 4], value: string) => interface CardNumberValidatorOptions { skipLuhnValidation?: boolean; + coBadgedSupport?: CoBadgedSupport[]; } interface TextValidatorOptions { diff --git a/tests/components/CardNumberElement.test.tsx b/tests/components/CardNumberElement.test.tsx index 2508d33..5464f2d 100644 --- a/tests/components/CardNumberElement.test.tsx +++ b/tests/components/CardNumberElement.test.tsx @@ -11,9 +11,16 @@ import { userEvent, fireEvent, screen, + waitFor, } from '@testing-library/react-native'; import { CardNumberElement } from '../../src'; +import { BasisTheoryProvider } from '../../src/BasisTheoryProvider'; +import { CoBadgedSupport } from '../../src/CardElementTypes'; import cardValidator from 'card-validator'; +import * as useBinLookupModule from '../../src/components/useBinLookup'; + +// Mock fetch for bin lookup tests +global.fetch = jest.fn(); describe('CardNumberElement', () => { beforeEach(() => { @@ -372,4 +379,542 @@ describe('CardNumberElement', () => { }); }); }); + + describe('Co-badge Support', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfo = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [ + { + brand: 'cartes-bancaires', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Initialize ConfigManager with test config + const { _useConfigManager } = require('../../src/BasisTheoryProvider'); + const configManager = _useConfigManager(); + configManager.setConfig({ + apiKey: 'test-api-key', + baseUrl: 'https://api.basistheory.com', + }); + + // Mock the useBinLookup hook + jest.spyOn(useBinLookupModule, 'useBinLookup').mockReturnValue({ + binInfo: mockBinInfo, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('renders BrandPicker when coBadgedSupport is provided', () => { + const onChange = jest.fn(); + + render( + + + + ); + + // BrandPicker should be rendered (it shows "Select card brand" when no brand is selected) + expect(screen.getByText('Select card brand')).toBeTruthy(); + }); + + test('does not render BrandPicker when coBadgedSupport is not provided', () => { + const onChange = jest.fn(); + + render( + + + + ); + + // BrandPicker should not be rendered + expect(screen.queryByText('Select card brand')).toBeNull(); + }); + + test('includes selectedNetwork in onChange event when coBadgedSupport is enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + selectedNetwork: undefined, // Initially undefined + }) + ); + }); + }); + + test('does not include selectedNetwork in onChange event when coBadgedSupport is not enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.not.objectContaining({ + selectedNetwork: expect.anything(), + }) + ); + }); + }); + + test('resets selectedNetwork when binInfo is cleared', async () => { + const onChange = jest.fn(); + + // Start with binInfo, then clear it + const mockUseBinLookup = jest.spyOn(useBinLookupModule, 'useBinLookup'); + mockUseBinLookup.mockReturnValueOnce({ binInfo: mockBinInfo }); + + const { rerender } = render( + + + + ); + + // Clear binInfo + mockUseBinLookup.mockReturnValue({ binInfo: undefined }); + + rerender( + + + + ); + + // Should trigger onChange with selectedNetwork reset + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + selectedNetwork: undefined, + }) + ); + }); + }); + }); + + describe('Bin Lookup', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfo = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Initialize ConfigManager with test config + const { _useConfigManager } = require('../../src/BasisTheoryProvider'); + const configManager = _useConfigManager(); + configManager.setConfig({ + apiKey: 'test-api-key', + baseUrl: 'https://api.basistheory.com', + }); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => mockBinInfo, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('includes binInfo in onChange event when binLookup is enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242'); // 6 digits to trigger bin lookup + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + binInfo: expect.objectContaining({ + brand: 'visa', + funding: 'debit', + issuer: expect.objectContaining({ + name: 'Test Bank', + }), + }), + }) + ); + }); + }); + + test('does not include binInfo in onChange event when binLookup is disabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.not.objectContaining({ + binInfo: expect.anything(), + }) + ); + }); + }); + + test('makes API call to correct endpoint for bin lookup', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242424242424242'); // Full card number + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.basistheory.com/enrichments/card-details?bin=424242', + { + method: 'GET', + headers: { + 'BT-API-KEY': 'test-api-key', + }, + } + ); + }); + }); + + test('handles bin lookup API errors gracefully', async () => { + const onChange = jest.fn(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + (global.fetch as jest.Mock).mockRejectedValue(new Error('API Error')); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242424242424242'); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('BIN lookup failed:', expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + + test('caches bin lookup results to avoid duplicate API calls', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + + // First call + fireEvent.changeText(el, '424242424242424242'); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + // Clear and enter same BIN again + fireEvent.changeText(el, ''); + fireEvent.changeText(el, '424242424242424242'); + + // Should not make another API call due to caching + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + test('only triggers bin lookup for 6-digit BINs', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + + // Less than 6 digits - should not trigger lookup + fireEvent.changeText(el, '42424'); + expect(global.fetch).not.toHaveBeenCalled(); + + // Exactly 6 digits - should trigger lookup + fireEvent.changeText(el, '424242'); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Combined Co-badge Support and Bin Lookup', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfoWithCoBadge = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [ + { + brand: 'cartes-bancaires', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Initialize ConfigManager with test config + const { _useConfigManager } = require('../../src/BasisTheoryProvider'); + const configManager = _useConfigManager(); + configManager.setConfig({ + apiKey: 'test-api-key', + baseUrl: 'https://api.basistheory.com', + }); + + jest.spyOn(useBinLookupModule, 'useBinLookup').mockReturnValue({ + binInfo: mockBinInfoWithCoBadge, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('includes both binInfo and selectedNetwork when both features are enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + binInfo: expect.objectContaining({ + brand: 'visa', + additional: expect.arrayContaining([ + expect.objectContaining({ + brand: 'cartes-bancaires', + }), + ]), + }), + selectedNetwork: undefined, // Initially undefined + }) + ); + }); + }); + }); }); diff --git a/tests/hooks/useBrandSelector.test.tsx b/tests/hooks/useBrandSelector.test.tsx new file mode 100644 index 0000000..a51352d --- /dev/null +++ b/tests/hooks/useBrandSelector.test.tsx @@ -0,0 +1,239 @@ +import { renderHook } from '@testing-library/react-native'; +import { useBrandSelector } from '../../src/components/shared/useBrandSelector'; +import { CardBrand, CoBadgedSupport } from '../../src/CardElementTypes'; +import type { BinInfo } from '../../src/CardElementTypes'; + +describe('useBrandSelector', () => { + const mockSetSelectedNetwork = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const defaultProps = { + binInfo: undefined, + coBadgedSupport: undefined, + selectedNetwork: undefined, + setSelectedNetwork: mockSetSelectedNetwork, + }; + + + describe('brandSelectorOptions', () => { + it('should return empty array when binInfo is undefined', () => { + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: undefined, + }) + ); + + expect(result.current.brandSelectorOptions).toEqual([]); + }); + + it('should return options with main brand when binInfo exists', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [], + }; + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + }) + ); + + expect(result.current.brandSelectorOptions).toContain('visa'); + }); + + it('should include additional brands that are in coBadgedSupport', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'cartes-bancaires', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + expect(options).toContain('cartes-bancaires'); + expect(options).toContain('visa'); + }); + + it('should skip additional brands without brand property', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'cartes-bancaires', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + { brand: 'discover', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + expect(options).toHaveLength(2); // cartes-bancaires, visa + }); + + it('should return unique brands (no duplicates)', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, // Duplicate of main brand + { brand: 'mastercard', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + const visaCount = options.filter(brand => brand === 'visa').length; + expect(visaCount).toBe(1); // Should only appear once + }); + }); + + describe('selectedNetwork reset effect', () => { + it('should call setSelectedNetwork with undefined when binInfo becomes undefined and selectedNetwork exists', () => { + const { rerender } = renderHook( + ({ binInfo, selectedNetwork }) => + useBrandSelector({ + ...defaultProps, + binInfo, + selectedNetwork, + }), + { + initialProps: { + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] } as BinInfo | undefined, + selectedNetwork: 'visa' as CardBrand, + }, + } + ); + + // Clear binInfo + rerender({ + binInfo: undefined, + selectedNetwork: 'visa' as CardBrand, + }); + + expect(mockSetSelectedNetwork).toHaveBeenCalledWith(undefined); + }); + + it('should not call setSelectedNetwork when binInfo is undefined but selectedNetwork is also undefined', () => { + renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: undefined, + selectedNetwork: undefined, + }) + ); + + expect(mockSetSelectedNetwork).not.toHaveBeenCalled(); + }); + + it('should not call setSelectedNetwork when binInfo exists', () => { + renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + selectedNetwork: 'visa' as CardBrand, + }) + ); + + expect(mockSetSelectedNetwork).not.toHaveBeenCalled(); + }); + }); + + describe('memoization', () => { + it('should memoize brandSelectorOptions when dependencies do not change', () => { + const mockBinInfo = { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }; + + const { result, rerender } = renderHook( + ({ binInfo, coBadgedSupport }) => + useBrandSelector({ + ...defaultProps, + binInfo, + coBadgedSupport, + }), + { + initialProps: { + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }, + } + ); + + const firstResult = result.current.brandSelectorOptions; + + // Rerender with same props + rerender({ + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }); + + const secondResult = result.current.brandSelectorOptions; + + expect(firstResult).toEqual(secondResult); // Should have the same content + }); + + it('should recalculate brandSelectorOptions when binInfo changes', () => { + const { result, rerender } = renderHook( + ({ binInfo }) => + useBrandSelector({ + ...defaultProps, + binInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }), + { + initialProps: { + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + }, + } + ); + + const firstResult = result.current.brandSelectorOptions; + + // Change binInfo + rerender({ + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + }); + + const secondResult = result.current.brandSelectorOptions; + + expect(firstResult).not.toBe(secondResult); // Should be different references + }); + }); +}); \ No newline at end of file From 3e1441214f70fb9fa9ff3c26922b71de46cbf00f Mon Sep 17 00:00:00 2001 From: Diego Diestra Date: Fri, 24 Oct 2025 10:03:40 -0500 Subject: [PATCH 2/2] Apply suggestion from @ddiestra chore: remove console.log --- demo/Collect.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/demo/Collect.tsx b/demo/Collect.tsx index eab1ef7..c5781de 100644 --- a/demo/Collect.tsx +++ b/demo/Collect.tsx @@ -62,7 +62,6 @@ export const Collect = () => { (eventSource: 'cardExpirationDate' | 'cardNumber' | 'cvc') => (event: ElementEvent) => { queueMicrotask(() => { - console.log(event); if (event.cvcLength) { setCvcLength(event.cvcLength); }