react-native-bootpay-api 13.13.42 → 13.13.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1095 @@
1
+ import React, { Component } from 'react';
2
+ import {
3
+ SafeAreaView,
4
+ StyleSheet,
5
+ Platform,
6
+ Animated,
7
+ BackHandler,
8
+ NativeEventSubscription,
9
+ Dimensions,
10
+ View,
11
+ } from 'react-native';
12
+ import WebView, { WebViewMessageEvent } from 'react-native-webview-bootpay';
13
+ import {
14
+ GestureHandlerRootView,
15
+ GestureDetector,
16
+ Gesture,
17
+ GestureStateChangeEvent,
18
+ GestureUpdateEvent,
19
+ PanGestureHandlerEventPayload,
20
+ } from 'react-native-gesture-handler';
21
+ import { BootpayWidgetProps, WidgetPayload, WidgetData } from './WidgetTypes';
22
+ import { Payload, Item, User, Extra } from './BootpayTypes';
23
+ import { debounce } from 'lodash';
24
+ import UserInfo from './UserInfo';
25
+
26
+ const SDK_VERSION = '13.13.4';
27
+ const DEBUG_MODE = false; // 디버그 모드 비활성화
28
+ const WIDGET_URL = 'https://webview.bootpay.co.kr/5.2.2/widget.html';
29
+
30
+ type PaymentResult = 'DONE' | 'ERROR' | 'CANCEL' | 'NONE';
31
+
32
+ interface BootpayWidgetState {
33
+ isFullScreen: boolean;
34
+ widgetHeight: number;
35
+ isReady: boolean;
36
+ paymentResult: PaymentResult;
37
+ screenWidth: number;
38
+ screenHeight: number;
39
+ isSwiping: boolean; // 스와이프 중인지 여부
40
+ }
41
+
42
+ /**
43
+ * BootpayWidget - 인라인 위젯 방식의 결제 컴포넌트
44
+ *
45
+ * Flutter의 BootpayWebView + BootpayHeroWebView와 동일한 동작:
46
+ * 1. 화면에 작은 WebView 위젯으로 표시 (widget.html 로드)
47
+ * 2. 사용자가 결제수단 선택 및 약관 동의
48
+ * 3. requestPayment() 호출 시 같은 WebView가 전체화면으로 전환
49
+ * 4. 결제 완료/취소/에러 시 위젯 상태로 복귀
50
+ * 5. 위젯 리로드하여 다시 결제 준비 상태로
51
+ *
52
+ * 핵심: 동일한 WebView 인스턴스를 유지하여 결제 상태 보존
53
+ */
54
+ export class BootpayWidget extends Component<
55
+ BootpayWidgetProps,
56
+ BootpayWidgetState
57
+ > {
58
+ webView: React.RefObject<WebView>;
59
+ payload?: WidgetPayload;
60
+ backHandler?: NativeEventSubscription;
61
+ animatedTop: Animated.Value;
62
+ animatedLeft: Animated.Value;
63
+ animatedWidth: Animated.Value;
64
+ animatedHeight: Animated.Value;
65
+ initialScript: string; // injectedJavaScript prop에 사용할 초기 스크립트
66
+ panGesture: ReturnType<typeof Gesture.Pan>; // iOS 스와이프 백 제스처용
67
+ swipeAnimatedValue: Animated.Value; // 스와이프 애니메이션 값
68
+ isValidSwipe: boolean = false; // 유효한 스와이프 여부
69
+ swipeStartX: number = 0; // 스와이프 시작 X 좌표
70
+
71
+ constructor(props: BootpayWidgetProps) {
72
+ super(props);
73
+ this.webView = React.createRef();
74
+
75
+ const { width, height } = Dimensions.get('window');
76
+ const initialHeight = props.height || 200;
77
+
78
+ // 애니메이션 값 초기화 (위젯 모드 크기)
79
+ this.animatedTop = new Animated.Value(0);
80
+ this.animatedLeft = new Animated.Value(0);
81
+ this.animatedWidth = new Animated.Value(width);
82
+ this.animatedHeight = new Animated.Value(initialHeight);
83
+ this.swipeAnimatedValue = new Animated.Value(0);
84
+
85
+ this.state = {
86
+ isFullScreen: false,
87
+ widgetHeight: initialHeight,
88
+ isReady: false,
89
+ paymentResult: 'NONE',
90
+ screenWidth: width,
91
+ screenHeight: height,
92
+ isSwiping: false,
93
+ };
94
+
95
+ // 초기 JavaScript 스크립트 생성 (Bootpay.tsx와 동일한 패턴)
96
+ this.initialScript = this.buildInitialScript();
97
+
98
+ // iOS 스와이프 백 제스처 설정 (react-native-gesture-handler 사용)
99
+ this.panGesture = Gesture.Pan()
100
+ .activeOffsetX(10) // 오른쪽으로 10px 이상 이동해야 활성화
101
+ .failOffsetX(-10) // 왼쪽으로 이동하면 실패
102
+ .failOffsetY([-20, 20]) // 위아래로 20px 이상 이동하면 실패
103
+ .onBegin((event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
104
+ // 시작 위치 저장, 왼쪽 70px 영역에서만 유효
105
+ this.swipeStartX = event.x;
106
+ this.isValidSwipe = event.x < 70;
107
+ if (DEBUG_MODE) {
108
+ console.log('[Gesture] onBegin x:', event.x, 'isValid:', this.isValidSwipe);
109
+ }
110
+ })
111
+ .onStart(() => {
112
+ if (this.isValidSwipe) {
113
+ this.setState({ isSwiping: true });
114
+ }
115
+ })
116
+ .onUpdate((event: GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
117
+ // 유효한 스와이프이고 오른쪽으로 이동할 때만 애니메이션
118
+ if (this.isValidSwipe && event.translationX > 0) {
119
+ this.swipeAnimatedValue.setValue(event.translationX);
120
+ }
121
+ })
122
+ .onEnd((event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
123
+ if (!this.isValidSwipe) {
124
+ this.setState({ isSwiping: false });
125
+ return;
126
+ }
127
+ const { screenWidth } = this.state;
128
+ const translationX = event.translationX || 0;
129
+
130
+ if (DEBUG_MODE) {
131
+ console.log('[Gesture] onEnd translationX:', translationX, 'threshold:', screenWidth * 0.4);
132
+ }
133
+
134
+ // 화면의 40% 이상 스와이프하면 닫기
135
+ if (translationX > screenWidth * 0.4) {
136
+ // 1. 먼저 위젯 reload 시작 (백그라운드에서 로드)
137
+ this._widgetRendered = false;
138
+ this.callJavaScript(`window.location.href = '${WIDGET_URL}';`);
139
+
140
+ // 2. 화면 밖으로 슬라이드 애니메이션
141
+ Animated.timing(this.swipeAnimatedValue, {
142
+ toValue: screenWidth,
143
+ duration: 200,
144
+ useNativeDriver: true,
145
+ }).start(() => {
146
+ // 3. 애니메이션 완료 후 위젯 모드로 전환
147
+ this.swipeAnimatedValue.setValue(0);
148
+ this.setState({ isSwiping: false, isFullScreen: false, isReady: false });
149
+ this._isProcessingPayment = false;
150
+ this.removeBackHandler();
151
+
152
+ // onClose 콜백 호출
153
+ if (this.props.onClose) {
154
+ this.props.onClose();
155
+ }
156
+ });
157
+ } else {
158
+ // 원위치로 복귀
159
+ Animated.spring(this.swipeAnimatedValue, {
160
+ toValue: 0,
161
+ useNativeDriver: true,
162
+ }).start(() => {
163
+ this.setState({ isSwiping: false });
164
+ });
165
+ }
166
+ this.isValidSwipe = false;
167
+ })
168
+ .onFinalize(() => {
169
+ // 제스처가 취소되거나 종료될 때 정리
170
+ if (this.state.isSwiping) {
171
+ Animated.spring(this.swipeAnimatedValue, {
172
+ toValue: 0,
173
+ useNativeDriver: true,
174
+ }).start(() => {
175
+ this.setState({ isSwiping: false });
176
+ });
177
+ }
178
+ this.isValidSwipe = false;
179
+ });
180
+ }
181
+
182
+ // iOS/Android 모두 지원하는 브릿지 헬퍼 함수 (Flutter와 동일한 패턴)
183
+ // window 전역에 등록하여 이후 callJavaScript에서도 사용 가능
184
+ getBridgeHelperJS = (): string => {
185
+ return `
186
+ window.bridgePost = function(message) {
187
+ // iOS WebKit MessageHandler
188
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.BootpayRNWebView) {
189
+ window.webkit.messageHandlers.BootpayRNWebView.postMessage(message);
190
+ }
191
+ // Android JavascriptInterface or WebMessageListener
192
+ else if (window.BootpayRNWebView && window.BootpayRNWebView.postMessage) {
193
+ window.BootpayRNWebView.postMessage(String(message));
194
+ }
195
+ // Fallback: ReactNativeWebView (기본 react-native-webview)
196
+ else if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
197
+ window.ReactNativeWebView.postMessage(String(message));
198
+ }
199
+ };
200
+ var bridgePost = window.bridgePost;
201
+ `;
202
+ };
203
+
204
+ // 초기 스크립트 빌드 - injectedJavaScript prop에 사용
205
+ // Flutter와 동일: bridgeHelper와 waitForBootpayWidget만 정의, widgetReady만 전송
206
+ // 이벤트 리스너는 renderWidget에서 등록 (Flutter _getRenderWidgetJS 패턴)
207
+ buildInitialScript = (): string => {
208
+ return `
209
+ (function() {
210
+ console.log('[BootpayWidget JS] Initial script started');
211
+
212
+ // iOS/Android 브릿지 헬퍼 함수 정의 (전역으로 등록)
213
+ ${this.getBridgeHelperJS()}
214
+
215
+ // waitForBootpayWidget 함수 정의 (전역으로 등록)
216
+ ${this.getWaitForBootpayWidgetJS()}
217
+
218
+ // BootpayWidget이 준비되면 widgetReady 이벤트만 전송
219
+ // 이벤트 리스너는 renderWidget에서 등록됨 (Flutter 패턴)
220
+ waitForBootpayWidget(function() {
221
+ console.log('[BootpayWidget JS] BootpayWidget SDK loaded');
222
+ try {
223
+ window.bridgePost(JSON.stringify({event:'widgetReady'}));
224
+ console.log('[BootpayWidget JS] Sent widgetReady message');
225
+ } catch(e) {
226
+ console.error('[BootpayWidget JS] postMessage error:', e);
227
+ }
228
+ });
229
+ })();
230
+ true;
231
+ `;
232
+ };
233
+
234
+ componentDidMount() {
235
+ if (DEBUG_MODE) {
236
+ console.log('[BootpayWidget] ===== componentDidMount =====');
237
+ console.log('[BootpayWidget] WIDGET_URL:', WIDGET_URL);
238
+ }
239
+
240
+ this.closeDismiss = debounce(this.closeDismiss, 30);
241
+
242
+ // 화면 크기 변경 감지
243
+ const subscription = Dimensions.addEventListener('change', ({ window }) => {
244
+ this.setState({
245
+ screenWidth: window.width,
246
+ screenHeight: window.height,
247
+ });
248
+ });
249
+
250
+ // cleanup을 위해 저장
251
+ this._dimensionsSubscription = subscription;
252
+ }
253
+
254
+ _dimensionsSubscription?: { remove: () => void };
255
+
256
+ componentWillUnmount() {
257
+ this.removeBackHandler();
258
+ this._dimensionsSubscription?.remove();
259
+ UserInfo.setBootpayLastTime(Date.now());
260
+ }
261
+
262
+ // Back button handler for Android
263
+ setupBackHandler = () => {
264
+ if (Platform.OS === 'android') {
265
+ this.backHandler = BackHandler.addEventListener(
266
+ 'hardwareBackPress',
267
+ () => {
268
+ if (this.state.isFullScreen) {
269
+ this.revertToWidget();
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+ );
275
+ }
276
+ };
277
+
278
+ removeBackHandler = () => {
279
+ if (this.backHandler) {
280
+ this.backHandler.remove();
281
+ this.backHandler = undefined;
282
+ }
283
+ };
284
+
285
+ // Widget을 전체화면으로 전환 - Flutter의 _expandToFullscreen과 동일
286
+ goFullScreen = () => {
287
+ console.log('[Fullscreen] >>> goFullScreen called');
288
+ this.setupBackHandler(); // Android 백 버튼 핸들러 등록
289
+ this.setState({ isFullScreen: true, paymentResult: 'NONE' });
290
+ };
291
+
292
+ // 전체화면에서 Widget으로 복귀 - iOS Swift SDK의 collapseToOriginal/collapseAndReload와 동일
293
+ revertToWidget = (shouldReload: boolean = true) => {
294
+ console.log(
295
+ '[Fullscreen] <<< revertToWidget called, paymentResult:',
296
+ this.state.paymentResult,
297
+ 'shouldReload:',
298
+ shouldReload
299
+ );
300
+
301
+ const { paymentResult } = this.state;
302
+
303
+ this._widgetRendered = false;
304
+ this._isProcessingPayment = false;
305
+ this.removeBackHandler(); // Android 백 버튼 핸들러 해제
306
+
307
+ // iOS Swift SDK와 동일: 축소 시작 전에 위젯 URL을 먼저 로드
308
+ // 이렇게 하면 축소 애니메이션 중에 위젯이 렌더링되어 자연스러운 전환이 됩니다.
309
+ if (shouldReload) {
310
+ console.log(
311
+ '[Fullscreen] reloading widget URL before collapse animation'
312
+ );
313
+ this.reloadWidget();
314
+ }
315
+
316
+ // 축소 (약간의 딜레이 후 - 위젯 로드 시작 후 축소)
317
+ setTimeout(() => {
318
+ this.setState({ isFullScreen: false, isReady: false });
319
+
320
+ if (paymentResult === 'NONE') {
321
+ if (this.props.onCancel) {
322
+ this.props.onCancel({
323
+ action: 'BootpayCancel',
324
+ status: -100,
325
+ message: '사용자에 의한 취소',
326
+ });
327
+ }
328
+ }
329
+
330
+ this.setState({ paymentResult: 'NONE' });
331
+ }, 100);
332
+ };
333
+
334
+ closeDismiss = () => {
335
+ console.log('[Fullscreen] closeDismiss called');
336
+ if (this.state.isFullScreen) {
337
+ this.revertToWidget();
338
+ }
339
+ if (this.props.onClose) {
340
+ this.props.onClose();
341
+ }
342
+ };
343
+
344
+ // WebView에 JavaScript 실행
345
+ callJavaScript = (script: string) => {
346
+ if (DEBUG_MODE) {
347
+ console.log('[BootpayWidget] callJavaScript:', script.substring(0, 200));
348
+ }
349
+ this.webView.current?.injectJavaScript(
350
+ `setTimeout(function() { ${script} }, 30); true;`
351
+ );
352
+ };
353
+
354
+ // Widget 리로드 - WebView에서 위젯 URL을 다시 로드하고 BootpayWidget.render 재실행
355
+ reloadWidget = () => {
356
+ console.log('[BootpayWidget] reloadWidget called');
357
+
358
+ // 리로드 시 상태 리셋
359
+ this._widgetRendered = false;
360
+ this.setState({ isReady: false });
361
+
362
+ // JavaScript로 페이지 리로드 (iOS에서 더 안정적)
363
+ this.callJavaScript(`window.location.href = '${WIDGET_URL}';`);
364
+ };
365
+
366
+ // Widget 업데이트
367
+ updateWidget = (payload: WidgetPayload, refresh: boolean = false) => {
368
+ this.payload = payload;
369
+ this.callJavaScript(
370
+ `BootpayWidget.update(${JSON.stringify(payload)}, ${refresh});`
371
+ );
372
+ };
373
+
374
+ // 위젯이 이미 렌더링되었는지 추적 (무한 루프 방지)
375
+ _widgetRendered: boolean = false;
376
+
377
+ // Widget 렌더링 (초기 설정) - 반드시 먼저 호출해야 함
378
+ // Flutter의 _renderWidget과 동일한 패턴:
379
+ // 1. setDevice/setVersion을 별도 JS 호출로 먼저 실행 (_runDeviceSetup)
380
+ // 2. render 스크립트에서 waitForBootpayWidget 내부에 이벤트 리스너 + BootpayWidget.render
381
+ renderWidget = (payload: WidgetPayload) => {
382
+ // 이미 렌더링된 경우 무시 (무한 루프 방지)
383
+ if (this._widgetRendered) {
384
+ if (DEBUG_MODE) {
385
+ console.log('[BootpayWidget] renderWidget ignored - already rendered');
386
+ }
387
+ return;
388
+ }
389
+
390
+ if (DEBUG_MODE) {
391
+ console.log('[BootpayWidget] ===== renderWidget called =====');
392
+ console.log('[BootpayWidget] isReady:', this.state.isReady);
393
+ console.log(
394
+ '[BootpayWidget] payload:',
395
+ JSON.stringify(payload).substring(0, 200)
396
+ );
397
+ }
398
+
399
+ this.payload = payload;
400
+ payload.application_id =
401
+ Platform.OS === 'ios'
402
+ ? this.props.ios_application_id
403
+ : this.props.android_application_id;
404
+
405
+ UserInfo.updateInfo();
406
+
407
+ // WebView가 이미 로드되었으면 Flutter 패턴으로 렌더링
408
+ if (this.state.isReady) {
409
+ if (DEBUG_MODE) {
410
+ console.log('[BootpayWidget] isReady=true, injecting render script');
411
+ console.log(
412
+ '[BootpayWidget] payload.application_id:',
413
+ payload.application_id
414
+ );
415
+ console.log('[BootpayWidget] Full payload:', JSON.stringify(payload));
416
+ }
417
+
418
+ // 렌더링 완료 플래그 설정
419
+ this._widgetRendered = true;
420
+
421
+ // Flutter의 _runDeviceSetup()과 동일: setDevice, setVersion을 별도 JS 호출로 먼저 실행
422
+ this.runDeviceSetup();
423
+
424
+ // Flutter의 _getRenderWidgetJS()와 동일: waitForBootpayWidget 내부에 이벤트 리스너 + render
425
+ const renderScript = this.getRenderWidgetScript();
426
+ if (DEBUG_MODE) {
427
+ console.log('[BootpayWidget] Render script:', renderScript);
428
+ }
429
+ this.callJavaScript(renderScript);
430
+ } else {
431
+ if (DEBUG_MODE) {
432
+ console.log(
433
+ '[BootpayWidget] isReady=false, will render on next onLoadEnd'
434
+ );
435
+ console.log(
436
+ '[BootpayWidget] payload.application_id:',
437
+ payload.application_id
438
+ );
439
+ console.log('[BootpayWidget] Full payload:', JSON.stringify(payload));
440
+ }
441
+ }
442
+ };
443
+
444
+ // Flutter의 _runDeviceSetup()과 동일
445
+ // setDevice, setVersion을 별도 JavaScript 호출로 실행
446
+ runDeviceSetup = () => {
447
+ if (DEBUG_MODE) {
448
+ console.log(
449
+ '[BootpayWidget] runDeviceSetup - calling setDevice/setVersion'
450
+ );
451
+ }
452
+ // Flutter: _webViewController.runJavaScript("Bootpay.setDevice('...');");
453
+ // Flutter: _webViewController.runJavaScript("Bootpay.setVersion('...', '...');");
454
+ this.callJavaScript(this.getBootpayPlatform());
455
+ this.callJavaScript(this.getSDKVersion());
456
+ };
457
+
458
+ // Flutter의 _getRenderWidgetJS()와 완전히 동일한 구조
459
+ // waitForBootpayWidget과 bridgeHelper를 포함하고, 콜백 내부에서 이벤트 리스너 등록 후 BootpayWidget.render 호출
460
+ getRenderWidgetScript = (): string => {
461
+ const widgetPayload = this.getWidgetPayloadForRender();
462
+ const payloadJson = JSON.stringify(widgetPayload);
463
+
464
+ // Flutter _getRenderWidgetJS와 동일: waitForBootpayWidget + bridgeHelper + 이벤트 리스너 + render
465
+ // 이벤트 리스너 중복 등록 방지를 위해 플래그 사용
466
+ return `
467
+ // Flutter waitForBootpayWidget과 동일
468
+ ${this.getWaitForBootpayWidgetJS()}
469
+
470
+ // Flutter bridgeHelper와 동일
471
+ ${this.getBridgeHelperJS()}
472
+
473
+ console.log('[WebView JS] renderWidget script started');
474
+ waitForBootpayWidget(function() {
475
+ console.log('[WebView JS] waitForBootpayWidget callback');
476
+
477
+ // 이벤트 리스너 중복 등록 방지
478
+ // 주의: readyWatch는 여기서 등록하지 않음 (무한 루프 방지)
479
+ // widgetReady는 buildInitialScript에서 한 번만 전송됨
480
+ if (!window._bootpayListenersRegistered) {
481
+ window._bootpayListenersRegistered = true;
482
+ console.log('[WebView JS] Registering event listeners');
483
+ ${this.resizeWatch()}
484
+ ${this.changeMethodWatch()}
485
+ ${this.changeTermsWatch()}
486
+ ${this.closeEventHandler()}
487
+ console.log('[WebView JS] Event listeners registered');
488
+ } else {
489
+ console.log('[WebView JS] Event listeners already registered, skipping');
490
+ }
491
+
492
+ console.log('[WebView JS] Calling BootpayWidget.render');
493
+
494
+ // BootpayWidget.render 호출
495
+ BootpayWidget.render('#bootpay-widget', ${payloadJson});
496
+ console.log('[WebView JS] BootpayWidget.render called');
497
+ });
498
+ `;
499
+ };
500
+
501
+ // 결제 요청 (Widget에서 전체화면으로 전환 후 결제)
502
+ // Flutter의 requestPayment와 동일: 같은 WebView에서 바로 결제 진행
503
+ requestPayment = async (
504
+ payload?: Payload,
505
+ items?: Item[],
506
+ user?: User,
507
+ extra?: Extra
508
+ ) => {
509
+ console.log('[Fullscreen] requestPayment called');
510
+
511
+ // payload 업데이트
512
+ if (payload) {
513
+ payload.application_id =
514
+ Platform.OS === 'ios'
515
+ ? this.props.ios_application_id
516
+ : this.props.android_application_id;
517
+ if (items) payload.items = items;
518
+ if (user) payload.user = Object.assign(new User(), user);
519
+ if (extra) payload.extra = Object.assign(new Extra(), extra);
520
+ }
521
+
522
+ const payloadToUse = payload || this.payload;
523
+ this._isProcessingPayment = true; // 결제 처리 중 플래그
524
+
525
+ // 전체화면으로 전환
526
+ this.goFullScreen();
527
+
528
+ // Flutter와 동일: 약간의 딜레이 후 기존 WebView에서 결제 요청
529
+ // 새 WebView가 아닌 기존 WebView이므로 위젯 선택 상태가 보존됨
530
+ setTimeout(() => {
531
+ this.executeRequestPayment(payloadToUse);
532
+ }, 400);
533
+ };
534
+
535
+ // 기존 WebView에서 결제 요청 실행 (Flutter의 _executeRequestPayment와 동일)
536
+ executeRequestPayment = (payload?: Payload | WidgetPayload | null) => {
537
+ if (!payload) {
538
+ console.log('[Fullscreen] executeRequestPayment - payload is null');
539
+ return;
540
+ }
541
+
542
+ console.log('[Fullscreen] executeRequestPayment called');
543
+
544
+ // Device/Version 설정 (Flutter와 동일)
545
+ this.callJavaScript(this.getBootpayPlatform());
546
+ this.callJavaScript(this.getSDKVersion());
547
+
548
+ // 결제 요청 스크립트 (Flutter의 _getRequestPaymentScript와 동일)
549
+ const requestScript = `
550
+ ${this.getBridgeHelperJS()}
551
+ BootpayWidget.requestPayment(${JSON.stringify(
552
+ this.getRequestPaymentPayload(payload)
553
+ )})
554
+ .then(function(res) {
555
+ console.log('[BootpayWidget] requestPayment response:', res);
556
+ ${this.confirmEventHandler()}
557
+ ${this.issuedEventHandler()}
558
+ ${this.doneEventHandler()}
559
+ }, function(res) {
560
+ console.log('[BootpayWidget] requestPayment error:', res);
561
+ ${this.errorEventHandler()}
562
+ ${this.cancelEventHandler()}
563
+ });
564
+ `;
565
+ this.callJavaScript(requestScript);
566
+ };
567
+
568
+ // Flutter의 _getRequestPaymentJson과 동일한 구조
569
+ // render 시 설정된 값 외에 결제 요청에 필요한 정보만 전달
570
+ getRequestPaymentPayload = (
571
+ payload: Payload | WidgetPayload
572
+ ): Record<string, unknown> => {
573
+ const result: Record<string, unknown> = {};
574
+
575
+ // 주문 정보
576
+ if ('order_name' in payload && payload.order_name)
577
+ result.order_name = payload.order_name;
578
+ if ('order_id' in payload && payload.order_id)
579
+ result.order_id = payload.order_id;
580
+ if ('metadata' in payload && payload.metadata)
581
+ result.metadata = payload.metadata;
582
+
583
+ // Extra (결제 요청용)
584
+ const extra: Record<string, unknown> = {};
585
+ const payloadExtra = 'extra' in payload ? payload.extra : undefined;
586
+ if (payloadExtra) {
587
+ if ('app_scheme' in payloadExtra && payloadExtra.app_scheme)
588
+ extra.app_scheme = payloadExtra.app_scheme;
589
+ extra.show_close_button =
590
+ 'show_close_button' in payloadExtra
591
+ ? payloadExtra.show_close_button
592
+ : false;
593
+ extra.display_success_result =
594
+ 'display_success_result' in payloadExtra
595
+ ? payloadExtra.display_success_result
596
+ : false;
597
+ extra.display_error_result =
598
+ 'display_error_result' in payloadExtra
599
+ ? payloadExtra.display_error_result
600
+ : true;
601
+ if (
602
+ 'separately_confirmed' in payloadExtra &&
603
+ payloadExtra.separately_confirmed
604
+ ) {
605
+ extra.separately_confirmed = true;
606
+ }
607
+ }
608
+ // redirect_url (기본값: https://api.bootpay.co.kr/v2/callback)
609
+ extra.redirect_url =
610
+ payloadExtra &&
611
+ 'redirect_url' in payloadExtra &&
612
+ payloadExtra.redirect_url
613
+ ? payloadExtra.redirect_url
614
+ : 'https://api.bootpay.co.kr/v2/callback';
615
+ // use_bootpay_inapp_sdk: native app에서 redirect를 완성도있게 지원하기 위한 옵션 (기본값: true)
616
+ extra.use_bootpay_inapp_sdk =
617
+ payloadExtra && 'use_bootpay_inapp_sdk' in payloadExtra
618
+ ? payloadExtra.use_bootpay_inapp_sdk
619
+ : true;
620
+ result.extra = extra;
621
+
622
+ // User
623
+ if ('user' in payload && payload.user) {
624
+ const user: Record<string, unknown> = {};
625
+ if (payload.user.id) user.id = payload.user.id;
626
+ if (payload.user.username) user.username = payload.user.username;
627
+ if (payload.user.email) user.email = payload.user.email;
628
+ if (payload.user.phone) user.phone = payload.user.phone;
629
+ if (Object.keys(user).length > 0) result.user = user;
630
+ }
631
+
632
+ // Items
633
+ if ('items' in payload && payload.items && payload.items.length > 0) {
634
+ result.items = payload.items;
635
+ }
636
+
637
+ return result;
638
+ };
639
+
640
+ // 결제 처리 중 플래그 (close 이벤트 무시용)
641
+ _isProcessingPayment: boolean = false;
642
+
643
+ // 위젯에서 선택한 결제 정보 (pg, method 등)
644
+ _widgetData: WidgetData | null = null;
645
+
646
+ transactionConfirm = () => {
647
+ const script = `
648
+ Bootpay.confirm()
649
+ .then(function(res) {
650
+ ${this.confirmEventHandler()}
651
+ ${this.issuedEventHandler()}
652
+ ${this.doneEventHandler()}
653
+ }, function(res) {
654
+ ${this.errorEventHandler()}
655
+ ${this.cancelEventHandler()}
656
+ });
657
+ `;
658
+ this.callJavaScript(script);
659
+ };
660
+
661
+ // Event handlers for JavaScript (window.bridgePost 사용)
662
+ confirmEventHandler = () => {
663
+ return "if (res.event === 'confirm') { window.bridgePost(JSON.stringify(res)); }";
664
+ };
665
+
666
+ doneEventHandler = () => {
667
+ return "else if (res.event === 'done') { window.bridgePost(JSON.stringify(res)); }";
668
+ };
669
+
670
+ issuedEventHandler = () => {
671
+ return "else if (res.event === 'issued') { window.bridgePost(JSON.stringify(res)); }";
672
+ };
673
+
674
+ errorEventHandler = () => {
675
+ return "if (res.event === 'error') { window.bridgePost(JSON.stringify(res)); }";
676
+ };
677
+
678
+ cancelEventHandler = () => {
679
+ return "else if (res.event === 'cancel') { window.bridgePost(JSON.stringify(res)); }";
680
+ };
681
+
682
+ closeEventHandler = () => {
683
+ return "document.addEventListener('bootpayclose', function(e) { window.bridgePost(JSON.stringify({event:'close'})); });";
684
+ };
685
+
686
+ // Widget event handlers (window.bridgePost 사용)
687
+ readyWatch = () => {
688
+ return "document.addEventListener('bootpay-widget-ready', function(e) { window.bridgePost(JSON.stringify({event:'widgetReady', detail: e.detail})); });";
689
+ };
690
+
691
+ resizeWatch = () => {
692
+ return "document.addEventListener('bootpay-widget-resize', function(e) { window.bridgePost(JSON.stringify({event:'widgetResize', detail: e.detail})); });";
693
+ };
694
+
695
+ changeMethodWatch = () => {
696
+ return "document.addEventListener('bootpay-widget-change-payment', function(e) { window.bridgePost(JSON.stringify({event:'widgetChangePayment', detail: e.detail})); });";
697
+ };
698
+
699
+ changeTermsWatch = () => {
700
+ return "document.addEventListener('bootpay-widget-change-terms', function(e) { window.bridgePost(JSON.stringify({event:'widgetChangeTerms', detail: e.detail})); });";
701
+ };
702
+
703
+ getSDKVersion = () => {
704
+ return `Bootpay.setVersion('${SDK_VERSION}', 'react_native');`;
705
+ };
706
+
707
+ getEnvironmentMode = () => {
708
+ return DEBUG_MODE ? "BootpayWidget.setEnvironmentMode('development');" : '';
709
+ };
710
+
711
+ getBootpayPlatform = () => {
712
+ return Platform.OS === 'ios'
713
+ ? "Bootpay.setDevice('IOS');"
714
+ : "Bootpay.setDevice('ANDROID');";
715
+ };
716
+
717
+ // Flutter의 waitForBootpayWidget 패턴 - BootpayWidget이 준비될 때까지 폴링
718
+ getWaitForBootpayWidgetJS = () => {
719
+ return `
720
+ function waitForBootpayWidget(callback) {
721
+ if (typeof BootpayWidget !== 'undefined') {
722
+ callback();
723
+ } else {
724
+ setTimeout(function() { waitForBootpayWidget(callback); }, 50);
725
+ }
726
+ }
727
+ `;
728
+ };
729
+
730
+ // Flutter SDK의 Payload.toString()과 동일한 형식으로 변환
731
+ // widget_key → key, widget_sandbox → sandbox, widget_use_terms → use_terms
732
+ // widgetKey가 있으면 widget: 1, use_bootpay_inapp_sdk: true 추가
733
+ getWidgetPayloadForRender = (): Record<string, unknown> => {
734
+ if (!this.payload) return {};
735
+
736
+ const payload = this.payload;
737
+ const result: Record<string, unknown> = {};
738
+
739
+ // application_id
740
+ if (payload.application_id) result.application_id = payload.application_id;
741
+
742
+ // 기본 필드
743
+ if (payload.pg) result.pg = payload.pg;
744
+ if (payload.method) result.method = payload.method;
745
+ if (payload.methods && payload.methods.length > 0) {
746
+ result.method = payload.methods;
747
+ }
748
+ if (payload.order_name) result.order_name = payload.order_name;
749
+ if (payload.price !== undefined) result.price = payload.price;
750
+ if (payload.tax_free !== undefined) result.tax_free = payload.tax_free;
751
+ if (payload.order_id) result.order_id = payload.order_id;
752
+ if (payload.subscription_id)
753
+ result.subscription_id = payload.subscription_id;
754
+ if (payload.metadata) result.metadata = payload.metadata;
755
+
756
+ // extra
757
+ if (payload.extra) result.extra = payload.extra;
758
+
759
+ // Widget 전용 필드 (Flutter와 동일한 필드명으로 변환)
760
+ // widget_use_terms → use_terms
761
+ if (payload.widget_use_terms !== undefined) {
762
+ result.use_terms = payload.widget_use_terms;
763
+ }
764
+ // widget_sandbox → sandbox
765
+ if (payload.widget_sandbox !== undefined) {
766
+ result.sandbox = payload.widget_sandbox;
767
+ }
768
+ // widget_key → key
769
+ if (payload.widget_key) {
770
+ result.key = payload.widget_key;
771
+ // Flutter: widgetKey가 있으면 widget: 1, use_bootpay_inapp_sdk: true 추가
772
+ result.widget = 1;
773
+ result.use_bootpay_inapp_sdk = true;
774
+ }
775
+
776
+ return result;
777
+ };
778
+
779
+ getRenderWidgetJS = () => {
780
+ if (!this.payload) return '';
781
+ const widgetPayload = this.getWidgetPayloadForRender();
782
+ return `BootpayWidget.render('#bootpay-widget', ${JSON.stringify(
783
+ widgetPayload
784
+ )});`;
785
+ };
786
+
787
+ // WebView 로드 완료 - injectedJavaScript가 실행됨
788
+ // Flutter의 onPageFinished와 동일한 역할
789
+ // 주의: 실제 렌더링은 onWidgetReady → renderWidget() 호출에서 수행됨
790
+ onLoadEnd = async () => {
791
+ if (DEBUG_MODE) {
792
+ console.log('[BootpayWidget] ===== onLoadEnd called =====');
793
+ console.log('[BootpayWidget] payload:', this.payload ? 'set' : 'null');
794
+ console.log('[BootpayWidget] isReady:', this.state.isReady);
795
+ console.log(
796
+ '[BootpayWidget] webView ref:',
797
+ this.webView.current ? 'exists' : 'null'
798
+ );
799
+ }
800
+
801
+ // injectedJavaScript가 waitForBootpayWidget을 통해 widgetReady를 전송함
802
+ // widgetReady 수신 후 사용자가 onWidgetReady 콜백에서 renderWidget()을 호출
803
+ // 따라서 여기서는 추가 렌더링 작업을 하지 않음 (Flutter 패턴과 동일)
804
+ };
805
+
806
+ // WebView 메시지 핸들러
807
+ onMessage = async (event: WebViewMessageEvent) => {
808
+ if (DEBUG_MODE) {
809
+ console.log('[BootpayWidget] ===== onMessage received =====');
810
+ console.log('[BootpayWidget] raw data:', event?.nativeEvent?.data);
811
+ }
812
+
813
+ if (!event) return;
814
+
815
+ try {
816
+ const data = JSON.parse(event.nativeEvent.data);
817
+
818
+ if (DEBUG_MODE) {
819
+ console.log('[BootpayWidget] parsed data:', JSON.stringify(data));
820
+ }
821
+
822
+ switch (data.event) {
823
+ // Widget events
824
+ case 'widgetReady':
825
+ // setState 콜백을 사용하여 isReady가 true로 업데이트된 후에 onWidgetReady 호출
826
+ this.setState({ isReady: true }, () => {
827
+ // Flutter와 동일: widgetReady는 항상 콜백 호출
828
+ // 결제 요청은 requestPayment()에서 setTimeout으로 처리
829
+ if (this.props.onWidgetReady) {
830
+ this.props.onWidgetReady();
831
+ }
832
+ });
833
+ break;
834
+
835
+ case 'widgetResize':
836
+ if (data.detail?.height) {
837
+ const height = parseFloat(data.detail.height);
838
+ // 높이가 실제로 변경된 경우에만 업데이트 (무한 루프 방지)
839
+ if (Math.abs(height - this.state.widgetHeight) > 1) {
840
+ if (DEBUG_MODE) {
841
+ console.log(
842
+ '[BootpayWidget] Height changed:',
843
+ this.state.widgetHeight,
844
+ '->',
845
+ height
846
+ );
847
+ }
848
+ this.setState({ widgetHeight: height });
849
+ // 위젯 모드일 때만 높이 애니메이션 적용
850
+ if (!this.state.isFullScreen) {
851
+ Animated.timing(this.animatedHeight, {
852
+ toValue: height,
853
+ duration: 100,
854
+ useNativeDriver: false,
855
+ }).start();
856
+ }
857
+ if (this.props.onWidgetResize) {
858
+ this.props.onWidgetResize(height);
859
+ }
860
+ }
861
+ }
862
+ break;
863
+
864
+ case 'widgetChangePayment':
865
+ {
866
+ const widgetData: WidgetData = data.detail || null;
867
+ // 위젯 데이터 저장 (pg, method 포함) - Flutter _handleChangePayment와 동일
868
+ this._widgetData = widgetData;
869
+ if (this.props.onWidgetChangePayment) {
870
+ this.props.onWidgetChangePayment(widgetData);
871
+ }
872
+ }
873
+ break;
874
+
875
+ case 'widgetChangeTerms':
876
+ {
877
+ const widgetData: WidgetData = data.detail || null;
878
+ // 위젯 데이터 저장 - Flutter _handleChangeAgreeTerm와 동일
879
+ this._widgetData = widgetData;
880
+ if (this.props.onWidgetChangeTerms) {
881
+ this.props.onWidgetChangeTerms(widgetData);
882
+ }
883
+ }
884
+ break;
885
+
886
+ // Full screen transition events (from web)
887
+ case 'bootpayWidgetFullSizeScreen':
888
+ this.goFullScreen();
889
+ break;
890
+
891
+ case 'bootpayWidgetRevertScreen':
892
+ this.revertToWidget();
893
+ break;
894
+
895
+ // Payment events
896
+ case 'cancel':
897
+ this._isProcessingPayment = false; // 결제 처리 완료
898
+ this.setState({ paymentResult: 'CANCEL' });
899
+ if (this.props.onCancel) {
900
+ this.props.onCancel(data);
901
+ }
902
+ this.closeDismiss();
903
+ break;
904
+
905
+ case 'error':
906
+ this._isProcessingPayment = false; // 결제 처리 완료
907
+ this.setState({ paymentResult: 'ERROR' });
908
+ if (this.props.onError) {
909
+ this.props.onError(data);
910
+ }
911
+ this.closeDismiss();
912
+ break;
913
+
914
+ case 'issued':
915
+ this._isProcessingPayment = false; // 결제 처리 완료
916
+ if (this.props.onIssued) {
917
+ this.props.onIssued(data);
918
+ }
919
+ this.closeDismiss();
920
+ break;
921
+
922
+ case 'confirm':
923
+ if (this.props.onConfirm && this.props.onConfirm(data)) {
924
+ this.transactionConfirm();
925
+ }
926
+ break;
927
+
928
+ case 'done':
929
+ this._isProcessingPayment = false; // 결제 처리 완료
930
+ this.setState({ paymentResult: 'DONE' });
931
+ if (this.props.onDone) {
932
+ this.props.onDone(data);
933
+ }
934
+ this.closeDismiss();
935
+ break;
936
+
937
+ case 'close':
938
+ console.log(
939
+ '[Fullscreen] close event received, _isProcessingPayment:',
940
+ this._isProcessingPayment
941
+ );
942
+ if (this._isProcessingPayment) {
943
+ break;
944
+ }
945
+ this.closeDismiss();
946
+ break;
947
+
948
+ default:
949
+ if (DEBUG_MODE) {
950
+ console.warn('Unknown event:', data.event);
951
+ }
952
+ break;
953
+ }
954
+ } catch (error) {
955
+ if (DEBUG_MODE) {
956
+ console.error('Error parsing message:', error);
957
+ }
958
+ }
959
+ };
960
+
961
+ // WebView 렌더링 - 위젯 모드와 전체화면 모드 모두에서 동일한 WebView 사용
962
+ renderWebView = () => {
963
+ return (
964
+ <WebView
965
+ ref={this.webView}
966
+ originWhitelist={['*']}
967
+ source={{ uri: WIDGET_URL }}
968
+ javaScriptEnabled
969
+ javaScriptCanOpenWindowsAutomatically
970
+ useSharedProcessPool={true}
971
+ sharedCookiesEnabled={true}
972
+ injectedJavaScript={this.initialScript}
973
+ onLoadStart={() => {
974
+ if (DEBUG_MODE) {
975
+ console.log('[BootpayWidget] WebView onLoadStart');
976
+ }
977
+ }}
978
+ onLoad={() => {
979
+ if (DEBUG_MODE) {
980
+ console.log('[BootpayWidget] WebView onLoad');
981
+ }
982
+ }}
983
+ onLoadEnd={this.onLoadEnd}
984
+ onMessage={this.onMessage}
985
+ style={styles.webview}
986
+ onError={(syntheticEvent) => {
987
+ const { nativeEvent } = syntheticEvent;
988
+ if (DEBUG_MODE) {
989
+ console.error('[BootpayWidget] WebView error:', nativeEvent);
990
+ }
991
+ if (this.props.onError) {
992
+ this.props.onError({
993
+ event: 'error',
994
+ code: nativeEvent.code,
995
+ message: nativeEvent.description,
996
+ });
997
+ }
998
+ }}
999
+ />
1000
+ );
1001
+ };
1002
+
1003
+ render() {
1004
+ const { isFullScreen, screenWidth, screenHeight } = this.state;
1005
+ const { style, widgetTop } = this.props;
1006
+
1007
+ // Flutter와 동일: 하나의 WebView만 렌더링
1008
+ // isFullScreen에 따라 WebView 컨테이너의 위치/크기만 변경
1009
+ // 새 WebView를 생성하지 않아 위젯 선택 상태가 보존됨
1010
+ // 항상 position: absolute 사용 - 위젯 모드에서는 widgetTop 위치에, 전체화면에서는 top: 0
1011
+
1012
+ // 중요: 항상 GestureHandlerRootView로 감싸야 WebView remount 방지
1013
+ // 조건부 래핑 시 React가 컴포넌트 트리 변경으로 인식하여 WebView를 다시 마운트함
1014
+ // 위젯/전체화면 모두 absolute로 배치하여 일관된 구조 유지
1015
+ return (
1016
+ <GestureHandlerRootView
1017
+ style={[
1018
+ styles.gestureRootBase,
1019
+ isFullScreen
1020
+ ? styles.gestureRootFullScreen
1021
+ : {
1022
+ top: widgetTop || 0,
1023
+ height: this.state.widgetHeight,
1024
+ zIndex: 1,
1025
+ },
1026
+ ]}
1027
+ >
1028
+ <Animated.View
1029
+ style={[
1030
+ styles.container,
1031
+ style,
1032
+ {
1033
+ flex: 1,
1034
+ width: screenWidth,
1035
+ },
1036
+ isFullScreen && {
1037
+ // iOS 스와이프 백 애니메이션
1038
+ transform: [{ translateX: this.swipeAnimatedValue }],
1039
+ },
1040
+ ]}
1041
+ >
1042
+ {isFullScreen && <SafeAreaView style={styles.fullScreenHeader} />}
1043
+ {this.renderWebView()}
1044
+ {/* iOS 스와이프 백: GestureDetector로 왼쪽 가장자리 터치 감지 */}
1045
+ {isFullScreen && Platform.OS === 'ios' && (
1046
+ <GestureDetector gesture={this.panGesture}>
1047
+ <View style={styles.swipeEdge} />
1048
+ </GestureDetector>
1049
+ )}
1050
+ </Animated.View>
1051
+ </GestureHandlerRootView>
1052
+ );
1053
+ }
1054
+ }
1055
+
1056
+ const styles = StyleSheet.create({
1057
+ gestureRootBase: {
1058
+ position: 'absolute',
1059
+ left: 0,
1060
+ right: 0,
1061
+ },
1062
+ gestureRootFullScreen: {
1063
+ top: 0,
1064
+ bottom: 0,
1065
+ zIndex: 9999,
1066
+ },
1067
+ container: {
1068
+ width: '100%',
1069
+ backgroundColor: '#fff',
1070
+ overflow: 'hidden',
1071
+ },
1072
+ hidden: {
1073
+ height: 0,
1074
+ opacity: 0,
1075
+ },
1076
+ fullScreenHeader: {
1077
+ backgroundColor: '#fff',
1078
+ },
1079
+ swipeEdge: {
1080
+ position: 'absolute',
1081
+ left: 0,
1082
+ top: 0,
1083
+ bottom: 0,
1084
+ width: 70, // 70px 영역에서 스와이프 감지
1085
+ backgroundColor: DEBUG_MODE ? 'rgba(255, 0, 0, 0.1)' : 'transparent',
1086
+ },
1087
+ webview: {
1088
+ flex: 1,
1089
+ backgroundColor: DEBUG_MODE ? '#f0f0f0' : 'transparent', // 디버그용 배경색
1090
+ borderWidth: DEBUG_MODE ? 1 : 0,
1091
+ borderColor: 'blue',
1092
+ },
1093
+ });
1094
+
1095
+ export default BootpayWidget;