react-native-fincra-checkout 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,10 @@ import {
10
10
  TouchableOpacity,
11
11
  View,
12
12
  } from 'react-native';
13
- import { SafeAreaView } from 'react-native-safe-area-context';
13
+ import {
14
+ SafeAreaProvider,
15
+ SafeAreaView,
16
+ } from 'react-native-safe-area-context';
14
17
  import type {
15
18
  WebViewNavigation,
16
19
  WebViewErrorEvent,
@@ -53,6 +56,7 @@ export function FincraWebViewCheckout({
53
56
  headerBackgroundColor = '#FFFFFF',
54
57
  headerTintColor = '#000000',
55
58
  showCancelConfirmationDialog = false,
59
+ showCloseButton = true,
56
60
  loadingComponent,
57
61
  closeIcon,
58
62
  renderError,
@@ -64,7 +68,30 @@ export function FincraWebViewCheckout({
64
68
  const [errorState, setErrorState] = useState<FincraPaymentError | null>(null);
65
69
  const [reloadKey, setReloadKey] = useState(0);
66
70
  const webViewRef = useRef<WebView<object> | null>(null);
67
- const hasCompleted = useRef(false);
71
+ // `settledRef`: a result (success / error / cancel) has been delivered.
72
+ // `isMountedRef`: late native callbacks after unmount must be ignored.
73
+ const settledRef = useRef(false);
74
+ const isMountedRef = useRef(false);
75
+
76
+ useEffect(() => {
77
+ isMountedRef.current = true;
78
+ return () => {
79
+ isMountedRef.current = false;
80
+ };
81
+ }, []);
82
+
83
+ /** True while callbacks may still update state or deliver a result. */
84
+ const isActive = useCallback(
85
+ () => isMountedRef.current && !settledRef.current,
86
+ []
87
+ );
88
+
89
+ /** Single exit point — delivers at most one result per session. */
90
+ const settle = useCallback((deliver: () => void) => {
91
+ if (!isMountedRef.current || settledRef.current) return;
92
+ settledRef.current = true;
93
+ deliver();
94
+ }, []);
68
95
 
69
96
  // Fix #10: Stable ref for handleCancellation — the BackHandler effect
70
97
  // always calls through this ref, never capturing a stale closure.
@@ -86,25 +113,24 @@ export function FincraWebViewCheckout({
86
113
  // ── Completion handler ──────────────────────────────────────────────────────
87
114
  const handleCompletion = useCallback(
88
115
  (url: string) => {
89
- if (hasCompleted.current) return;
90
- hasCompleted.current = true;
91
-
92
116
  const params = UrlHandler.extractResponseParams(url);
93
- // Safely assume success if status is missing (matches Flutter behaviour)
94
- const rawStatus = params['status']?.toLowerCase() ?? 'success';
117
+ // `status` or `payment_status`; missing → success (sandbox omits it).
118
+ // Merchants must verify every payment server-side.
119
+ const rawStatus = UrlHandler.extractStatus(params);
95
120
 
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
- }
121
+ settle(() => {
122
+ if (UrlHandler.isSuccessStatus(rawStatus)) {
123
+ onSuccess?.(UrlHandler.parsePaymentResponse(params));
124
+ } else {
125
+ const err: FincraPaymentError = {
126
+ code: rawStatus,
127
+ message: params['message'] ?? 'Payment failed',
128
+ };
129
+ onFailed?.(err);
130
+ }
131
+ });
106
132
  },
107
- [onSuccess, onFailed]
133
+ [settle, onSuccess, onFailed]
108
134
  );
109
135
 
110
136
  // ── URL interception ────────────────────────────────────────────────────────
@@ -121,7 +147,7 @@ export function FincraWebViewCheckout({
121
147
 
122
148
  // ── Cancellation ────────────────────────────────────────────────────────────
123
149
  const handleCancellation = useCallback(() => {
124
- if (hasCompleted.current) return;
150
+ if (!isActive()) return;
125
151
 
126
152
  if (showCancelConfirmationDialog) {
127
153
  Alert.alert(
@@ -132,49 +158,55 @@ export function FincraWebViewCheckout({
132
158
  {
133
159
  text: 'Yes',
134
160
  style: 'destructive',
135
- onPress: () => {
136
- if (!hasCompleted.current) {
137
- hasCompleted.current = true;
138
- onCancelled?.();
139
- }
140
- },
161
+ // May fire after unmount — settle() ignores it then.
162
+ onPress: () => settle(() => onCancelled?.()),
141
163
  },
142
164
  ]
143
165
  );
144
166
  } else {
145
- hasCompleted.current = true;
146
- onCancelled?.();
167
+ settle(() => onCancelled?.());
147
168
  }
148
- }, [showCancelConfirmationDialog, onCancelled]);
169
+ }, [isActive, showCancelConfirmationDialog, settle, onCancelled]);
149
170
 
150
171
  // Keep the ref in sync after every render (Fix #10)
151
172
  useEffect(() => {
152
173
  handleCancellationRef.current = handleCancellation;
153
174
  });
154
175
 
155
- // ── WebView error ───────────────────────────────────────────────────────────
176
+ // ── Load errors ─────────────────────────────────────────────────────────────
177
+ // react-native-webview only reports main-frame failures via onError /
178
+ // onHttpError (sub-resource errors are filtered natively). Load errors never
179
+ // settle the payment: they show the recoverable Retry/Cancel overlay.
180
+ // Errors for the redirect URL are expected (we block that navigation).
181
+ const showLoadError = useCallback(
182
+ (url: string | undefined, err: FincraPaymentError): void => {
183
+ if (!isActive()) return;
184
+ if (url && UrlHandler.isCompletionUrl(url, redirectUrl)) return;
185
+ setErrorState(err);
186
+ setIsLoading(false);
187
+ },
188
+ [isActive, redirectUrl]
189
+ );
190
+
156
191
  const handleError = useCallback(
157
- (syntheticEvent: WebViewErrorEvent): void => {
158
- if (hasCompleted.current) return;
159
- const { nativeEvent } = syntheticEvent;
160
- const err: FincraPaymentError = {
192
+ ({ nativeEvent }: WebViewErrorEvent): void => {
193
+ showLoadError(nativeEvent.url, {
161
194
  code: String(nativeEvent.code ?? 'webview_error'),
162
195
  message: nativeEvent.description ?? 'A WebView error occurred.',
163
- };
164
- setErrorState(err);
165
- setIsLoading(false);
196
+ });
166
197
  },
167
- []
198
+ [showLoadError]
168
199
  );
169
200
 
170
- // ── HTTP error (non-completion URLs only) ───────────────────────────────────
171
201
  const handleHttpError = useCallback(
172
- (e: WebViewHttpErrorEvent): void => {
173
- if (!UrlHandler.isCompletionUrl(e.nativeEvent.url, redirectUrl)) {
174
- handleError(e as unknown as WebViewErrorEvent);
175
- }
202
+ ({ nativeEvent }: WebViewHttpErrorEvent): void => {
203
+ showLoadError(nativeEvent.url, {
204
+ code: String(nativeEvent.statusCode ?? 'http_error'),
205
+ message:
206
+ nativeEvent.description || `HTTP error ${nativeEvent.statusCode}`,
207
+ });
176
208
  },
177
- [redirectUrl, handleError]
209
+ [showLoadError]
178
210
  );
179
211
 
180
212
  // ── Retry handler ───────────────────────────────────────────────────────────
@@ -188,7 +220,7 @@ export function FincraWebViewCheckout({
188
220
  // Fix #2: guard setIsLoading — don't flip loading state after completion.
189
221
  const onNavigationStateChange = useCallback(
190
222
  (navState: WebViewNavigation) => {
191
- if (hasCompleted.current) return;
223
+ if (!isActive()) return;
192
224
  if (
193
225
  navState.url &&
194
226
  UrlHandler.isCompletionUrl(navState.url, redirectUrl)
@@ -196,7 +228,7 @@ export function FincraWebViewCheckout({
196
228
  handleCompletion(navState.url);
197
229
  }
198
230
  },
199
- [redirectUrl, handleCompletion]
231
+ [isActive, redirectUrl, handleCompletion]
200
232
  );
201
233
 
202
234
  // ── Computed status bar style (Fix #11) ─────────────────────────────────────
@@ -204,111 +236,125 @@ export function FincraWebViewCheckout({
204
236
  headerTintColor === '#000000' ? 'dark-content' : 'light-content';
205
237
 
206
238
  // ── Render ──────────────────────────────────────────────────────────────────
239
+ // Own SafeAreaProvider: SafeAreaView reads insets from the nearest provider,
240
+ // and a Modal (FincraCheckoutHost) is a separate native tree. Without one the
241
+ // insets are 0 and the header sits under the status bar / Dynamic Island,
242
+ // where iOS swallows taps. Nested providers are fine if the app has its own.
207
243
  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 }}
244
+ <SafeAreaProvider style={styles.provider}>
245
+ <SafeAreaView style={styles.container}>
246
+ <StatusBar
247
+ barStyle={statusBarStyle}
248
+ backgroundColor={headerBackgroundColor}
249
+ />
250
+ {/* ── Header bar ── */}
251
+ <View
252
+ style={[styles.header, { backgroundColor: headerBackgroundColor }]}
223
253
  >
224
- {closeIcon ?? (
225
- <Text style={[styles.closeIcon, { color: headerTintColor }]}>
226
- ✕
227
- </Text>
254
+ {/* Spacer keeps the title centred when the close button is hidden */}
255
+ {showCloseButton ? (
256
+ <TouchableOpacity
257
+ style={styles.closeButton}
258
+ onPress={handleCancellation}
259
+ accessibilityLabel="Close checkout"
260
+ accessibilityRole="button"
261
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
262
+ >
263
+ {closeIcon ?? (
264
+ <Text style={[styles.closeIcon, { color: headerTintColor }]}>
265
+ ✕
266
+ </Text>
267
+ )}
268
+ </TouchableOpacity>
269
+ ) : (
270
+ <View style={styles.closeButton} />
228
271
  )}
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>
272
+ <Text
273
+ style={[styles.headerTitle, { color: headerTintColor }]}
274
+ numberOfLines={1}
275
+ >
276
+ {headerTitle}
277
+ </Text>
278
+ {/* Spacer to centre the title */}
279
+ <View style={styles.closeButton} />
280
+ </View>
239
281
 
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
- />
282
+ {/* ── WebView ── */}
283
+ <View style={styles.webViewContainer}>
284
+ <WebView
285
+ key={reloadKey}
286
+ ref={webViewRef}
287
+ source={{ uri: checkoutUrl }}
288
+ style={styles.webView}
289
+ javaScriptEnabled
290
+ domStorageEnabled
291
+ startInLoadingState={false}
292
+ onLoadStart={() => {
293
+ if (isActive()) setIsLoading(true);
294
+ }}
295
+ onLoadEnd={() => {
296
+ if (isActive()) setIsLoading(false);
297
+ }}
298
+ onError={handleError}
299
+ onHttpError={handleHttpError}
300
+ onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
301
+ onNavigationStateChange={onNavigationStateChange}
302
+ // Fix #14: restrict to HTTPS + about:blank — prevents intent:// and
303
+ // other dangerous scheme navigations in a payment context.
304
+ originWhitelist={['https://*', 'about:blank']}
305
+ />
264
306
 
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
- )}
307
+ {/* ── Loading overlay ── */}
308
+ {isLoading && !errorState && (
309
+ <View style={styles.loadingOverlay} pointerEvents="none">
310
+ {loadingComponent ?? (
311
+ <ActivityIndicator size="large" color="#0066FF" />
312
+ )}
313
+ </View>
314
+ )}
273
315
 
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>
316
+ {/* ── Error Recovery overlay ── */}
317
+ {errorState && (
318
+ <View style={styles.errorOverlay}>
319
+ {renderError ? (
320
+ renderError(errorState, handleRetry)
321
+ ) : (
322
+ <View style={styles.errorContainer}>
323
+ <Text style={styles.errorIcon}>⚠️</Text>
324
+ <Text style={styles.errorTitle}>Connection Error</Text>
325
+ <Text style={styles.errorMessage}>{errorState.message}</Text>
326
+ <TouchableOpacity
327
+ style={styles.retryButton}
328
+ onPress={handleRetry}
329
+ accessibilityRole="button"
330
+ accessibilityLabel="Retry loading checkout"
331
+ >
332
+ <Text style={styles.retryButtonText}>Retry</Text>
333
+ </TouchableOpacity>
334
+ <TouchableOpacity
335
+ style={styles.cancelButton}
336
+ onPress={handleCancellation}
337
+ accessibilityRole="button"
338
+ accessibilityLabel="Cancel checkout"
339
+ >
340
+ <Text style={styles.cancelButtonText}>Cancel</Text>
341
+ </TouchableOpacity>
342
+ </View>
343
+ )}
344
+ </View>
345
+ )}
346
+ </View>
347
+ </SafeAreaView>
348
+ </SafeAreaProvider>
306
349
  );
307
350
  }
308
351
 
309
352
  // ─── Styles ────────────────────────────────────────────────────────────────────
310
353
 
311
354
  const styles = StyleSheet.create({
355
+ provider: {
356
+ flex: 1,
357
+ },
312
358
  container: {
313
359
  flex: 1,
314
360
  backgroundColor: '#FFFFFF',
@@ -23,7 +23,10 @@ export enum FincraBridgeEvent {
23
23
  /** A parsed message posted by the Fincra JS SDK via `postMessage`. */
24
24
  export interface FincraBridgeMessage {
25
25
  event: FincraBridgeEvent;
26
- /** Populated only for `success` and `error` events. */
26
+ /**
27
+ * Always populated for `success` events; populated for `error` events
28
+ * that carry a data object.
29
+ */
27
30
  data?: FincraPaymentResponse | { message: string };
28
31
  }
29
32
 
@@ -48,25 +51,15 @@ export function parseMessage(jsonString: string): FincraBridgeMessage {
48
51
  const eventStr = typeof map['event'] === 'string' ? map['event'] : null;
49
52
  const event = _parseEvent(eventStr);
50
53
 
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
- }
54
+ if (event === FincraBridgeEvent.Success) {
55
+ // A success event is always a success — even with missing/malformed
56
+ // data (empty references), so a real payment is never hidden.
57
+ const rawData = map['data'];
58
+ const params =
59
+ rawData != null && typeof rawData === 'object' && !Array.isArray(rawData)
60
+ ? normalizeSuccessData(rawData as Record<string, unknown>)
61
+ : {};
62
+ if (!params['status']) params['status'] = 'success';
70
63
  return { event, data: UrlHandler.parsePaymentResponse(params) };
71
64
  }
72
65
 
@@ -92,6 +85,21 @@ export function parseMessage(jsonString: string): FincraBridgeMessage {
92
85
 
93
86
  // ── Internal ──────────────────────────────────────────────────────────────────
94
87
 
88
+ /**
89
+ * Converts success data to a string map: objects/arrays are JSON-encoded,
90
+ * other values stringified, and `null`/`undefined` values dropped.
91
+ */
92
+ function normalizeSuccessData(
93
+ data: Record<string, unknown>
94
+ ): Record<string, string> {
95
+ const params: Record<string, string> = {};
96
+ for (const [k, v] of Object.entries(data)) {
97
+ if (v == null) continue;
98
+ params[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
99
+ }
100
+ return params;
101
+ }
102
+
95
103
  function _parseEvent(eventStr: string | null): FincraBridgeEvent {
96
104
  switch (eventStr) {
97
105
  case 'ready':
@@ -4,9 +4,9 @@ import type { InlineCheckoutConfig } from '../types';
4
4
  //
5
5
  // Mirrors `_generateHtml()` in flutter_fincra_checkout/lib/src/inline/inline_checkout.dart
6
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).
7
+ // Security: the whole options object is encoded via toScriptJson() — JSON
8
+ // plus escaping of `<`, `>`, `&`, U+2028 and U+2029 — so no input value can
9
+ // inject JS or close the surrounding <script> element.
10
10
 
11
11
  const FINCRA_CDN_URL =
12
12
  'https://unpkg.com/@fincra-engineering/checkout@2.2.0/dist/inline.min.js';
@@ -31,6 +31,64 @@ export type InlinePaymentConfig = Pick<
31
31
  | 'paymentMethods'
32
32
  >;
33
33
 
34
+ /** Options passed to `Fincra.initialize()` (callbacks are added in the page). */
35
+ export interface InlineSdkOptions {
36
+ key: string;
37
+ amount: number;
38
+ currency: string;
39
+ feeBearer: string;
40
+ reference?: string;
41
+ paymentMethods?: string[];
42
+ customer: { name: string; email: string; phoneNumber?: string };
43
+ }
44
+
45
+ /**
46
+ * Builds the plain options object for `Fincra.initialize()`.
47
+ *
48
+ * Optional fields are omitted entirely when absent. `customer.phoneNumber`
49
+ * is optional in Fincra's API: it is trimmed, and left out when blank.
50
+ *
51
+ * Pure function — internal, not part of the package's public API.
52
+ */
53
+ export function buildInlineOptions(
54
+ config: InlinePaymentConfig
55
+ ): InlineSdkOptions {
56
+ const customer: InlineSdkOptions['customer'] = {
57
+ name: config.customerName,
58
+ email: config.customerEmail,
59
+ };
60
+ const phone = config.customerPhoneNumber?.trim();
61
+ if (phone) customer.phoneNumber = phone;
62
+
63
+ const options: InlineSdkOptions = {
64
+ key: config.publicKey,
65
+ amount: config.amount,
66
+ currency: config.currency.toUpperCase(),
67
+ feeBearer: config.feeBearer,
68
+ customer,
69
+ };
70
+ if (config.reference != null) options.reference = config.reference;
71
+ if (config.paymentMethods != null && config.paymentMethods.length > 0) {
72
+ options.paymentMethods = config.paymentMethods;
73
+ }
74
+ return options;
75
+ }
76
+
77
+ /**
78
+ * JSON-encodes a value for embedding inside an inline `<script>` block.
79
+ * Besides JSON escaping, `<`, `>` and `&` are escaped so a value containing
80
+ * `</script>` or `<!--` cannot break out of the script element, and
81
+ * U+2028/U+2029 are escaped for older JS engines.
82
+ */
83
+ export function toScriptJson(value: unknown): string {
84
+ return JSON.stringify(value)
85
+ .replace(/</g, '\\u003c')
86
+ .replace(/>/g, '\\u003e')
87
+ .replace(/&/g, '\\u0026')
88
+ .replace(/\u2028/g, '\\u2028')
89
+ .replace(/\u2029/g, '\\u2029');
90
+ }
91
+
34
92
  /**
35
93
  * Generates the self-contained HTML page that loads the Fincra inline JS SDK,
36
94
  * initializes it with the provided config, and posts lifecycle events back to
@@ -43,25 +101,8 @@ export type InlinePaymentConfig = Pick<
43
101
  * @returns A complete HTML string to be loaded into a WebView.
44
102
  */
45
103
  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
- : '';
104
+ // ── Safe encoding — every value goes through toScriptJson() ──
105
+ const optionsJson = toScriptJson(buildInlineOptions(config));
65
106
 
66
107
  return `<!DOCTYPE html>
67
108
  <html>
@@ -108,25 +149,14 @@ export function generateInlineHtml(config: InlinePaymentConfig): string {
108
149
  // SDK is available — signal "ready" so the host hides the loading spinner
109
150
  postToRN('ready', null);
110
151
 
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
- },
152
+ var options = Object.assign(${optionsJson}, {
123
153
  onClose: function() {
124
154
  postToRN('closed', null);
125
155
  },
126
156
  onSuccess: function(data) {
127
157
  postToRN('success', data);
128
158
  },
129
- };
159
+ });
130
160
 
131
161
  Fincra.initialize(options);
132
162
  }
@@ -56,7 +56,8 @@ export interface FincraPaymentResponse {
56
56
  /**
57
57
  * Full raw response map. All values are strings.
58
58
  * For inline mode, numeric/boolean fields from Fincra (e.g. `amount`) are
59
- * coerced to strings to match the URL-params format used in WebView mode.
59
+ * coerced to strings to match the URL-params format used in WebView mode;
60
+ * nested objects/arrays are JSON-encoded and `null` values are dropped.
60
61
  */
61
62
  rawResponse: Record<string, string>;
62
63
  }
@@ -114,6 +115,13 @@ export interface BaseCheckoutProps {
114
115
  showCancelConfirmationDialog?: boolean;
115
116
  /** Custom loading indicator to display while the WebView is loading. */
116
117
  loadingComponent?: ReactNode;
118
+ /**
119
+ * Show the ✕ close button in the header. Default: true.
120
+ * If you hide it, users can still leave via the error screen's Cancel
121
+ * button and the Android back button — but on iOS there is no other way to
122
+ * leave while the page is loading, or if it hangs.
123
+ */
124
+ showCloseButton?: boolean;
117
125
  /** Custom close icon/element for the header. */
118
126
  closeIcon?: ReactNode;
119
127
  /** Custom error screen renderer for network/loading recovery. */
@@ -154,8 +162,11 @@ export interface InlineCheckoutConfig extends BaseCheckoutProps {
154
162
  customerEmail: string;
155
163
  /** Customer's full name. */
156
164
  customerName: string;
157
- /** Customer's phone number. */
158
- customerPhoneNumber: string;
165
+ /**
166
+ * Customer's phone number (optional in Fincra's API). Trimmed; when blank
167
+ * it is not sent to Fincra at all.
168
+ */
169
+ customerPhoneNumber?: string;
159
170
  /** Who bears the Fincra processing fee. */
160
171
  feeBearer: FeeBearer;
161
172
  /** Optional unique transaction reference. Fincra generates one if omitted. */