react-native-fincra-checkout 1.0.1 → 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.
- package/README.md +52 -10
- package/lib/commonjs/checkout/FincraCheckout.js +41 -45
- package/lib/commonjs/components/FincraInlineCheckout.js +110 -97
- package/lib/commonjs/components/FincraWebViewCheckout.js +99 -72
- package/lib/commonjs/inline/JsBridge.js +21 -15
- package/lib/commonjs/inline/htmlGenerator.js +53 -31
- package/lib/commonjs/utils/UrlHandler.js +62 -3
- package/lib/typescript/checkout/FincraCheckout.d.ts +0 -1
- package/lib/typescript/components/FincraInlineCheckout.d.ts +1 -2
- package/lib/typescript/components/FincraWebViewCheckout.d.ts +1 -1
- package/lib/typescript/inline/JsBridge.d.ts +4 -1
- package/lib/typescript/inline/htmlGenerator.d.ts +30 -0
- package/lib/typescript/types/index.d.ts +14 -3
- package/lib/typescript/utils/UrlHandler.d.ts +30 -1
- package/package.json +1 -1
- package/src/checkout/FincraCheckout.tsx +75 -69
- package/src/components/FincraInlineCheckout.tsx +202 -159
- package/src/components/FincraWebViewCheckout.tsx +184 -138
- package/src/inline/JsBridge.ts +28 -20
- package/src/inline/htmlGenerator.ts +65 -35
- package/src/types/index.ts +14 -3
- package/src/utils/UrlHandler.ts +76 -3
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ A **production-ready**, **100% TypeScript** React Native SDK for [Fincra Checkou
|
|
|
17
17
|
- ✅ **Imperative API**: `await FincraCheckout.openWebView({...})` from anywhere
|
|
18
18
|
- ✅ **Declarative API**: `<FincraWebViewCheckout />` and `<FincraInlineCheckout />`
|
|
19
19
|
- ✅ **Strongly-typed result**: Discriminated union — `success | error | cancelled`
|
|
20
|
-
- ✅ **URL interception**:
|
|
20
|
+
- ✅ **URL interception**: Strict redirect URL match (scheme, host, port, path boundary) + query-param fallback
|
|
21
21
|
- ✅ **15-second init timeout** for the Inline mode
|
|
22
22
|
- ✅ **Modern SafeAreaView** via `react-native-safe-area-context`
|
|
23
23
|
- ✅ **Built-in Error Recovery & Offline Retry UI** with custom `renderError` prop support
|
|
@@ -55,6 +55,38 @@ cd ios && pod install
|
|
|
55
55
|
> - For **Inline Checkout**: Only your **public key** (`pk_...`) is used. This is safe to bundle.
|
|
56
56
|
>
|
|
57
57
|
> Storing secret keys in client code exposes them to reverse engineering and can lead to fraudulent transactions.
|
|
58
|
+
>
|
|
59
|
+
> **Expo users:** every `EXPO_PUBLIC_*` environment variable is inlined into the JS bundle. Never give your secret key that prefix.
|
|
60
|
+
|
|
61
|
+
## ⚠️ Verify every payment server-side
|
|
62
|
+
|
|
63
|
+
> The SDK result is a **UX signal, not proof of payment.** A redirect or a JS callback can be missing data or be tampered with on the device, and in WebView mode a redirect **without** a `status` / `payment_status` parameter is reported as `success` (the Fincra sandbox omits it).
|
|
64
|
+
>
|
|
65
|
+
> **Before fulfilling an order, always verify the transaction on your backend** — via the Fincra API (look up the `reference`) or your Fincra webhook.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Creating a hosted checkout link (server-side)
|
|
70
|
+
|
|
71
|
+
WebView mode needs a hosted checkout link, created **on your server** with your secret key:
|
|
72
|
+
|
|
73
|
+
```http
|
|
74
|
+
POST https://sandboxapi.fincra.com/checkout/payments (production: https://api.fincra.com/checkout/payments)
|
|
75
|
+
api-key: <your SECRET key>
|
|
76
|
+
x-pub-key: <your PUBLIC key>
|
|
77
|
+
content-type: application/json
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
"amount": 5000,
|
|
81
|
+
"currency": "NGN",
|
|
82
|
+
"customer": { "name": "Jane Doe", "email": "jane@example.com" },
|
|
83
|
+
"redirectUrl": "https://api.yourapp.com/payment/callback",
|
|
84
|
+
"reference": "ORDER-001", // optional
|
|
85
|
+
"feeBearer": "business" // optional
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The link is in the response at `data.link`. Return it to the app and pass it as `checkoutUrl`, with the same `redirectUrl`. The example app ships a script for this: `cd example && npm run checkout-link`.
|
|
58
90
|
|
|
59
91
|
---
|
|
60
92
|
|
|
@@ -142,7 +174,7 @@ async function handleInlinePayment() {
|
|
|
142
174
|
currency: 'NGN',
|
|
143
175
|
customerEmail: 'customer@example.com',
|
|
144
176
|
customerName: 'Jane Doe',
|
|
145
|
-
customerPhoneNumber: '08012345678',
|
|
177
|
+
customerPhoneNumber: '08012345678', // optional
|
|
146
178
|
feeBearer: 'customer',
|
|
147
179
|
reference: 'ORDER-001', // optional — Fincra generates one if omitted
|
|
148
180
|
paymentMethods: ['card', 'bank_transfer'], // optional
|
|
@@ -203,7 +235,6 @@ function InlinePaymentScreen() {
|
|
|
203
235
|
currency="NGN"
|
|
204
236
|
customerEmail="customer@example.com"
|
|
205
237
|
customerName="John Doe"
|
|
206
|
-
customerPhoneNumber="08099887766"
|
|
207
238
|
feeBearer="business"
|
|
208
239
|
onSuccess={(response) => console.log(response)}
|
|
209
240
|
onFailed={(error) => console.error(error)}
|
|
@@ -229,9 +260,9 @@ import type {
|
|
|
229
260
|
} from 'react-native-fincra-checkout';
|
|
230
261
|
|
|
231
262
|
// Discriminated union result
|
|
232
|
-
|
|
263
|
+
type FincraCheckoutResult =
|
|
233
264
|
| { type: 'success'; response: FincraPaymentResponse }
|
|
234
|
-
| { type: 'error';
|
|
265
|
+
| { type: 'error'; error: FincraPaymentError }
|
|
235
266
|
| { type: 'cancelled' };
|
|
236
267
|
```
|
|
237
268
|
|
|
@@ -255,7 +286,9 @@ const result: FincraCheckoutResult =
|
|
|
255
286
|
| `headerTintColor` | `string` | `'#000000'` | Nav bar text/icon color |
|
|
256
287
|
| `showCancelConfirmationDialog` | `boolean` | `false` | Show Alert before closing |
|
|
257
288
|
| `loadingComponent` | `ReactNode` | `ActivityIndicator` | Custom loading spinner |
|
|
289
|
+
| `showCloseButton` | `boolean` | `true` | Show the ✕ in the header. If hidden, iOS users can't leave while the page loads or hangs (the error screen's Cancel and Android back still work) |
|
|
258
290
|
| `closeIcon` | `ReactNode` | `✕` text | Custom close button content |
|
|
291
|
+
| `renderError` | `(error, retry) => ReactNode` | built-in | Custom load-error screen |
|
|
259
292
|
|
|
260
293
|
### `WebViewCheckoutConfig`
|
|
261
294
|
|
|
@@ -269,11 +302,11 @@ const result: FincraCheckoutResult =
|
|
|
269
302
|
| Prop | Type | Required | Description |
|
|
270
303
|
|---|---|---|---|
|
|
271
304
|
| `publicKey` | `string` | ✅ | Your Fincra public key (`pk_...`) |
|
|
272
|
-
| `amount` | `number` | ✅ | Amount
|
|
305
|
+
| `amount` | `number` | ✅ | Amount to charge (a number, not a string) |
|
|
273
306
|
| `currency` | `FincraCurrency` | ✅ | Payment currency |
|
|
274
307
|
| `customerEmail` | `string` | ✅ | Customer email |
|
|
275
308
|
| `customerName` | `string` | ✅ | Customer full name |
|
|
276
|
-
| `customerPhoneNumber` | `string` |
|
|
309
|
+
| `customerPhoneNumber` | `string` | — | Customer phone number. Trimmed; omitted from the request when blank |
|
|
277
310
|
| `feeBearer` | `FeeBearer` | ✅ | `'business'` or `'customer'` |
|
|
278
311
|
| `reference` | `string` | — | Custom transaction reference |
|
|
279
312
|
| `paymentMethods` | `string[]` | — | Restrict to specific methods |
|
|
@@ -284,8 +317,17 @@ const result: FincraCheckoutResult =
|
|
|
284
317
|
|
|
285
318
|
The WebView mode intercepts navigation requests:
|
|
286
319
|
|
|
287
|
-
1. **If `redirectUrl` is set
|
|
288
|
-
|
|
320
|
+
1. **If `redirectUrl` is set**, a URL completes the checkout only if it matches strictly:
|
|
321
|
+
- same scheme, host (case-insensitive) and port (`:443` / `:80` defaults normalised);
|
|
322
|
+
- path equal to the redirect path, or continuing it at a `/` (`/callback`, `/callback/`, `/callback/done` match; `/callbacks-other` does not);
|
|
323
|
+
- query string and fragment are ignored; a redirect URL with no path matches any path on that host.
|
|
324
|
+
|
|
325
|
+
Lookalike hosts such as `https://myapp.com.evil.io/callback` never match.
|
|
326
|
+
2. **Fallback** (no `redirectUrl`): completion is detected when both `status` (or `payment_status`) **and** `reference` query params are present.
|
|
327
|
+
|
|
328
|
+
The status is read from `status`, falling back to `payment_status`. `success` / `successful` → `onSuccess`; any other value → `onFailed` with `code` = the status and `message` = the `message` param (or `'Payment failed'`). A **missing** status is treated as success — see **Verify every payment server-side** above.
|
|
329
|
+
|
|
330
|
+
Page-load errors (main frame only) show a Retry / Cancel screen; they never report the payment as failed.
|
|
289
331
|
|
|
290
332
|
Response parameters are normalized:
|
|
291
333
|
- `customerReference` → `reference` (preferred)
|
|
@@ -300,7 +342,7 @@ Response parameters are normalized:
|
|
|
300
342
|
npm test
|
|
301
343
|
```
|
|
302
344
|
|
|
303
|
-
Tests cover `UrlHandler` (
|
|
345
|
+
Tests cover `UrlHandler` (strict redirect matching, status extraction, reference normalization), `JsBridge` (event parsing, data normalization), the inline HTML/options builder, and the checkout components and host (settle-once, late callbacks, back button) — no device or emulator required.
|
|
304
346
|
|
|
305
347
|
---
|
|
306
348
|
|
|
@@ -62,64 +62,61 @@ const FincraInlineCheckout_1 = require("../components/FincraInlineCheckout");
|
|
|
62
62
|
exports.FincraCheckoutHost = (0, react_1.forwardRef)(function FincraCheckoutHost(_props, ref) {
|
|
63
63
|
const [modalState, setModalState] = (0, react_1.useState)({ mode: null });
|
|
64
64
|
const resolveRef = (0, react_1.useRef)(null);
|
|
65
|
-
//
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// Id of the session that may still settle; null once it has settled.
|
|
66
|
+
const activeSessionRef = (0, react_1.useRef)(null);
|
|
67
|
+
const nextSessionIdRef = (0, react_1.useRef)(0);
|
|
68
|
+
// ── Settle and dismiss ─────────────────────────────────────────────────────
|
|
69
|
+
// Single exit point. A session settles at most once: a late callback (e.g. a
|
|
70
|
+
// bridge message after the Android back button closed the modal) is
|
|
71
|
+
// ignored, so the merchant's callbacks never fire twice.
|
|
72
|
+
const settleSession = (0, react_1.useCallback)((sessionId, result, notify) => {
|
|
73
|
+
if (activeSessionRef.current !== sessionId)
|
|
74
|
+
return;
|
|
75
|
+
activeSessionRef.current = null;
|
|
76
|
+
const res = resolveRef.current;
|
|
69
77
|
resolveRef.current = null;
|
|
70
|
-
|
|
78
|
+
setModalState({ mode: null });
|
|
79
|
+
notify?.();
|
|
80
|
+
res?.(result);
|
|
81
|
+
}, [setModalState]);
|
|
71
82
|
// ── Expose imperative methods via ref ──────────────────────────────────────
|
|
72
|
-
(0, react_1.useImperativeHandle)(ref, () =>
|
|
83
|
+
(0, react_1.useImperativeHandle)(ref, () => {
|
|
73
84
|
// Fix #1: guard against double-open — reject instead of orphaning the
|
|
74
85
|
// pending Promise and silently clobbering resolveRef.
|
|
75
|
-
|
|
76
|
-
if (resolveRef.current) {
|
|
77
|
-
return Promise.reject(new Error('[FincraCheckout] A checkout session is already open. ' +
|
|
78
|
-
'Await the current session before opening another.'));
|
|
79
|
-
}
|
|
80
|
-
return new Promise((res) => {
|
|
81
|
-
resolveRef.current = res;
|
|
82
|
-
setModalState({ mode: 'webview', webViewConfig: config });
|
|
83
|
-
});
|
|
84
|
-
},
|
|
85
|
-
_openInline(config) {
|
|
86
|
+
const open = (next) => {
|
|
86
87
|
if (resolveRef.current) {
|
|
87
88
|
return Promise.reject(new Error('[FincraCheckout] A checkout session is already open. ' +
|
|
88
89
|
'Await the current session before opening another.'));
|
|
89
90
|
}
|
|
90
91
|
return new Promise((res) => {
|
|
92
|
+
const sessionId = ++nextSessionIdRef.current;
|
|
93
|
+
activeSessionRef.current = sessionId;
|
|
91
94
|
resolveRef.current = res;
|
|
92
|
-
setModalState({
|
|
95
|
+
setModalState({ ...next, sessionId });
|
|
93
96
|
});
|
|
94
|
-
}
|
|
95
|
-
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
_openWebView: (config) => open({ mode: 'webview', webViewConfig: config }),
|
|
100
|
+
_openInline: (config) => open({ mode: 'inline', inlineConfig: config }),
|
|
101
|
+
};
|
|
102
|
+
}, [setModalState]);
|
|
96
103
|
const isVisible = modalState.mode !== null;
|
|
104
|
+
const sessionId = modalState.sessionId ?? -1;
|
|
97
105
|
// ── Shared callback builders ───────────────────────────────────────────────
|
|
98
|
-
const buildCallbacks = (
|
|
99
|
-
onSuccess: (response) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
resolve({ type: 'cancelled' });
|
|
110
|
-
},
|
|
111
|
-
}), [resolve]);
|
|
112
|
-
return (react_1.default.createElement(react_native_1.Modal, { visible: isVisible, animationType: "slide", presentationStyle: "fullScreen", statusBarTranslucent: true, onRequestClose: () => {
|
|
113
|
-
// Android hardware back — treat as cancellation
|
|
114
|
-
const cfg = modalState.webViewConfig ?? modalState.inlineConfig;
|
|
115
|
-
if (cfg) {
|
|
116
|
-
cfg.onCancelled?.();
|
|
117
|
-
}
|
|
118
|
-
resolve({ type: 'cancelled' });
|
|
119
|
-
} },
|
|
106
|
+
const buildCallbacks = (config) => ({
|
|
107
|
+
onSuccess: (response) => settleSession(sessionId, { type: 'success', response }, () => config.onSuccess?.(response)),
|
|
108
|
+
onFailed: (error) => settleSession(sessionId, { type: 'error', error }, () => config.onFailed?.(error)),
|
|
109
|
+
onCancelled: () => settleSession(sessionId, { type: 'cancelled' }, () => config.onCancelled?.()),
|
|
110
|
+
});
|
|
111
|
+
// Android hardware back (Modal intercepts it) — settles as cancelled.
|
|
112
|
+
const handleRequestClose = () => {
|
|
113
|
+
const cfg = modalState.webViewConfig ?? modalState.inlineConfig;
|
|
114
|
+
settleSession(sessionId, { type: 'cancelled' }, () => cfg?.onCancelled?.());
|
|
115
|
+
};
|
|
116
|
+
return (react_1.default.createElement(react_native_1.Modal, { visible: isVisible, animationType: "slide", presentationStyle: "fullScreen", statusBarTranslucent: true, onRequestClose: handleRequestClose },
|
|
120
117
|
react_1.default.createElement(react_native_1.View, { style: styles.fullscreen },
|
|
121
|
-
modalState.mode === 'webview' && modalState.webViewConfig && (react_1.default.createElement(FincraWebViewCheckout_1.FincraWebViewCheckout, { ...modalState.webViewConfig, ...buildCallbacks(modalState.webViewConfig) })),
|
|
122
|
-
modalState.mode === 'inline' && modalState.inlineConfig && (react_1.default.createElement(FincraInlineCheckout_1.FincraInlineCheckout, { ...modalState.inlineConfig, ...buildCallbacks(modalState.inlineConfig) })))));
|
|
118
|
+
modalState.mode === 'webview' && modalState.webViewConfig && (react_1.default.createElement(FincraWebViewCheckout_1.FincraWebViewCheckout, { key: sessionId, ...modalState.webViewConfig, ...buildCallbacks(modalState.webViewConfig) })),
|
|
119
|
+
modalState.mode === 'inline' && modalState.inlineConfig && (react_1.default.createElement(FincraInlineCheckout_1.FincraInlineCheckout, { key: sessionId, ...modalState.inlineConfig, ...buildCallbacks(modalState.inlineConfig) })))));
|
|
123
120
|
});
|
|
124
121
|
// ─── Singleton Ref ─────────────────────────────────────────────────────────────
|
|
125
122
|
// A module-level ref that FincraCheckout.open*() calls are routed through.
|
|
@@ -163,7 +160,6 @@ function _unregisterHostRef() {
|
|
|
163
160
|
* currency: 'NGN',
|
|
164
161
|
* customerEmail: 'user@example.com',
|
|
165
162
|
* customerName: 'Jane Doe',
|
|
166
|
-
* customerPhoneNumber: '08012345678',
|
|
167
163
|
* feeBearer: 'customer',
|
|
168
164
|
* });
|
|
169
165
|
*
|
|
@@ -61,43 +61,63 @@ const INIT_TIMEOUT_MS = 15000;
|
|
|
61
61
|
* currency="NGN"
|
|
62
62
|
* customerEmail="user@example.com"
|
|
63
63
|
* customerName="John Doe"
|
|
64
|
-
* customerPhoneNumber="08012345678"
|
|
65
64
|
* feeBearer="customer"
|
|
66
65
|
* onSuccess={(res) => console.log(res.reference)}
|
|
67
66
|
* onCancelled={() => navigation.goBack()}
|
|
68
67
|
* />
|
|
69
68
|
* ```
|
|
70
69
|
*/
|
|
71
|
-
function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroundColor = '#FFFFFF', headerTintColor = '#000000', showCancelConfirmationDialog = false, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled,
|
|
70
|
+
function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroundColor = '#FFFFFF', headerTintColor = '#000000', showCancelConfirmationDialog = false, showCloseButton = true, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled,
|
|
72
71
|
// Payment fields — everything the HTML generator needs
|
|
73
72
|
...paymentConfig }) {
|
|
74
73
|
const [isLoading, setIsLoading] = (0, react_1.useState)(true);
|
|
75
74
|
const [errorState, setErrorState] = (0, react_1.useState)(null);
|
|
76
75
|
const [reloadKey, setReloadKey] = (0, react_1.useState)(0);
|
|
77
76
|
const webViewRef = (0, react_1.useRef)(null);
|
|
78
|
-
|
|
77
|
+
// `settledRef`: a result (success / error / cancel) has been delivered.
|
|
78
|
+
// `isMountedRef`: late bridge messages after unmount must be ignored.
|
|
79
|
+
const settledRef = (0, react_1.useRef)(false);
|
|
80
|
+
const isMountedRef = (0, react_1.useRef)(false);
|
|
79
81
|
const timeoutRef = (0, react_1.useRef)(null);
|
|
82
|
+
(0, react_1.useEffect)(() => {
|
|
83
|
+
isMountedRef.current = true;
|
|
84
|
+
return () => {
|
|
85
|
+
isMountedRef.current = false;
|
|
86
|
+
};
|
|
87
|
+
}, []);
|
|
88
|
+
/** True while callbacks may still update state or deliver a result. */
|
|
89
|
+
const isActive = (0, react_1.useCallback)(() => isMountedRef.current && !settledRef.current, []);
|
|
90
|
+
const clearInitTimeout = (0, react_1.useCallback)(() => {
|
|
91
|
+
if (timeoutRef.current)
|
|
92
|
+
clearTimeout(timeoutRef.current);
|
|
93
|
+
timeoutRef.current = null;
|
|
94
|
+
}, []);
|
|
95
|
+
/** Single exit point — delivers at most one result per session. */
|
|
96
|
+
const settle = (0, react_1.useCallback)((deliver) => {
|
|
97
|
+
if (!isMountedRef.current || settledRef.current)
|
|
98
|
+
return;
|
|
99
|
+
settledRef.current = true;
|
|
100
|
+
clearInitTimeout();
|
|
101
|
+
deliver();
|
|
102
|
+
}, [clearInitTimeout]);
|
|
80
103
|
// Fix #3: Memoize HTML generation — prevents WebView reload on parent re-renders.
|
|
81
104
|
// The HTML is intentionally generated once per mount; payment params are immutable.
|
|
82
105
|
const configJson = JSON.stringify(paymentConfig);
|
|
83
106
|
const html = (0, react_1.useMemo)(() => (0, htmlGenerator_1.generateInlineHtml)(JSON.parse(configJson)), [configJson]);
|
|
84
|
-
// ── 15-second init timeout
|
|
107
|
+
// ── 15-second init timeout (re-armed on every Retry) ────────────────────────
|
|
85
108
|
(0, react_1.useEffect)(() => {
|
|
86
109
|
timeoutRef.current = setTimeout(() => {
|
|
87
|
-
if (!
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
if (!isActive())
|
|
111
|
+
return;
|
|
112
|
+
const err = {
|
|
113
|
+
code: 'timeout',
|
|
114
|
+
message: 'Fincra Checkout failed to load. Please check your internet connection.',
|
|
115
|
+
};
|
|
116
|
+
setErrorState(err);
|
|
117
|
+
setIsLoading(false);
|
|
95
118
|
}, INIT_TIMEOUT_MS);
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
clearTimeout(timeoutRef.current);
|
|
99
|
-
};
|
|
100
|
-
}, []);
|
|
119
|
+
return clearInitTimeout;
|
|
120
|
+
}, [reloadKey, isActive, clearInitTimeout]);
|
|
101
121
|
// Fix #10: Stable ref for handleCancellation so the BackHandler effect
|
|
102
122
|
// doesn't need it as a dependency and never captures a stale closure.
|
|
103
123
|
const handleCancellationRef = (0, react_1.useRef)(() => { });
|
|
@@ -111,30 +131,26 @@ function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroun
|
|
|
111
131
|
});
|
|
112
132
|
return () => subscription.remove();
|
|
113
133
|
}, []); // safe: always calls through the ref
|
|
114
|
-
// ──
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
134
|
+
// ── Load errors (main frame only — see FincraWebViewCheckout) ───────────────
|
|
135
|
+
const showLoadError = (0, react_1.useCallback)((err) => {
|
|
136
|
+
if (!isActive())
|
|
117
137
|
return;
|
|
118
|
-
|
|
119
|
-
const err = {
|
|
120
|
-
code: String(nativeEvent.code ?? 'webview_error'),
|
|
121
|
-
message: nativeEvent.description ?? 'A WebView error occurred.',
|
|
122
|
-
};
|
|
138
|
+
clearInitTimeout();
|
|
123
139
|
setErrorState(err);
|
|
124
140
|
setIsLoading(false);
|
|
125
|
-
}, [setErrorState, setIsLoading]);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
}, [isActive, clearInitTimeout, setErrorState, setIsLoading]);
|
|
142
|
+
const handleError = (0, react_1.useCallback)(({ nativeEvent }) => {
|
|
143
|
+
showLoadError({
|
|
144
|
+
code: String(nativeEvent.code ?? 'webview_error'),
|
|
145
|
+
message: nativeEvent.description ?? 'A WebView error occurred.',
|
|
146
|
+
});
|
|
147
|
+
}, [showLoadError]);
|
|
148
|
+
const handleHttpError = (0, react_1.useCallback)(({ nativeEvent }) => {
|
|
149
|
+
showLoadError({
|
|
132
150
|
code: String(nativeEvent.statusCode ?? 'http_error'),
|
|
133
151
|
message: nativeEvent.description ?? 'A WebView HTTP error occurred.',
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
setIsLoading(false);
|
|
137
|
-
}, [setErrorState, setIsLoading]);
|
|
152
|
+
});
|
|
153
|
+
}, [showLoadError]);
|
|
138
154
|
// ── Retry handler ───────────────────────────────────────────────────────────
|
|
139
155
|
const handleRetry = (0, react_1.useCallback)(() => {
|
|
140
156
|
setErrorState(null);
|
|
@@ -142,46 +158,36 @@ function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroun
|
|
|
142
158
|
setReloadKey((key) => key + 1);
|
|
143
159
|
}, [setErrorState, setIsLoading, setReloadKey]);
|
|
144
160
|
// ── JS Bridge message handler ───────────────────────────────────────────────
|
|
161
|
+
// Messages that arrive after the session settled (e.g. closed via the
|
|
162
|
+
// Android back button) or after unmount are ignored.
|
|
145
163
|
const handleMessage = (0, react_1.useCallback)((event) => {
|
|
146
|
-
if (
|
|
164
|
+
if (!isActive())
|
|
147
165
|
return;
|
|
148
166
|
const msg = (0, JsBridge_1.parseMessage)(event.nativeEvent.data);
|
|
149
167
|
switch (msg.event) {
|
|
150
168
|
case JsBridge_1.FincraBridgeEvent.Ready:
|
|
151
169
|
// SDK loaded — clear timeout and hide the loading spinner
|
|
152
|
-
|
|
153
|
-
clearTimeout(timeoutRef.current);
|
|
170
|
+
clearInitTimeout();
|
|
154
171
|
setIsLoading(false);
|
|
155
172
|
break;
|
|
156
|
-
case JsBridge_1.FincraBridgeEvent.Success:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (msg.data && 'reference' in msg.data) {
|
|
161
|
-
onSuccess?.(msg.data);
|
|
162
|
-
}
|
|
163
|
-
else {
|
|
164
|
-
onCancelled?.();
|
|
165
|
-
}
|
|
173
|
+
case JsBridge_1.FincraBridgeEvent.Success: {
|
|
174
|
+
// Always a success, even without data (empty references).
|
|
175
|
+
const response = msg.data;
|
|
176
|
+
settle(() => onSuccess?.(response));
|
|
166
177
|
break;
|
|
178
|
+
}
|
|
167
179
|
case JsBridge_1.FincraBridgeEvent.Closed:
|
|
168
|
-
|
|
169
|
-
if (timeoutRef.current)
|
|
170
|
-
clearTimeout(timeoutRef.current);
|
|
171
|
-
onCancelled?.();
|
|
180
|
+
settle(() => onCancelled?.());
|
|
172
181
|
break;
|
|
173
182
|
case JsBridge_1.FincraBridgeEvent.Error: {
|
|
174
|
-
|
|
175
|
-
if (timeoutRef.current)
|
|
176
|
-
clearTimeout(timeoutRef.current);
|
|
177
|
-
const errorMessage = msg.data && 'message' in msg.data
|
|
183
|
+
const errorMessage = msg.data && 'message' in msg.data && msg.data.message
|
|
178
184
|
? msg.data.message
|
|
179
185
|
: 'An unknown error occurred';
|
|
180
186
|
const err = {
|
|
181
187
|
code: 'fincra_sdk_error',
|
|
182
188
|
message: errorMessage,
|
|
183
189
|
};
|
|
184
|
-
onFailed?.(err);
|
|
190
|
+
settle(() => onFailed?.(err));
|
|
185
191
|
break;
|
|
186
192
|
}
|
|
187
193
|
case JsBridge_1.FincraBridgeEvent.Unknown:
|
|
@@ -189,11 +195,19 @@ function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroun
|
|
|
189
195
|
// Ignore — mirrors Flutter's `case FincraBridgeEvent.unknown: break;`
|
|
190
196
|
break;
|
|
191
197
|
}
|
|
192
|
-
}, [
|
|
198
|
+
}, [
|
|
199
|
+
isActive,
|
|
200
|
+
clearInitTimeout,
|
|
201
|
+
settle,
|
|
202
|
+
setIsLoading,
|
|
203
|
+
onSuccess,
|
|
204
|
+
onFailed,
|
|
205
|
+
onCancelled,
|
|
206
|
+
]);
|
|
193
207
|
// ── Cancellation ────────────────────────────────────────────────────────────
|
|
194
208
|
// Fix #4: Use static Alert import — no dynamic require() needed.
|
|
195
209
|
const handleCancellation = (0, react_1.useCallback)(() => {
|
|
196
|
-
if (
|
|
210
|
+
if (!isActive())
|
|
197
211
|
return;
|
|
198
212
|
if (showCancelConfirmationDialog) {
|
|
199
213
|
react_native_1.Alert.alert('Cancel Payment?', 'Are you sure you want to cancel this payment?', [
|
|
@@ -201,24 +215,15 @@ function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroun
|
|
|
201
215
|
{
|
|
202
216
|
text: 'Yes',
|
|
203
217
|
style: 'destructive',
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
hasCompleted.current = true;
|
|
207
|
-
if (timeoutRef.current)
|
|
208
|
-
clearTimeout(timeoutRef.current);
|
|
209
|
-
onCancelled?.();
|
|
210
|
-
}
|
|
211
|
-
},
|
|
218
|
+
// May fire after unmount — settle() ignores it then.
|
|
219
|
+
onPress: () => settle(() => onCancelled?.()),
|
|
212
220
|
},
|
|
213
221
|
]);
|
|
214
222
|
}
|
|
215
223
|
else {
|
|
216
|
-
|
|
217
|
-
if (timeoutRef.current)
|
|
218
|
-
clearTimeout(timeoutRef.current);
|
|
219
|
-
onCancelled?.();
|
|
224
|
+
settle(() => onCancelled?.());
|
|
220
225
|
}
|
|
221
|
-
}, [showCancelConfirmationDialog, onCancelled]);
|
|
226
|
+
}, [isActive, showCancelConfirmationDialog, settle, onCancelled]);
|
|
222
227
|
// Keep the ref in sync with the latest handleCancellation (Fix #10)
|
|
223
228
|
(0, react_1.useEffect)(() => {
|
|
224
229
|
handleCancellationRef.current = handleCancellation;
|
|
@@ -226,30 +231,35 @@ function FincraInlineCheckout({ headerTitle = 'Secure Checkout', headerBackgroun
|
|
|
226
231
|
// ── Computed status bar style (Fix #11) ─────────────────────────────────────
|
|
227
232
|
const statusBarStyle = headerTintColor === '#000000' ? 'dark-content' : 'light-content';
|
|
228
233
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
react_1.default.createElement(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
react_1.default.createElement(react_native_1.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.
|
|
234
|
+
// Own SafeAreaProvider: SafeAreaView reads insets from the nearest provider,
|
|
235
|
+
// and a Modal (FincraCheckoutHost) is a separate native tree. Without one the
|
|
236
|
+
// insets are 0 and the header sits under the status bar / Dynamic Island,
|
|
237
|
+
// where iOS swallows taps. Nested providers are fine if the app has its own.
|
|
238
|
+
return (react_1.default.createElement(react_native_safe_area_context_1.SafeAreaProvider, { style: styles.provider },
|
|
239
|
+
react_1.default.createElement(react_native_safe_area_context_1.SafeAreaView, { style: styles.container },
|
|
240
|
+
react_1.default.createElement(react_native_1.StatusBar, { barStyle: statusBarStyle, backgroundColor: headerBackgroundColor }),
|
|
241
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.header, { backgroundColor: headerBackgroundColor }] },
|
|
242
|
+
showCloseButton ? (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")))) : (react_1.default.createElement(react_native_1.View, { style: styles.closeButton })),
|
|
243
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.headerTitle, { color: headerTintColor }], numberOfLines: 1 }, headerTitle),
|
|
244
|
+
react_1.default.createElement(react_native_1.View, { style: styles.closeButton })),
|
|
245
|
+
react_1.default.createElement(react_native_1.View, { style: styles.webViewContainer },
|
|
246
|
+
react_1.default.createElement(react_native_webview_1.WebView, { key: reloadKey, ref: webViewRef,
|
|
247
|
+
// Fix #14: restrict to HTTPS + about:blank only (removed wildcard)
|
|
248
|
+
originWhitelist: ['https://*', 'about:blank'], source: { html }, style: styles.webView, javaScriptEnabled: true, domStorageEnabled: true, onMessage: handleMessage, onError: handleError, onHttpError: handleHttpError,
|
|
249
|
+
// Inject the ReactNativeWebView bridge shim so older WKWebView versions work
|
|
250
|
+
injectedJavaScriptBeforeContentLoaded: WEBVIEW_BRIDGE_SHIM,
|
|
251
|
+
// Allow the external CDN script to load
|
|
252
|
+
mixedContentMode: "always" }),
|
|
253
|
+
isLoading && !errorState && (react_1.default.createElement(react_native_1.View, { style: styles.loadingOverlay },
|
|
254
|
+
react_1.default.createElement(react_native_1.View, { style: styles.loadingCard }, loadingComponent ?? (react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: "#0066FF" }))))),
|
|
255
|
+
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 },
|
|
256
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorIcon }, "\u26A0\uFE0F"),
|
|
257
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorTitle }, "Connection Error"),
|
|
258
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorMessage }, errorState.message),
|
|
259
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.retryButton, onPress: handleRetry, accessibilityRole: "button", accessibilityLabel: "Retry loading checkout" },
|
|
260
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.retryButtonText }, "Retry")),
|
|
261
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.cancelButton, onPress: handleCancellation, accessibilityRole: "button", accessibilityLabel: "Cancel checkout" },
|
|
262
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.cancelButtonText }, "Cancel"))))))))));
|
|
253
263
|
}
|
|
254
264
|
// ─── WebView Bridge Shim ───────────────────────────────────────────────────────
|
|
255
265
|
// Ensures `window.ReactNativeWebView` is available before page scripts run.
|
|
@@ -269,6 +279,9 @@ const WEBVIEW_BRIDGE_SHIM = `
|
|
|
269
279
|
`;
|
|
270
280
|
// ─── Styles ────────────────────────────────────────────────────────────────────
|
|
271
281
|
const styles = react_native_1.StyleSheet.create({
|
|
282
|
+
provider: {
|
|
283
|
+
flex: 1,
|
|
284
|
+
},
|
|
272
285
|
container: {
|
|
273
286
|
flex: 1,
|
|
274
287
|
backgroundColor: '#FFFFFF',
|