import { useRef, useMemo, useCallback } from 'react'; import { findNodeHandle } from 'react-native'; import { useBolt } from '../client/useBolt'; import NativeBoltCardFieldModule from '../native/NativeBoltCardFieldModule'; import type { TokenResult, EventType, EventCallback, EventListeners, } from './types'; import type { NativeCardFieldStyles } from './NativeCardFieldStyles'; /** * Controller returned by NativeCreditCard.useController(). * * This is a distinct interface from CreditCardController — they share * on() and tokenize() signatures but differ on setStyles() type (ADR-5). */ export interface NativeCreditCardController { /** * Ref to the native view — used by NativeCreditCard.Component. * Consumers should not access this directly. */ nativeRef: React.RefObject; /** @internal Listeners ref — used by NativeCreditCardComponent for event forwarding. */ _listenersRef: React.RefObject>; /** @internal Styles ref — used by NativeCreditCardComponent for style props. */ _stylesRef: React.RefObject; /** * @internal Last terminal validation state — written by * NativeCreditCardComponent when onCardValid/onCardError fire and read by * `on()` to replay state to late subscribers. */ _lastStateRef: React.RefObject<'valid' | { error: string } | null>; /** * Register an event listener for field events. */ on: (eventType: EventType, callback: EventCallback) => void; /** * Tokenize the entered credit card data. * Returns TokenResult on success, Error on failure — never throws. * * Implemented via the companion BoltCardFieldModule TurboModule (ADR-1). */ tokenize: () => Promise; /** * Update the styles of the native card input fields. */ setStyles: (styles: NativeCardFieldStyles) => void; } export interface NativeCreditCardControllerOptions { styles?: NativeCardFieldStyles; showPostalCode?: boolean; } /** * Creates a controller for the native credit card input. * * Usage: * const cc = NativeCreditCard.useController() * * cc.on('valid', () => setCanSubmit(true)) * const result = await cc.tokenize() * if (result instanceof Error) { ... } */ export const useNativeCreditCardController = ( _options?: NativeCreditCardControllerOptions ): NativeCreditCardController => { const bolt = useBolt(); const nativeRef = useRef(null); const listenersRef = useRef>({}); const stylesRef = useRef(_options?.styles); // Tracks the last terminal validation state so late subscribers don't miss // the Valid/Error transition that fired during mount or autofill. Written // by NativeCreditCardComponent's event handlers; read by `on()` below. const lastStateRef = useRef<'valid' | { error: string } | null>(null); const on = useCallback((eventType: EventType, callback: EventCallback) => { listenersRef.current[eventType] = callback; // Replay the most recent validation state so subscribers that register // after the underlying transition (the common `useEffect` pattern) still // receive the notification. Deferred to a microtask so we never invoke // the callback during React render. const state = lastStateRef.current; if (state === 'valid' && eventType === 'valid') { queueMicrotask(() => { if (listenersRef.current.valid === callback) { (callback as () => void)(); } }); } else if ( state !== null && typeof state === 'object' && eventType === 'error' ) { const message = state.error; queueMicrotask(() => { if (listenersRef.current.error === callback) { (callback as (e: string) => void)(message); } }); } }, []); const tokenize = useCallback((): Promise => { return new Promise((resolve) => { const viewTag = findNodeHandle(nativeRef.current); if (viewTag == null) { resolve(new Error('Native credit card view not mounted')); return; } if (!NativeBoltCardFieldModule) { resolve(new Error('BoltCardField native module not available')); return; } const publishableKey = bolt.publishableKey; const apiUrl = bolt.apiUrl; NativeBoltCardFieldModule.tokenize(viewTag, publishableKey, apiUrl) .then((resultJson: string) => { try { const parsed = JSON.parse(resultJson) as Record; if (!parsed.token) { resolve(new Error('Tokenization failed: no token returned')); return; } resolve({ token: parsed.token != null ? String(parsed.token) : undefined, last4: parsed.last4 != null ? String(parsed.last4) : undefined, bin: parsed.bin != null ? String(parsed.bin) : undefined, network: parsed.network != null ? String(parsed.network) : undefined, expiration: parsed.expiration != null ? String(parsed.expiration) : undefined, postal_code: parsed.postal_code != null ? String(parsed.postal_code) : undefined, }); } catch { resolve(new Error('Failed to parse tokenization result')); } }) .catch((err: Error) => { // Native module now uses promise.reject() — convert to Error resolve(err instanceof Error ? err : new Error(String(err))); }); }); }, [bolt]); const setStyles = useCallback((styles: NativeCardFieldStyles) => { stylesRef.current = styles; // Styles are applied via props on the native component. // The component reads stylesRef.current on each render. }, []); return useMemo( () => ({ nativeRef, _listenersRef: listenersRef, _stylesRef: stylesRef, _lastStateRef: lastStateRef, on, tokenize, setStyles, }), [on, tokenize, setStyles] ); };