react-native-payvessel 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,507 @@
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import {
3
+ View,
4
+ Modal,
5
+ StyleSheet,
6
+ SafeAreaView,
7
+ TouchableOpacity,
8
+ Text,
9
+ ActivityIndicator,
10
+ ViewStyle,
11
+ TextStyle,
12
+ } from 'react-native';
13
+ import { WebView, WebViewMessageEvent } from 'react-native-webview';
14
+ import {
15
+ PaymentStatus,
16
+ PayvesselCheckoutProps,
17
+ TransactionData,
18
+ PayvesselSuccessResponse,
19
+ PayvesselErrorResponse,
20
+ } from './types';
21
+
22
+ const PAYVESSEL_BRAND_COLOR = '#ff6b00';
23
+ const CHECKOUT_URL = 'https://checkout.payvessel.com';
24
+
25
+ interface InitializePayload {
26
+ amount: string;
27
+ currency: string;
28
+ customer_email: string;
29
+ customer_name: string;
30
+ metadata: Record<string, unknown>;
31
+ customer_phone_number?: string;
32
+ channels?: string[];
33
+ reference?: string;
34
+ }
35
+
36
+ interface ApiResponse {
37
+ success?: boolean;
38
+ message?: string;
39
+ data?: TransactionData;
40
+ }
41
+
42
+ interface WebViewEventData {
43
+ event: string;
44
+ payload?: Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * PayvesselCheckout Component
49
+ *
50
+ * A React Native component that displays Payvessel checkout in a WebView modal.
51
+ * Directly calls the Payvessel API to initialize transactions.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * <PayvesselCheckout
56
+ * visible={showCheckout}
57
+ * apiKey="YOUR_API_KEY"
58
+ * customerEmail="customer@example.com"
59
+ * customerPhoneNumber="08012345678"
60
+ * amount="1000"
61
+ * currency="NGN"
62
+ * customerName="John Doe"
63
+ * channels={['BANK_TRANSFER', 'CARD']}
64
+ * metadata={{ order_id: '12345' }}
65
+ * onSuccess={(response) => console.log('Initialized:', response)}
66
+ * onSuccessfulOrder={(response) => console.log('Payment confirmed:', response)}
67
+ * onError={(error) => console.log('Error:', error)}
68
+ * onClose={() => setShowCheckout(false)}
69
+ * />
70
+ * ```
71
+ */
72
+ const PayvesselCheckout: React.FC<PayvesselCheckoutProps> = ({
73
+ // Visibility
74
+ visible = false,
75
+
76
+ // Config
77
+ apiKey,
78
+
79
+ // Customer details (required)
80
+ customerEmail,
81
+ customerPhoneNumber,
82
+ amount,
83
+ currency = 'NGN',
84
+ customerName,
85
+
86
+ // Optional
87
+ channels = ['BANK_TRANSFER'],
88
+ metadata,
89
+ reference,
90
+
91
+ // Callbacks
92
+ onSuccess,
93
+ onError,
94
+ onClose,
95
+ onSuccessfulOrder,
96
+
97
+ // UI customization
98
+ showCloseButton = true,
99
+ closeButtonText = '✕',
100
+ loadingText = 'Initializing checkout...',
101
+ headerTitle = 'Checkout',
102
+ showHeader = true,
103
+ }) => {
104
+ const webViewRef = useRef<WebView>(null);
105
+ const [isLoading, setIsLoading] = useState<boolean>(true);
106
+ const [isInitializing, setIsInitializing] = useState<boolean>(false);
107
+ const [hasError, setHasError] = useState<boolean>(false);
108
+ const [errorMessage, setErrorMessage] = useState<string>('');
109
+ const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
110
+ const [transactionData, setTransactionData] = useState<TransactionData | null>(null);
111
+
112
+ // Get the API base URL based on API key (test or live)
113
+ const getApiBaseUrl = useCallback((): string => {
114
+ if (apiKey?.startsWith('PVTESTKEY-')) {
115
+ return 'https://sandbox.payvessel.com';
116
+ }
117
+ return 'https://api.payvessel.com';
118
+ }, [apiKey]);
119
+
120
+ // Initialize the transaction by calling Payvessel API directly
121
+ const initializeTransaction = useCallback(async (): Promise<void> => {
122
+ if (!apiKey) {
123
+ setHasError(true);
124
+ setErrorMessage('API key is required');
125
+ onError?.({
126
+ status: PaymentStatus.FAILED,
127
+ message: 'API key is required',
128
+ });
129
+ return;
130
+ }
131
+
132
+ if (!customerEmail || !amount || !customerName) {
133
+ setHasError(true);
134
+ setErrorMessage('Customer email, amount, and name are required');
135
+ onError?.({
136
+ status: PaymentStatus.FAILED,
137
+ message: 'Customer email, amount, and name are required',
138
+ });
139
+ return;
140
+ }
141
+
142
+ setIsInitializing(true);
143
+ setHasError(false);
144
+ setErrorMessage('');
145
+
146
+ try {
147
+ const baseUrl = getApiBaseUrl();
148
+
149
+ const payload: InitializePayload = {
150
+ amount: String(amount),
151
+ currency,
152
+ customer_email: customerEmail,
153
+ customer_name: customerName,
154
+ metadata: metadata || {},
155
+ };
156
+
157
+ // Add optional fields
158
+ if (customerPhoneNumber) {
159
+ payload.customer_phone_number = customerPhoneNumber;
160
+ }
161
+ if (channels && channels.length > 0) {
162
+ payload.channels = channels as string[];
163
+ }
164
+ if (reference) {
165
+ payload.reference = reference;
166
+ }
167
+
168
+ const response = await fetch(`${baseUrl}/pms/checkout/initialize/`, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ 'api-key': apiKey,
173
+ },
174
+ body: JSON.stringify(payload),
175
+ });
176
+
177
+ const result: ApiResponse = await response.json();
178
+
179
+ if (result?.success === false || !result?.data?.access_code) {
180
+ throw new Error(result?.message || 'Failed to initialize checkout');
181
+ }
182
+
183
+ const data = result.data;
184
+
185
+ // Store transaction data
186
+ setTransactionData(data);
187
+
188
+ // Call onSuccess callback with transaction initialization response
189
+ onSuccess?.({
190
+ status: PaymentStatus.SUCCESS,
191
+ reference: data.reference,
192
+ transactionId: data.id,
193
+ accessCode: data.access_code,
194
+ data: data,
195
+ });
196
+
197
+ // Set the checkout URL with access code
198
+ setCheckoutUrl(`${CHECKOUT_URL}/${data.access_code}`);
199
+
200
+ } catch (error) {
201
+ const errorMsg = error instanceof Error ? error.message : 'Failed to initialize checkout';
202
+ setHasError(true);
203
+ setErrorMessage(errorMsg);
204
+ onError?.({
205
+ status: PaymentStatus.FAILED,
206
+ message: errorMsg,
207
+ });
208
+ } finally {
209
+ setIsInitializing(false);
210
+ }
211
+ }, [
212
+ apiKey,
213
+ customerEmail,
214
+ customerPhoneNumber,
215
+ amount,
216
+ currency,
217
+ customerName,
218
+ channels,
219
+ metadata,
220
+ reference,
221
+ getApiBaseUrl,
222
+ onSuccess,
223
+ onError
224
+ ]);
225
+
226
+ // Handle messages from WebView
227
+ const handleMessage = useCallback((event: WebViewMessageEvent): void => {
228
+ try {
229
+ const data: WebViewEventData = JSON.parse(event.nativeEvent.data);
230
+ const { event: eventType, payload: eventPayload } = data;
231
+
232
+ switch (eventType) {
233
+ case 'payment_success':
234
+ onSuccessfulOrder?.({
235
+ status: PaymentStatus.SUCCESS,
236
+ reference: transactionData?.reference,
237
+ transactionId: transactionData?.id,
238
+ data: eventPayload,
239
+ });
240
+ break;
241
+
242
+ case 'payment_failed':
243
+ onError?.({
244
+ status: PaymentStatus.FAILED,
245
+ message: 'Payment failed',
246
+ });
247
+ break;
248
+
249
+ case 'payment_closed':
250
+ case 'checkout_closed':
251
+ onClose?.();
252
+ break;
253
+
254
+ default:
255
+ break;
256
+ }
257
+ } catch {
258
+ // Not a JSON message, ignore
259
+ }
260
+ }, [transactionData, onSuccessfulOrder, onError, onClose]);
261
+
262
+ // Inject script to capture postMessage events from checkout
263
+ const injectedJavaScript = `
264
+ (function() {
265
+ // Override window.parent.postMessage to capture events
266
+ const originalPostMessage = window.parent.postMessage;
267
+ window.parent.postMessage = function(message, targetOrigin) {
268
+ // Forward to React Native
269
+ if (window.ReactNativeWebView) {
270
+ try {
271
+ const data = typeof message === 'string' ? JSON.parse(message) : message;
272
+ window.ReactNativeWebView.postMessage(JSON.stringify(data));
273
+ } catch (e) {
274
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'message', payload: message }));
275
+ }
276
+ }
277
+ // Call original
278
+ return originalPostMessage.call(window.parent, message, targetOrigin);
279
+ };
280
+
281
+ // Also listen for message events
282
+ window.addEventListener('message', function(event) {
283
+ if (window.ReactNativeWebView) {
284
+ try {
285
+ const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
286
+ window.ReactNativeWebView.postMessage(JSON.stringify(data));
287
+ } catch (e) {}
288
+ }
289
+ });
290
+
291
+ true;
292
+ })();
293
+ `;
294
+
295
+ // Handle close button press
296
+ const handleClose = useCallback((): void => {
297
+ onClose?.();
298
+ }, [onClose]);
299
+
300
+ // Initialize transaction when modal becomes visible
301
+ useEffect(() => {
302
+ if (visible) {
303
+ setIsLoading(true);
304
+ setHasError(false);
305
+ setErrorMessage('');
306
+ setCheckoutUrl(null);
307
+ setTransactionData(null);
308
+ initializeTransaction();
309
+ }
310
+ }, [visible, initializeTransaction]);
311
+
312
+ // Update loading state when checkout URL is set
313
+ useEffect(() => {
314
+ if (checkoutUrl) {
315
+ setIsLoading(false);
316
+ }
317
+ }, [checkoutUrl]);
318
+
319
+ if (!visible) return null;
320
+
321
+ return (
322
+ <Modal
323
+ visible={visible}
324
+ animationType="slide"
325
+ transparent={false}
326
+ onRequestClose={handleClose}
327
+ >
328
+ <SafeAreaView style={styles.container}>
329
+ {/* Header */}
330
+ {showHeader && (
331
+ <View style={styles.header}>
332
+ <Text style={styles.headerTitle}>{headerTitle}</Text>
333
+ {showCloseButton && (
334
+ <TouchableOpacity onPress={handleClose} style={styles.closeButton}>
335
+ <Text style={styles.closeButtonText}>{closeButtonText}</Text>
336
+ </TouchableOpacity>
337
+ )}
338
+ </View>
339
+ )}
340
+
341
+ {/* Content */}
342
+ <View style={styles.webviewContainer}>
343
+ {/* Loading/Initializing State */}
344
+ {(isLoading || isInitializing) && !hasError && (
345
+ <View style={styles.loadingOverlay}>
346
+ <ActivityIndicator size="large" color={PAYVESSEL_BRAND_COLOR} />
347
+ <Text style={styles.loadingText}>{loadingText}</Text>
348
+ </View>
349
+ )}
350
+
351
+ {/* WebView - only show when we have checkout URL */}
352
+ {checkoutUrl && !hasError && (
353
+ <WebView
354
+ ref={webViewRef}
355
+ source={{ uri: checkoutUrl }}
356
+ style={styles.webview}
357
+ onMessage={handleMessage}
358
+ onLoadStart={() => setIsLoading(true)}
359
+ onLoadEnd={() => setIsLoading(false)}
360
+ onError={(e) => {
361
+ setHasError(true);
362
+ setErrorMessage(e.nativeEvent.description || 'WebView error');
363
+ setIsLoading(false);
364
+ onError?.({
365
+ status: PaymentStatus.FAILED,
366
+ message: e.nativeEvent.description || 'WebView error',
367
+ });
368
+ }}
369
+ injectedJavaScript={injectedJavaScript}
370
+ javaScriptEnabled={true}
371
+ domStorageEnabled={true}
372
+ startInLoadingState={false}
373
+ scalesPageToFit={true}
374
+ mixedContentMode="compatibility"
375
+ allowsInlineMediaPlayback={true}
376
+ mediaPlaybackRequiresUserAction={false}
377
+ originWhitelist={['*']}
378
+ />
379
+ )}
380
+
381
+ {/* Error View */}
382
+ {hasError && (
383
+ <View style={styles.errorOverlay}>
384
+ <Text style={styles.errorIcon}>⚠️</Text>
385
+ <Text style={styles.errorTitle}>Failed to load checkout</Text>
386
+ <Text style={styles.errorMessage}>
387
+ {errorMessage || 'Please try again or contact support.'}
388
+ </Text>
389
+ <TouchableOpacity
390
+ style={styles.retryButton}
391
+ onPress={() => {
392
+ setHasError(false);
393
+ setIsLoading(true);
394
+ initializeTransaction();
395
+ }}
396
+ >
397
+ <Text style={styles.retryButtonText}>Retry</Text>
398
+ </TouchableOpacity>
399
+ </View>
400
+ )}
401
+ </View>
402
+ </SafeAreaView>
403
+ </Modal>
404
+ );
405
+ };
406
+
407
+ interface Styles {
408
+ container: ViewStyle;
409
+ header: ViewStyle;
410
+ headerTitle: TextStyle;
411
+ closeButton: ViewStyle;
412
+ closeButtonText: TextStyle;
413
+ webviewContainer: ViewStyle;
414
+ webview: ViewStyle;
415
+ loadingOverlay: ViewStyle;
416
+ loadingText: TextStyle;
417
+ errorOverlay: ViewStyle;
418
+ errorIcon: TextStyle;
419
+ errorTitle: TextStyle;
420
+ errorMessage: TextStyle;
421
+ retryButton: ViewStyle;
422
+ retryButtonText: TextStyle;
423
+ }
424
+
425
+ const styles = StyleSheet.create<Styles>({
426
+ container: {
427
+ flex: 1,
428
+ backgroundColor: '#fff',
429
+ },
430
+ header: {
431
+ flexDirection: 'row',
432
+ alignItems: 'center',
433
+ justifyContent: 'center',
434
+ paddingHorizontal: 16,
435
+ paddingVertical: 12,
436
+ borderBottomWidth: 1,
437
+ borderBottomColor: '#eee',
438
+ backgroundColor: '#fff',
439
+ },
440
+ headerTitle: {
441
+ fontSize: 17,
442
+ fontWeight: '600',
443
+ color: '#333',
444
+ },
445
+ closeButton: {
446
+ position: 'absolute',
447
+ right: 16,
448
+ padding: 8,
449
+ },
450
+ closeButtonText: {
451
+ fontSize: 20,
452
+ color: '#666',
453
+ },
454
+ webviewContainer: {
455
+ flex: 1,
456
+ },
457
+ webview: {
458
+ flex: 1,
459
+ },
460
+ loadingOverlay: {
461
+ ...StyleSheet.absoluteFillObject,
462
+ backgroundColor: '#fff',
463
+ alignItems: 'center',
464
+ justifyContent: 'center',
465
+ },
466
+ loadingText: {
467
+ marginTop: 16,
468
+ fontSize: 14,
469
+ color: '#666',
470
+ },
471
+ errorOverlay: {
472
+ ...StyleSheet.absoluteFillObject,
473
+ backgroundColor: '#fff',
474
+ alignItems: 'center',
475
+ justifyContent: 'center',
476
+ padding: 24,
477
+ },
478
+ errorIcon: {
479
+ fontSize: 48,
480
+ marginBottom: 16,
481
+ },
482
+ errorTitle: {
483
+ fontSize: 18,
484
+ fontWeight: '600',
485
+ color: '#333',
486
+ marginBottom: 8,
487
+ },
488
+ errorMessage: {
489
+ fontSize: 14,
490
+ color: '#666',
491
+ textAlign: 'center',
492
+ marginBottom: 24,
493
+ },
494
+ retryButton: {
495
+ backgroundColor: PAYVESSEL_BRAND_COLOR,
496
+ paddingHorizontal: 32,
497
+ paddingVertical: 12,
498
+ borderRadius: 8,
499
+ },
500
+ retryButtonText: {
501
+ color: '#fff',
502
+ fontSize: 16,
503
+ fontWeight: '600',
504
+ },
505
+ });
506
+
507
+ export default PayvesselCheckout;
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * React Native Payvessel SDK
3
+ *
4
+ * A React Native package for integrating Payvessel payment gateway.
5
+ * Supports Bank Transfer and Card payments via WebView checkout.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ // Main component
11
+ export { default as PayvesselCheckout } from './PayvesselCheckout';
12
+ export { default } from './PayvesselCheckout';
13
+
14
+ // Hook
15
+ export { usePayvessel, default as usePayvesselHook } from './usePayvessel';
16
+ export type { UsePayvesselOptions, UsePayvesselReturn } from './usePayvessel';
17
+
18
+ // Types
19
+ export {
20
+ // Enums
21
+ PaymentStatus,
22
+ PaymentChannel,
23
+
24
+ // Checkout params
25
+ type CheckoutParams,
26
+ type CheckoutConfig,
27
+ type PaymentInfo,
28
+
29
+ // Customer
30
+ type CustomerInfo,
31
+
32
+ // Transaction data
33
+ type TransactionData,
34
+
35
+ // Callbacks
36
+ type OnSuccessCallback,
37
+ type OnErrorCallback,
38
+ type OnCloseCallback,
39
+ type OnSuccessfulOrderCallback,
40
+
41
+ // Props
42
+ type PayvesselCheckoutProps,
43
+
44
+ // API responses
45
+ type PayvesselSuccessResponse,
46
+ type PayvesselErrorResponse,
47
+ type PayvesselCloseResponse,
48
+ type PayvesselResponse,
49
+ } from './types';
50
+