import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Linking, Platform, StyleSheet, View, Modal, ScrollView, Dimensions, TouchableOpacity, Image, TouchableWithoutFeedback, Text, NativeModules, NativeEventEmitter, } from 'react-native'; import WebView, { type WebViewMessageEvent } from 'react-native-webview'; import { getContact } from '../functions'; import Share from 'react-native-share'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { encryptUrlParams } from '../utils/encryption'; const OmnipayActivity = NativeModules.OmnipayActivity || {}; type AnalyticsConfig = { /** * Callback to send analytics events back to the parent app. * Receives the event name and optional payload. */ onEvent: (eventName: string, payload?: Record) => void; /** * Optional whitelist of events to forward. * If omitted or empty, all events will be forwarded. */ allowedEvents?: string[]; }; type OmnipayProviderProps = { publicKey: string; env: 'dev' | 'prod'; color: string; children: React.ReactElement | React.ReactElement[]; analyticsConfig?: AnalyticsConfig; }; type PosTransactionType = { amount: number; purchaseType: 'PURCHASE' | 'KEY EXCHANGE'; color: string; print: boolean; rrn: string; stan: string; terminalId: string; }; type PostMessage = { [key: string]: unknown; }; type WebviewMessageData = { dataKey: string; dataValue: unknown; }; type MessageHandler = (dataValue: unknown) => void | Promise; type Status = 'error' | 'loading' | 'success'; type InitiateBillsType = { phoneNumber: string; onClose?: () => void; metadata?: Record; }; type InitiateWalletType = { phoneNumber?: string; customerRef?: string; userRef?: string; usesPaylater?: boolean; usesPromo?: boolean; usesAirtimeData?: boolean; usesTransfer?: boolean; usesBills?: boolean; usesPos?: boolean; onClose?: () => void; promoBalanceOffset?: number; deviceId?: string; deviceName?: string; hideWalletTransfer?: boolean; isBvnValidationRequired?: boolean; walletTab?: 'Paylater' | 'Account' | 'Omoni'; sessionId?: string; kycStatus?: 'verified' | 'unverified'; launchPage?: string; promoName?: string; metadata?: Record; }; export type OmnipayContextType = { initiateBills: ({ phoneNumber, onClose }: InitiateBillsType) => void; initiateWallet: ({ phoneNumber, customerRef, userRef, onClose, usesPaylater, usesPromo, usesAirtimeData, usesTransfer, usesBills, usesPos, promoBalanceOffset, deviceId, deviceName, hideWalletTransfer, isBvnValidationRequired, walletTab, sessionId, launchPage, kycStatus, promoName, metadata, }: InitiateWalletType) => void; }; let defaultValue = { initiateBills: () => null, initiateWallet: () => null, }; export const OmnipayContext = React.createContext( defaultValue ); export const OmnipayProvider = ({ children, publicKey, env, color, analyticsConfig, }: OmnipayProviderProps) => { const [webviewStatus, setWebviewStatus] = useState('loading'); const [isVisible, setIsVisible] = useState(false); const webviewRef = useRef(null); const visibilityRef = useRef(isVisible); const webHost = getWebHost(); const [webviewUrl, setWebviewUrl] = useState(webHost); const showWebview = webviewUrl.includes('view') && isVisible; const [containerOffset, setContainerOffset] = useState(30); const isValidEnv = ['prod', 'dev'].includes(env); const isValidColor = color.length > 2; const onCloseRef = useRef<(() => void) | undefined>(undefined); const [canUsePos, setCanUsePos] = useState(false); const allowedEvents = React.useMemo( () => analyticsConfig?.allowedEvents && analyticsConfig.allowedEvents.length > 0 ? new Set(analyticsConfig.allowedEvents) : null, [analyticsConfig?.allowedEvents] ); useEffect(() => { checkPaymentApp(); }, []); useEffect(() => { visibilityRef.current = isVisible; }, [isVisible]); useEffect(() => { if (canUsePos) { const eventEmitter = new NativeEventEmitter(OmnipayActivity); eventEmitter.addListener('OmnipayEvent', (event) => { console.log('native event', event); }); } }, [canUsePos]); async function checkPaymentApp() { try { if (Platform.OS === 'android') { const isInstalled = await OmnipayActivity.isPackageInstalled( 'com.horizonpay.sample' ); if (isInstalled) { setCanUsePos(true); } } } catch (error) {} } async function startPosTransaction({ amount, purchaseType, print, rrn, stan, terminalId, }: PosTransactionType) { try { if (Platform.OS === 'android') { let result = ''; if (purchaseType === 'KEY EXCHANGE') { const isKeyExchanged = await AsyncStorage.getItem('isKeyExchanged'); if (!isKeyExchanged) { result = await OmnipayActivity.initiateHorizonTransaction( amount, purchaseType, color, print, rrn, stan, terminalId ); if ( terminalId && result && result.toLowerCase().includes('-message-success') ) { await AsyncStorage.setItem('isKeyExchanged', terminalId); } postMessage({ dataKey: 'onPosKeyExchanged', dataValue: result, }); } } else { result = await OmnipayActivity.initiateHorizonTransaction( amount, purchaseType, color, print, rrn, stan, terminalId ); postMessage({ dataKey: 'onPosTransactionSuccess', dataValue: result, }); } } } catch (error) { console.log(error); postMessage({ dataKey: 'onPosTransactionFailure', dataValue: '', }); } } function getWebviewStyle() { if (!showWebview) { return { opacity: 0, height: 0, width: 0, flex: 0 }; } return styles.webview; } function getWebHost() { if (env === 'dev') { return 'https://websdk-dev.ompy.ng/'; } return 'https://websdk.ompy.ng/'; } const onWebviewMount = ` (function() { window.nativeOs = "${Platform.OS}"; true; })(); true; `; function postMessage(data: PostMessage) { if (!webviewRef.current) { return; } try { webviewRef.current.postMessage(JSON.stringify(data)); } catch (error) {} } const handleChooseContact = async () => { const contactDetails = await getContact(); postMessage({ dataKey: 'contactSelected', dataValue: contactDetails, }); }; const handleOpenLink = (dataValue: unknown) => { if (typeof dataValue === 'string') { Linking.openURL(dataValue); } }; const handleModalOpen = () => { setContainerOffset(0); }; const handleModalClosed = (dataValue: unknown) => { setContainerOffset(30); if (dataValue === 'closeSdk') { closeWebview(); } }; const handleCloseSdk = () => { closeWebview(); }; const handleShareReceipt = async (receiptData: unknown) => { if (typeof receiptData !== 'string') return; try { const { fileName = '', image = '' } = JSON.parse(receiptData); await Share.open({ url: image, filename: Platform.OS === 'android' ? fileName : `${fileName}.jpg`, }); } catch (error) { console.log('Error sharing receipt:', error); } }; const handlePosTransaction = (dataValue: unknown) => { if (typeof dataValue !== 'string') return; try { startPosTransaction(JSON.parse(dataValue)); } catch (error) { console.warn('Error parsing POS transaction data:', error); } }; const handleAnalyticsEvent = (dataValue: unknown) => { if (!analyticsConfig?.onEvent) { return; } try { const payload = typeof dataValue === 'string' ? JSON.parse(dataValue) : dataValue; const { eventName, eventProperties } = payload; if (typeof eventName === 'string' && eventName.trim()) { const shouldForward = !allowedEvents || allowedEvents.has(eventName); if (shouldForward) { analyticsConfig.onEvent(eventName, eventProperties || {}); } } else { console.warn('Omnipay analytics: Invalid event name', eventName); } } catch (error) { console.warn('Omnipay analytics error:', error); } }; /** * Maps message keys to handlers for O(1) lookup and easy extensibility. * Add new message types here instead of chaining if statements. */ const messageHandlers: Record = { chooseContact: handleChooseContact, openLink: handleOpenLink, modalOpen: handleModalOpen, modalClosed: handleModalClosed, closeSdk: handleCloseSdk, shareReceipt: handleShareReceipt, startPosTransaction: handlePosTransaction, analyticsEvent: handleAnalyticsEvent, }; async function onWebviewMessage(e: WebViewMessageEvent) { try { if (!e.nativeEvent?.data) { return; } const eventData: WebviewMessageData = JSON.parse(e.nativeEvent.data); const { dataKey, dataValue } = eventData; const handler = messageHandlers[dataKey]; if (handler) { await handler(dataValue); } } catch (error) { console.warn('Error handling webview message:', error); } } const _initiateBills = ({ phoneNumber, onClose, metadata = {}, }: InitiateBillsType) => { if (typeof phoneNumber === 'string' && phoneNumber.length >= 10) { // Encrypt params to prevent exposure of sensitive data in URL/logs const params = { theme: color, view: 'bills', publicKey: publicKey, phoneNumber: phoneNumber, ...metadata, }; const encryptedPayload = encryptUrlParams(params); const webUrl = `${webHost}?cfg=${encryptedPayload}&view=bills`; setWebviewUrl(webUrl); setIsVisible(true); onCloseRef.current = onClose; return; } console.warn('Omnipay error: Invalid phone number'); }; const _initiateWallet = ({ phoneNumber = '', customerRef = '', userRef = '', onClose, usesPaylater = false, usesPromo = false, usesAirtimeData = false, usesTransfer = false, usesBills = false, usesPos = false, promoBalanceOffset = 0, deviceId = '', deviceName = '', hideWalletTransfer = false, isBvnValidationRequired = false, walletTab = 'Account', sessionId = '', kycStatus, launchPage = 'wallet', promoName = '', metadata = {}, }: InitiateWalletType) => { // Guard against race conditions when wallet is already initializing/visible if (visibilityRef.current) { return; } const isPhoneNumberValid = !!phoneNumber && phoneNumber.length >= 10; const isValidCustomerRef = !!customerRef && !!customerRef.trim(); const isValidUserRef = !!userRef && !!userRef.trim(); const usesNativeShare = true; if (isPhoneNumberValid || isValidCustomerRef || isValidUserRef) { // Encrypt params to prevent exposure of PII and API keys in URL/logs const params = { theme: color, view: 'wallet', publicKey: publicKey, phoneNumber: phoneNumber, usesPaylater: usesPaylater, usesPromo: usesPromo, usesAirtimeData: usesAirtimeData, usesTransfer: usesTransfer, usesBills: usesBills, usesPos: usesPos, customerRef: customerRef, userRef: userRef, promoBalanceOffset: promoBalanceOffset, deviceId: deviceId, deviceName: deviceName, hideWalletTransfer: hideWalletTransfer, bvnRequired: isBvnValidationRequired, usesNativeShare: usesNativeShare, isPosEnabled: canUsePos, walletTab: walletTab, sessionId: sessionId, kycStatus: kycStatus || '', launchPage: launchPage, promoName: promoName, ...metadata, }; const encryptedPayload = encryptUrlParams(params); const webUrl = `${webHost}?cfg=${encryptedPayload}&view=wallet`; setWebviewUrl(webUrl); setIsVisible(true); onCloseRef.current = onClose; return; } console.warn('Omnipay error: Invalid phone number'); }; function closeWebview() { setIsVisible(false); if (onCloseRef.current && typeof onCloseRef.current === 'function') { onCloseRef.current(); onCloseRef.current = undefined; } } function reloadWebview() { if (webviewRef.current) { webviewRef.current.reload(); } } const webviewStyle = getWebviewStyle(); const isPropsValid = isValidColor && !!publicKey && isValidEnv; const isWalletView = webviewUrl.includes('view=wallet'); return ( {isPropsValid && ( <> {webviewUrl.includes('view') && ( {containerOffset !== 0 && webviewStatus === 'success' && !isWalletView && ( )} { setWebviewStatus('loading'); }} domStorageEnabled={true} originWhitelist={['*']} allowsInlineMediaPlayback={true} onLoadEnd={() => setWebviewStatus('success')} renderError={() => ( Unable to open your wallet. Please try again <> Retry )} /> {webviewStatus === 'loading' && showWebview && ( )} )} )} {children} ); }; const styles = StyleSheet.create({ hide: { display: 'none', }, full: { flex: 1, width: '100%', height: '100%', }, webview: { flex: 1, width: '100%', height: Dimensions.get('window').height - 40, backgroundColor: 'white', borderTopRightRadius: 20, borderTopLeftRadius: 20, }, webviewLoader: { zIndex: 3, backgroundColor: 'white', alignItems: 'center', justifyContent: 'center', flex: 1, width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, borderTopRightRadius: 20, borderTopLeftRadius: 20, }, backdrop: { backgroundColor: 'rgba(0,0,0,0.48)', flex: 1, justifyContent: 'flex-end', position: 'relative', height: '100%', }, container: { backgroundColor: 'white', borderTopRightRadius: 20, borderTopLeftRadius: 20, maxHeight: Dimensions.get('window').height - 40, flex: 1, position: 'relative', ...(Platform.OS === 'android' && { overflow: 'hidden' }), }, modal: { flex: 1, backgroundColor: 'rgba(0,0,0,0.48)', height: '100%', width: '100%', }, close: { position: 'absolute', top: 10, right: 10, backgroundColor: 'white', height: 24, width: 24, borderRadius: 1000, alignItems: 'center', justifyContent: 'center', zIndex: 2, }, closeIcon: { height: 12, width: 12, }, contentContainer: { flex: 1, height: Dimensions.get('window').height - 40, position: 'relative', borderTopRightRadius: 20, borderTopLeftRadius: 20, }, testContent: { paddingTop: 30, paddingLeft: 16, }, testTwoContent: { paddingTop: 10, paddingLeft: 16, }, errorSubtitle: { textAlign: 'center', fontSize: 14, color: '#5e7079', marginBottom: 20, paddingHorizontal: 8, marginTop: 14, }, errorContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', width: Dimensions.get('window').width, height: Dimensions.get('window').height, zIndex: 2, backgroundColor: 'white', }, retryButton: { minWidth: 160, marginHorizontal: 'auto', }, button: { borderRadius: 6, paddingHorizontal: 12, paddingVertical: 14, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, buttonText: { color: 'white', fontSize: 16, paddingHorizontal: 30 }, });