react-native-fincra-checkout 1.0.0

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,490 @@
1
+ import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ BackHandler,
6
+ Platform,
7
+ StatusBar,
8
+ StyleSheet,
9
+ Text,
10
+ TouchableOpacity,
11
+ View,
12
+ } from 'react-native';
13
+ import { SafeAreaView } from 'react-native-safe-area-context';
14
+ import { WebView } from 'react-native-webview';
15
+ import type {
16
+ WebViewMessageEvent,
17
+ WebViewErrorEvent,
18
+ WebViewHttpErrorEvent,
19
+ } from 'react-native-webview/lib/WebViewTypes';
20
+ import type {
21
+ InlineCheckoutConfig,
22
+ FincraPaymentError,
23
+ FincraPaymentResponse,
24
+ } from '../types';
25
+ import { generateInlineHtml } from '../inline/htmlGenerator';
26
+ import { FincraBridgeEvent, parseMessage } from '../inline/JsBridge';
27
+
28
+ // ─── FincraInlineCheckout ─────────────────────────────────────────────────────
29
+ //
30
+ // Declarative component for the Inline JavaScript checkout flow.
31
+ // Mirrors InlineCheckout widget in inline_checkout.dart
32
+ //
33
+ // Loads a self-contained HTML page that bootstraps the Fincra JS SDK,
34
+ // communicates results back via window.ReactNativeWebView.postMessage(),
35
+ // and implements a 15-second timeout fallback.
36
+
37
+ /** 15 seconds in milliseconds — mirrors Flutter's `Timer(Duration(seconds: 15), ...)` */
38
+ const INIT_TIMEOUT_MS = 15_000;
39
+
40
+ /**
41
+ * A transparent WebView that runs the Fincra inline JavaScript SDK.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <FincraInlineCheckout
46
+ * publicKey="pk_live_xxxx"
47
+ * amount={5000}
48
+ * currency="NGN"
49
+ * customerEmail="user@example.com"
50
+ * customerName="John Doe"
51
+ * customerPhoneNumber="08012345678"
52
+ * feeBearer="customer"
53
+ * onSuccess={(res) => console.log(res.reference)}
54
+ * onCancelled={() => navigation.goBack()}
55
+ * />
56
+ * ```
57
+ */
58
+ export function FincraInlineCheckout({
59
+ headerTitle = 'Secure Checkout',
60
+ headerBackgroundColor = '#FFFFFF',
61
+ headerTintColor = '#000000',
62
+ showCancelConfirmationDialog = false,
63
+ loadingComponent,
64
+ closeIcon,
65
+ renderError,
66
+ onSuccess,
67
+ onFailed,
68
+ onCancelled,
69
+ // Payment fields — everything the HTML generator needs
70
+ ...paymentConfig
71
+ }: InlineCheckoutConfig) {
72
+ const [isLoading, setIsLoading] = useState(true);
73
+ const [errorState, setErrorState] = useState<FincraPaymentError | null>(null);
74
+ const [reloadKey, setReloadKey] = useState(0);
75
+ const webViewRef = useRef<WebView<object> | null>(null);
76
+ const hasCompleted = useRef(false);
77
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
+
79
+ // Fix #3: Memoize HTML generation — prevents WebView reload on parent re-renders.
80
+ // The HTML is intentionally generated once per mount; payment params are immutable.
81
+ const configJson = JSON.stringify(paymentConfig);
82
+ const html = useMemo(
83
+ () => generateInlineHtml(JSON.parse(configJson)),
84
+ [configJson]
85
+ );
86
+
87
+ // ── 15-second init timeout ──────────────────────────────────────────────────
88
+ useEffect(() => {
89
+ timeoutRef.current = setTimeout(() => {
90
+ if (!hasCompleted.current) {
91
+ const err: FincraPaymentError = {
92
+ code: 'timeout',
93
+ message:
94
+ 'Fincra Checkout failed to load. Please check your internet connection.',
95
+ };
96
+ setErrorState(err);
97
+ setIsLoading(false);
98
+ }
99
+ }, INIT_TIMEOUT_MS);
100
+
101
+ return () => {
102
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
103
+ };
104
+ }, []);
105
+
106
+ // Fix #10: Stable ref for handleCancellation so the BackHandler effect
107
+ // doesn't need it as a dependency and never captures a stale closure.
108
+ const handleCancellationRef = useRef<() => void>(() => {});
109
+
110
+ // ── Android back button ─────────────────────────────────────────────────────
111
+ useEffect(() => {
112
+ if (Platform.OS !== 'android') return;
113
+ const subscription = BackHandler.addEventListener(
114
+ 'hardwareBackPress',
115
+ () => {
116
+ handleCancellationRef.current();
117
+ return true;
118
+ }
119
+ );
120
+ return () => subscription.remove();
121
+ }, []); // safe: always calls through the ref
122
+
123
+ // ── WebView error ───────────────────────────────────────────────────────────
124
+ const handleError = useCallback(
125
+ (syntheticEvent: WebViewErrorEvent): void => {
126
+ if (hasCompleted.current) return;
127
+ const { nativeEvent } = syntheticEvent;
128
+ const err: FincraPaymentError = {
129
+ code: String(nativeEvent.code ?? 'webview_error'),
130
+ message: nativeEvent.description ?? 'A WebView error occurred.',
131
+ };
132
+ setErrorState(err);
133
+ setIsLoading(false);
134
+ },
135
+ [setErrorState, setIsLoading]
136
+ );
137
+
138
+ // ── HTTP error ──────────────────────────────────────────────────────────────
139
+ const handleHttpError = useCallback(
140
+ (syntheticEvent: WebViewHttpErrorEvent): void => {
141
+ if (hasCompleted.current) return;
142
+ const { nativeEvent } = syntheticEvent;
143
+ const err: FincraPaymentError = {
144
+ code: String(nativeEvent.statusCode ?? 'http_error'),
145
+ message: nativeEvent.description ?? 'A WebView HTTP error occurred.',
146
+ };
147
+ setErrorState(err);
148
+ setIsLoading(false);
149
+ },
150
+ [setErrorState, setIsLoading]
151
+ );
152
+
153
+ // ── Retry handler ───────────────────────────────────────────────────────────
154
+ const handleRetry = useCallback(() => {
155
+ setErrorState(null);
156
+ setIsLoading(true);
157
+ setReloadKey((key) => key + 1);
158
+ }, [setErrorState, setIsLoading, setReloadKey]);
159
+
160
+ // ── JS Bridge message handler ───────────────────────────────────────────────
161
+ const handleMessage = useCallback(
162
+ (event: WebViewMessageEvent) => {
163
+ if (hasCompleted.current) return;
164
+
165
+ const msg = parseMessage(event.nativeEvent.data);
166
+
167
+ switch (msg.event) {
168
+ case FincraBridgeEvent.Ready:
169
+ // SDK loaded — clear timeout and hide the loading spinner
170
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
171
+ setIsLoading(false);
172
+ break;
173
+
174
+ case FincraBridgeEvent.Success:
175
+ hasCompleted.current = true;
176
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
177
+ if (msg.data && 'reference' in msg.data) {
178
+ onSuccess?.(msg.data as FincraPaymentResponse);
179
+ } else {
180
+ onCancelled?.();
181
+ }
182
+ break;
183
+
184
+ case FincraBridgeEvent.Closed:
185
+ hasCompleted.current = true;
186
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
187
+ onCancelled?.();
188
+ break;
189
+
190
+ case FincraBridgeEvent.Error: {
191
+ hasCompleted.current = true;
192
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
193
+ const errorMessage =
194
+ msg.data && 'message' in msg.data
195
+ ? (msg.data as { message: string }).message
196
+ : 'An unknown error occurred';
197
+ const err: FincraPaymentError = {
198
+ code: 'fincra_sdk_error',
199
+ message: errorMessage,
200
+ };
201
+ onFailed?.(err);
202
+ break;
203
+ }
204
+
205
+ case FincraBridgeEvent.Unknown:
206
+ default:
207
+ // Ignore — mirrors Flutter's `case FincraBridgeEvent.unknown: break;`
208
+ break;
209
+ }
210
+ },
211
+ [onSuccess, onFailed, onCancelled, setIsLoading]
212
+ );
213
+
214
+ // ── Cancellation ────────────────────────────────────────────────────────────
215
+ // Fix #4: Use static Alert import — no dynamic require() needed.
216
+ const handleCancellation = useCallback(() => {
217
+ if (hasCompleted.current) return;
218
+
219
+ if (showCancelConfirmationDialog) {
220
+ Alert.alert(
221
+ 'Cancel Payment?',
222
+ 'Are you sure you want to cancel this payment?',
223
+ [
224
+ { text: 'No', style: 'cancel' },
225
+ {
226
+ text: 'Yes',
227
+ style: 'destructive',
228
+ onPress: () => {
229
+ if (!hasCompleted.current) {
230
+ hasCompleted.current = true;
231
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
232
+ onCancelled?.();
233
+ }
234
+ },
235
+ },
236
+ ]
237
+ );
238
+ } else {
239
+ hasCompleted.current = true;
240
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
241
+ onCancelled?.();
242
+ }
243
+ }, [showCancelConfirmationDialog, onCancelled]);
244
+
245
+ // Keep the ref in sync with the latest handleCancellation (Fix #10)
246
+ useEffect(() => {
247
+ handleCancellationRef.current = handleCancellation;
248
+ });
249
+
250
+ // ── Computed status bar style (Fix #11) ─────────────────────────────────────
251
+ const statusBarStyle =
252
+ headerTintColor === '#000000' ? 'dark-content' : 'light-content';
253
+
254
+ // ── Render ──────────────────────────────────────────────────────────────────
255
+ return (
256
+ <SafeAreaView style={styles.container}>
257
+ <StatusBar
258
+ barStyle={statusBarStyle}
259
+ backgroundColor={headerBackgroundColor}
260
+ />
261
+
262
+ {/* ── Header bar — shown during loading so the user can abort ── */}
263
+ <View
264
+ style={[styles.header, { backgroundColor: headerBackgroundColor }]}
265
+ >
266
+ <TouchableOpacity
267
+ style={styles.closeButton}
268
+ onPress={handleCancellation}
269
+ accessibilityLabel="Close checkout"
270
+ accessibilityRole="button"
271
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
272
+ >
273
+ {closeIcon ?? (
274
+ <Text style={[styles.closeIcon, { color: headerTintColor }]}>
275
+ ✕
276
+ </Text>
277
+ )}
278
+ </TouchableOpacity>
279
+ <Text
280
+ style={[styles.headerTitle, { color: headerTintColor }]}
281
+ numberOfLines={1}
282
+ >
283
+ {headerTitle}
284
+ </Text>
285
+ <View style={styles.closeButton} />
286
+ </View>
287
+
288
+ {/* ── WebView running Fincra inline JS SDK ── */}
289
+ <View style={styles.webViewContainer}>
290
+ <WebView
291
+ key={reloadKey}
292
+ ref={webViewRef}
293
+ // Fix #14: restrict to HTTPS + about:blank only (removed wildcard)
294
+ originWhitelist={['https://*', 'about:blank']}
295
+ source={{ html }}
296
+ style={styles.webView}
297
+ javaScriptEnabled
298
+ domStorageEnabled
299
+ onMessage={handleMessage}
300
+ onError={handleError}
301
+ onHttpError={handleHttpError}
302
+ // Inject the ReactNativeWebView bridge shim so older WKWebView versions work
303
+ injectedJavaScriptBeforeContentLoaded={WEBVIEW_BRIDGE_SHIM}
304
+ // Allow the external CDN script to load
305
+ mixedContentMode="always"
306
+ // Fix #15: removed allowFileAccess and allowUniversalAccessFromFileURLs —
307
+ // the HTML is served as an inline blob, not a file:// URL, so these are
308
+ // unnecessary and allowUniversalAccessFromFileURLs is a security footgun.
309
+ />
310
+
311
+ {/* ── Loading overlay ── */}
312
+ {isLoading && !errorState && (
313
+ <View style={styles.loadingOverlay}>
314
+ <View style={styles.loadingCard}>
315
+ {loadingComponent ?? (
316
+ <ActivityIndicator size="large" color="#0066FF" />
317
+ )}
318
+ </View>
319
+ </View>
320
+ )}
321
+
322
+ {/* ── Error Recovery overlay ── */}
323
+ {errorState && (
324
+ <View style={styles.errorOverlay}>
325
+ {renderError ? (
326
+ renderError(errorState, handleRetry)
327
+ ) : (
328
+ <View style={styles.errorContainer}>
329
+ <Text style={styles.errorIcon}>⚠️</Text>
330
+ <Text style={styles.errorTitle}>Connection Error</Text>
331
+ <Text style={styles.errorMessage}>{errorState.message}</Text>
332
+ <TouchableOpacity
333
+ style={styles.retryButton}
334
+ onPress={handleRetry}
335
+ accessibilityRole="button"
336
+ accessibilityLabel="Retry loading checkout"
337
+ >
338
+ <Text style={styles.retryButtonText}>Retry</Text>
339
+ </TouchableOpacity>
340
+ <TouchableOpacity
341
+ style={styles.cancelButton}
342
+ onPress={handleCancellation}
343
+ accessibilityRole="button"
344
+ accessibilityLabel="Cancel checkout"
345
+ >
346
+ <Text style={styles.cancelButtonText}>Cancel</Text>
347
+ </TouchableOpacity>
348
+ </View>
349
+ )}
350
+ </View>
351
+ )}
352
+ </View>
353
+ </SafeAreaView>
354
+ );
355
+ }
356
+
357
+ // ─── WebView Bridge Shim ───────────────────────────────────────────────────────
358
+ // Ensures `window.ReactNativeWebView` is available before page scripts run.
359
+ const WEBVIEW_BRIDGE_SHIM = `
360
+ (function() {
361
+ if (!window.ReactNativeWebView) {
362
+ window.ReactNativeWebView = {
363
+ postMessage: function(msg) {
364
+ window.webkit && window.webkit.messageHandlers &&
365
+ window.webkit.messageHandlers.ReactNativeWebView &&
366
+ window.webkit.messageHandlers.ReactNativeWebView.postMessage(msg);
367
+ }
368
+ };
369
+ }
370
+ })();
371
+ true;
372
+ `;
373
+
374
+ // ─── Styles ────────────────────────────────────────────────────────────────────
375
+
376
+ const styles = StyleSheet.create({
377
+ container: {
378
+ flex: 1,
379
+ backgroundColor: '#FFFFFF',
380
+ },
381
+ header: {
382
+ flexDirection: 'row',
383
+ alignItems: 'center',
384
+ justifyContent: 'space-between',
385
+ height: 56,
386
+ paddingHorizontal: 12,
387
+ borderBottomWidth: StyleSheet.hairlineWidth,
388
+ borderBottomColor: '#E5E7EB',
389
+ elevation: 2,
390
+ shadowColor: '#000',
391
+ shadowOffset: { width: 0, height: 1 },
392
+ shadowOpacity: 0.08,
393
+ shadowRadius: 2,
394
+ },
395
+ closeButton: {
396
+ width: 40,
397
+ height: 40,
398
+ alignItems: 'center',
399
+ justifyContent: 'center',
400
+ },
401
+ closeIcon: {
402
+ fontSize: 18,
403
+ fontWeight: '600',
404
+ },
405
+ headerTitle: {
406
+ flex: 1,
407
+ textAlign: 'center',
408
+ fontSize: 16,
409
+ fontWeight: '600',
410
+ letterSpacing: 0.2,
411
+ },
412
+ webViewContainer: {
413
+ flex: 1,
414
+ },
415
+ webView: {
416
+ flex: 1,
417
+ backgroundColor: 'transparent',
418
+ },
419
+ loadingOverlay: {
420
+ ...StyleSheet.absoluteFillObject,
421
+ alignItems: 'center',
422
+ justifyContent: 'center',
423
+ backgroundColor: 'rgba(255,255,255,0.85)',
424
+ },
425
+ loadingCard: {
426
+ padding: 24,
427
+ backgroundColor: '#FFFFFF',
428
+ borderRadius: 12,
429
+ elevation: 4,
430
+ shadowColor: '#000',
431
+ shadowOffset: { width: 0, height: 2 },
432
+ shadowOpacity: 0.12,
433
+ shadowRadius: 8,
434
+ },
435
+ errorOverlay: {
436
+ ...StyleSheet.absoluteFillObject,
437
+ backgroundColor: '#FFFFFF',
438
+ alignItems: 'center',
439
+ justifyContent: 'center',
440
+ padding: 24,
441
+ },
442
+ errorContainer: {
443
+ alignItems: 'center',
444
+ maxWidth: 320,
445
+ width: '100%',
446
+ },
447
+ errorIcon: {
448
+ fontSize: 48,
449
+ marginBottom: 16,
450
+ },
451
+ errorTitle: {
452
+ fontSize: 20,
453
+ fontWeight: '700',
454
+ color: '#1F2937',
455
+ marginBottom: 8,
456
+ textAlign: 'center',
457
+ },
458
+ errorMessage: {
459
+ fontSize: 14,
460
+ color: '#6B7280',
461
+ textAlign: 'center',
462
+ marginBottom: 24,
463
+ lineHeight: 20,
464
+ },
465
+ retryButton: {
466
+ backgroundColor: '#0066FF',
467
+ paddingVertical: 12,
468
+ paddingHorizontal: 32,
469
+ borderRadius: 8,
470
+ width: '100%',
471
+ alignItems: 'center',
472
+ marginBottom: 12,
473
+ },
474
+ retryButtonText: {
475
+ color: '#FFFFFF',
476
+ fontSize: 16,
477
+ fontWeight: '600',
478
+ },
479
+ cancelButton: {
480
+ paddingVertical: 12,
481
+ paddingHorizontal: 32,
482
+ width: '100%',
483
+ alignItems: 'center',
484
+ },
485
+ cancelButtonText: {
486
+ color: '#6B7280',
487
+ fontSize: 15,
488
+ fontWeight: '500',
489
+ },
490
+ });