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.
- package/README.md +37 -0
- package/lib/Bootpay.js +4 -4
- package/lib/BootpayCommerce.js +14 -0
- package/lib/BootpayWidget.js +103 -0
- package/lib/CommerceTypes.js +1 -0
- package/lib/UserInfo.js +1 -1
- package/lib/WidgetTypes.js +1 -0
- package/lib/bootpayAnalytics.js +1 -1
- package/lib/index.js +1 -1
- package/package.json +40 -41
- package/react-native.config.js +1 -59
- package/src/Bootpay.tsx +20 -6
- package/src/BootpayCommerce.tsx +429 -0
- package/src/BootpayTypes.ts +19 -7
- package/src/BootpayWidget.tsx +1095 -0
- package/src/CommerceTypes.ts +123 -0
- package/src/UserInfo.ts +4 -1
- package/src/WidgetTypes.ts +110 -0
- package/src/__tests__/index.test.d.ts +1 -1
- package/src/__tests__/index.test.js +1 -1
- package/src/index.tsx +132 -2
|
@@ -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;
|