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