react-native-fincra-checkout 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import React, {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ useEffect,
8
+ } from 'react';
9
+ import { Modal, StyleSheet, View } from 'react-native';
10
+ import type {
11
+ WebViewCheckoutConfig,
12
+ InlineCheckoutConfig,
13
+ FincraCheckoutResult,
14
+ FincraPaymentResponse,
15
+ FincraPaymentError,
16
+ } from '../types';
17
+ import { FincraWebViewCheckout } from '../components/FincraWebViewCheckout';
18
+ import { FincraInlineCheckout } from '../components/FincraInlineCheckout';
19
+
20
+ // ─── Imperative Modal API ─────────────────────────────────────────────────────
21
+ //
22
+ // Mirrors the static FincraCheckout class in fincra_checkout.dart,
23
+ // adapted to React Native's component model using a singleton ref pattern
24
+ // (the same approach used by react-native-toast-message).
25
+ //
26
+ // Usage:
27
+ // 1. Add <FincraCheckoutHost /> to your App root (once, at the top level).
28
+ // 2. Call FincraCheckout.openWebView({...}) / FincraCheckout.openInline({...})
29
+ // from anywhere — no context or navigation prop needed.
30
+
31
+ // ── Internal state types ──────────────────────────────────────────────────────
32
+
33
+ type ModalMode = 'webview' | 'inline' | null;
34
+
35
+ // Fix #12: removed the dead `resolve` field — the resolve fn is stored in
36
+ // resolveRef, not in ModalState. Keeping it here only confused readers.
37
+ interface ModalState {
38
+ mode: ModalMode;
39
+ webViewConfig?: WebViewCheckoutConfig;
40
+ inlineConfig?: InlineCheckoutConfig;
41
+ }
42
+
43
+ // ── Host ref API ──────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * @internal
47
+ * Imperative handle for the FincraCheckoutHost ref.
48
+ * Do not call `_openWebView` / `_openInline` directly — use `FincraCheckout.open*()`.
49
+ */
50
+ export interface FincraCheckoutHostHandle {
51
+ _openWebView(
52
+ config: WebViewCheckoutConfig
53
+ ): Promise<FincraCheckoutResult>;
54
+ _openInline(
55
+ config: InlineCheckoutConfig
56
+ ): Promise<FincraCheckoutResult>;
57
+ }
58
+
59
+ /**
60
+ * Place this component once at your app root (inside your root view, after
61
+ * your navigator/providers). It renders nothing until `FincraCheckout.open*()`
62
+ * is called — then it mounts a full-screen `Modal` over the current UI.
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // App.tsx
67
+ * export default function App() {
68
+ * return (
69
+ * <NavigationContainer>
70
+ * <RootNavigator />
71
+ * <FincraCheckoutHost /> {/* ← add this once *\/}
72
+ * </NavigationContainer>
73
+ * );
74
+ * }
75
+ * ```
76
+ */
77
+ export const FincraCheckoutHost = forwardRef<FincraCheckoutHostHandle>(
78
+ function FincraCheckoutHost(_props, ref) {
79
+ const [modalState, setModalState] = useState<ModalState>({ mode: null });
80
+ const resolveRef = useRef<((result: FincraCheckoutResult) => void) | null>(
81
+ null
82
+ );
83
+
84
+ // ── Resolve and dismiss ────────────────────────────────────────────────────
85
+ const resolve = useCallback((result: FincraCheckoutResult) => {
86
+ setModalState({ mode: null });
87
+ resolveRef.current?.(result);
88
+ resolveRef.current = null;
89
+ }, []);
90
+
91
+ // ── Expose imperative methods via ref ──────────────────────────────────────
92
+ useImperativeHandle(
93
+ ref,
94
+ () => ({
95
+ // Fix #1: guard against double-open — reject instead of orphaning the
96
+ // pending Promise and silently clobbering resolveRef.
97
+ _openWebView(config: WebViewCheckoutConfig): Promise<FincraCheckoutResult> {
98
+ if (resolveRef.current) {
99
+ return Promise.reject(
100
+ new Error(
101
+ '[FincraCheckout] A checkout session is already open. ' +
102
+ 'Await the current session before opening another.'
103
+ )
104
+ );
105
+ }
106
+ return new Promise((res) => {
107
+ resolveRef.current = res;
108
+ setModalState({ mode: 'webview', webViewConfig: config });
109
+ });
110
+ },
111
+ _openInline(config: InlineCheckoutConfig): Promise<FincraCheckoutResult> {
112
+ if (resolveRef.current) {
113
+ return Promise.reject(
114
+ new Error(
115
+ '[FincraCheckout] A checkout session is already open. ' +
116
+ 'Await the current session before opening another.'
117
+ )
118
+ );
119
+ }
120
+ return new Promise((res) => {
121
+ resolveRef.current = res;
122
+ setModalState({ mode: 'inline', inlineConfig: config });
123
+ });
124
+ },
125
+ }),
126
+ [/* resolve not needed — used via resolveRef */]
127
+ );
128
+
129
+ const isVisible = modalState.mode !== null;
130
+
131
+ // ── Shared callback builders ───────────────────────────────────────────────
132
+ const buildCallbacks = useCallback(
133
+ (config: WebViewCheckoutConfig | InlineCheckoutConfig) => ({
134
+ onSuccess: (response: FincraPaymentResponse) => {
135
+ config.onSuccess?.(response);
136
+ resolve({ type: 'success', response });
137
+ },
138
+ onFailed: (error: FincraPaymentError) => {
139
+ config.onFailed?.(error);
140
+ resolve({ type: 'error', error });
141
+ },
142
+ onCancelled: () => {
143
+ config.onCancelled?.();
144
+ resolve({ type: 'cancelled' });
145
+ },
146
+ }),
147
+ [resolve]
148
+ );
149
+
150
+ return (
151
+ <Modal
152
+ visible={isVisible}
153
+ animationType="slide"
154
+ presentationStyle="fullScreen"
155
+ statusBarTranslucent
156
+ onRequestClose={() => {
157
+ // Android hardware back — treat as cancellation
158
+ const cfg =
159
+ modalState.webViewConfig ?? modalState.inlineConfig;
160
+ if (cfg) {
161
+ cfg.onCancelled?.();
162
+ }
163
+ resolve({ type: 'cancelled' });
164
+ }}
165
+ >
166
+ <View style={styles.fullscreen}>
167
+ {modalState.mode === 'webview' && modalState.webViewConfig && (
168
+ <FincraWebViewCheckout
169
+ {...modalState.webViewConfig}
170
+ {...buildCallbacks(modalState.webViewConfig)}
171
+ />
172
+ )}
173
+ {modalState.mode === 'inline' && modalState.inlineConfig && (
174
+ <FincraInlineCheckout
175
+ {...modalState.inlineConfig}
176
+ {...buildCallbacks(modalState.inlineConfig)}
177
+ />
178
+ )}
179
+ </View>
180
+ </Modal>
181
+ );
182
+ }
183
+ );
184
+
185
+ // ─── Singleton Ref ─────────────────────────────────────────────────────────────
186
+ // A module-level ref that FincraCheckout.open*() calls are routed through.
187
+ // Set by the first <FincraCheckoutHost /> that mounts.
188
+ let _hostRef: React.RefObject<FincraCheckoutHostHandle | null> | null = null;
189
+
190
+ /**
191
+ * @internal
192
+ * Called by `<FincraCheckoutHostRegistrar />` to register the singleton ref.
193
+ * Not part of the public API — do not call this directly.
194
+ */
195
+ export function _registerHostRef(
196
+ ref: React.RefObject<FincraCheckoutHostHandle | null>
197
+ ): void {
198
+ _hostRef = ref;
199
+ }
200
+
201
+ /**
202
+ * @internal
203
+ * Clears the singleton ref when the host unmounts.
204
+ * Not part of the public API — do not call this directly.
205
+ */
206
+ export function _unregisterHostRef(): void {
207
+ _hostRef = null;
208
+ }
209
+
210
+ // ─── Public FincraCheckout Static API ─────────────────────────────────────────
211
+
212
+ /**
213
+ * Imperative static API for opening Fincra Checkout modals from anywhere
214
+ * in your app — no navigation prop or context required.
215
+ *
216
+ * **Prerequisite**: `<FincraCheckoutHost />` must be mounted at your app root.
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * // WebView mode (recommended — backend-generated URL)
221
+ * const result = await FincraCheckout.openWebView({
222
+ * checkoutUrl: 'https://checkout.fincra.com/pay/...',
223
+ * redirectUrl: 'https://api.yourapp.com/payment/callback',
224
+ * });
225
+ *
226
+ * // Inline mode (frontend-initiated)
227
+ * const result = await FincraCheckout.openInline({
228
+ * publicKey: 'pk_live_...',
229
+ * amount: 5000,
230
+ * currency: 'NGN',
231
+ * customerEmail: 'user@example.com',
232
+ * customerName: 'Jane Doe',
233
+ * customerPhoneNumber: '08012345678',
234
+ * feeBearer: 'customer',
235
+ * });
236
+ *
237
+ * switch (result.type) {
238
+ * case 'success': console.log(result.response.reference); break;
239
+ * case 'error': console.error(result.error.message); break;
240
+ * case 'cancelled': console.log('User cancelled'); break;
241
+ * }
242
+ * ```
243
+ */
244
+ export class FincraCheckout {
245
+ /**
246
+ * Opens the WebView checkout in a full-screen modal.
247
+ *
248
+ * This is the **recommended** flow — your backend generates the `checkoutUrl`
249
+ * using the Fincra API with your **secret key** (never in the app).
250
+ *
251
+ * @param config - WebView checkout configuration.
252
+ * @returns A promise resolving to a `FincraCheckoutResult` discriminated union.
253
+ * @throws Error if `<FincraCheckoutHost />` is not mounted.
254
+ * @throws Error if a checkout session is already open.
255
+ */
256
+ static openWebView(
257
+ config: WebViewCheckoutConfig
258
+ ): Promise<FincraCheckoutResult> {
259
+ FincraCheckout._assertHostMounted();
260
+ return _hostRef!.current!._openWebView(config);
261
+ }
262
+
263
+ /**
264
+ * Opens the Inline JavaScript checkout in a full-screen modal.
265
+ *
266
+ * Uses only the Fincra **public key** (`pk_...`).
267
+ * The Fincra JS SDK is loaded from the CDN at runtime.
268
+ *
269
+ * @param config - Inline checkout configuration.
270
+ * @returns A promise resolving to a `FincraCheckoutResult` discriminated union.
271
+ * @throws Error if `<FincraCheckoutHost />` is not mounted.
272
+ * @throws Error if a checkout session is already open.
273
+ */
274
+ static openInline(
275
+ config: InlineCheckoutConfig
276
+ ): Promise<FincraCheckoutResult> {
277
+ FincraCheckout._assertHostMounted();
278
+ return _hostRef!.current!._openInline(config);
279
+ }
280
+
281
+ private static _assertHostMounted(): void {
282
+ if (!_hostRef?.current) {
283
+ throw new Error(
284
+ '[react-native-fincra-checkout] FincraCheckoutHost is not mounted. ' +
285
+ 'Add <FincraCheckoutHost /> to your App root before calling FincraCheckout.open*().'
286
+ );
287
+ }
288
+ }
289
+ }
290
+
291
+ // ─── Self-registering Host wrapper ────────────────────────────────────────────
292
+
293
+ /**
294
+ * The component you add to your app root.
295
+ * It self-registers as the singleton checkout host.
296
+ *
297
+ * @example
298
+ * ```tsx
299
+ * // App.tsx
300
+ * import { FincraCheckoutHost } from 'react-native-fincra-checkout';
301
+ *
302
+ * export default function App() {
303
+ * return (
304
+ * <>
305
+ * <YourApp />
306
+ * <FincraCheckoutHost />
307
+ * </>
308
+ * );
309
+ * }
310
+ * ```
311
+ */
312
+ export function FincraCheckoutHostRegistrar() {
313
+ const ref = useRef<FincraCheckoutHostHandle | null>(null);
314
+
315
+ useEffect(() => {
316
+ _registerHostRef(ref as React.RefObject<FincraCheckoutHostHandle | null>);
317
+ return () => _unregisterHostRef();
318
+ }, []);
319
+
320
+ return <FincraCheckoutHost ref={ref} />;
321
+ }
322
+
323
+ // ─── Styles ────────────────────────────────────────────────────────────────────
324
+
325
+ const styles = StyleSheet.create({
326
+ fullscreen: {
327
+ flex: 1,
328
+ },
329
+ });