@thepayulink/checkout-react-native 1.0.0 → 2.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.
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # @thepayulink/checkout-react-native
2
2
 
3
- Elite Yatra / PayuLink UPI checkout for **React Native**. Opens the same secure hosted checkout used on the web inside a native full-screen modal (WebView), and bridges the payment result back to your app.
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
- > Why a WebView? UPI collection, the QR, PayuLink signing and webhook verification all live on the Elite Yatra server. The app never touches secrets it just shows the checkout and receives the confirmed result.
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
- ## Secure usage (recommended)
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
- Create the order on **your backend** with the secret key, then pass the `orderId` to the app. The amount is fixed on the server and cannot be tampered with.
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 PayulinkCheckout from '@thepayulink/checkout-react-native';
44
+ import PayuLinkCheckout from '@thepayulink/checkout-react-native';
22
45
 
23
46
  export default function PayScreen() {
24
- const [order, setOrder] = useState(null);
47
+ const [orderId, setOrderId] = useState(null);
25
48
 
26
49
  async function startPayment() {
27
- // Call YOUR backend, which calls POST /api/orders with the secret key.
28
- const { orderId } = await fetch('https://your-backend/create-order', {
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({ packageId: 'kailash-heli', travellers: 2 }),
32
- }).then((r) => r.json());
33
- setOrder(orderId);
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="Book19,900" onPress={startPayment} />
39
- {order && (
40
- <PayulinkCheckout
61
+ <Button title="Pay199.50" onPress={startPayment} />
62
+ {orderId && (
63
+ <PayuLinkCheckout
41
64
  visible
42
- apiBase="https://eliteyatra.vip"
43
- orderId={order}
44
- publishableKey="pl_pk_live_xxxxx"
45
- prefill={{ name: 'Asha', phone: '9876543210', email: 'asha@example.com' }}
46
- onSuccess={(r) => { setOrder(null); console.log('Paid!', r.order_id, r.utr); }}
47
- onDismiss={() => setOrder(null)}
48
- onError={(e) => { setOrder(null); console.warn(e.message); }}
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
- ## Convenience usage (amount from the app)
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
- ```jsx
61
- <PayulinkCheckout
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
- | `visible` | boolean | Show/hide the checkout modal. |
76
- | `apiBase` | string | Pay server URL. Default `https://eliteyatra.vip`. |
77
- | `orderId` | string | Secure: backend-created order id. |
78
- | `amount` | number | Convenience: rupees (ignored if `orderId` set). |
79
- | `item` | string | Description (convenience flow). |
80
- | `publishableKey` | string | `pl_pk_…`, safe to embed. |
81
- | `prefill` | object | `{ name, phone, email }`. |
82
- | `onSuccess` | function | `({ order_id, utr, amount })`. Fired once, server-confirmed. |
83
- | `onDismiss` | function | User closed / hardware back. |
84
- | `onError` | function | Load or order error. |
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
- ## Important: confirm server-side
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
- `onSuccess` is triggered by the webhook-verified status streamed from the server — reliable for UX. For anything money-critical (unlocking a booking, sending tickets), still verify on **your backend** by reading the order status or handling the PayuLink webhook. Never trust the client alone.
136
+ ## Upgrading from 1.x
89
137
 
90
- ## Expo
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
- Works with Expo (Dev Client / prebuild) since it needs `react-native-webview`. It does **not** work in Expo Go if your Expo SDK's Go build excludes the WebView native module use a development build.
146
+ The default export is now `PayuLinkCheckout`; the 1.x name `PayulinkCheckout` is still exported.
93
147
 
94
148
  ## License
95
149
 
96
- UNLICENSED — internal to Elite Yatra.
150
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thepayulink/checkout-react-native",
3
- "version": "1.0.0",
4
- "description": "Elite Yatra / PayuLink UPI checkout for React Native — opens the secure hosted checkout in a native modal.",
3
+ "version": "2.0.0",
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": "Elite Yatra",
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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, viewport-fit=cover">
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 PayulinkPrefill {
4
+ export interface PayuLinkPrefill {
5
5
  name?: string;
6
- phone?: string;
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
- export interface PayulinkSuccess {
13
- order_id: string;
14
- utr: string | null;
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 PayulinkCheckoutProps {
33
+ export interface PayuLinkCheckoutProps {
34
+ /** Show or hide the checkout. Default true. */
19
35
  visible?: boolean;
20
- /** Elite Yatra pay server base URL. Default: https://eliteyatra.vip */
21
- apiBase?: string;
22
- /** SECURE flow: order id created by your backend via POST /api/orders. */
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
- prefill?: PayulinkPrefill;
30
- onSuccess?: (result: PayulinkSuccess) => void;
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: Error) => void;
54
+ onError?: (error: PayuLinkCheckoutError) => void;
33
55
  style?: StyleProp<ViewStyle>;
34
56
  }
35
57
 
36
- export default function PayulinkCheckout(props: PayulinkCheckoutProps): React.JSX.Element;
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<PayulinkCheckoutProps, 'visible'>): {
39
- Component: (extra?: Partial<PayulinkCheckoutProps>) => React.JSX.Element;
40
- promise: Promise<PayulinkSuccess>;
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,186 @@
1
1
  // @thepayulink/checkout-react-native
2
- // Renders the Elite Yatra hosted checkout inside a native full-screen Modal +
3
- // WebView, and bridges success/dismiss events back to JS via postMessage.
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, 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
- const DEFAULT_API_BASE = 'https://eliteyatra.vip';
10
+ export { buildCheckoutHtml, DEFAULT_API_BASE, CHECKOUT_SCRIPT_URL };
9
11
 
10
- function buildUrl(apiBase, params) {
11
- const base = String(apiBase || DEFAULT_API_BASE).replace(/\/$/, '');
12
- const q = Object.entries(params)
13
- .filter(([, v]) => v != null && v !== '')
14
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
15
- .join('&');
16
- return `${base}/pay${q ? `?${q}` : ''}`;
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
- * <PayulinkCheckout /> — controlled component.
22
+ * <PayuLinkCheckout /> — controlled component.
21
23
  *
22
- * <PayulinkCheckout
23
- * visible={show}
24
- * apiBase="https://eliteyatra.vip"
25
- * orderId={orderId} // SECURE: created by your backend (recommended)
26
- * // or: amount={19900} item="Tour" // convenience flow
27
- * publishableKey="pl_pk_live_…"
28
- * prefill={{ name, phone, email }}
29
- * onSuccess={(r) => {}} // { order_id, utr, amount }
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 PayulinkCheckout(props) {
34
+ export default function PayuLinkCheckout(props) {
35
35
  const {
36
36
  visible = true,
37
- apiBase = DEFAULT_API_BASE,
38
- orderId,
39
- amount,
40
- item,
37
+ keyId: keyIdProp,
41
38
  publishableKey,
42
- prefill = {},
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
- const uri = React.useMemo(
50
- () => buildUrl(apiBase, {
51
- order_id: orderId,
52
- key: publishableKey,
53
- amount: orderId ? undefined : amount,
54
- item: orderId ? undefined : item,
55
- name: prefill.name,
56
- phone: prefill.phone || prefill.contact || prefill.mobile,
57
- email: prefill.email,
58
- back: '', // no in-page navigation; the app owns closing
59
- }),
60
- [apiBase, orderId, amount, item, publishableKey, prefill.name, prefill.phone, prefill.contact, prefill.mobile, prefill.email]
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 && 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
- switch (msg && msg.type) {
80
- case 'checkout.success':
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
- onSuccess && onSuccess({ order_id: msg.order_id, utr: msg.utr || null, amount: msg.amount });
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
- case 'checkout.dismiss':
86
- onDismiss && onDismiss();
117
+ }
118
+ case 'dismiss':
119
+ if (onDismiss) onDismiss();
87
120
  break;
88
- case 'checkout.error':
89
- onError && onError(new Error(msg.error || 'Checkout error'));
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
  <View style={[styles.fill, style]}>
99
137
  <WebView
100
- source={{ uri }}
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'))}
138
+ source={{ html, baseUrl: 'https://payulink.io' }}
107
139
  originWhitelist={['*']}
108
140
  javaScriptEnabled
109
141
  domStorageEnabled
110
- // Let UPI apps open from upi:// intent links.
111
- onShouldStartLoadWithRequest={(r) => {
112
- if (/^(upi|tez|phonepe|paytmmp|gpay|intent):/i.test(r.url)) return true;
113
- return true;
142
+ setSupportMultipleWindows={false}
143
+ onMessage={handleMessage}
144
+ // A page navigation to upi:// (or another app) opens that app instead of the WebView.
145
+ onShouldStartLoadWithRequest={(req) => {
146
+ const url = (req && req.url) || '';
147
+ if (/^(https?|about|data|blob):/i.test(url)) return true;
148
+ if (APP_LINK.test(url)) openApp(url);
149
+ return false;
114
150
  }}
151
+ startInLoadingState
152
+ renderLoading={() => (
153
+ <View style={styles.loading}><ActivityIndicator size="large" color="#2dd4bf" /></View>
154
+ )}
155
+ onError={(e) => onError && onError(checkoutError(
156
+ (e && e.nativeEvent && e.nativeEvent.description) || 'Could not load the checkout.', 'WEBVIEW_ERROR', true))}
115
157
  />
116
158
  </View>
117
159
  </Modal>
118
160
  );
119
161
  }
120
162
 
163
+ // 1.x name, kept so existing imports keep compiling.
164
+ export const PayulinkCheckout = PayuLinkCheckout;
165
+ export { PayuLinkCheckout };
166
+
121
167
  /**
122
- * Imperative helper for apps that prefer a promise over a component.
123
- * Returns { Component, promise }. Render <Component/> once; the promise
124
- * resolves on success and rejects on dismiss/error.
125
- *
126
- * Most apps should just use the <PayulinkCheckout/> component above.
168
+ * Promise-style helper. Returns { Component, promise }: render <Component /> once; the promise
169
+ * resolves with the signed handoff and rejects on dismiss or error.
127
170
  */
128
171
  export function createCheckout(options) {
129
- let resolve, reject;
172
+ let resolve;
173
+ let reject;
130
174
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
175
+ // A dismiss before the app awaits must not surface as an unhandled rejection; callers that
176
+ // await or .catch() still receive it.
177
+ promise.catch(() => {});
131
178
  const Component = (extra) => (
132
- <PayulinkCheckout
179
+ <PayuLinkCheckout
133
180
  {...options}
134
181
  {...extra}
135
182
  onSuccess={(r) => resolve(r)}
136
- onDismiss={() => reject(new Error('dismissed'))}
183
+ onDismiss={() => reject(checkoutError('Checkout dismissed', 'DISMISSED', false))}
137
184
  onError={(e) => reject(e)}
138
185
  />
139
186
  );
@@ -141,6 +188,6 @@ export function createCheckout(options) {
141
188
  }
142
189
 
143
190
  const styles = StyleSheet.create({
144
- fill: { flex: 1, backgroundColor: '#0d1117' },
191
+ fill: { flex: 1, backgroundColor: '#0b1f33' },
145
192
  loading: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
146
193
  });