@thepayulink/checkout-react-native 1.0.0 → 2.0.1
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 +102 -48
- package/package.json +5 -5
- package/src/html.js +87 -0
- package/src/index.d.ts +52 -21
- package/src/index.js +121 -73
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# @thepayulink/checkout-react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PayuLink UPI checkout for **React Native**. Opens an order your server created in a native
|
|
4
|
+
full-screen modal and hands your app the **signed payment result** to verify on your server.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Inside the modal is a WebView running the published PayuLink web checkout — the same script
|
|
7
|
+
websites use — so the UPI QR, opening UPI apps, test mode and the signed result behave exactly
|
|
8
|
+
as on the web.
|
|
9
|
+
|
|
10
|
+
> **Upgrading from 1.x?** 1.0.0 no longer works (its hosted page was retired). See
|
|
11
|
+
> [Upgrading from 1.x](#upgrading-from-1x) — it's a few prop changes.
|
|
6
12
|
|
|
7
13
|
## Install
|
|
8
14
|
|
|
@@ -11,41 +17,69 @@ npm install @thepayulink/checkout-react-native react-native-webview
|
|
|
11
17
|
cd ios && pod install # iOS only
|
|
12
18
|
```
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
On Android 11+, let the app see UPI apps — add inside `<manifest>` in `AndroidManifest.xml`:
|
|
21
|
+
|
|
22
|
+
```xml
|
|
23
|
+
<queries>
|
|
24
|
+
<intent>
|
|
25
|
+
<action android:name="android.intent.action.VIEW" />
|
|
26
|
+
<data android:scheme="upi" />
|
|
27
|
+
</intent>
|
|
28
|
+
</queries>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
On iOS, add the UPI app schemes you want to open to `LSApplicationQueriesSchemes` in
|
|
32
|
+
`Info.plist` (for example `upi`, `phonepe`, `tez`, `paytmmp`).
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
15
35
|
|
|
16
|
-
|
|
36
|
+
1. **Your server** creates the order with the secret key — `POST /v1/orders` (or
|
|
37
|
+
`pl.orders.create()` with `@thepayulink/server`) — and returns `order.id` to the app.
|
|
38
|
+
2. **The app** opens it with the publishable key.
|
|
39
|
+
3. **Your server** verifies the handoff before fulfilling.
|
|
17
40
|
|
|
18
41
|
```jsx
|
|
19
42
|
import React, { useState } from 'react';
|
|
20
43
|
import { Button } from 'react-native';
|
|
21
|
-
import
|
|
44
|
+
import PayuLinkCheckout from '@thepayulink/checkout-react-native';
|
|
22
45
|
|
|
23
46
|
export default function PayScreen() {
|
|
24
|
-
const [
|
|
47
|
+
const [orderId, setOrderId] = useState(null);
|
|
25
48
|
|
|
26
49
|
async function startPayment() {
|
|
27
|
-
//
|
|
28
|
-
const
|
|
50
|
+
// YOUR backend calls POST /v1/orders with the secret key and returns { orderId }.
|
|
51
|
+
const r = await fetch('https://api.yourapp.com/create-order', {
|
|
29
52
|
method: 'POST',
|
|
30
53
|
headers: { 'Content-Type': 'application/json' },
|
|
31
|
-
body: JSON.stringify({
|
|
32
|
-
}).then((
|
|
33
|
-
|
|
54
|
+
body: JSON.stringify({ cartId: 'cart_42' }),
|
|
55
|
+
}).then((res) => res.json());
|
|
56
|
+
setOrderId(r.orderId);
|
|
34
57
|
}
|
|
35
58
|
|
|
36
59
|
return (
|
|
37
60
|
<>
|
|
38
|
-
<Button title="
|
|
39
|
-
{
|
|
40
|
-
<
|
|
61
|
+
<Button title="Pay ₹199.50" onPress={startPayment} />
|
|
62
|
+
{orderId && (
|
|
63
|
+
<PayuLinkCheckout
|
|
41
64
|
visible
|
|
42
|
-
|
|
43
|
-
orderId={
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
onSuccess={(
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
keyId="pl_live_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
66
|
+
orderId={orderId}
|
|
67
|
+
prefill={{ name: 'Asha', contact: '9876543210', email: 'asha@example.com' }}
|
|
68
|
+
theme={{ color: '#38BDF8' }}
|
|
69
|
+
onSuccess={async (handoff) => {
|
|
70
|
+
setOrderId(null);
|
|
71
|
+
// Verify on YOUR server — never trust the app alone.
|
|
72
|
+
await fetch('https://api.yourapp.com/verify', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(handoff),
|
|
76
|
+
});
|
|
77
|
+
}}
|
|
78
|
+
onDismiss={() => setOrderId(null)}
|
|
79
|
+
onError={(err) => {
|
|
80
|
+
setOrderId(null);
|
|
81
|
+
console.warn(err.code, err.message); // e.g. GATEWAY_BUSY (err.retriable === true)
|
|
82
|
+
}}
|
|
49
83
|
/>
|
|
50
84
|
)}
|
|
51
85
|
</>
|
|
@@ -53,44 +87,64 @@ export default function PayScreen() {
|
|
|
53
87
|
}
|
|
54
88
|
```
|
|
55
89
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Only if `PAYULINK_ALLOW_CLIENT_AMOUNT=1` on the server. Less secure — the amount is client-controlled.
|
|
90
|
+
On your server, verify with `@thepayulink/server`:
|
|
59
91
|
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
visible={show}
|
|
63
|
-
apiBase="https://eliteyatra.vip"
|
|
64
|
-
amount={19900}
|
|
65
|
-
item="Kailash Mansarovar Yatra"
|
|
66
|
-
onSuccess={(r) => setShow(false)}
|
|
67
|
-
onDismiss={() => setShow(false)}
|
|
68
|
-
/>
|
|
92
|
+
```js
|
|
93
|
+
if (!pl.verifyPaymentSignature(req.body)) return res.status(400).send('bad signature');
|
|
69
94
|
```
|
|
70
95
|
|
|
96
|
+
Also rely on the `payin.verified` webhook: the customer may pay and close the app before
|
|
97
|
+
`onSuccess` runs.
|
|
98
|
+
|
|
71
99
|
## Props
|
|
72
100
|
|
|
73
101
|
| Prop | Type | Description |
|
|
74
102
|
| --- | --- | --- |
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
103
|
+
| `keyId` | string | **Required.** Publishable key, `pl_live_…` or `pl_test_…`. |
|
|
104
|
+
| `orderId` | string | **Required.** `order_…` from `POST /v1/orders`. |
|
|
105
|
+
| `visible` | boolean | Show or hide the modal. Default `true`. |
|
|
106
|
+
| `prefill` | object | `{ name, contact, email }`. |
|
|
107
|
+
| `theme` | object | `{ color }` — brand colour of the checkout panel. |
|
|
108
|
+
| `name` | string | Overrides the merchant name in the header. |
|
|
109
|
+
| `onSuccess` | function | Receives the signed handoff: `payulink_order_id`, `payulink_payment_id`, `payulink_signature`, `utr`, `amount` (paise), plus `order_id` / `payment_id` aliases. |
|
|
110
|
+
| `onDismiss` | function | The customer closed the checkout, or pressed Android back. |
|
|
111
|
+
| `onError` | function | An `Error` with `code` and `retriable`: `GATEWAY_BUSY` (retriable), `ORDER_EXPIRED`, `ORDER_ALREADY_PAID`, `UPI_APP_NOT_FOUND`, `SCRIPT_LOAD_FAILED`, `INVALID_OPTIONS`, … |
|
|
112
|
+
| `apiBase` | string | Default `https://payulink.io/api`. |
|
|
113
|
+
| `scriptUrl` | string | Web checkout script. Default: the pinned PayuLink CDN build. |
|
|
114
|
+
| `style` | style | Style for the modal's container. |
|
|
115
|
+
|
|
116
|
+
Passing a `keySecret` throws — the secret belongs on your server only. There is no `amount`
|
|
117
|
+
prop: amounts are fixed by your server when it creates the order.
|
|
118
|
+
|
|
119
|
+
## Test mode
|
|
120
|
+
|
|
121
|
+
With a `pl_test_` key (and an order created with the same test key), the checkout shows a
|
|
122
|
+
**TEST** badge and *Simulate success* / *Simulate failure* buttons. No real money moves;
|
|
123
|
+
`onSuccess` receives a handoff signed with the test key's secret, so your verify step is
|
|
124
|
+
exercised end to end.
|
|
125
|
+
|
|
126
|
+
## Promise helper
|
|
85
127
|
|
|
86
|
-
|
|
128
|
+
```jsx
|
|
129
|
+
import { createCheckout } from '@thepayulink/checkout-react-native';
|
|
130
|
+
|
|
131
|
+
const { Component, promise } = createCheckout({ keyId, orderId });
|
|
132
|
+
// render <Component /> somewhere, then:
|
|
133
|
+
const handoff = await promise; // rejects on dismiss (code DISMISSED) or error
|
|
134
|
+
```
|
|
87
135
|
|
|
88
|
-
|
|
136
|
+
## Upgrading from 1.x
|
|
89
137
|
|
|
90
|
-
|
|
138
|
+
| 1.x | 2.x |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `publishableKey="pl_pk_…"` | `keyId="pl_live_…"` (`publishableKey` still accepted) |
|
|
141
|
+
| `amount` / `item` (app sets the price) | Removed — create the order on your server |
|
|
142
|
+
| `prefill.phone` | `prefill.contact` (`phone` still accepted) |
|
|
143
|
+
| `onSuccess({ order_id, utr, amount })` | Signed handoff — verify `payulink_signature` on your server |
|
|
144
|
+
| Loaded `…/api/pay` (retired, returns 404) | Runs the PayuLink web checkout |
|
|
91
145
|
|
|
92
|
-
|
|
146
|
+
The default export is now `PayuLinkCheckout`; the 1.x name `PayulinkCheckout` is still exported.
|
|
93
147
|
|
|
94
148
|
## License
|
|
95
149
|
|
|
96
|
-
|
|
150
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thepayulink/checkout-react-native",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "PayuLink UPI checkout for React Native — opens a server-created order and returns the signed payment result.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"README.md"
|
|
10
10
|
],
|
|
11
11
|
"keywords": [
|
|
12
|
-
"eliteyatra",
|
|
13
12
|
"payulink",
|
|
14
13
|
"react-native",
|
|
15
14
|
"payment",
|
|
@@ -21,6 +20,7 @@
|
|
|
21
20
|
"react-native": ">=0.64.0",
|
|
22
21
|
"react-native-webview": ">=11.0.0"
|
|
23
22
|
},
|
|
24
|
-
"author": "
|
|
25
|
-
"license": "MIT"
|
|
23
|
+
"author": "PayuLink",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"homepage": "https://payulink.io"
|
|
26
26
|
}
|
package/src/html.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Builds the page the React Native WebView renders: the published PayuLink web checkout
|
|
2
|
+
// (the same script websites use, served from the PayuLink CDN), opened on a server-created
|
|
3
|
+
// order, with its callbacks bridged to the app through window.ReactNativeWebView.postMessage.
|
|
4
|
+
//
|
|
5
|
+
// Kept free of React Native imports so it can be tested in a plain browser.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_BASE = 'https://payulink.io/api';
|
|
8
|
+
export const CHECKOUT_SCRIPT_URL = 'https://assetcdn.payulink.io/checkout/v1/checkout-2.2.1.js';
|
|
9
|
+
|
|
10
|
+
// UPI and wallet app schemes a payment page may hand off to.
|
|
11
|
+
export const APP_LINK = /^(upi|intent|tez|gpay|phonepe|paytmmp|paytm|bhim|credpay|mobikwik|amazonpay|whatsapp):/i;
|
|
12
|
+
|
|
13
|
+
// JSON that is safe inside an inline <script>: no "</script>", no line separators.
|
|
14
|
+
function scriptJson(value) {
|
|
15
|
+
return JSON.stringify(value)
|
|
16
|
+
.replace(/</g, '\\u003c')
|
|
17
|
+
.replace(/>/g, '\\u003e')
|
|
18
|
+
.replace(/&/g, '\\u0026')
|
|
19
|
+
.replace(/[\u2028\u2029]/g, (c) => '\\u' + c.charCodeAt(0).toString(16));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function attr(value) {
|
|
23
|
+
return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} o
|
|
28
|
+
* @param {string} o.keyId Publishable key (pl_live_… / pl_test_…)
|
|
29
|
+
* @param {string} o.orderId Order created by your server (POST /v1/orders)
|
|
30
|
+
* @param {object} [o.prefill] { name, contact, email }
|
|
31
|
+
* @param {object} [o.theme] { color }
|
|
32
|
+
* @param {string} [o.name] Header title override
|
|
33
|
+
* @param {string} [o.apiBase]
|
|
34
|
+
* @param {string} [o.scriptUrl] Web checkout script to load (defaults to the pinned CDN build)
|
|
35
|
+
*/
|
|
36
|
+
export function buildCheckoutHtml(o) {
|
|
37
|
+
const config = {
|
|
38
|
+
key: o.keyId,
|
|
39
|
+
orderId: o.orderId,
|
|
40
|
+
prefill: o.prefill || {},
|
|
41
|
+
theme: o.theme || undefined,
|
|
42
|
+
name: o.name || undefined,
|
|
43
|
+
apiBase: o.apiBase || DEFAULT_API_BASE,
|
|
44
|
+
};
|
|
45
|
+
const src = o.scriptUrl || CHECKOUT_SCRIPT_URL;
|
|
46
|
+
return `<!doctype html>
|
|
47
|
+
<html><head><meta charset="utf-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
49
|
+
<style>html,body{margin:0;min-height:100%;background:#0b1f33;}</style>
|
|
50
|
+
<script src="${attr(src)}"></script>
|
|
51
|
+
</head><body>
|
|
52
|
+
<script>
|
|
53
|
+
(function () {
|
|
54
|
+
var cfg = ${scriptJson(config)};
|
|
55
|
+
var appLink = ${APP_LINK.toString()};
|
|
56
|
+
function post(msg) {
|
|
57
|
+
try { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); } catch (e) {}
|
|
58
|
+
}
|
|
59
|
+
// UPI app buttons are links with upi:// (or similar) targets; the app opens them natively.
|
|
60
|
+
document.addEventListener('click', function (e) {
|
|
61
|
+
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
|
62
|
+
var href = a ? a.getAttribute('href') || '' : '';
|
|
63
|
+
if (appLink.test(href)) { e.preventDefault(); post({ type: 'open_url', url: href }); }
|
|
64
|
+
}, true);
|
|
65
|
+
if (typeof window.PayuLink !== 'function') {
|
|
66
|
+
post({ type: 'error', code: 'SCRIPT_LOAD_FAILED', retriable: true,
|
|
67
|
+
message: 'Could not load the PayuLink checkout. Check the internet connection and try again.' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
new window.PayuLink({ key: cfg.key, apiBase: cfg.apiBase, name: cfg.name, theme: cfg.theme }).open({
|
|
72
|
+
order_id: cfg.orderId,
|
|
73
|
+
prefill: cfg.prefill,
|
|
74
|
+
handler: function (result) { post({ type: 'success', result: result }); },
|
|
75
|
+
onDismiss: function () { post({ type: 'dismiss' }); },
|
|
76
|
+
onError: function (err) {
|
|
77
|
+
post({ type: 'error', code: err && err.code, retriable: !!(err && err.retriable),
|
|
78
|
+
message: (err && err.message) || 'Checkout error' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
post({ type: 'error', code: 'CHECKOUT_FAILED', message: e && e.message });
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
</script>
|
|
86
|
+
</body></html>`;
|
|
87
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,41 +1,72 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { StyleProp, ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
-
export interface
|
|
4
|
+
export interface PayuLinkPrefill {
|
|
5
5
|
name?: string;
|
|
6
|
-
phone
|
|
6
|
+
/** 10-digit mobile number. `phone` and `mobile` are accepted as aliases. */
|
|
7
7
|
contact?: string;
|
|
8
|
+
phone?: string;
|
|
8
9
|
mobile?: string;
|
|
9
10
|
email?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** The signed handoff. Send it to YOUR server and verify `payulink_signature` there. */
|
|
14
|
+
export interface PayuLinkSuccess {
|
|
15
|
+
payulink_order_id: string;
|
|
16
|
+
payulink_payment_id: string;
|
|
17
|
+
/** lowercase hex HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
|
|
18
|
+
payulink_signature: string;
|
|
19
|
+
utr?: string | null;
|
|
20
|
+
/** PAISE */
|
|
15
21
|
amount?: number;
|
|
22
|
+
/** Aliases of payulink_order_id / payulink_payment_id. */
|
|
23
|
+
order_id: string;
|
|
24
|
+
payment_id: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PayuLinkCheckoutError extends Error {
|
|
28
|
+
/** e.g. GATEWAY_BUSY, ORDER_EXPIRED, UPI_APP_NOT_FOUND, SCRIPT_LOAD_FAILED, INVALID_OPTIONS */
|
|
29
|
+
code?: string;
|
|
30
|
+
retriable: boolean;
|
|
16
31
|
}
|
|
17
32
|
|
|
18
|
-
export interface
|
|
33
|
+
export interface PayuLinkCheckoutProps {
|
|
34
|
+
/** Show or hide the checkout. Default true. */
|
|
19
35
|
visible?: boolean;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
orderId?: string;
|
|
24
|
-
/** Convenience flow: amount in rupees (ignored when orderId is set). */
|
|
25
|
-
amount?: number;
|
|
26
|
-
item?: string;
|
|
27
|
-
/** Publishable key (pl_pk_…). Safe to embed. */
|
|
36
|
+
/** Publishable key: pl_live_… / pl_test_…. Never a key_secret. */
|
|
37
|
+
keyId?: string;
|
|
38
|
+
/** 1.x name for `keyId`. */
|
|
28
39
|
publishableKey?: string;
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
/** Required. Created by YOUR server via POST /v1/orders. */
|
|
41
|
+
orderId: string;
|
|
42
|
+
prefill?: PayuLinkPrefill;
|
|
43
|
+
/** Brand colour of the checkout panel, e.g. { color: '#38BDF8' }. */
|
|
44
|
+
theme?: { color?: string };
|
|
45
|
+
/** Overrides the merchant name in the header. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** Default https://payulink.io/api */
|
|
48
|
+
apiBase?: string;
|
|
49
|
+
/** Web checkout script to load. Default: the pinned PayuLink CDN build. */
|
|
50
|
+
scriptUrl?: string;
|
|
51
|
+
onSuccess?: (handoff: PayuLinkSuccess) => void;
|
|
52
|
+
/** The customer closed the checkout (or pressed Android back). */
|
|
31
53
|
onDismiss?: () => void;
|
|
32
|
-
onError?: (error:
|
|
54
|
+
onError?: (error: PayuLinkCheckoutError) => void;
|
|
33
55
|
style?: StyleProp<ViewStyle>;
|
|
34
56
|
}
|
|
35
57
|
|
|
36
|
-
export default function
|
|
58
|
+
export default function PayuLinkCheckout(props: PayuLinkCheckoutProps): React.JSX.Element | null;
|
|
59
|
+
export { PayuLinkCheckout };
|
|
60
|
+
/** 1.x name. */
|
|
61
|
+
export const PayulinkCheckout: typeof PayuLinkCheckout;
|
|
37
62
|
|
|
38
|
-
export function createCheckout(options: Omit<
|
|
39
|
-
Component: (extra?: Partial<
|
|
40
|
-
promise: Promise<
|
|
63
|
+
export function createCheckout(options: Omit<PayuLinkCheckoutProps, 'visible' | 'onSuccess' | 'onDismiss' | 'onError'>): {
|
|
64
|
+
Component: (extra?: Partial<PayuLinkCheckoutProps>) => React.JSX.Element | null;
|
|
65
|
+
promise: Promise<PayuLinkSuccess>;
|
|
41
66
|
};
|
|
67
|
+
|
|
68
|
+
export function buildCheckoutHtml(options: {
|
|
69
|
+
keyId: string; orderId: string; prefill?: PayuLinkPrefill; theme?: { color?: string }; name?: string; apiBase?: string; scriptUrl?: string;
|
|
70
|
+
}): string;
|
|
71
|
+
export const DEFAULT_API_BASE: string;
|
|
72
|
+
export const CHECKOUT_SCRIPT_URL: string;
|
package/src/index.js
CHANGED
|
@@ -1,139 +1,187 @@
|
|
|
1
1
|
// @thepayulink/checkout-react-native
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Opens a server-created PayuLink order in a native full-screen Modal. Inside is a WebView
|
|
3
|
+
// running the published PayuLink web checkout (the same script websites use), so the UPI QR,
|
|
4
|
+
// app hand-off, test mode and the signed result all behave exactly as on the web.
|
|
4
5
|
import React from 'react';
|
|
5
|
-
import { Modal, View, ActivityIndicator, StyleSheet, BackHandler, Platform } from 'react-native';
|
|
6
|
+
import { Modal, View, SafeAreaView, ActivityIndicator, StyleSheet, BackHandler, Platform, Linking } from 'react-native';
|
|
6
7
|
import { WebView } from 'react-native-webview';
|
|
8
|
+
import { buildCheckoutHtml, DEFAULT_API_BASE, CHECKOUT_SCRIPT_URL, APP_LINK } from './html';
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
export { buildCheckoutHtml, DEFAULT_API_BASE, CHECKOUT_SCRIPT_URL };
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
12
|
+
const KEY_ID = /^pl_(live|test)_[0-9a-f]{24}$/;
|
|
13
|
+
|
|
14
|
+
function checkoutError(message, code, retriable) {
|
|
15
|
+
const e = new Error(message);
|
|
16
|
+
e.code = code;
|
|
17
|
+
e.retriable = !!retriable;
|
|
18
|
+
return e;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
* <
|
|
22
|
+
* <PayuLinkCheckout /> — controlled component.
|
|
21
23
|
*
|
|
22
|
-
* <
|
|
23
|
-
* visible={
|
|
24
|
-
*
|
|
25
|
-
* orderId={orderId} //
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* onDismiss={() => setShow(false)}
|
|
31
|
-
* onError={(e) => {}}
|
|
24
|
+
* <PayuLinkCheckout
|
|
25
|
+
* visible={!!orderId}
|
|
26
|
+
* keyId="pl_live_…" // publishable key — safe in the app
|
|
27
|
+
* orderId={orderId} // created by YOUR server: POST /v1/orders
|
|
28
|
+
* prefill={{ name, contact, email }}
|
|
29
|
+
* onSuccess={(handoff) => {}} // send to your server and verify the signature there
|
|
30
|
+
* onDismiss={() => setOrderId(null)}
|
|
31
|
+
* onError={(err) => {}} // err.code, e.g. GATEWAY_BUSY (err.retriable === true)
|
|
32
32
|
* />
|
|
33
33
|
*/
|
|
34
|
-
export default function
|
|
34
|
+
export default function PayuLinkCheckout(props) {
|
|
35
35
|
const {
|
|
36
36
|
visible = true,
|
|
37
|
-
|
|
38
|
-
orderId,
|
|
39
|
-
amount,
|
|
40
|
-
item,
|
|
37
|
+
keyId: keyIdProp,
|
|
41
38
|
publishableKey,
|
|
42
|
-
|
|
39
|
+
orderId,
|
|
40
|
+
prefill,
|
|
41
|
+
theme,
|
|
42
|
+
name,
|
|
43
|
+
apiBase = DEFAULT_API_BASE,
|
|
44
|
+
scriptUrl,
|
|
43
45
|
onSuccess,
|
|
44
46
|
onDismiss,
|
|
45
47
|
onError,
|
|
46
48
|
style,
|
|
47
49
|
} = props;
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
})
|
|
60
|
-
|
|
51
|
+
if (props.keySecret || props.key_secret) {
|
|
52
|
+
throw new Error('PayuLink: never pass a key_secret to the app. It belongs on your server only.');
|
|
53
|
+
}
|
|
54
|
+
if (props.amount != null) {
|
|
55
|
+
throw new Error('PayuLink: `amount` is not supported. Create the order on your server (POST /v1/orders) and pass `orderId`.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keyId = keyIdProp || publishableKey;
|
|
59
|
+
const invalid = !keyId || !KEY_ID.test(keyId)
|
|
60
|
+
? 'PayuLink: `keyId` must be your publishable key (pl_live_… or pl_test_…).'
|
|
61
|
+
: !orderId || !/^order_[0-9a-f]{20}$/.test(orderId)
|
|
62
|
+
? 'PayuLink: `orderId` must be an order id from POST /v1/orders (order_…).'
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const p = prefill || {};
|
|
66
|
+
const html = React.useMemo(
|
|
67
|
+
() => (invalid ? null : buildCheckoutHtml({
|
|
68
|
+
keyId,
|
|
69
|
+
orderId,
|
|
70
|
+
prefill: { name: p.name, contact: p.contact || p.phone || p.mobile, email: p.email },
|
|
71
|
+
theme,
|
|
72
|
+
name,
|
|
73
|
+
apiBase,
|
|
74
|
+
scriptUrl,
|
|
75
|
+
})),
|
|
76
|
+
[invalid, keyId, orderId, p.name, p.contact, p.phone, p.mobile, p.email, theme && theme.color, name, apiBase, scriptUrl]
|
|
61
77
|
);
|
|
62
78
|
|
|
63
79
|
const settled = React.useRef(false);
|
|
64
|
-
React.useEffect(() => { if (visible) settled.current = false; }, [visible]);
|
|
80
|
+
React.useEffect(() => { if (visible) settled.current = false; }, [visible, orderId]);
|
|
81
|
+
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (visible && invalid && onError) onError(checkoutError(invalid, 'INVALID_OPTIONS', false));
|
|
84
|
+
}, [visible, invalid]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
65
85
|
|
|
66
86
|
// Android hardware back closes the sheet.
|
|
67
87
|
React.useEffect(() => {
|
|
68
|
-
if (!visible || Platform.OS !== 'android') return;
|
|
88
|
+
if (!visible || Platform.OS !== 'android') return undefined;
|
|
69
89
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
70
|
-
onDismiss
|
|
90
|
+
if (onDismiss) onDismiss();
|
|
71
91
|
return true;
|
|
72
92
|
});
|
|
73
93
|
return () => sub.remove();
|
|
74
94
|
}, [visible, onDismiss]);
|
|
75
95
|
|
|
96
|
+
const openApp = (url) => {
|
|
97
|
+
Linking.openURL(url).catch(() => {
|
|
98
|
+
if (onError) {
|
|
99
|
+
onError(checkoutError('No UPI app on this phone could open the payment. Install a UPI app, or pay by scanning the QR from another phone.',
|
|
100
|
+
'UPI_APP_NOT_FOUND', true));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
76
105
|
const handleMessage = (event) => {
|
|
77
106
|
let msg;
|
|
78
|
-
try { msg = JSON.parse(event.nativeEvent.data); } catch { return; }
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
try { msg = JSON.parse(event.nativeEvent.data); } catch (e) { return; }
|
|
108
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
109
|
+
switch (msg.type) {
|
|
110
|
+
case 'success': {
|
|
81
111
|
if (settled.current) return;
|
|
82
112
|
settled.current = true;
|
|
83
|
-
|
|
113
|
+
const r = msg.result || {};
|
|
114
|
+
// The signed handoff, plus the short aliases 1.x apps read.
|
|
115
|
+
if (onSuccess) onSuccess({ ...r, order_id: r.payulink_order_id, payment_id: r.payulink_payment_id });
|
|
84
116
|
break;
|
|
85
|
-
|
|
86
|
-
|
|
117
|
+
}
|
|
118
|
+
case 'dismiss':
|
|
119
|
+
if (onDismiss) onDismiss();
|
|
87
120
|
break;
|
|
88
|
-
case '
|
|
89
|
-
onError
|
|
121
|
+
case 'error':
|
|
122
|
+
if (onError) onError(checkoutError(msg.message || 'Checkout error', msg.code, msg.retriable));
|
|
123
|
+
break;
|
|
124
|
+
case 'open_url':
|
|
125
|
+
if (typeof msg.url === 'string' && APP_LINK.test(msg.url)) openApp(msg.url);
|
|
90
126
|
break;
|
|
91
127
|
default:
|
|
92
128
|
break;
|
|
93
129
|
}
|
|
94
130
|
};
|
|
95
131
|
|
|
132
|
+
if (invalid) return null;
|
|
133
|
+
|
|
96
134
|
return (
|
|
97
135
|
<Modal visible={visible} animationType="slide" transparent={false} onRequestClose={() => onDismiss && onDismiss()}>
|
|
98
|
-
|
|
136
|
+
{/* SafeAreaView keeps the checkout's header (merchant, amount) clear of the notch / Dynamic Island. */}
|
|
137
|
+
<SafeAreaView style={[styles.fill, style]}>
|
|
99
138
|
<WebView
|
|
100
|
-
source={{
|
|
101
|
-
onMessage={handleMessage}
|
|
102
|
-
startInLoadingState
|
|
103
|
-
renderLoading={() => (
|
|
104
|
-
<View style={styles.loading}><ActivityIndicator size="large" /></View>
|
|
105
|
-
)}
|
|
106
|
-
onError={(e) => onError && onError(new Error(e.nativeEvent.description || 'WebView error'))}
|
|
139
|
+
source={{ html, baseUrl: 'https://payulink.io' }}
|
|
107
140
|
originWhitelist={['*']}
|
|
108
141
|
javaScriptEnabled
|
|
109
142
|
domStorageEnabled
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
setSupportMultipleWindows={false}
|
|
144
|
+
onMessage={handleMessage}
|
|
145
|
+
// A page navigation to upi:// (or another app) opens that app instead of the WebView.
|
|
146
|
+
onShouldStartLoadWithRequest={(req) => {
|
|
147
|
+
const url = (req && req.url) || '';
|
|
148
|
+
if (/^(https?|about|data|blob):/i.test(url)) return true;
|
|
149
|
+
if (APP_LINK.test(url)) openApp(url);
|
|
150
|
+
return false;
|
|
114
151
|
}}
|
|
152
|
+
startInLoadingState
|
|
153
|
+
renderLoading={() => (
|
|
154
|
+
<View style={styles.loading}><ActivityIndicator size="large" color="#2dd4bf" /></View>
|
|
155
|
+
)}
|
|
156
|
+
onError={(e) => onError && onError(checkoutError(
|
|
157
|
+
(e && e.nativeEvent && e.nativeEvent.description) || 'Could not load the checkout.', 'WEBVIEW_ERROR', true))}
|
|
115
158
|
/>
|
|
116
|
-
</
|
|
159
|
+
</SafeAreaView>
|
|
117
160
|
</Modal>
|
|
118
161
|
);
|
|
119
162
|
}
|
|
120
163
|
|
|
164
|
+
// 1.x name, kept so existing imports keep compiling.
|
|
165
|
+
export const PayulinkCheckout = PayuLinkCheckout;
|
|
166
|
+
export { PayuLinkCheckout };
|
|
167
|
+
|
|
121
168
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* resolves on success and rejects on dismiss/error.
|
|
125
|
-
*
|
|
126
|
-
* Most apps should just use the <PayulinkCheckout/> component above.
|
|
169
|
+
* Promise-style helper. Returns { Component, promise }: render <Component /> once; the promise
|
|
170
|
+
* resolves with the signed handoff and rejects on dismiss or error.
|
|
127
171
|
*/
|
|
128
172
|
export function createCheckout(options) {
|
|
129
|
-
let resolve
|
|
173
|
+
let resolve;
|
|
174
|
+
let reject;
|
|
130
175
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
176
|
+
// A dismiss before the app awaits must not surface as an unhandled rejection; callers that
|
|
177
|
+
// await or .catch() still receive it.
|
|
178
|
+
promise.catch(() => {});
|
|
131
179
|
const Component = (extra) => (
|
|
132
|
-
<
|
|
180
|
+
<PayuLinkCheckout
|
|
133
181
|
{...options}
|
|
134
182
|
{...extra}
|
|
135
183
|
onSuccess={(r) => resolve(r)}
|
|
136
|
-
onDismiss={() => reject(
|
|
184
|
+
onDismiss={() => reject(checkoutError('Checkout dismissed', 'DISMISSED', false))}
|
|
137
185
|
onError={(e) => reject(e)}
|
|
138
186
|
/>
|
|
139
187
|
);
|
|
@@ -141,6 +189,6 @@ export function createCheckout(options) {
|
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
const styles = StyleSheet.create({
|
|
144
|
-
fill: { flex: 1, backgroundColor: '#
|
|
192
|
+
fill: { flex: 1, backgroundColor: '#0b1f33' },
|
|
145
193
|
loading: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
|
|
146
194
|
});
|