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 +58 -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 +4 -4
- 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
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
TouchableOpacity,
|
|
11
11
|
View,
|
|
12
12
|
} from 'react-native';
|
|
13
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 (
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
(
|
|
158
|
-
|
|
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
|
-
(
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
[
|
|
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 (
|
|
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
|
-
<
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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',
|
package/src/inline/JsBridge.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 —
|
|
47
|
-
const
|
|
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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
158
|
-
|
|
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. */
|