From 9b345a6f943cfb342fed3ab96878d83087599f3b Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Fri, 30 Jan 2026 14:50:13 +0200 Subject: [PATCH] feat(pay): add WebView for hosted collect data form - Add CollectDataWebView component for WalletConnect Pay hosted form - Use fullscreen WebView when collectData.url is available - Handle IC_COMPLETE/IC_ERROR postMessage events from WebView - X button in WebView navigates to confirm step (not closes modal) - Add proper cleanup with isMountedRef to prevent memory leaks - Add react-native-webview dependency --- wallets/rn_cli_wallet/package.json | 1 + wallets/rn_cli_wallet/src/hooks/usePairing.ts | 2 + .../CollectDataWebView.tsx | 161 ++++++++++++++++++ .../PaymentOptionsModal/ViewWrapper.tsx | 38 ++++- .../src/modals/PaymentOptionsModal/index.tsx | 72 +++++++- .../src/modals/PaymentOptionsModal/reducer.ts | 1 + wallets/rn_cli_wallet/yarn.lock | 16 +- 7 files changed, 274 insertions(+), 17 deletions(-) create mode 100644 wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 013c4c77..44ebfa76 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -67,6 +67,7 @@ "react-native-svg": "15.14.0", "react-native-toast-message": "2.3.3", "react-native-vision-camera": "4.7.2", + "react-native-webview": "^13.16.0", "react-native-worklets": "^0.7.1", "stream-browserify": "3.0.0", "tronweb": "^6.1.1", diff --git a/wallets/rn_cli_wallet/src/hooks/usePairing.ts b/wallets/rn_cli_wallet/src/hooks/usePairing.ts index 2944be49..6b3b8248 100644 --- a/wallets/rn_cli_wallet/src/hooks/usePairing.ts +++ b/wallets/rn_cli_wallet/src/hooks/usePairing.ts @@ -36,6 +36,8 @@ export function usePairing() { includePaymentInfo: true, }); + console.log('[Pay] getPaymentOptions response:', JSON.stringify(paymentOptions, null, 2)); + ModalStore.open('PaymentOptionsModal', { paymentOptions }); } catch (error: any) { ModalStore.open('PaymentOptionsModal', { diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx new file mode 100644 index 00000000..0c3e211e --- /dev/null +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/CollectDataWebView.tsx @@ -0,0 +1,161 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { WebView, WebViewNavigation } from 'react-native-webview'; + +import { useTheme } from '@/hooks/useTheme'; +import { Text } from '@/components/Text'; +import { Spacing } from '@/utils/ThemeUtil'; +import LogStore from '@/store/LogStore'; + +interface CollectDataWebViewProps { + url: string; + onComplete: () => void; + onError: (error: string) => void; +} + +export function CollectDataWebView({ + url, + onComplete, + onError, +}: CollectDataWebViewProps) { + const Theme = useTheme(); + const webViewRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + LogStore.log( + 'WebView unmounted', + 'CollectDataWebView', + 'cleanup', + ); + }; + }, []); + + const handleNavigationStateChange = useCallback( + (navState: WebViewNavigation) => { + if (!isMountedRef.current) return; + + LogStore.log( + 'WebView navigation', + 'CollectDataWebView', + 'handleNavigationStateChange', + { url: navState.url }, + ); + + if ( + navState.url.includes('/success') || + navState.url.includes('/complete') + ) { + LogStore.log( + 'Data collection completed', + 'CollectDataWebView', + 'handleNavigationStateChange', + ); + onComplete(); + } + }, + [onComplete], + ); + + const handleError = useCallback( + (syntheticEvent: { nativeEvent: { description: string } }) => { + if (!isMountedRef.current) return; + + const { description } = syntheticEvent.nativeEvent; + LogStore.error('WebView error', 'CollectDataWebView', 'handleError', { + error: description, + }); + onError(description || 'Failed to load the form'); + }, + [onError], + ); + + const handleMessage = useCallback( + (event: { nativeEvent: { data: string } }) => { + if (!isMountedRef.current) return; + + try { + const message = JSON.parse(event.nativeEvent.data) as { + type: 'IC_COMPLETE' | 'IC_ERROR'; + success: boolean; + error?: string; + }; + LogStore.log( + 'WebView message received', + 'CollectDataWebView', + 'handleMessage', + { message }, + ); + + if (message.type === 'IC_COMPLETE' && message.success) { + onComplete(); + } else if (message.type === 'IC_ERROR' || !message.success) { + onError(message.error || 'Form submission failed'); + } + } catch { + LogStore.log( + 'WebView message (non-JSON)', + 'CollectDataWebView', + 'handleMessage', + { data: event.nativeEvent.data }, + ); + } + }, + [onComplete, onError], + ); + + return ( + + {isLoading && ( + + + + Loading form... + + + )} + setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onNavigationStateChange={handleNavigationStateChange} + onError={handleError} + onMessage={handleMessage} + javaScriptEnabled + domStorageEnabled + startInLoadingState + scalesPageToFit + showsVerticalScrollIndicator={false} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + webView: { + flex: 1, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + loadingText: { + marginTop: Spacing[4], + }, +}); diff --git a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ViewWrapper.tsx b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ViewWrapper.tsx index 2f2d8409..8019eeb0 100644 --- a/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ViewWrapper.tsx +++ b/wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/ViewWrapper.tsx @@ -11,6 +11,7 @@ type Step = | 'loading' | 'intro' | 'collectData' + | 'collectDataWebView' | 'confirm' | 'confirming' | 'result'; @@ -38,17 +39,30 @@ export function ViewWrapper({ // Determine if we should show step pills const showStepPills = - hasCollectData && (step === 'collectData' || step === 'confirm'); + hasCollectData && + (step === 'collectData' || step === 'collectDataWebView' || step === 'confirm'); const currentPillIndex = - step === 'collectData' ? 0 : step === 'confirm' ? 1 : -1; + step === 'collectData' || step === 'collectDataWebView' + ? 0 + : step === 'confirm' + ? 1 + : -1; + + const isWebViewStep = step === 'collectDataWebView'; return ( - + {/* Header */} - {/* Back Button */} + {/* Back Button - hidden in WebView step since X handles back */} - {showBackButton && ( + {showBackButton && !isWebViewStep && (