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,415 @@
1
+ import React, { useCallback, useEffect, useRef, useState } 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 type {
15
+ WebViewNavigation,
16
+ WebViewErrorEvent,
17
+ WebViewHttpErrorEvent,
18
+ ShouldStartLoadRequest,
19
+ } from 'react-native-webview/lib/WebViewTypes';
20
+ import { WebView } from 'react-native-webview';
21
+ import type { WebViewCheckoutConfig, FincraPaymentError } from '../types';
22
+ import { UrlHandler } from '../utils/UrlHandler';
23
+
24
+ // ─── FincraWebViewCheckout ────────────────────────────────────────────────────
25
+ //
26
+ // Declarative component for the WebView-based checkout flow.
27
+ // Mirrors CheckoutWebView widget in checkout_webview.dart
28
+ //
29
+ // Renders a backend-generated Fincra checkout URL inside react-native-webview,
30
+ // intercepts navigation to the redirect URL (or status+reference query params),
31
+ // and fires the appropriate callback.
32
+
33
+ export type FincraWebViewCheckoutProps = WebViewCheckoutConfig;
34
+
35
+ /**
36
+ * A full-screen WebView that loads a backend-generated Fincra checkout URL.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * <FincraWebViewCheckout
41
+ * checkoutUrl="https://checkout.fincra.com/pay/..."
42
+ * redirectUrl="https://your-backend.com/payment/callback"
43
+ * onSuccess={(res) => console.log('Paid:', res.reference)}
44
+ * onFailed={(err) => console.log('Failed:', err.message)}
45
+ * onCancelled={() => navigation.goBack()}
46
+ * />
47
+ * ```
48
+ */
49
+ export function FincraWebViewCheckout({
50
+ checkoutUrl,
51
+ redirectUrl,
52
+ headerTitle = 'Secure Checkout',
53
+ headerBackgroundColor = '#FFFFFF',
54
+ headerTintColor = '#000000',
55
+ showCancelConfirmationDialog = false,
56
+ loadingComponent,
57
+ closeIcon,
58
+ renderError,
59
+ onSuccess,
60
+ onFailed,
61
+ onCancelled,
62
+ }: FincraWebViewCheckoutProps) {
63
+ const [isLoading, setIsLoading] = useState(true);
64
+ const [errorState, setErrorState] = useState<FincraPaymentError | null>(null);
65
+ const [reloadKey, setReloadKey] = useState(0);
66
+ const webViewRef = useRef<WebView<object> | null>(null);
67
+ const hasCompleted = useRef(false);
68
+
69
+ // Fix #10: Stable ref for handleCancellation — the BackHandler effect
70
+ // always calls through this ref, never capturing a stale closure.
71
+ const handleCancellationRef = useRef<() => void>(() => {});
72
+
73
+ // ── Back handler (Android) ──────────────────────────────────────────────────
74
+ useEffect(() => {
75
+ if (Platform.OS !== 'android') return;
76
+ const subscription = BackHandler.addEventListener(
77
+ 'hardwareBackPress',
78
+ () => {
79
+ handleCancellationRef.current();
80
+ return true; // consume the event
81
+ }
82
+ );
83
+ return () => subscription.remove();
84
+ }, []); // safe: always calls through the ref
85
+
86
+ // ── Completion handler ──────────────────────────────────────────────────────
87
+ const handleCompletion = useCallback(
88
+ (url: string) => {
89
+ if (hasCompleted.current) return;
90
+ hasCompleted.current = true;
91
+
92
+ const params = UrlHandler.extractResponseParams(url);
93
+ // Safely assume success if status is missing (matches Flutter behaviour)
94
+ const rawStatus = params['status']?.toLowerCase() ?? 'success';
95
+
96
+ if (UrlHandler.isSuccessStatus(rawStatus)) {
97
+ const response = UrlHandler.parsePaymentResponse(params);
98
+ onSuccess?.(response);
99
+ } else {
100
+ const err: FincraPaymentError = {
101
+ code: rawStatus,
102
+ message: params['message'] ?? 'Payment failed',
103
+ };
104
+ onFailed?.(err);
105
+ }
106
+ },
107
+ [onSuccess, onFailed]
108
+ );
109
+
110
+ // ── URL interception ────────────────────────────────────────────────────────
111
+ const onShouldStartLoadWithRequest = useCallback(
112
+ (request: ShouldStartLoadRequest): boolean => {
113
+ if (UrlHandler.isCompletionUrl(request.url, redirectUrl)) {
114
+ handleCompletion(request.url);
115
+ return false; // prevent navigation — mirrors NavigationDecision.prevent
116
+ }
117
+ return true;
118
+ },
119
+ [redirectUrl, handleCompletion]
120
+ );
121
+
122
+ // ── Cancellation ────────────────────────────────────────────────────────────
123
+ const handleCancellation = useCallback(() => {
124
+ if (hasCompleted.current) return;
125
+
126
+ if (showCancelConfirmationDialog) {
127
+ Alert.alert(
128
+ 'Cancel Payment?',
129
+ 'Are you sure you want to cancel this payment?',
130
+ [
131
+ { text: 'No', style: 'cancel' },
132
+ {
133
+ text: 'Yes',
134
+ style: 'destructive',
135
+ onPress: () => {
136
+ if (!hasCompleted.current) {
137
+ hasCompleted.current = true;
138
+ onCancelled?.();
139
+ }
140
+ },
141
+ },
142
+ ]
143
+ );
144
+ } else {
145
+ hasCompleted.current = true;
146
+ onCancelled?.();
147
+ }
148
+ }, [showCancelConfirmationDialog, onCancelled]);
149
+
150
+ // Keep the ref in sync after every render (Fix #10)
151
+ useEffect(() => {
152
+ handleCancellationRef.current = handleCancellation;
153
+ });
154
+
155
+ // ── WebView error ───────────────────────────────────────────────────────────
156
+ const handleError = useCallback(
157
+ (syntheticEvent: WebViewErrorEvent): void => {
158
+ if (hasCompleted.current) return;
159
+ const { nativeEvent } = syntheticEvent;
160
+ const err: FincraPaymentError = {
161
+ code: String(nativeEvent.code ?? 'webview_error'),
162
+ message: nativeEvent.description ?? 'A WebView error occurred.',
163
+ };
164
+ setErrorState(err);
165
+ setIsLoading(false);
166
+ },
167
+ []
168
+ );
169
+
170
+ // ── HTTP error (non-completion URLs only) ───────────────────────────────────
171
+ const handleHttpError = useCallback(
172
+ (e: WebViewHttpErrorEvent): void => {
173
+ if (!UrlHandler.isCompletionUrl(e.nativeEvent.url, redirectUrl)) {
174
+ handleError(e as unknown as WebViewErrorEvent);
175
+ }
176
+ },
177
+ [redirectUrl, handleError]
178
+ );
179
+
180
+ // ── Retry handler ───────────────────────────────────────────────────────────
181
+ const handleRetry = useCallback(() => {
182
+ setErrorState(null);
183
+ setIsLoading(true);
184
+ setReloadKey((key) => key + 1);
185
+ }, []);
186
+
187
+ // ── Navigation state change (iOS fallback) ──────────────────────────────────
188
+ // Fix #2: guard setIsLoading — don't flip loading state after completion.
189
+ const onNavigationStateChange = useCallback(
190
+ (navState: WebViewNavigation) => {
191
+ if (hasCompleted.current) return;
192
+ if (
193
+ navState.url &&
194
+ UrlHandler.isCompletionUrl(navState.url, redirectUrl)
195
+ ) {
196
+ handleCompletion(navState.url);
197
+ }
198
+ },
199
+ [redirectUrl, handleCompletion]
200
+ );
201
+
202
+ // ── Computed status bar style (Fix #11) ─────────────────────────────────────
203
+ const statusBarStyle =
204
+ headerTintColor === '#000000' ? 'dark-content' : 'light-content';
205
+
206
+ // ── Render ──────────────────────────────────────────────────────────────────
207
+ return (
208
+ <SafeAreaView style={styles.container}>
209
+ <StatusBar
210
+ barStyle={statusBarStyle}
211
+ backgroundColor={headerBackgroundColor}
212
+ />
213
+ {/* ── Header bar ── */}
214
+ <View
215
+ style={[styles.header, { backgroundColor: headerBackgroundColor }]}
216
+ >
217
+ <TouchableOpacity
218
+ style={styles.closeButton}
219
+ onPress={handleCancellation}
220
+ accessibilityLabel="Close checkout"
221
+ accessibilityRole="button"
222
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
223
+ >
224
+ {closeIcon ?? (
225
+ <Text style={[styles.closeIcon, { color: headerTintColor }]}>
226
+ ✕
227
+ </Text>
228
+ )}
229
+ </TouchableOpacity>
230
+ <Text
231
+ style={[styles.headerTitle, { color: headerTintColor }]}
232
+ numberOfLines={1}
233
+ >
234
+ {headerTitle}
235
+ </Text>
236
+ {/* Spacer to centre the title */}
237
+ <View style={styles.closeButton} />
238
+ </View>
239
+
240
+ {/* ── WebView ── */}
241
+ <View style={styles.webViewContainer}>
242
+ <WebView
243
+ key={reloadKey}
244
+ ref={webViewRef}
245
+ source={{ uri: checkoutUrl }}
246
+ style={styles.webView}
247
+ javaScriptEnabled
248
+ domStorageEnabled
249
+ startInLoadingState={false}
250
+ onLoadStart={() => {
251
+ if (!hasCompleted.current) setIsLoading(true);
252
+ }}
253
+ onLoadEnd={() => {
254
+ if (!hasCompleted.current) setIsLoading(false);
255
+ }}
256
+ onError={handleError}
257
+ onHttpError={handleHttpError}
258
+ onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
259
+ onNavigationStateChange={onNavigationStateChange}
260
+ // Fix #14: restrict to HTTPS + about:blank — prevents intent:// and
261
+ // other dangerous scheme navigations in a payment context.
262
+ originWhitelist={['https://*', 'about:blank']}
263
+ />
264
+
265
+ {/* ── Loading overlay ── */}
266
+ {isLoading && !errorState && (
267
+ <View style={styles.loadingOverlay} pointerEvents="none">
268
+ {loadingComponent ?? (
269
+ <ActivityIndicator size="large" color="#0066FF" />
270
+ )}
271
+ </View>
272
+ )}
273
+
274
+ {/* ── Error Recovery overlay ── */}
275
+ {errorState && (
276
+ <View style={styles.errorOverlay}>
277
+ {renderError ? (
278
+ renderError(errorState, handleRetry)
279
+ ) : (
280
+ <View style={styles.errorContainer}>
281
+ <Text style={styles.errorIcon}>⚠️</Text>
282
+ <Text style={styles.errorTitle}>Connection Error</Text>
283
+ <Text style={styles.errorMessage}>{errorState.message}</Text>
284
+ <TouchableOpacity
285
+ style={styles.retryButton}
286
+ onPress={handleRetry}
287
+ accessibilityRole="button"
288
+ accessibilityLabel="Retry loading checkout"
289
+ >
290
+ <Text style={styles.retryButtonText}>Retry</Text>
291
+ </TouchableOpacity>
292
+ <TouchableOpacity
293
+ style={styles.cancelButton}
294
+ onPress={handleCancellation}
295
+ accessibilityRole="button"
296
+ accessibilityLabel="Cancel checkout"
297
+ >
298
+ <Text style={styles.cancelButtonText}>Cancel</Text>
299
+ </TouchableOpacity>
300
+ </View>
301
+ )}
302
+ </View>
303
+ )}
304
+ </View>
305
+ </SafeAreaView>
306
+ );
307
+ }
308
+
309
+ // ─── Styles ────────────────────────────────────────────────────────────────────
310
+
311
+ const styles = StyleSheet.create({
312
+ container: {
313
+ flex: 1,
314
+ backgroundColor: '#FFFFFF',
315
+ },
316
+ header: {
317
+ flexDirection: 'row',
318
+ alignItems: 'center',
319
+ justifyContent: 'space-between',
320
+ height: 56,
321
+ paddingHorizontal: 12,
322
+ borderBottomWidth: StyleSheet.hairlineWidth,
323
+ borderBottomColor: '#E5E7EB',
324
+ elevation: 2,
325
+ shadowColor: '#000',
326
+ shadowOffset: { width: 0, height: 1 },
327
+ shadowOpacity: 0.08,
328
+ shadowRadius: 2,
329
+ },
330
+ closeButton: {
331
+ width: 40,
332
+ height: 40,
333
+ alignItems: 'center',
334
+ justifyContent: 'center',
335
+ },
336
+ closeIcon: {
337
+ fontSize: 18,
338
+ fontWeight: '600',
339
+ },
340
+ headerTitle: {
341
+ flex: 1,
342
+ textAlign: 'center',
343
+ fontSize: 16,
344
+ fontWeight: '600',
345
+ letterSpacing: 0.2,
346
+ },
347
+ webViewContainer: {
348
+ flex: 1,
349
+ },
350
+ webView: {
351
+ flex: 1,
352
+ backgroundColor: 'transparent',
353
+ },
354
+ loadingOverlay: {
355
+ ...StyleSheet.absoluteFillObject,
356
+ alignItems: 'center',
357
+ justifyContent: 'center',
358
+ backgroundColor: 'rgba(255,255,255,0.85)',
359
+ },
360
+ errorOverlay: {
361
+ ...StyleSheet.absoluteFillObject,
362
+ backgroundColor: '#FFFFFF',
363
+ alignItems: 'center',
364
+ justifyContent: 'center',
365
+ padding: 24,
366
+ },
367
+ errorContainer: {
368
+ alignItems: 'center',
369
+ maxWidth: 320,
370
+ width: '100%',
371
+ },
372
+ errorIcon: {
373
+ fontSize: 48,
374
+ marginBottom: 16,
375
+ },
376
+ errorTitle: {
377
+ fontSize: 20,
378
+ fontWeight: '700',
379
+ color: '#1F2937',
380
+ marginBottom: 8,
381
+ textAlign: 'center',
382
+ },
383
+ errorMessage: {
384
+ fontSize: 14,
385
+ color: '#6B7280',
386
+ textAlign: 'center',
387
+ marginBottom: 24,
388
+ lineHeight: 20,
389
+ },
390
+ retryButton: {
391
+ backgroundColor: '#0066FF',
392
+ paddingVertical: 12,
393
+ paddingHorizontal: 32,
394
+ borderRadius: 8,
395
+ width: '100%',
396
+ alignItems: 'center',
397
+ marginBottom: 12,
398
+ },
399
+ retryButtonText: {
400
+ color: '#FFFFFF',
401
+ fontSize: 16,
402
+ fontWeight: '600',
403
+ },
404
+ cancelButton: {
405
+ paddingVertical: 12,
406
+ paddingHorizontal: 32,
407
+ width: '100%',
408
+ alignItems: 'center',
409
+ },
410
+ cancelButtonText: {
411
+ color: '#6B7280',
412
+ fontSize: 15,
413
+ fontWeight: '500',
414
+ },
415
+ });
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // ─── Public API ───────────────────────────────────────────────────────────────
2
+ //
3
+ // This is the single entry point for react-native-fincra-checkout.
4
+ // Import only what you need:
5
+ //
6
+ // import { FincraCheckout, FincraCheckoutHost } from 'react-native-fincra-checkout';
7
+ // import type { FincraCheckoutResult, WebViewCheckoutConfig } from 'react-native-fincra-checkout';
8
+
9
+ // ── Imperative API & Host ──────────────────────────────────────────────────────
10
+ export {
11
+ FincraCheckout,
12
+ FincraCheckoutHostRegistrar as FincraCheckoutHost,
13
+ } from './checkout/FincraCheckout';
14
+
15
+ // ── Declarative Components ─────────────────────────────────────────────────────
16
+ export { FincraWebViewCheckout } from './components/FincraWebViewCheckout';
17
+ export { FincraInlineCheckout } from './components/FincraInlineCheckout';
18
+
19
+ // ── Primitives & Types ─────────────────────────────────────────────────────────
20
+ export { FincraCurrency, FeeBearer } from './types';
21
+ export type {
22
+ // Data models
23
+ FincraPaymentResponse,
24
+ FincraPaymentError,
25
+ // Result discriminated union
26
+ FincraCheckoutResult,
27
+ // Component props / config
28
+ BaseCheckoutProps,
29
+ WebViewCheckoutConfig,
30
+ InlineCheckoutConfig,
31
+ } from './types';
@@ -0,0 +1,108 @@
1
+ import type { FincraPaymentResponse } from '../types';
2
+ import { UrlHandler } from '../utils/UrlHandler';
3
+
4
+ // ─── JS Bridge ────────────────────────────────────────────────────────────────
5
+ //
6
+ // Direct TypeScript port of flutter_fincra_checkout/lib/src/inline/javascript_bridge.dart
7
+ // Handles parsing of postMessage events from the Fincra inline JS SDK.
8
+
9
+ /** Events that can be posted from the Fincra inline JavaScript SDK. */
10
+ export enum FincraBridgeEvent {
11
+ /** The Fincra SDK has loaded and is ready — hide the loader. */
12
+ Ready = 'ready',
13
+ /** Payment completed successfully. */
14
+ Success = 'success',
15
+ /** The user closed the Fincra checkout modal. */
16
+ Closed = 'closed',
17
+ /** The Fincra SDK emitted an error (e.g., load failure). */
18
+ Error = 'error',
19
+ /** Unrecognized or malformed event — should be ignored. */
20
+ Unknown = 'unknown',
21
+ }
22
+
23
+ /** A parsed message posted by the Fincra JS SDK via `postMessage`. */
24
+ export interface FincraBridgeMessage {
25
+ event: FincraBridgeEvent;
26
+ /** Populated only for `success` and `error` events. */
27
+ data?: FincraPaymentResponse | { message: string };
28
+ }
29
+
30
+ /**
31
+ * Parses a raw `postMessage` JSON string into a `FincraBridgeMessage`.
32
+ *
33
+ * Expected format from the HTML template:
34
+ * ```json
35
+ * { "event": "success", "data": { "reference": "...", ... } }
36
+ * ```
37
+ *
38
+ * On any parse failure, returns an `Unknown` event (mirrors Flutter's catch block).
39
+ */
40
+ export function parseMessage(jsonString: string): FincraBridgeMessage {
41
+ try {
42
+ const map = JSON.parse(jsonString) as Record<string, unknown>;
43
+ // Guard: JSON.parse('null') returns null, not an object
44
+ if (map == null || typeof map !== 'object') {
45
+ return { event: FincraBridgeEvent.Unknown };
46
+ }
47
+
48
+ const eventStr = typeof map['event'] === 'string' ? map['event'] : null;
49
+ const event = _parseEvent(eventStr);
50
+
51
+ if (
52
+ event === FincraBridgeEvent.Success &&
53
+ map['data'] != null &&
54
+ typeof map['data'] === 'object' &&
55
+ !Array.isArray(map['data'])
56
+ ) {
57
+ // Fix #5: use a spread copy instead of mutating the JSON.parse result
58
+ const rawData = map['data'] as Record<string, unknown>;
59
+ const dataMap: Record<string, unknown> = { ...rawData };
60
+
61
+ // Ensure status is always set for the response
62
+ if (!dataMap['status']) {
63
+ dataMap['status'] = 'success';
64
+ }
65
+ // Coerce all values to strings (mirrors Flutter's `.map((k,v) => MapEntry(k, v.toString()))`)
66
+ const params: Record<string, string> = {};
67
+ for (const [k, v] of Object.entries(dataMap)) {
68
+ params[k] = String(v);
69
+ }
70
+ return { event, data: UrlHandler.parsePaymentResponse(params) };
71
+ }
72
+
73
+ if (
74
+ event === FincraBridgeEvent.Error &&
75
+ map['data'] != null &&
76
+ typeof map['data'] === 'object' &&
77
+ !Array.isArray(map['data'])
78
+ ) {
79
+ const dataMap = map['data'] as Record<string, unknown>;
80
+ const errorMessage =
81
+ typeof dataMap['message'] === 'string'
82
+ ? dataMap['message']
83
+ : 'An unknown error occurred';
84
+ return { event, data: { message: errorMessage } };
85
+ }
86
+
87
+ return { event };
88
+ } catch {
89
+ return { event: FincraBridgeEvent.Unknown };
90
+ }
91
+ }
92
+
93
+ // ── Internal ──────────────────────────────────────────────────────────────────
94
+
95
+ function _parseEvent(eventStr: string | null): FincraBridgeEvent {
96
+ switch (eventStr) {
97
+ case 'ready':
98
+ return FincraBridgeEvent.Ready;
99
+ case 'success':
100
+ return FincraBridgeEvent.Success;
101
+ case 'closed':
102
+ return FincraBridgeEvent.Closed;
103
+ case 'error':
104
+ return FincraBridgeEvent.Error;
105
+ default:
106
+ return FincraBridgeEvent.Unknown;
107
+ }
108
+ }
@@ -0,0 +1,138 @@
1
+ import type { InlineCheckoutConfig } from '../types';
2
+
3
+ // ─── HTML Generator ────────────────────────────────────────────────────────────
4
+ //
5
+ // Mirrors `_generateHtml()` in flutter_fincra_checkout/lib/src/inline/inline_checkout.dart
6
+ //
7
+ // Security: ALL user-supplied string values are encoded via JSON.stringify(),
8
+ // which escapes special characters and wraps in double-quotes, preventing
9
+ // HTML/JS injection attacks (same approach as the Flutter implementation).
10
+
11
+ const FINCRA_CDN_URL =
12
+ 'https://unpkg.com/@fincra-engineering/checkout@2.2.0/dist/inline.min.js';
13
+
14
+ /**
15
+ * Narrowed config type: only the payment fields needed to generate the HTML.
16
+ * Deliberately excludes UI props (`onSuccess`, `loadingComponent`, etc.) so
17
+ * the generator can never accidentally embed callbacks as JS values.
18
+ *
19
+ * Fix #8: use a Pick instead of the full InlineCheckoutConfig.
20
+ */
21
+ export type InlinePaymentConfig = Pick<
22
+ InlineCheckoutConfig,
23
+ | 'publicKey'
24
+ | 'amount'
25
+ | 'currency'
26
+ | 'customerName'
27
+ | 'customerEmail'
28
+ | 'customerPhoneNumber'
29
+ | 'feeBearer'
30
+ | 'reference'
31
+ | 'paymentMethods'
32
+ >;
33
+
34
+ /**
35
+ * Generates the self-contained HTML page that loads the Fincra inline JS SDK,
36
+ * initializes it with the provided config, and posts lifecycle events back to
37
+ * the React Native app via `window.ReactNativeWebView.postMessage(...)`.
38
+ *
39
+ * This function is **pure** — given the same config it always returns the same
40
+ * string, making it safe to memoize with `useMemo`.
41
+ *
42
+ * @param config - The inline payment configuration (payment fields only).
43
+ * @returns A complete HTML string to be loaded into a WebView.
44
+ */
45
+ export function generateInlineHtml(config: InlinePaymentConfig): string {
46
+ // ── Safe encoding — all string values JSON-encoded to prevent injection ──
47
+ const key = JSON.stringify(config.publicKey);
48
+ const amount = config.amount; // numeric — safe to embed directly
49
+ const currency = JSON.stringify(config.currency.toUpperCase());
50
+ const name = JSON.stringify(config.customerName);
51
+ const email = JSON.stringify(config.customerEmail);
52
+ const phone = JSON.stringify(config.customerPhoneNumber);
53
+ const feeBearer = JSON.stringify(config.feeBearer);
54
+
55
+ // Optional fields — only emit the JS property if the value is present
56
+ const referenceLine =
57
+ config.reference != null
58
+ ? `reference: ${JSON.stringify(config.reference)},`
59
+ : '';
60
+
61
+ const paymentMethodsLine =
62
+ config.paymentMethods != null && config.paymentMethods.length > 0
63
+ ? `paymentMethods: ${JSON.stringify(config.paymentMethods)},`
64
+ : '';
65
+
66
+ return `<!DOCTYPE html>
67
+ <html>
68
+ <head>
69
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
70
+ <script src="${FINCRA_CDN_URL}"></script>
71
+ <style>
72
+ html, body {
73
+ margin: 0;
74
+ padding: 0;
75
+ background-color: transparent;
76
+ -webkit-overflow-scrolling: touch;
77
+ }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <script>
82
+ /**
83
+ * Posts a structured message to the React Native host.
84
+ * Uses window.ReactNativeWebView which is injected by react-native-webview.
85
+ */
86
+ function postToRN(event, data) {
87
+ if (window.ReactNativeWebView) {
88
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: event, data: data || null }));
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Retry loop — waits up to 15 seconds (150 × 100ms) for window.Fincra to load.
94
+ * Mirrors the identical pattern in the Flutter package's _generateHtml().
95
+ */
96
+ function initFincra(attempts) {
97
+ if (attempts === undefined) attempts = 0;
98
+
99
+ if (typeof Fincra === 'undefined') {
100
+ if (attempts > 150) {
101
+ postToRN('error', { message: 'Fincra SDK failed to load. Check your internet connection.' });
102
+ return;
103
+ }
104
+ setTimeout(function() { initFincra(attempts + 1); }, 100);
105
+ return;
106
+ }
107
+
108
+ // SDK is available — signal "ready" so the host hides the loading spinner
109
+ postToRN('ready', null);
110
+
111
+ var options = {
112
+ key: ${key},
113
+ amount: ${amount},
114
+ currency: ${currency},
115
+ ${referenceLine}
116
+ ${paymentMethodsLine}
117
+ feeBearer: ${feeBearer},
118
+ customer: {
119
+ name: ${name},
120
+ email: ${email},
121
+ phoneNumber: ${phone},
122
+ },
123
+ onClose: function() {
124
+ postToRN('closed', null);
125
+ },
126
+ onSuccess: function(data) {
127
+ postToRN('success', data);
128
+ },
129
+ };
130
+
131
+ Fincra.initialize(options);
132
+ }
133
+
134
+ window.onload = function() { initFincra(0); };
135
+ </script>
136
+ </body>
137
+ </html>`;
138
+ }