react-native-fincra-checkout 1.0.0 → 1.0.2

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.
@@ -10,7 +10,10 @@ import {
10
10
  TouchableOpacity,
11
11
  View,
12
12
  } from 'react-native';
13
- import { SafeAreaView } from 'react-native-safe-area-context';
13
+ import {
14
+ SafeAreaProvider,
15
+ SafeAreaView,
16
+ } from 'react-native-safe-area-context';
14
17
  import { WebView } from 'react-native-webview';
15
18
  import type {
16
19
  WebViewMessageEvent,
@@ -48,7 +51,6 @@ const INIT_TIMEOUT_MS = 15_000;
48
51
  * currency="NGN"
49
52
  * customerEmail="user@example.com"
50
53
  * customerName="John Doe"
51
- * customerPhoneNumber="08012345678"
52
54
  * feeBearer="customer"
53
55
  * onSuccess={(res) => console.log(res.reference)}
54
56
  * onCancelled={() => navigation.goBack()}
@@ -60,6 +62,7 @@ export function FincraInlineCheckout({
60
62
  headerBackgroundColor = '#FFFFFF',
61
63
  headerTintColor = '#000000',
62
64
  showCancelConfirmationDialog = false,
65
+ showCloseButton = true,
63
66
  loadingComponent,
64
67
  closeIcon,
65
68
  renderError,
@@ -73,9 +76,41 @@ export function FincraInlineCheckout({
73
76
  const [errorState, setErrorState] = useState<FincraPaymentError | null>(null);
74
77
  const [reloadKey, setReloadKey] = useState(0);
75
78
  const webViewRef = useRef<WebView<object> | null>(null);
76
- const hasCompleted = useRef(false);
79
+ // `settledRef`: a result (success / error / cancel) has been delivered.
80
+ // `isMountedRef`: late bridge messages after unmount must be ignored.
81
+ const settledRef = useRef(false);
82
+ const isMountedRef = useRef(false);
77
83
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
84
 
85
+ useEffect(() => {
86
+ isMountedRef.current = true;
87
+ return () => {
88
+ isMountedRef.current = false;
89
+ };
90
+ }, []);
91
+
92
+ /** True while callbacks may still update state or deliver a result. */
93
+ const isActive = useCallback(
94
+ () => isMountedRef.current && !settledRef.current,
95
+ []
96
+ );
97
+
98
+ const clearInitTimeout = useCallback(() => {
99
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
100
+ timeoutRef.current = null;
101
+ }, []);
102
+
103
+ /** Single exit point — delivers at most one result per session. */
104
+ const settle = useCallback(
105
+ (deliver: () => void) => {
106
+ if (!isMountedRef.current || settledRef.current) return;
107
+ settledRef.current = true;
108
+ clearInitTimeout();
109
+ deliver();
110
+ },
111
+ [clearInitTimeout]
112
+ );
113
+
79
114
  // Fix #3: Memoize HTML generation — prevents WebView reload on parent re-renders.
80
115
  // The HTML is intentionally generated once per mount; payment params are immutable.
81
116
  const configJson = JSON.stringify(paymentConfig);
@@ -84,24 +119,21 @@ export function FincraInlineCheckout({
84
119
  [configJson]
85
120
  );
86
121
 
87
- // ── 15-second init timeout ──────────────────────────────────────────────────
122
+ // ── 15-second init timeout (re-armed on every Retry) ────────────────────────
88
123
  useEffect(() => {
89
124
  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
- }
125
+ if (!isActive()) return;
126
+ const err: FincraPaymentError = {
127
+ code: 'timeout',
128
+ message:
129
+ 'Fincra Checkout failed to load. Please check your internet connection.',
130
+ };
131
+ setErrorState(err);
132
+ setIsLoading(false);
99
133
  }, INIT_TIMEOUT_MS);
100
134
 
101
- return () => {
102
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
103
- };
104
- }, []);
135
+ return clearInitTimeout;
136
+ }, [reloadKey, isActive, clearInitTimeout]);
105
137
 
106
138
  // Fix #10: Stable ref for handleCancellation so the BackHandler effect
107
139
  // doesn't need it as a dependency and never captures a stale closure.
@@ -120,34 +152,35 @@ export function FincraInlineCheckout({
120
152
  return () => subscription.remove();
121
153
  }, []); // safe: always calls through the ref
122
154
 
123
- // ── WebView error ───────────────────────────────────────────────────────────
155
+ // ── Load errors (main frame only — see FincraWebViewCheckout) ───────────────
156
+ const showLoadError = useCallback(
157
+ (err: FincraPaymentError): void => {
158
+ if (!isActive()) return;
159
+ clearInitTimeout();
160
+ setErrorState(err);
161
+ setIsLoading(false);
162
+ },
163
+ [isActive, clearInitTimeout, setErrorState, setIsLoading]
164
+ );
165
+
124
166
  const handleError = useCallback(
125
- (syntheticEvent: WebViewErrorEvent): void => {
126
- if (hasCompleted.current) return;
127
- const { nativeEvent } = syntheticEvent;
128
- const err: FincraPaymentError = {
167
+ ({ nativeEvent }: WebViewErrorEvent): void => {
168
+ showLoadError({
129
169
  code: String(nativeEvent.code ?? 'webview_error'),
130
170
  message: nativeEvent.description ?? 'A WebView error occurred.',
131
- };
132
- setErrorState(err);
133
- setIsLoading(false);
171
+ });
134
172
  },
135
- [setErrorState, setIsLoading]
173
+ [showLoadError]
136
174
  );
137
175
 
138
- // ── HTTP error ──────────────────────────────────────────────────────────────
139
176
  const handleHttpError = useCallback(
140
- (syntheticEvent: WebViewHttpErrorEvent): void => {
141
- if (hasCompleted.current) return;
142
- const { nativeEvent } = syntheticEvent;
143
- const err: FincraPaymentError = {
177
+ ({ nativeEvent }: WebViewHttpErrorEvent): void => {
178
+ showLoadError({
144
179
  code: String(nativeEvent.statusCode ?? 'http_error'),
145
180
  message: nativeEvent.description ?? 'A WebView HTTP error occurred.',
146
- };
147
- setErrorState(err);
148
- setIsLoading(false);
181
+ });
149
182
  },
150
- [setErrorState, setIsLoading]
183
+ [showLoadError]
151
184
  );
152
185
 
153
186
  // ── Retry handler ───────────────────────────────────────────────────────────
@@ -158,47 +191,42 @@ export function FincraInlineCheckout({
158
191
  }, [setErrorState, setIsLoading, setReloadKey]);
159
192
 
160
193
  // ── JS Bridge message handler ───────────────────────────────────────────────
194
+ // Messages that arrive after the session settled (e.g. closed via the
195
+ // Android back button) or after unmount are ignored.
161
196
  const handleMessage = useCallback(
162
197
  (event: WebViewMessageEvent) => {
163
- if (hasCompleted.current) return;
198
+ if (!isActive()) return;
164
199
 
165
200
  const msg = parseMessage(event.nativeEvent.data);
166
201
 
167
202
  switch (msg.event) {
168
203
  case FincraBridgeEvent.Ready:
169
204
  // SDK loaded — clear timeout and hide the loading spinner
170
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
205
+ clearInitTimeout();
171
206
  setIsLoading(false);
172
207
  break;
173
208
 
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
- }
209
+ case FincraBridgeEvent.Success: {
210
+ // Always a success, even without data (empty references).
211
+ const response = msg.data as FincraPaymentResponse;
212
+ settle(() => onSuccess?.(response));
182
213
  break;
214
+ }
183
215
 
184
216
  case FincraBridgeEvent.Closed:
185
- hasCompleted.current = true;
186
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
187
- onCancelled?.();
217
+ settle(() => onCancelled?.());
188
218
  break;
189
219
 
190
220
  case FincraBridgeEvent.Error: {
191
- hasCompleted.current = true;
192
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
193
221
  const errorMessage =
194
- msg.data && 'message' in msg.data
195
- ? (msg.data as { message: string }).message
222
+ msg.data && 'message' in msg.data && msg.data.message
223
+ ? msg.data.message
196
224
  : 'An unknown error occurred';
197
225
  const err: FincraPaymentError = {
198
226
  code: 'fincra_sdk_error',
199
227
  message: errorMessage,
200
228
  };
201
- onFailed?.(err);
229
+ settle(() => onFailed?.(err));
202
230
  break;
203
231
  }
204
232
 
@@ -208,13 +236,21 @@ export function FincraInlineCheckout({
208
236
  break;
209
237
  }
210
238
  },
211
- [onSuccess, onFailed, onCancelled, setIsLoading]
239
+ [
240
+ isActive,
241
+ clearInitTimeout,
242
+ settle,
243
+ setIsLoading,
244
+ onSuccess,
245
+ onFailed,
246
+ onCancelled,
247
+ ]
212
248
  );
213
249
 
214
250
  // ── Cancellation ────────────────────────────────────────────────────────────
215
251
  // Fix #4: Use static Alert import — no dynamic require() needed.
216
252
  const handleCancellation = useCallback(() => {
217
- if (hasCompleted.current) return;
253
+ if (!isActive()) return;
218
254
 
219
255
  if (showCancelConfirmationDialog) {
220
256
  Alert.alert(
@@ -225,22 +261,15 @@ export function FincraInlineCheckout({
225
261
  {
226
262
  text: 'Yes',
227
263
  style: 'destructive',
228
- onPress: () => {
229
- if (!hasCompleted.current) {
230
- hasCompleted.current = true;
231
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
232
- onCancelled?.();
233
- }
234
- },
264
+ // May fire after unmount — settle() ignores it then.
265
+ onPress: () => settle(() => onCancelled?.()),
235
266
  },
236
267
  ]
237
268
  );
238
269
  } else {
239
- hasCompleted.current = true;
240
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
241
- onCancelled?.();
270
+ settle(() => onCancelled?.());
242
271
  }
243
- }, [showCancelConfirmationDialog, onCancelled]);
272
+ }, [isActive, showCancelConfirmationDialog, settle, onCancelled]);
244
273
 
245
274
  // Keep the ref in sync with the latest handleCancellation (Fix #10)
246
275
  useEffect(() => {
@@ -252,105 +281,116 @@ export function FincraInlineCheckout({
252
281
  headerTintColor === '#000000' ? 'dark-content' : 'light-content';
253
282
 
254
283
  // ── Render ──────────────────────────────────────────────────────────────────
284
+ // Own SafeAreaProvider: SafeAreaView reads insets from the nearest provider,
285
+ // and a Modal (FincraCheckoutHost) is a separate native tree. Without one the
286
+ // insets are 0 and the header sits under the status bar / Dynamic Island,
287
+ // where iOS swallows taps. Nested providers are fine if the app has its own.
255
288
  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 }}
289
+ <SafeAreaProvider style={styles.provider}>
290
+ <SafeAreaView style={styles.container}>
291
+ <StatusBar
292
+ barStyle={statusBarStyle}
293
+ backgroundColor={headerBackgroundColor}
294
+ />
295
+
296
+ {/* ── Header bar — shown during loading so the user can abort ── */}
297
+ <View
298
+ style={[styles.header, { backgroundColor: headerBackgroundColor }]}
272
299
  >
273
- {closeIcon ?? (
274
- <Text style={[styles.closeIcon, { color: headerTintColor }]}>
275
- ✕
276
- </Text>
300
+ {/* Spacer keeps the title centred when the close button is hidden */}
301
+ {showCloseButton ? (
302
+ <TouchableOpacity
303
+ style={styles.closeButton}
304
+ onPress={handleCancellation}
305
+ accessibilityLabel="Close checkout"
306
+ accessibilityRole="button"
307
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
308
+ >
309
+ {closeIcon ?? (
310
+ <Text style={[styles.closeIcon, { color: headerTintColor }]}>
311
+ ✕
312
+ </Text>
313
+ )}
314
+ </TouchableOpacity>
315
+ ) : (
316
+ <View style={styles.closeButton} />
317
+ )}
318
+ <Text
319
+ style={[styles.headerTitle, { color: headerTintColor }]}
320
+ numberOfLines={1}
321
+ >
322
+ {headerTitle}
323
+ </Text>
324
+ <View style={styles.closeButton} />
325
+ </View>
326
+
327
+ {/* ── WebView running Fincra inline JS SDK ── */}
328
+ <View style={styles.webViewContainer}>
329
+ <WebView
330
+ key={reloadKey}
331
+ ref={webViewRef}
332
+ // Fix #14: restrict to HTTPS + about:blank only (removed wildcard)
333
+ originWhitelist={['https://*', 'about:blank']}
334
+ source={{ html }}
335
+ style={styles.webView}
336
+ javaScriptEnabled
337
+ domStorageEnabled
338
+ onMessage={handleMessage}
339
+ onError={handleError}
340
+ onHttpError={handleHttpError}
341
+ // Inject the ReactNativeWebView bridge shim so older WKWebView versions work
342
+ injectedJavaScriptBeforeContentLoaded={WEBVIEW_BRIDGE_SHIM}
343
+ // Allow the external CDN script to load
344
+ mixedContentMode="always"
345
+ // Fix #15: removed allowFileAccess and allowUniversalAccessFromFileURLs —
346
+ // the HTML is served as an inline blob, not a file:// URL, so these are
347
+ // unnecessary and allowUniversalAccessFromFileURLs is a security footgun.
348
+ />
349
+
350
+ {/* ── Loading overlay ── */}
351
+ {isLoading && !errorState && (
352
+ <View style={styles.loadingOverlay}>
353
+ <View style={styles.loadingCard}>
354
+ {loadingComponent ?? (
355
+ <ActivityIndicator size="large" color="#0066FF" />
356
+ )}
357
+ </View>
358
+ </View>
277
359
  )}
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
360
 
311
- {/* ── Loading overlay ── */}
312
- {isLoading && !errorState && (
313
- <View style={styles.loadingOverlay}>
314
- <View style={styles.loadingCard}>
315
- {loadingComponent ?? (
316
- <ActivityIndicator size="large" color="#0066FF" />
361
+ {/* ── Error Recovery overlay ── */}
362
+ {errorState && (
363
+ <View style={styles.errorOverlay}>
364
+ {renderError ? (
365
+ renderError(errorState, handleRetry)
366
+ ) : (
367
+ <View style={styles.errorContainer}>
368
+ <Text style={styles.errorIcon}>⚠️</Text>
369
+ <Text style={styles.errorTitle}>Connection Error</Text>
370
+ <Text style={styles.errorMessage}>{errorState.message}</Text>
371
+ <TouchableOpacity
372
+ style={styles.retryButton}
373
+ onPress={handleRetry}
374
+ accessibilityRole="button"
375
+ accessibilityLabel="Retry loading checkout"
376
+ >
377
+ <Text style={styles.retryButtonText}>Retry</Text>
378
+ </TouchableOpacity>
379
+ <TouchableOpacity
380
+ style={styles.cancelButton}
381
+ onPress={handleCancellation}
382
+ accessibilityRole="button"
383
+ accessibilityLabel="Cancel checkout"
384
+ >
385
+ <Text style={styles.cancelButtonText}>Cancel</Text>
386
+ </TouchableOpacity>
387
+ </View>
317
388
  )}
318
389
  </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>
390
+ )}
391
+ </View>
392
+ </SafeAreaView>
393
+ </SafeAreaProvider>
354
394
  );
355
395
  }
356
396
 
@@ -374,6 +414,9 @@ const WEBVIEW_BRIDGE_SHIM = `
374
414
  // ─── Styles ────────────────────────────────────────────────────────────────────
375
415
 
376
416
  const styles = StyleSheet.create({
417
+ provider: {
418
+ flex: 1,
419
+ },
377
420
  container: {
378
421
  flex: 1,
379
422
  backgroundColor: '#FFFFFF',