import React, { Component } from 'react'; import { SafeAreaView, Modal, TouchableOpacity, Image, StyleSheet, } from 'react-native'; import WebView, { WebViewMessageEvent, } from 'react-native-webview-bootpay'; import type { ShouldStartLoadRequest, WebViewErrorEvent, WebViewNavigationEvent, } from 'react-native-webview-bootpay/lib/WebViewTypes'; import { BootpayCommerceProps, CommerceEventData, CommercePayload, } from './CommerceTypes'; import { debounce } from 'lodash'; import { buildEnvironmentScript } from './environment'; const COMMERCE_URL = 'https://webview.bootpay.co.kr/commerce/1.0.5/index.html'; const DEBUG_MODE = false; export class BootpayCommerce extends Component { webView: React.RefObject; payload?: CommercePayload; isScriptInjected: boolean = false; constructor(props: BootpayCommerceProps) { super(props); this.webView = React.createRef(); } state = { visibility: false, script: '', firstLoad: false, showCloseButton: true, isShowProgress: false, }; dismiss = () => { this.setState({ visibility: false }); }; showProgressBar = (isShow: boolean) => { this.setState({ isShowProgress: isShow }); }; closeDismiss = () => { // iOS SDK와 동일하게 destroy 호출 this.callJavaScript('BootpayCommerce.destroy();'); if (this.props.onClose) this.props.onClose(); this.dismiss(); }; callJavaScript = (script: string) => { this.webView.current?.injectJavaScript( `setTimeout(function() { ${script} }, 30);` ); }; getEnvironmentMode = () => { return buildEnvironmentScript('BootpayCommerce', DEBUG_MODE); }; generateCommerceScript = (payload: CommercePayload) => { const scripts: string[] = []; // 개발 환경에서만 로그 레벨 설정 if (DEBUG_MODE) { scripts.push('BootpayCommerce.setLogLevel(1);'); } // close 이벤트 리스너 scripts.push( "document.addEventListener('bootpayclose', function(e) { " + "window.BootpayRNWebView.postMessage('close'); " + '});' ); // 결제 요청 호출 scripts.push('BootpayCommerce.requestCheckout('); scripts.push(payload.toJSONString()); scripts.push(')'); scripts.push('.then(function(res) {'); scripts.push(' window.BootpayRNWebView.postMessage(JSON.stringify(res));'); scripts.push('}).catch(function(err) {'); scripts.push( " window.BootpayRNWebView.postMessage(JSON.stringify({event: 'error', data: err}));" ); scripts.push('});'); return scripts.join(''); }; getMountJavascript = () => { // iOS SDK와 동일: Commerce에서는 setDevice 호출하지 않음 return ` ${this.getEnvironmentMode()} `; }; componentWillUnmount() { this.setState({ visibility: false, firstLoad: false, showCloseButton: true, }); } componentDidMount() { this.closeDismiss = debounce(this.closeDismiss, 30); } removePaymentWindow = () => { this.dismiss(); }; requestCheckout = (payload: CommercePayload) => { this.payload = payload; this.isScriptInjected = false; this.setState({ visibility: true, script: ` ${this.getMountJavascript()} ${this.generateCommerceScript(payload)} `, firstLoad: false, showCloseButton: true, spinner: false, }); }; // iOS SDK와 동일하게 페이지 로드 완료 후 JavaScript 주입 onLoadEnd = (event: WebViewNavigationEvent | WebViewErrorEvent) => { const url = event.nativeEvent.url; if (DEBUG_MODE) { console.log('[BootpayCommerce] onLoadEnd URL:', url); } // payload가 없으면 스크립트 주입하지 않음 if (!this.payload) { if (DEBUG_MODE) { console.log('[BootpayCommerce] No payload, skipping script injection'); } return; } // Commerce 페이지 로드 완료 시에만 JavaScript 주입 (iOS SDK didFinish와 동일) if (url.includes('webview.bootpay.co.kr/commerce') && !this.isScriptInjected) { this.isScriptInjected = true; if (DEBUG_MODE) { console.log('[BootpayCommerce] Injecting JavaScript...'); } // iOS SDK와 동일한 순서로 JavaScript 주입 // 1. 환경 설정 // 2. 디바이스 설정 // 3. 결제 요청 const fullScript = ` try { ${this.getMountJavascript()} ${this.generateCommerceScript(this.payload)} } catch(e) { console.error('[BootpayCommerce] Script error:', e); window.BootpayRNWebView.postMessage(JSON.stringify({event: 'error', message: e.message})); } `; this.callJavaScript(fullScript); } }; // iOS SDK decidePolicyFor와 동일: redirect URL 처리 onShouldStartLoadWithRequest = (request: ShouldStartLoadRequest): boolean => { const url = request.url; if (DEBUG_MODE) { console.log('[BootpayCommerce] onShouldStartLoadWithRequest:', url); } // redirect_url 콜백 처리 (api.bootpay.co.kr/v2/callback) - iOS SDK와 동일 if (url.includes('api.bootpay.co.kr/v2/callback')) { if (DEBUG_MODE) { console.log('[BootpayCommerce] Callback URL detected, handling...'); } this.handleCallbackURL(url); return false; // 네비게이션 취소 } // about:blank 허용 if (url.startsWith('about:blank')) { return true; } // http/https가 아닌 스키마는 앱 실행 시도 (외부 앱 호출) if (!url.startsWith('http://') && !url.startsWith('https://')) { if (DEBUG_MODE) { console.log('[BootpayCommerce] Non-http scheme, should open external app:', url); } // React Native에서는 Linking을 사용하여 외부 앱 열기 // 여기서는 일단 네비게이션만 취소 return false; } return true; }; // URL query string 파싱 헬퍼 (React Native에서 URL.searchParams 미지원) parseQueryString = (urlString: string): Record => { const result: Record = {}; const queryIndex = urlString.indexOf('?'); if (queryIndex === -1) return result; const queryString = urlString.substring(queryIndex + 1); const pairs = queryString.split('&'); for (const pair of pairs) { const eqIndex = pair.indexOf('='); if (eqIndex > 0) { const key = pair.substring(0, eqIndex); const value = pair.substring(eqIndex + 1); try { result[key] = decodeURIComponent(value); } catch { result[key] = value; } } } return result; }; // iOS SDK handleCallbackURL과 동일: URL query parameters 파싱 및 콜백 호출 handleCallbackURL = (urlString: string) => { try { const params = this.parseQueryString(urlString); const data: Record = {}; // Query parameters 변환 for (const [key, value] of Object.entries(params)) { // metadata는 JSON 문자열이므로 파싱 시도 if (key === 'metadata') { try { data[key] = JSON.parse(value); } catch { data[key] = value; } } else { data[key] = value; } } if (DEBUG_MODE) { console.log('[BootpayCommerce] Callback data:', data); } // event에 따라 콜백 호출 - iOS SDK와 동일 const event = (data.event as string) || ''; this.showProgressBar(false); switch (event) { case 'done': if (this.props.onDone) { this.props.onDone(data as CommerceEventData); } break; case 'issued': // 가상계좌 발급 완료 - iOS SDK와 동일 if (this.props.onIssued) { this.props.onIssued(data as CommerceEventData); } break; case 'cancel': if (this.props.onCancel) { this.props.onCancel(data as CommerceEventData); } break; case 'error': if (this.props.onError) { this.props.onError(data as CommerceEventData); } break; default: // event가 없으면 receipt_id로 판단 - iOS SDK와 동일 if (data.receipt_id) { if (this.props.onDone) { this.props.onDone(data as CommerceEventData); } } else { if (this.props.onCancel) { this.props.onCancel(data as CommerceEventData); } } break; } this.closeDismiss(); } catch (error) { console.error('[BootpayCommerce] Error parsing callback URL:', error); if (this.props.onError) { this.props.onError({ event: 'error', message: 'Failed to parse callback URL', }); } this.closeDismiss(); } }; onMessage = async (event: WebViewMessageEvent) => { if (!event) return; try { const messageData = event.nativeEvent.data; // close 이벤트 처리 if (messageData === 'close') { this.showProgressBar(false); this.closeDismiss(); return; } const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData; const handleEvent = ( _eventName: string, callback: ((data: CommerceEventData) => void) | undefined ) => { this.showProgressBar(false); if (callback) callback(data as CommerceEventData); this.closeDismiss(); }; // 이벤트별 처리 switch (data.event) { case 'cancel': handleEvent('cancel', this.props.onCancel); break; case 'error': handleEvent('error', this.props.onError); break; case 'done': handleEvent('done', this.props.onDone); break; case 'issued': // 가상계좌 발급 완료 - iOS SDK와 동일 handleEvent('issued', this.props.onIssued); break; case 'close': case 'bootpayWidgetRevertScreen': this.showProgressBar(false); this.closeDismiss(); break; default: // receipt_id가 있으면 done 이벤트로 처리 if (data.receipt_id) { handleEvent('done', this.props.onDone); } else { console.warn(`Unknown commerce event type: ${data.event}`); } break; } } catch (error) { console.error('Error processing commerce message:', error); } }; render() { return ( {this.state.showCloseButton && ( )} { const { nativeEvent } = syntheticEvent; if (nativeEvent.code === 3) { this.showProgressBar(false); if (this.props.onError) this.props.onError({ event: 'error', code: nativeEvent.code, message: nativeEvent.description, }); this.closeDismiss(); } }} /> ); } } const styles = StyleSheet.create({ overlay: { width: 25, height: 25, right: 5, alignSelf: 'flex-end', }, });