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.
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**: Redirect URL prefix match + query-param fallback
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
- const result: FincraCheckoutResult =
263
+ type FincraCheckoutResult =
233
264
  | { type: 'success'; response: FincraPaymentResponse }
234
- | { type: 'error'; error: FincraPaymentError }
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 in smallest currency unit |
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` | ✅ | Customer phone number |
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**: Any URL starting with `redirectUrl` triggers completion (prefix match — mirrors Flutter's `url.startsWith(redirectUrl)`).
288
- 2. **Fallback** (no `redirectUrl`): Completion is detected when both `status` (or `payment_status`) **and** `reference` query params are present.
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,13 @@ Response parameters are normalized:
300
342
  npm test
301
343
  ```
302
344
 
303
- Tests cover `UrlHandler` (URL detection, param extraction, reference normalization) and `JsBridge` (event parsing, data coercion, malformed input handling) — no device or emulator required.
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.
346
+
347
+ ---
348
+
349
+ ## Changelog
350
+
351
+ See [CHANGELOG.md](./CHANGELOG.md) for a list of release notes and changes.
304
352
 
305
353
  ---
306
354
 
@@ -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
- // ── Resolve and dismiss ────────────────────────────────────────────────────
66
- const resolve = (0, react_1.useCallback)((result) => {
67
- setModalState({ mode: null });
68
- resolveRef.current?.(result);
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
- _openWebView(config) {
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({ mode: 'inline', inlineConfig: config });
95
+ setModalState({ ...next, sessionId });
93
96
  });
94
- },
95
- }), [ /* resolve not needed — used via resolveRef */]);
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 = (0, react_1.useCallback)((config) => ({
99
- onSuccess: (response) => {
100
- config.onSuccess?.(response);
101
- resolve({ type: 'success', response });
102
- },
103
- onFailed: (error) => {
104
- config.onFailed?.(error);
105
- resolve({ type: 'error', error });
106
- },
107
- onCancelled: () => {
108
- config.onCancelled?.();
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
- const hasCompleted = (0, react_1.useRef)(false);
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 (!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
- }
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
- if (timeoutRef.current)
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
- // ── WebView error ───────────────────────────────────────────────────────────
115
- const handleError = (0, react_1.useCallback)((syntheticEvent) => {
116
- if (hasCompleted.current)
134
+ // ── Load errors (main frame only — see FincraWebViewCheckout) ───────────────
135
+ const showLoadError = (0, react_1.useCallback)((err) => {
136
+ if (!isActive())
117
137
  return;
118
- const { nativeEvent } = syntheticEvent;
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
- // ── HTTP error ──────────────────────────────────────────────────────────────
127
- const handleHttpError = (0, react_1.useCallback)((syntheticEvent) => {
128
- if (hasCompleted.current)
129
- return;
130
- const { nativeEvent } = syntheticEvent;
131
- const err = {
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
- setErrorState(err);
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 (hasCompleted.current)
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
- if (timeoutRef.current)
153
- clearTimeout(timeoutRef.current);
170
+ clearInitTimeout();
154
171
  setIsLoading(false);
155
172
  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
- }
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
- hasCompleted.current = true;
169
- if (timeoutRef.current)
170
- clearTimeout(timeoutRef.current);
171
- onCancelled?.();
180
+ settle(() => onCancelled?.());
172
181
  break;
173
182
  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
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
- }, [onSuccess, onFailed, onCancelled, setIsLoading]);
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 (hasCompleted.current)
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
- onPress: () => {
205
- if (!hasCompleted.current) {
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
- hasCompleted.current = true;
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
- 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")))))))));
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',