@thepayulink/checkout 2.0.1 → 2.1.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/dist/payulink-checkout.cjs +336 -42
- package/dist/payulink-checkout.esm.js +336 -42
- package/dist/payulink-checkout.umd.js +336 -42
- package/package.json +5 -2
- package/src/index.js +121 -74
- package/src/modal.js +362 -0
- package/src/style.js +251 -0
package/src/index.js
CHANGED
|
@@ -7,23 +7,46 @@
|
|
|
7
7
|
// order_id: orderId, // created by YOUR server
|
|
8
8
|
// prefill: { name: 'A Kumar', contact: '9876543210' },
|
|
9
9
|
// handler: (r) => {
|
|
10
|
-
// // POST these
|
|
10
|
+
// // POST these to your server and verify the signature there.
|
|
11
11
|
// // r.payulink_order_id, r.payulink_payment_id, r.payulink_signature
|
|
12
12
|
// },
|
|
13
|
-
// onDismiss: () => {},
|
|
14
13
|
// });
|
|
15
14
|
//
|
|
16
|
-
//
|
|
17
|
-
// order, so nothing the browser does
|
|
18
|
-
|
|
15
|
+
// Renders the branded PayuLink modal in-page. The secret key is NEVER used here,
|
|
16
|
+
// and the amount always comes from the stored order, so nothing the browser does
|
|
17
|
+
// can change what is charged.
|
|
18
|
+
import { openModal } from './modal.js';
|
|
19
|
+
import qrcode from 'qrcode-generator';
|
|
19
20
|
|
|
20
21
|
const DEFAULTS = {
|
|
21
22
|
apiBase: 'https://payulink.io/api',
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
assetBase: 'https://assetcdn.payulink.io/checkout/v1/assets',
|
|
24
|
+
name: null, // falls back to the merchant name on the order
|
|
25
|
+
logo: null,
|
|
26
|
+
brandColor: null,
|
|
24
27
|
pollMs: 2500,
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Encode the UPI intent as a QR in the browser.
|
|
32
|
+
*
|
|
33
|
+
* The gateway mints the tracked QR server-side and hands us its `upi_intent_url`;
|
|
34
|
+
* we only draw it. That keeps a single source of truth for the payment itself —
|
|
35
|
+
* we are not re-implementing QR/pool logic here, and status still comes solely
|
|
36
|
+
* from the session endpoint.
|
|
37
|
+
*/
|
|
38
|
+
function toQrDataUrl(text) {
|
|
39
|
+
if (!text) return null;
|
|
40
|
+
try {
|
|
41
|
+
const qr = qrcode(0, 'M'); // type 0 = auto-size, medium ECC
|
|
42
|
+
qr.addData(String(text));
|
|
43
|
+
qr.make();
|
|
44
|
+
return qr.createDataURL(6, 2); // cell size, margin
|
|
45
|
+
} catch {
|
|
46
|
+
return null; // modal falls back to the intent link
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
export default class PayuLink {
|
|
28
51
|
constructor(options = {}) {
|
|
29
52
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
@@ -32,7 +55,7 @@ export default class PayuLink {
|
|
|
32
55
|
}
|
|
33
56
|
const key = options.key || options.keyId || options.key_id;
|
|
34
57
|
if (!key) throw new Error('PayuLink: `key` (your publishable pl_live_… / pl_test_… key) is required');
|
|
35
|
-
if (
|
|
58
|
+
if (!/^pl_(live|test)_/.test(key)) {
|
|
36
59
|
console.warn('PayuLink: `key` should look like pl_live_… or pl_test_…');
|
|
37
60
|
}
|
|
38
61
|
// Guard the classic footgun.
|
|
@@ -55,6 +78,7 @@ export default class PayuLink {
|
|
|
55
78
|
open(p = {}) {
|
|
56
79
|
const o = Object.assign({}, this.options, p);
|
|
57
80
|
const apiBase = String(o.apiBase || '').replace(/\/$/, '');
|
|
81
|
+
const assetBase = String(o.assetBase || `${apiBase}/assets`).replace(/\/$/, '');
|
|
58
82
|
const orderId = o.order_id || o.orderId;
|
|
59
83
|
if (!orderId) throw new Error('PayuLink: `order_id` is required. Create it on your server with POST /v1/orders.');
|
|
60
84
|
|
|
@@ -63,86 +87,108 @@ export default class PayuLink {
|
|
|
63
87
|
else console.error('PayuLink:', e.message || e);
|
|
64
88
|
};
|
|
65
89
|
|
|
66
|
-
|
|
67
|
-
let
|
|
68
|
-
|
|
69
|
-
const finish = (result) => {
|
|
70
|
-
if (settled) return;
|
|
71
|
-
settled = true;
|
|
72
|
-
handle._stop?.();
|
|
73
|
-
handle._overlay?.close();
|
|
74
|
-
if (typeof o.handler === 'function') o.handler(result);
|
|
75
|
-
if (typeof o.onSuccess === 'function') o.onSuccess(result);
|
|
76
|
-
};
|
|
90
|
+
let session = null; // the open session (carries session_token)
|
|
91
|
+
let handoff = null; // captured from the poller — the ONLY source of the signature
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}),
|
|
94
|
-
});
|
|
95
|
-
session = await res.json().catch(() => ({}));
|
|
96
|
-
if (!res.ok) {
|
|
97
|
-
const err = session?.error || {};
|
|
98
|
-
throw new Error(err.description || `Could not start payment (HTTP ${res.status})`);
|
|
99
|
-
}
|
|
100
|
-
} catch (e) {
|
|
101
|
-
return onError(e);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 2. Show the hosted payment page. It already handles the QR, UPI intent
|
|
105
|
-
// and auto-verification — we deliberately don't reimplement that here.
|
|
106
|
-
handle._overlay = openOverlay({
|
|
107
|
-
url: session.payment_link,
|
|
108
|
-
upiIntentUrl: session.upi_intent_url,
|
|
109
|
-
amount: session.amount,
|
|
110
|
-
currency: session.currency,
|
|
111
|
-
merchantName: o.name || session.merchant_name,
|
|
112
|
-
themeColor: (o.theme && o.theme.color) || DEFAULTS.theme.color,
|
|
113
|
-
mode: session.mode,
|
|
114
|
-
onDismiss: () => {
|
|
115
|
-
if (settled) return;
|
|
116
|
-
settled = true;
|
|
117
|
-
handle._stop?.();
|
|
118
|
-
if (typeof o.onDismiss === 'function') o.onDismiss();
|
|
119
|
-
},
|
|
93
|
+
// 1. Open a session with the PUBLISHABLE key. No secret, ever.
|
|
94
|
+
// The modal calls this once it has the customer's contact details.
|
|
95
|
+
const createOrder = async (customer) => {
|
|
96
|
+
const res = await fetch(`${apiBase}/v1/checkout/session`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
key_id: o.key,
|
|
101
|
+
order_id: orderId,
|
|
102
|
+
customer: customer ? {
|
|
103
|
+
name: customer.name,
|
|
104
|
+
contact: customer.mobile || customer.contact,
|
|
105
|
+
email: customer.email,
|
|
106
|
+
} : undefined,
|
|
107
|
+
}),
|
|
120
108
|
});
|
|
109
|
+
const s = await res.json().catch(() => ({}));
|
|
110
|
+
if (!res.ok) throw new Error(s?.error?.description || `Could not start payment (HTTP ${res.status})`);
|
|
111
|
+
session = s;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
merchant_order_no: s.order_id,
|
|
115
|
+
// /v1 speaks PAISE; the modal's currency formatter expects rupees.
|
|
116
|
+
amount: s.amount / 100,
|
|
117
|
+
upi_intent_url: s.upi_intent_url,
|
|
118
|
+
payment_link: s.payment_link,
|
|
119
|
+
qr_data_url: toQrDataUrl(s.upi_intent_url),
|
|
120
|
+
// expires_at is epoch SECONDS — server_now must be in the same unit.
|
|
121
|
+
expires_at: s.expires_at,
|
|
122
|
+
server_now: Math.floor(Date.now() / 1000),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// 2. Poll the session for the authoritative result. This endpoint is the only
|
|
127
|
+
// place the signed handoff is released, and only for this session.
|
|
128
|
+
const watchOrder = (_orderNo, onUpdate) => {
|
|
129
|
+
const tick = async () => {
|
|
130
|
+
if (!session?.session_token) return;
|
|
125
131
|
try {
|
|
126
132
|
const r = await fetch(`${apiBase}/v1/checkout/session/${encodeURIComponent(session.session_token)}`);
|
|
127
133
|
const s = await r.json().catch(() => ({}));
|
|
128
134
|
if (s.status === 'paid' && s.payulink_signature) {
|
|
129
|
-
|
|
135
|
+
handoff = {
|
|
130
136
|
payulink_order_id: s.payulink_order_id,
|
|
131
137
|
payulink_payment_id: s.payulink_payment_id,
|
|
132
138
|
payulink_signature: s.payulink_signature,
|
|
133
|
-
utr: s.utr,
|
|
139
|
+
utr: s.utr || null,
|
|
134
140
|
amount: s.amount,
|
|
135
|
-
}
|
|
136
|
-
} else if (s.status === 'failed' || s.status === 'expired') {
|
|
137
|
-
clearInterval(poll);
|
|
138
|
-
handle._overlay?.close();
|
|
139
|
-
onError(new Error(`Payment ${s.status}`));
|
|
141
|
+
};
|
|
140
142
|
}
|
|
143
|
+
// Map to the status ints the modal understands.
|
|
144
|
+
onUpdate({
|
|
145
|
+
status: s.status === 'paid' ? 0
|
|
146
|
+
: s.status === 'failed' ? 1
|
|
147
|
+
: s.status === 'expired' ? 3
|
|
148
|
+
: 2,
|
|
149
|
+
status_label: String(s.status || 'pending').toUpperCase(),
|
|
150
|
+
utr: s.utr || null,
|
|
151
|
+
});
|
|
141
152
|
} catch { /* transient — keep polling */ }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
};
|
|
154
|
+
const t = setInterval(tick, o.pollMs || DEFAULTS.pollMs);
|
|
155
|
+
tick();
|
|
156
|
+
return () => clearInterval(t);
|
|
157
|
+
};
|
|
145
158
|
|
|
159
|
+
// Fetch the amount BEFORE rendering. This is a cheap, read-only preview —
|
|
160
|
+
// it allocates nothing. We never accept a client-supplied amount.
|
|
161
|
+
const handle = { _inner: null, close() { this._inner?.close(); } };
|
|
162
|
+
fetch(`${apiBase}/v1/checkout/order/${encodeURIComponent(orderId)}?key_id=${encodeURIComponent(o.key)}`)
|
|
163
|
+
.then(async (r) => {
|
|
164
|
+
const meta = await r.json().catch(() => ({}));
|
|
165
|
+
if (!r.ok) throw new Error(meta?.error?.description || 'Unknown or expired order');
|
|
166
|
+
handle._inner = openModal(Object.assign({}, {
|
|
167
|
+
apiBase,
|
|
168
|
+
assetBase,
|
|
169
|
+
name: o.name || meta.merchant_name,
|
|
170
|
+
logo: o.logo || `${assetBase}/payulink-logo.png`,
|
|
171
|
+
brandColor: o.brandColor,
|
|
172
|
+
note: o.note,
|
|
173
|
+
prefill: o.prefill || {},
|
|
174
|
+
amount: meta.amount / 100, // PAISE -> rupees for the modal's formatter
|
|
175
|
+
description: o.description || o.item || meta.merchant_name,
|
|
176
|
+
createOrder,
|
|
177
|
+
watchOrder,
|
|
178
|
+
onSuccess: () => {
|
|
179
|
+
const result = handoff || {
|
|
180
|
+
payulink_order_id: session?.order_id || orderId,
|
|
181
|
+
payulink_payment_id: null,
|
|
182
|
+
payulink_signature: null,
|
|
183
|
+
};
|
|
184
|
+
if (typeof o.handler === 'function') o.handler(result);
|
|
185
|
+
if (typeof o.onSuccess === 'function') o.onSuccess(result);
|
|
186
|
+
},
|
|
187
|
+
onDismiss: () => { if (typeof o.onDismiss === 'function') o.onDismiss(); },
|
|
188
|
+
onError,
|
|
189
|
+
}));
|
|
190
|
+
})
|
|
191
|
+
.catch(onError);
|
|
146
192
|
return handle;
|
|
147
193
|
}
|
|
148
194
|
|
|
@@ -151,6 +197,7 @@ export default class PayuLink {
|
|
|
151
197
|
}
|
|
152
198
|
}
|
|
153
199
|
|
|
200
|
+
// eslint-disable-next-line no-unused-vars
|
|
154
201
|
if (typeof window !== 'undefined') {
|
|
155
202
|
window.PayuLink = PayuLink;
|
|
156
203
|
// Back-compat with the older global name.
|
package/src/modal.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// The checkout modal render engine. Framework-agnostic; renders into document.body.
|
|
2
|
+
// Adapted from Elite Yatra's hosted PayulinkCheckout widget.
|
|
3
|
+
import { injectStyles } from './style.js';
|
|
4
|
+
|
|
5
|
+
const inr = (r) => '₹' + Number(r || 0).toLocaleString('en-IN', { maximumFractionDigits: 2 });
|
|
6
|
+
const mmss = (s) => `${Math.floor(s / 60)}:${(s % 60) < 10 ? '0' : ''}${s % 60}`;
|
|
7
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
8
|
+
|
|
9
|
+
const SVG = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
|
10
|
+
const IC = {
|
|
11
|
+
qr: SVG('<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>'),
|
|
12
|
+
card: SVG('<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>'),
|
|
13
|
+
netbanking: SVG('<path d="M10 18v-7"/><path d="M11.119 2.205a2 2 0 0 1 1.762 0l7.84 3.846A.5.5 0 0 1 20.5 7h-17a.5.5 0 0 1-.22-.949z"/><path d="M14 18v-7"/><path d="M18 18v-7"/><path d="M3 22h18"/><path d="M6 18v-7"/>'),
|
|
14
|
+
wallet: SVG('<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>'),
|
|
15
|
+
emi: SVG('<path d="M16 14v2.2l1.6 1"/><path d="M16 2v4"/><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M3 10h5"/><path d="M8 2v4"/><circle cx="16" cy="16" r="6"/>'),
|
|
16
|
+
back: SVG('<path d="M15 18l-6-6 6-6"/>'),
|
|
17
|
+
mail: SVG('<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>'),
|
|
18
|
+
arrow: SVG('<path d="M5 12h14M13 6l6 6-6 6"/>'),
|
|
19
|
+
verified: SVG('<path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/>'),
|
|
20
|
+
lock: SVG('<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
|
|
21
|
+
};
|
|
22
|
+
const shield = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1l9 4v6c0 5.5-3.8 10.7-9 12-5.2-1.3-9-6.5-9-12V5l9-4z"/></svg>';
|
|
23
|
+
|
|
24
|
+
const SOON = [
|
|
25
|
+
{ m: 'card', t: 'Cards', s: 'Visa, Mastercard & RuPay', ic: 'card' },
|
|
26
|
+
{ m: 'netbanking', t: 'Netbanking', s: 'All Indian banks', ic: 'netbanking' },
|
|
27
|
+
{ m: 'wallet', t: 'Wallet', s: 'Paytm, PhonePe & more', ic: 'wallet' },
|
|
28
|
+
{ m: 'emi', t: 'EMI', s: 'EMI via cards & more', ic: 'emi' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders the checkout modal. Returns a handle: { close() }.
|
|
33
|
+
* @param {object} o - options (see index.js for the public shape).
|
|
34
|
+
* Required plumbing: o.createOrder(customer) -> order, o.watchOrder(orderNo, onUpdate) -> stopFn.
|
|
35
|
+
* Optional: o.apiBase (used for the UTR fallback poll after success).
|
|
36
|
+
*/
|
|
37
|
+
export function openModal(o) {
|
|
38
|
+
injectStyles();
|
|
39
|
+
o = Object.assign({ name: 'Payment', description: '', amount: 0, assetBase: '/assets', logo: '/assets/payulink-logo.png', prefill: {}, apiBase: '' }, o);
|
|
40
|
+
const apiBase = String(o.apiBase || '').replace(/\/$/, '');
|
|
41
|
+
const amt = inr(o.amount);
|
|
42
|
+
const UPI = ['gpay', 'phonepe', 'paytm', 'bhim', 'amazonpay'].reduce((m, k) => (m[k] = `${o.assetBase}/upi/${k}.png`, m), {});
|
|
43
|
+
const APPS = ['phonepe', 'gpay', 'paytm', 'bhim', 'amazonpay'];
|
|
44
|
+
|
|
45
|
+
const state = { screen: 'loader', order: null, customer: null, timer: null, stop: null, done: false, utr: null, expiryMs: null };
|
|
46
|
+
|
|
47
|
+
const root = document.createElement('div');
|
|
48
|
+
root.id = 'pl-overlay';
|
|
49
|
+
if (o.brandColor) root.style.setProperty('--pl-brand', o.brandColor);
|
|
50
|
+
root.innerHTML = `
|
|
51
|
+
<div class="pl-modal" role="dialog" aria-modal="true">
|
|
52
|
+
<div class="pl-splash" id="pl-splash">
|
|
53
|
+
<div class="pl-shield">
|
|
54
|
+
<svg class="pl-shield-bg" viewBox="0 0 120 140" aria-hidden="true">
|
|
55
|
+
<defs><linearGradient id="pl-shgrad" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5a93ff"/><stop offset="1" stop-color="#1f5fe0"/></linearGradient></defs>
|
|
56
|
+
<path d="M60 3 L113 25 V70 C113 105 90 128 60 137 C30 128 7 105 7 70 V25 Z" fill="url(#pl-shgrad)"/>
|
|
57
|
+
</svg>
|
|
58
|
+
<span class="pl-shield-shine"></span>
|
|
59
|
+
<img class="pl-shield-logo" src="${esc(o.assetBase)}/payulink-logo.png" alt="payulink">
|
|
60
|
+
</div>
|
|
61
|
+
<div class="pl-splash-foot">Secured By <span class="pl-pb"><img src="${esc(o.assetBase)}/payulink-logo.png" alt=""><strong>payulink</strong></span></div>
|
|
62
|
+
</div>
|
|
63
|
+
<aside class="pl-left">
|
|
64
|
+
<div class="pl-brandrow">
|
|
65
|
+
<div class="pl-logo"><img src="${esc(o.logo)}" alt=""></div>
|
|
66
|
+
<div class="pl-bmeta">
|
|
67
|
+
<div class="pl-bname">${esc(o.name)}</div>
|
|
68
|
+
<div class="pl-btrust">${IC.verified}<span>Trusted Business</span></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="pl-summary">
|
|
72
|
+
<div class="pl-sl">Price Summary</div>
|
|
73
|
+
<div class="pl-samt">${amt}</div>
|
|
74
|
+
<div class="pl-snote">${esc(o.note || ('Paying for ' + (o.description || o.name)))}</div>
|
|
75
|
+
</div>
|
|
76
|
+
<svg class="pl-illus" viewBox="0 0 230 150" fill="none"><rect x="20" y="70" width="58" height="68" rx="5" fill="#fff" fill-opacity=".92"/><path d="M33 70c0-10 5-16 16-16s16 6 16 16" stroke="#fff" stroke-width="3.5"/><rect x="88" y="55" width="68" height="83" rx="5" fill="#fff"/><path d="M104 55c0-12 6-19 18-19s18 7 18 19" stroke="#fff" stroke-width="3.5"/><rect x="150" y="92" width="52" height="46" rx="4" fill="#fff" fill-opacity=".92"/><circle cx="178" cy="40" r="18" fill="#fff"/><path d="M178 30v20M168 40h20" stroke="currentColor" stroke-width="3.5" opacity=".5"/></svg>
|
|
77
|
+
<div class="pl-secured">${shield}Secured by <span class="pl-pb"><img src="${esc(o.assetBase)}/payulink-logo.png" alt=""><strong>payulink</strong></span></div>
|
|
78
|
+
</aside>
|
|
79
|
+
<section class="pl-main">
|
|
80
|
+
<header class="pl-head">
|
|
81
|
+
<button class="pl-hic pl-back" id="pl-back" aria-label="Back">${IC.back}</button>
|
|
82
|
+
<div class="pl-title" id="pl-title">Payment Options</div>
|
|
83
|
+
<button class="pl-hic pl-dots" aria-label="More">⋯</button>
|
|
84
|
+
<button class="pl-hic pl-close-x" id="pl-close" aria-label="Close">×</button>
|
|
85
|
+
</header>
|
|
86
|
+
<div class="pl-vp"><div class="pl-screen pl-active" id="pl-screen"></div></div>
|
|
87
|
+
<footer class="pl-foot">${IC.lock}<span>Encrypted & secure payments</span></footer>
|
|
88
|
+
</section>
|
|
89
|
+
</div>`;
|
|
90
|
+
document.body.appendChild(root);
|
|
91
|
+
const prevOverflow = document.body.style.overflow;
|
|
92
|
+
document.body.style.overflow = 'hidden';
|
|
93
|
+
|
|
94
|
+
const vp = root.querySelector('.pl-vp');
|
|
95
|
+
const titleEl = root.querySelector('#pl-title');
|
|
96
|
+
const backEl = root.querySelector('#pl-back');
|
|
97
|
+
|
|
98
|
+
function dismiss() { cleanup(); o.onDismiss && o.onDismiss(); }
|
|
99
|
+
function cleanup() {
|
|
100
|
+
if (state.stop) { try { state.stop(); } catch {} }
|
|
101
|
+
clearInterval(state.timer);
|
|
102
|
+
root.classList.add('pl-closing');
|
|
103
|
+
document.body.style.overflow = prevOverflow;
|
|
104
|
+
setTimeout(() => root.remove(), 240);
|
|
105
|
+
}
|
|
106
|
+
root.querySelector('#pl-close').onclick = dismiss;
|
|
107
|
+
root.addEventListener('mousedown', (e) => { if (e.target.id === 'pl-overlay') dismiss(); });
|
|
108
|
+
backEl.onclick = () => { if (state.screen === 'contact' || state.screen === 'success') return; render('options'); };
|
|
109
|
+
|
|
110
|
+
const TITLES = { loader: 'Payment Options', contact: 'Contact Details', options: 'Payment Options', success: 'Payment Successful' };
|
|
111
|
+
|
|
112
|
+
function render(name) {
|
|
113
|
+
state.screen = name;
|
|
114
|
+
titleEl.textContent = TITLES[name] || 'Payment';
|
|
115
|
+
backEl.classList.toggle('pl-show', name === 'options');
|
|
116
|
+
clearInterval(state.timer);
|
|
117
|
+
const fresh = document.createElement('div');
|
|
118
|
+
fresh.className = 'pl-screen pl-active' + (name === 'options' ? ' pl-screen-options' : '');
|
|
119
|
+
fresh.innerHTML = SCREEN[name] ? SCREEN[name]() : '';
|
|
120
|
+
vp.replaceChildren(fresh);
|
|
121
|
+
bind(name, fresh);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const upiThumbs = APPS.slice(0, 4).map((k) => `<img src="${UPI[k]}" alt="">`).join('');
|
|
125
|
+
const appRow = APPS.map((k) => `<a class="pl-uapp" data-app="${k}" title="${k}"><img src="${UPI[k]}" alt="${k}"></a>`).join('');
|
|
126
|
+
|
|
127
|
+
const SCREEN = {
|
|
128
|
+
loader: () => `<div class="pl-loader"><div class="pl-bload"><img src="${esc(o.logo)}" alt=""><span class="pl-bring"></span><span class="pl-bripple"></span><span class="pl-bripple pl-bripple2"></span></div><p>Securely loading payment options…</p></div>`,
|
|
129
|
+
|
|
130
|
+
contact: () => `
|
|
131
|
+
<div class="pl-pad">
|
|
132
|
+
<div class="pl-h1">Contact details</div>
|
|
133
|
+
<div class="pl-sub">Enter your details to continue the payment</div>
|
|
134
|
+
<div class="pl-field"><label>Full Name</label>
|
|
135
|
+
<input class="pl-input" id="pl-name" type="text" placeholder="Enter your name" value="${esc(o.prefill.name || '')}"></div>
|
|
136
|
+
<div class="pl-field"><label>Mobile Number</label>
|
|
137
|
+
<div class="pl-phone"><div class="pl-cc">+91</div>
|
|
138
|
+
<input class="pl-input" id="pl-mobile" type="tel" maxlength="10" placeholder="Enter mobile number" value="${esc(o.prefill.contact || '')}"></div></div>
|
|
139
|
+
<div class="pl-field"><label>Email Address <span class="pl-opt">(optional)</span></label>
|
|
140
|
+
<div class="pl-inwrap"><span class="pl-inicon">${IC.mail}</span>
|
|
141
|
+
<input class="pl-input pl-padleft" id="pl-email" type="email" placeholder="Enter email address" value="${esc(o.prefill.email || '')}"></div></div>
|
|
142
|
+
<button class="pl-cta" id="pl-continue">Continue <span class="pl-cta-arr">${IC.arrow}</span></button>
|
|
143
|
+
<div class="pl-err" id="pl-cerr"></div>
|
|
144
|
+
<div class="pl-trust">${shield}100% secure payments</div>
|
|
145
|
+
</div>`,
|
|
146
|
+
|
|
147
|
+
options: () => `
|
|
148
|
+
<div class="pl-options">
|
|
149
|
+
<div class="pl-nav" id="pl-nav">
|
|
150
|
+
<div class="pl-navlabel">Recommended</div>
|
|
151
|
+
<div class="pl-navitem active" data-m="upi">
|
|
152
|
+
<div class="pl-ni-ic">${IC.qr}</div>
|
|
153
|
+
<div class="pl-ni-body"><div class="pl-ni-t">UPI</div></div>
|
|
154
|
+
<div class="pl-ni-apps">${upiThumbs}</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="pl-navlabel">Cards, Banking & More</div>
|
|
157
|
+
${SOON.map((s) => `
|
|
158
|
+
<div class="pl-navitem pl-disabled" data-m="${s.m}">
|
|
159
|
+
<div class="pl-ni-ic pl-ic-${s.ic}">${IC[s.ic]}</div>
|
|
160
|
+
<div class="pl-ni-body"><div class="pl-ni-t">${s.t}</div><div class="pl-ni-s">${s.s}</div></div>
|
|
161
|
+
<span class="pl-soon">Coming soon</span>
|
|
162
|
+
</div>`).join('')}
|
|
163
|
+
</div>
|
|
164
|
+
<div class="pl-pane" id="pl-pane">${upiPane()}</div>
|
|
165
|
+
</div>`,
|
|
166
|
+
|
|
167
|
+
success: () => {
|
|
168
|
+
const colors = ['#00c060', '#3395ff', '#ffb020', '#ff5a5f', '#7a52d8', '#19c3c3'];
|
|
169
|
+
const confetti = Array.from({ length: 18 }, (_, i) => {
|
|
170
|
+
const ang = (i / 18) * Math.PI * 2 + (i % 2 ? 0.22 : -0.22);
|
|
171
|
+
const dist = 60 + ((i * 13) % 40);
|
|
172
|
+
const tx = Math.round(Math.cos(ang) * dist), ty = Math.round(Math.sin(ang) * dist);
|
|
173
|
+
const c = colors[i % colors.length];
|
|
174
|
+
const r = (i % 2 ? 1 : -1) * (140 + i * 14);
|
|
175
|
+
const w = i % 3 === 0 ? 6 : 8, h = i % 4 === 0 ? 6 : 12;
|
|
176
|
+
return `<i class="pl-confetti" style="--tx:${tx}px;--ty:${ty}px;--c:${c};--r:${r}deg;width:${w}px;height:${h}px;border-radius:${i % 4 === 0 ? '50%' : '2px'};animation-delay:${(0.15 + i * 0.024).toFixed(3)}s"></i>`;
|
|
177
|
+
}).join('');
|
|
178
|
+
return `
|
|
179
|
+
<div class="pl-success">
|
|
180
|
+
<div class="pl-burst">
|
|
181
|
+
${confetti}
|
|
182
|
+
<span class="pl-ripple"></span><span class="pl-ripple pl-ripple2"></span>
|
|
183
|
+
<div class="pl-check"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div>
|
|
184
|
+
</div>
|
|
185
|
+
<h2>Payment Successful</h2>
|
|
186
|
+
<p>${amt} paid${o.name ? ' to ' + esc(o.name) : ''}</p>
|
|
187
|
+
<div class="pl-pid" id="pl-pid">${esc((state.order && state.order.merchant_order_no) || '')}</div>
|
|
188
|
+
${state.utr ? `<div class="pl-pid pl-utr">UTR ${esc(state.utr)}</div>` : ''}
|
|
189
|
+
</div>`;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function upiPane() {
|
|
194
|
+
return `
|
|
195
|
+
<div class="pl-paneh">UPI QR</div>
|
|
196
|
+
<div class="pl-qrcard">
|
|
197
|
+
<div class="pl-qrbox" id="pl-qr"><div class="pl-qrloading"></div></div>
|
|
198
|
+
<div class="pl-qrside">
|
|
199
|
+
<div class="pl-scan">Scan the QR using any UPI App</div>
|
|
200
|
+
<div class="pl-approw" id="pl-applist">${appRow}</div>
|
|
201
|
+
<div class="pl-validrow"><span class="pl-dot"></span><span id="pl-timer">Valid 5:00</span></div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="pl-paneh pl-paneh2">Pay with UPI ID / Number</div>
|
|
205
|
+
<div class="pl-vparow">
|
|
206
|
+
<input class="pl-vpa" id="pl-vpa" placeholder="example@okhdfcbank" autocomplete="off" spellcheck="false">
|
|
207
|
+
<button class="pl-verify" id="pl-verify">Verify and Pay</button>
|
|
208
|
+
</div>
|
|
209
|
+
<div id="pl-status" class="pl-pendrow"><span class="pl-mini-spin"></span> Waiting for payment…</div>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bind(name, el) {
|
|
213
|
+
if (name === 'contact') {
|
|
214
|
+
el.querySelector('#pl-continue').onclick = () => {
|
|
215
|
+
const nm = el.querySelector('#pl-name').value.trim();
|
|
216
|
+
const mob = el.querySelector('#pl-mobile').value.replace(/\D/g, '');
|
|
217
|
+
const em = el.querySelector('#pl-email').value.trim();
|
|
218
|
+
const err = el.querySelector('#pl-cerr');
|
|
219
|
+
if (nm.length < 2) return (err.textContent = 'Please enter your name');
|
|
220
|
+
if (mob.length !== 10) return (err.textContent = 'Enter a valid 10-digit mobile number');
|
|
221
|
+
state.customer = { name: nm, mobile: mob, email: em };
|
|
222
|
+
render('options');
|
|
223
|
+
};
|
|
224
|
+
} else if (name === 'options') {
|
|
225
|
+
el.querySelectorAll('.pl-navitem').forEach((m) => (m.onclick = () => {
|
|
226
|
+
const k = m.dataset.m;
|
|
227
|
+
if (k === 'upi') return;
|
|
228
|
+
toast('Coming soon — please pay via UPI / QR for now');
|
|
229
|
+
}));
|
|
230
|
+
const vbtn = el.querySelector('#pl-verify');
|
|
231
|
+
if (vbtn) vbtn.onclick = () => {
|
|
232
|
+
const v = (el.querySelector('#pl-vpa').value || '').trim();
|
|
233
|
+
if (!/^[\w.\-]{2,}@[a-zA-Z]{2,}$/.test(v)) { el.querySelector('#pl-vpa').focus(); return toast('Enter a valid UPI ID, e.g. name@okhdfcbank'); }
|
|
234
|
+
const intent = state.order && (state.order.upi_intent_url || state.order.payment_link);
|
|
235
|
+
if (intent && /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent)) { location.href = intent; }
|
|
236
|
+
else toast('Scan the QR with your phone’s UPI app to complete the payment.');
|
|
237
|
+
};
|
|
238
|
+
startTimer();
|
|
239
|
+
selectUpi(true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let creating = false;
|
|
244
|
+
async function selectUpi(onScreen) {
|
|
245
|
+
if (!onScreen) render('options');
|
|
246
|
+
if (state.order) { fillQr(); return; }
|
|
247
|
+
if (creating) return;
|
|
248
|
+
creating = true;
|
|
249
|
+
try {
|
|
250
|
+
if (!state.customer) state.customer = { name: o.prefill.name || 'Customer', mobile: (o.prefill.contact || '').replace(/\D/g, ''), email: o.prefill.email || '' };
|
|
251
|
+
state.order = await o.createOrder(state.customer);
|
|
252
|
+
fillQr();
|
|
253
|
+
watch();
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const q = root.querySelector('#pl-qr');
|
|
256
|
+
if (q) { q.innerHTML = `<div class="pl-qrerr"><span>${esc(e.message || 'Could not start payment')}</span><button class="pl-retry" id="pl-retry">Try again</button></div>`; const r = q.querySelector('#pl-retry'); if (r) r.onclick = () => { creating = false; q.innerHTML = '<div class="pl-qrloading"></div>'; selectUpi(true); }; }
|
|
257
|
+
} finally { creating = false; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function fillQr() {
|
|
261
|
+
const ord = state.order; if (!ord) return;
|
|
262
|
+
setExpiryFromOrder(ord);
|
|
263
|
+
const q = root.querySelector('#pl-qr');
|
|
264
|
+
if (q) {
|
|
265
|
+
if (ord.qr_data_url) q.innerHTML = `<img src="${ord.qr_data_url}" alt="UPI QR">`;
|
|
266
|
+
else q.innerHTML = `<div class="pl-qrerr"><span>QR unavailable — use a UPI app below</span></div>`;
|
|
267
|
+
}
|
|
268
|
+
const intent = ord.upi_intent_url || ord.payment_link || '#';
|
|
269
|
+
const list = root.querySelector('#pl-applist');
|
|
270
|
+
if (list) list.querySelectorAll('.pl-uapp').forEach((a) => { a.setAttribute('href', intent); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); });
|
|
271
|
+
startTimer();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function setExpiryFromOrder(ord) {
|
|
275
|
+
if (ord && ord.expires_at && ord.server_now) {
|
|
276
|
+
state.expiryMs = Date.now() + Math.max(0, ord.expires_at - ord.server_now);
|
|
277
|
+
} else if (!state.expiryMs) {
|
|
278
|
+
state.expiryMs = Date.now() + 5 * 60 * 1000;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function expireQr() {
|
|
283
|
+
if (state.done) return;
|
|
284
|
+
const q = root.querySelector('#pl-qr');
|
|
285
|
+
if (q) q.innerHTML = `<div class="pl-qrerr"><span>QR code expired</span><button class="pl-retry" id="pl-regen">Generate new QR</button></div>`;
|
|
286
|
+
const b = root.querySelector('#pl-regen'); if (b) b.onclick = regenerate;
|
|
287
|
+
const t = root.querySelector('#pl-timer'); if (t) t.textContent = 'Expired';
|
|
288
|
+
const vr = root.querySelector('.pl-validrow'); if (vr) vr.classList.add('pl-expired');
|
|
289
|
+
}
|
|
290
|
+
function regenerate() {
|
|
291
|
+
if (state.stop) { try { state.stop(); } catch {} state.stop = null; }
|
|
292
|
+
state.order = null; state.expiryMs = null; creating = false;
|
|
293
|
+
const vr = root.querySelector('.pl-validrow'); if (vr) vr.classList.remove('pl-expired');
|
|
294
|
+
const q = root.querySelector('#pl-qr'); if (q) q.innerHTML = '<div class="pl-qrloading"></div>';
|
|
295
|
+
selectUpi(true);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function watch() {
|
|
299
|
+
if (!state.order || state.stop) return;
|
|
300
|
+
state.stop = o.watchOrder(state.order.merchant_order_no, (u) => {
|
|
301
|
+
if (!u || typeof u.status !== 'number') return;
|
|
302
|
+
if (u.status === 0) { state.utr = u.utr || state.utr; succeed(); }
|
|
303
|
+
else if (u.status === 3) { clearInterval(state.timer); state.expiryMs = Date.now(); expireQr(); setStatus('failed', 'QR expired — generate a new one'); }
|
|
304
|
+
else if ([1, 4].includes(u.status)) setStatus('failed', 'Payment ' + ((u.status_label || 'failed').toLowerCase()));
|
|
305
|
+
else if (u.status === 6) setStatus('pending', 'Processing payment…');
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function setStatus(kind, text) {
|
|
309
|
+
const s = root.querySelector('#pl-status'); if (!s) return;
|
|
310
|
+
s.className = kind === 'failed' ? 'pl-pendrow pl-fail' : 'pl-pendrow';
|
|
311
|
+
s.innerHTML = (kind === 'pending' ? '<span class="pl-mini-spin"></span> ' : '') + esc(text);
|
|
312
|
+
}
|
|
313
|
+
function succeed() {
|
|
314
|
+
if (state.done) return; state.done = true;
|
|
315
|
+
clearInterval(state.timer);
|
|
316
|
+
render('success');
|
|
317
|
+
o.onSuccess && o.onSuccess({ merchant_order_no: state.order.merchant_order_no, utr: state.utr, amount: o.amount });
|
|
318
|
+
if (!state.utr) pollUtr();
|
|
319
|
+
}
|
|
320
|
+
async function pollUtr() {
|
|
321
|
+
for (let i = 0; i < 10 && !state.utr; i++) {
|
|
322
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
323
|
+
try {
|
|
324
|
+
const o2 = await fetch(`${apiBase}/api/order/${state.order.merchant_order_no}`).then((r) => r.json());
|
|
325
|
+
if (o2.utr) { state.utr = o2.utr; const pid = root.querySelector('#pl-pid'); if (pid && state.screen === 'success') pid.insertAdjacentHTML('afterend', `<div class="pl-pid pl-utr">UTR ${esc(o2.utr)}</div>`); break; }
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function startTimer() {
|
|
330
|
+
clearInterval(state.timer);
|
|
331
|
+
const tick = () => {
|
|
332
|
+
const t = root.querySelector('#pl-timer'); if (!t) return;
|
|
333
|
+
if (!state.expiryMs) { t.textContent = 'Valid 5:00'; return; }
|
|
334
|
+
const left = Math.max(0, Math.round((state.expiryMs - Date.now()) / 1000));
|
|
335
|
+
t.textContent = left > 0 ? 'Valid ' + mmss(left) : 'Expired';
|
|
336
|
+
if (left <= 0) { clearInterval(state.timer); expireQr(); }
|
|
337
|
+
};
|
|
338
|
+
tick();
|
|
339
|
+
state.timer = setInterval(tick, 1000);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function toast(msg) {
|
|
343
|
+
let t = document.getElementById('pl-toast');
|
|
344
|
+
if (!t) { t = document.createElement('div'); t.id = 'pl-toast'; document.body.appendChild(t); }
|
|
345
|
+
t.textContent = msg; t.classList.add('pl-show');
|
|
346
|
+
clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('pl-show'), 2600);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// boot: secured splash -> options (or contact first if not prefilled)
|
|
350
|
+
render('loader');
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
const pf = o.prefill || {};
|
|
353
|
+
if (pf.name && (pf.contact || '').replace(/\D/g, '').length === 10) {
|
|
354
|
+
state.customer = { name: pf.name, mobile: pf.contact.replace(/\D/g, ''), email: pf.email || '' };
|
|
355
|
+
render('options');
|
|
356
|
+
} else render('contact');
|
|
357
|
+
const sp = root.querySelector('#pl-splash');
|
|
358
|
+
if (sp) { sp.classList.add('pl-splash-hide'); setTimeout(() => sp.remove(), 450); }
|
|
359
|
+
}, 1600);
|
|
360
|
+
|
|
361
|
+
return { close: cleanup };
|
|
362
|
+
}
|