longhopaysdk 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.
- package/dist/payment-sdk.es.js +396 -0
- package/dist/payment-sdk.iife.js +5 -0
- package/doc/payment-sdk-plugin.md +143 -0
- package/package.json +22 -0
- package/src/index.js +653 -0
- package/vite.config.js +17 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
(function(A) {
|
|
2
|
+
const P = {
|
|
3
|
+
apiBaseUrl: "",
|
|
4
|
+
webBaseUrl: "",
|
|
5
|
+
hostedPath: "/checkout/",
|
|
6
|
+
routes: {
|
|
7
|
+
pay: "/pay",
|
|
8
|
+
checkoutSession: "/checkout/session"
|
|
9
|
+
},
|
|
10
|
+
timeoutMs: 3e4
|
|
11
|
+
}, z = (b) => {
|
|
12
|
+
const n = Number(b);
|
|
13
|
+
if (!Number.isFinite(n))
|
|
14
|
+
throw new Error("Amount must be a valid number");
|
|
15
|
+
return n;
|
|
16
|
+
}, C = (b) => typeof b == "string" && b.trim().length > 0, j = (b) => {
|
|
17
|
+
const n = A.LonghoPaySDKDefaults || {}, e = { ...P, ...n, ...b || {} }, a = (s) => s ? String(s).replace(/\/+$/, "") : "";
|
|
18
|
+
return {
|
|
19
|
+
apiBaseUrl: a(e.apiBaseUrl),
|
|
20
|
+
webBaseUrl: a(e.webBaseUrl),
|
|
21
|
+
hostedPath: e.hostedPath || P.hostedPath,
|
|
22
|
+
routes: { ...P.routes, ...e.routes || {} },
|
|
23
|
+
publicKey: e.publicKey || null,
|
|
24
|
+
authToken: e.authToken || null,
|
|
25
|
+
storeCode: e.storeCode || null,
|
|
26
|
+
timeoutMs: Number(e.timeoutMs) || P.timeoutMs
|
|
27
|
+
};
|
|
28
|
+
}, F = (b = {}) => {
|
|
29
|
+
const n = [], e = { ...b };
|
|
30
|
+
if (!C(e.tag) && !C(e.productCode) && n.push("tag is required (or provide productCode)"), e.amount === void 0 || e.amount === null)
|
|
31
|
+
n.push("amount is required");
|
|
32
|
+
else
|
|
33
|
+
try {
|
|
34
|
+
if (e.amount = z(e.amount), e.amount < 100)
|
|
35
|
+
throw new Error("Amount must be at least 100");
|
|
36
|
+
if (e.amount > 1e6)
|
|
37
|
+
throw new Error("Amount must be at most 1,000,000");
|
|
38
|
+
if (e.amount % 5 !== 0)
|
|
39
|
+
throw new Error("Amount must be a multiple of 5");
|
|
40
|
+
} catch (a) {
|
|
41
|
+
n.push(a.message || "Invalid amount");
|
|
42
|
+
}
|
|
43
|
+
if (C(e.phone) || n.push("phone is required"), C(e.name) || n.push("name is required"), n.length) {
|
|
44
|
+
const a = new Error(n.join("; "));
|
|
45
|
+
throw a.details = n, a;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
productCode: e.productCode,
|
|
49
|
+
tag: e.tag || e.productCode,
|
|
50
|
+
amount: e.amount,
|
|
51
|
+
phone: String(e.phone),
|
|
52
|
+
name: e.name,
|
|
53
|
+
email: e.email || "",
|
|
54
|
+
address: e.address || "",
|
|
55
|
+
currency: e.currency || "USD",
|
|
56
|
+
trid: e.trid,
|
|
57
|
+
metadata: e.metadata
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
class q {
|
|
61
|
+
constructor(n = {}) {
|
|
62
|
+
this.configure(n);
|
|
63
|
+
}
|
|
64
|
+
configure(n = {}) {
|
|
65
|
+
this.config = j(n);
|
|
66
|
+
}
|
|
67
|
+
buildHostedPaymentUrl(n) {
|
|
68
|
+
if (!C(n))
|
|
69
|
+
throw new Error("productCode is required to build a payment URL");
|
|
70
|
+
if (!C(this.config.webBaseUrl))
|
|
71
|
+
throw new Error("webBaseUrl is required to build hosted checkout URLs");
|
|
72
|
+
const e = this.config.webBaseUrl.replace(/\/+$/, ""), a = this.config.hostedPath || "/";
|
|
73
|
+
return `${e}${a}${n}`;
|
|
74
|
+
}
|
|
75
|
+
async createPayment(n) {
|
|
76
|
+
const e = F({
|
|
77
|
+
...n
|
|
78
|
+
});
|
|
79
|
+
if (!C(this.config.apiBaseUrl))
|
|
80
|
+
throw new Error("apiBaseUrl is required to create a payment");
|
|
81
|
+
const a = typeof AbortController < "u" ? new AbortController() : null, s = a ? setTimeout(() => a.abort(), this.config.timeoutMs) : null;
|
|
82
|
+
try {
|
|
83
|
+
const d = typeof this.config.authToken == "function" ? this.config.authToken() : this.config.authToken ? this.config.authToken : null, c = await fetch(`${this.config.apiBaseUrl}${this.config.routes.pay}`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
...this.config.publicKey ? { "X-Public-Key": this.config.publicKey } : {},
|
|
88
|
+
...d ? { Authorization: `Bearer ${d}` } : {}
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(e),
|
|
91
|
+
...a ? { signal: a.signal } : {}
|
|
92
|
+
}), u = await c.json().catch(() => ({}));
|
|
93
|
+
if (!c.ok || u?.success === !1) {
|
|
94
|
+
const t = u?.message || "Payment failed.", r = new Error(t);
|
|
95
|
+
throw r.response = u, r;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
success: !0,
|
|
99
|
+
message: u?.message || "Payment created.",
|
|
100
|
+
data: u?.data || u
|
|
101
|
+
};
|
|
102
|
+
} catch (d) {
|
|
103
|
+
const c = d?.message || "Payment request failed.";
|
|
104
|
+
return Promise.reject({
|
|
105
|
+
message: c,
|
|
106
|
+
cause: d
|
|
107
|
+
});
|
|
108
|
+
} finally {
|
|
109
|
+
s && clearTimeout(s);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create a hosted checkout session (Stripe-style) and optionally redirect.
|
|
114
|
+
* @param {Object} options - { payload, redirect: boolean, onRedirect }
|
|
115
|
+
*/
|
|
116
|
+
async createCheckoutSession(n = {}) {
|
|
117
|
+
const { payload: e = {}, redirect: a = !0, onRedirect: s } = n, d = F(e);
|
|
118
|
+
if (!C(this.config.apiBaseUrl))
|
|
119
|
+
throw new Error("apiBaseUrl is required to create a checkout session");
|
|
120
|
+
const c = typeof this.config.authToken == "function" ? this.config.authToken() : this.config.authToken ? this.config.authToken : null, u = await fetch(`${this.config.apiBaseUrl}${this.config.routes.checkoutSession}`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
...this.config.publicKey ? { "X-Public-Key": this.config.publicKey } : {},
|
|
125
|
+
...c ? { Authorization: `Bearer ${c}` } : {}
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify(d)
|
|
128
|
+
}), t = await u.json().catch(() => ({}));
|
|
129
|
+
if (!u.ok || t?.success === !1) {
|
|
130
|
+
const o = t?.message || "Unable to create checkout session.", i = new Error(o);
|
|
131
|
+
throw i.response = t, i;
|
|
132
|
+
}
|
|
133
|
+
const r = t?.data?.checkoutUrl || t?.checkoutUrl;
|
|
134
|
+
return a && r && (typeof s == "function" && s(r), window.location.href = r), {
|
|
135
|
+
success: !0,
|
|
136
|
+
message: t?.message || "Checkout session created.",
|
|
137
|
+
data: t?.data || t,
|
|
138
|
+
checkoutUrl: r
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
mountPayButton(n = {}) {
|
|
142
|
+
const {
|
|
143
|
+
selector: e,
|
|
144
|
+
buttonText: a = "Pay with LonghoPay",
|
|
145
|
+
payment: s = {},
|
|
146
|
+
onSuccess: d,
|
|
147
|
+
onError: c,
|
|
148
|
+
onFailure: u,
|
|
149
|
+
reuseTarget: t
|
|
150
|
+
} = n, r = typeof e == "string" ? document.querySelector(e) : e;
|
|
151
|
+
if (!r)
|
|
152
|
+
throw new Error("mountPayButton: target element not found");
|
|
153
|
+
const o = t ? r : document.createElement("button");
|
|
154
|
+
t || (o.type = "button", o.textContent = a, o.style.cursor = "pointer", o.style.padding = "10px 16px", o.style.backgroundColor = "#1C6EC6", o.style.color = "#ffffff", o.style.border = "none", o.style.borderRadius = "6px", o.style.fontWeight = "600", r.appendChild(o));
|
|
155
|
+
const i = (l) => {
|
|
156
|
+
o.disabled = l, o.style.opacity = l ? "0.65" : "1";
|
|
157
|
+
}, h = async () => {
|
|
158
|
+
i(!0), this.renderPaymentForm({
|
|
159
|
+
product: {
|
|
160
|
+
productCode: s.productCode,
|
|
161
|
+
name: s.name,
|
|
162
|
+
amount: s.amount,
|
|
163
|
+
tag: s.tag,
|
|
164
|
+
currency: s.currency
|
|
165
|
+
},
|
|
166
|
+
defaults: {
|
|
167
|
+
phone: s.phone,
|
|
168
|
+
email: s.email,
|
|
169
|
+
address: s.address,
|
|
170
|
+
amount: s.amount
|
|
171
|
+
},
|
|
172
|
+
onSuccess: (l) => {
|
|
173
|
+
typeof d == "function" && d(l);
|
|
174
|
+
},
|
|
175
|
+
onError: (l) => {
|
|
176
|
+
typeof c == "function" && c(l), typeof u == "function" && u(l);
|
|
177
|
+
}
|
|
178
|
+
}), i(!1);
|
|
179
|
+
};
|
|
180
|
+
return o.addEventListener("click", h), o;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Render a lightweight payment form overlay.
|
|
184
|
+
* @param {Object} options
|
|
185
|
+
* @param {Object} options.product - { productCode, storeCode, name, amount, tag }
|
|
186
|
+
* @param {Object} [options.defaults] - { phone, name, email, address, allowAmountEdit }
|
|
187
|
+
* @param {Function} [options.onSuccess] - callback(result)
|
|
188
|
+
* @param {Function} [options.onError] - callback(error)
|
|
189
|
+
* @param {Function} [options.onClose] - callback()
|
|
190
|
+
* @param {string} [options.title] - form heading
|
|
191
|
+
* @param {string} [options.submitText] - button text
|
|
192
|
+
* @param {Object} [options.fields] - per-field config, e.g. { phone: { label, placeholder, visible } }
|
|
193
|
+
*/
|
|
194
|
+
renderPaymentForm(n = {}) {
|
|
195
|
+
const {
|
|
196
|
+
product: e = {},
|
|
197
|
+
defaults: a = {},
|
|
198
|
+
onSuccess: s,
|
|
199
|
+
onError: d,
|
|
200
|
+
onClose: c,
|
|
201
|
+
title: u = "Complete your payment",
|
|
202
|
+
submitText: t = "Pay now",
|
|
203
|
+
fields: r = {}
|
|
204
|
+
} = n, o = (g, m, p = {}) => ({
|
|
205
|
+
label: r[g]?.label || m,
|
|
206
|
+
placeholder: r[g]?.placeholder || "",
|
|
207
|
+
visible: r[g]?.visible !== !1,
|
|
208
|
+
...p
|
|
209
|
+
}), i = {
|
|
210
|
+
name: o("name", "Your name"),
|
|
211
|
+
phone: o("phone", "Phone number"),
|
|
212
|
+
email: o("email", "Email"),
|
|
213
|
+
address: o("address", "Address"),
|
|
214
|
+
amount: o("amount", "Amount", { readonly: r.amount?.readonly ?? !a.allowAmountEdit })
|
|
215
|
+
}, h = document.createElement("div");
|
|
216
|
+
h.style.cssText = `
|
|
217
|
+
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
|
218
|
+
display: flex; align-items: center; justify-content: center;
|
|
219
|
+
z-index: 9999; padding: 16px;
|
|
220
|
+
`;
|
|
221
|
+
const l = document.createElement("div");
|
|
222
|
+
l.style.cssText = "width: 100%; max-width: 440px; background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.12); padding: 20px; position: relative; border: 1px solid #e5e7eb; border-top: 4px solid #1C6EC6;";
|
|
223
|
+
const x = document.createElement("button");
|
|
224
|
+
x.type = "button", x.textContent = "×", x.setAttribute("aria-label", "Close"), x.style.cssText = "position: absolute; top: 10px; right: 12px; border: none; background: transparent; font-size: 22px; cursor: pointer;";
|
|
225
|
+
const k = document.createElement("h3");
|
|
226
|
+
k.textContent = u, k.style.cssText = "margin: 0 0 12px; font-size: 18px; font-weight: 700; color: #0b1f3a;";
|
|
227
|
+
const S = document.createElement("p");
|
|
228
|
+
S.textContent = e.name || e.productCode || e.tag || "", S.style.cssText = "margin: 0 0 12px; color: #1C6EC6; font-size: 14px; font-weight: 600;";
|
|
229
|
+
const w = document.createElement("form");
|
|
230
|
+
w.style.cssText = "display: flex; flex-direction: column; gap: 10px;";
|
|
231
|
+
const B = (g, m = "error") => {
|
|
232
|
+
let p = w.querySelector(".lhp-alert");
|
|
233
|
+
p || (p = document.createElement("div"), p.className = "lhp-alert", p.style.cssText = "padding: 10px 12px; border-radius: 8px; font-size: 14px; border: 1px solid transparent;", w.prepend(p)), p.textContent = g, p.style.background = m === "error" ? "#FDECEA" : "#E8F5E9", p.style.borderColor = m === "error" ? "#F5C6CB" : "#C8E6C9", p.style.color = m === "error" ? "#721C24" : "#1B5E20";
|
|
234
|
+
}, K = ({ key: g, label: m, placeholder: p, value: L = "", type: N = "text", readonly: M = !1 }) => {
|
|
235
|
+
const T = document.createElement("label");
|
|
236
|
+
T.style.cssText = "display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #222;", T.textContent = m;
|
|
237
|
+
const f = document.createElement("input");
|
|
238
|
+
return f.name = g, f.type = N, f.value = L, f.placeholder = p || m, f.readOnly = M, f.style.cssText = "padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 8px; font-size: 14px; outline: none;", f.addEventListener("focus", () => {
|
|
239
|
+
f.style.borderColor = "#1C6EC6", f.style.boxShadow = "0 0 0 3px rgba(28, 110, 198, 0.15)";
|
|
240
|
+
}), f.addEventListener("blur", () => {
|
|
241
|
+
f.style.borderColor = "#d0d5dd", f.style.boxShadow = "none";
|
|
242
|
+
}), T.appendChild(f), { wrapper: T, input: f };
|
|
243
|
+
}, y = {}, v = (g, m) => {
|
|
244
|
+
if (!i[g].visible) return;
|
|
245
|
+
const { wrapper: p, input: L } = K(m);
|
|
246
|
+
y[g] = L, w.appendChild(p);
|
|
247
|
+
};
|
|
248
|
+
v("name", {
|
|
249
|
+
key: "name",
|
|
250
|
+
label: i.name.label,
|
|
251
|
+
placeholder: i.name.placeholder,
|
|
252
|
+
value: a.name || ""
|
|
253
|
+
}), v("phone", {
|
|
254
|
+
key: "phone",
|
|
255
|
+
label: i.phone.label,
|
|
256
|
+
placeholder: i.phone.placeholder,
|
|
257
|
+
value: a.phone || ""
|
|
258
|
+
}), v("email", {
|
|
259
|
+
key: "email",
|
|
260
|
+
label: i.email.label,
|
|
261
|
+
placeholder: i.email.placeholder,
|
|
262
|
+
value: a.email || ""
|
|
263
|
+
}), v("address", {
|
|
264
|
+
key: "address",
|
|
265
|
+
label: i.address.label,
|
|
266
|
+
placeholder: i.address.placeholder,
|
|
267
|
+
value: a.address || ""
|
|
268
|
+
}), v("amount", {
|
|
269
|
+
key: "amount",
|
|
270
|
+
label: i.amount.label,
|
|
271
|
+
placeholder: i.amount.placeholder,
|
|
272
|
+
value: e.amount ?? a.amount ?? "",
|
|
273
|
+
type: "number",
|
|
274
|
+
readonly: i.amount.readonly
|
|
275
|
+
});
|
|
276
|
+
const E = document.createElement("button");
|
|
277
|
+
E.type = "submit", E.textContent = t, E.style.cssText = "margin-top: 6px; padding: 12px; background: #1C6EC6; color: #fff; border: none; border-radius: 10px; font-weight: 700; cursor: pointer;";
|
|
278
|
+
const U = document.createElement("div");
|
|
279
|
+
U.style.cssText = "display: flex; justify-content: flex-end; gap: 8px; align-items: center;", U.appendChild(E), w.appendChild(U);
|
|
280
|
+
const $ = () => {
|
|
281
|
+
h.remove(), typeof c == "function" && c();
|
|
282
|
+
};
|
|
283
|
+
return x.addEventListener("click", $), w.addEventListener("submit", async (g) => {
|
|
284
|
+
g.preventDefault(), E.disabled = !0, E.style.opacity = "0.7", B("Processing payment...", "info");
|
|
285
|
+
try {
|
|
286
|
+
const m = {
|
|
287
|
+
productCode: e.productCode,
|
|
288
|
+
tag: e.tag || e.productCode,
|
|
289
|
+
amount: y.amount ? y.amount.value : e.amount,
|
|
290
|
+
currency: e.currency || a.currency,
|
|
291
|
+
phone: y.phone ? y.phone.value : "",
|
|
292
|
+
name: y.name ? y.name.value : "",
|
|
293
|
+
email: y.email ? y.email.value : "",
|
|
294
|
+
address: y.address ? y.address.value : ""
|
|
295
|
+
}, p = await this.createPayment(m);
|
|
296
|
+
B(p.message || "Payment successful", "success"), typeof s == "function" && s(p);
|
|
297
|
+
} catch (m) {
|
|
298
|
+
B(m?.message || "Payment failed", "error"), typeof d == "function" && d(m);
|
|
299
|
+
} finally {
|
|
300
|
+
E.disabled = !1, E.style.opacity = "1";
|
|
301
|
+
}
|
|
302
|
+
}), l.appendChild(x), l.appendChild(k), l.appendChild(S), l.appendChild(w), h.appendChild(l), document.body.appendChild(h), { close: $, overlay: h, form: w, inputs: y };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Render a simple product grid and hook click-to-pay form.
|
|
306
|
+
* @param {Object} options
|
|
307
|
+
* @param {string|HTMLElement} options.selector - container
|
|
308
|
+
* @param {Array} options.products - [{ productCode, name, amount, description, imageUrl, tag }]
|
|
309
|
+
* @param {Function} [options.renderCard] - (product) => HTMLElement
|
|
310
|
+
* @param {Object} [options.form] - passed to renderPaymentForm
|
|
311
|
+
*/
|
|
312
|
+
mountProductGrid(n = {}) {
|
|
313
|
+
const { selector: e, products: a = [], renderCard: s, form: d = {} } = n, c = typeof e == "string" ? document.querySelector(e) : e;
|
|
314
|
+
if (!c)
|
|
315
|
+
throw new Error("mountProductGrid: container not found");
|
|
316
|
+
const u = (t) => {
|
|
317
|
+
const r = document.createElement("div");
|
|
318
|
+
if (r.style.cssText = "border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; display: flex; gap: 12px; align-items: center; cursor: pointer; transition: box-shadow 0.2s;", r.addEventListener("mouseenter", () => {
|
|
319
|
+
r.style.boxShadow = "0 8px 24px rgba(0,0,0,0.08)";
|
|
320
|
+
}), r.addEventListener("mouseleave", () => {
|
|
321
|
+
r.style.boxShadow = "none";
|
|
322
|
+
}), t.imageUrl) {
|
|
323
|
+
const x = document.createElement("img");
|
|
324
|
+
x.src = t.imageUrl, x.alt = t.name || t.productCode || "Product", x.style.cssText = "width: 64px; height: 64px; object-fit: cover; border-radius: 10px;", r.appendChild(x);
|
|
325
|
+
}
|
|
326
|
+
const o = document.createElement("div");
|
|
327
|
+
o.style.cssText = "display: flex; flex-direction: column; gap: 4px;";
|
|
328
|
+
const i = document.createElement("div");
|
|
329
|
+
i.textContent = t.name || t.productCode || t.tag, i.style.cssText = "font-weight: 700; color: #0b1f3a;";
|
|
330
|
+
const h = document.createElement("div");
|
|
331
|
+
h.textContent = t.description || "", h.style.cssText = "font-size: 13px; color: #555;";
|
|
332
|
+
const l = document.createElement("div");
|
|
333
|
+
return l.textContent = t.amount != null ? `${t.currency || "USD"} ${t.amount}` : "", l.style.cssText = "font-size: 13px; color: #1C6EC6; font-weight: 700;", o.appendChild(i), h.textContent && o.appendChild(h), l.textContent && o.appendChild(l), r.appendChild(o), r;
|
|
334
|
+
};
|
|
335
|
+
return c.innerHTML = "", a.forEach((t) => {
|
|
336
|
+
const r = typeof s == "function" ? s(t) : u(t);
|
|
337
|
+
r.addEventListener("click", () => {
|
|
338
|
+
this.renderPaymentForm({
|
|
339
|
+
product: t,
|
|
340
|
+
defaults: { amount: t.amount },
|
|
341
|
+
...d
|
|
342
|
+
});
|
|
343
|
+
}), c.appendChild(r);
|
|
344
|
+
}), c;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Plug-and-play binding: attach to any element with data attributes.
|
|
348
|
+
* Expected data attributes:
|
|
349
|
+
* - data-lhp-tag (required)
|
|
350
|
+
* - data-lhp-amount (required)
|
|
351
|
+
* - data-lhp-name (optional, product name)
|
|
352
|
+
* - data-lhp-currency (optional)
|
|
353
|
+
* - data-lhp-phone / data-lhp-email (optional defaults)
|
|
354
|
+
*/
|
|
355
|
+
autoBindPayButtons(n = {}) {
|
|
356
|
+
const {
|
|
357
|
+
selector: e = "[data-lhp-pay]",
|
|
358
|
+
onSuccess: a,
|
|
359
|
+
onError: s,
|
|
360
|
+
onFailure: d,
|
|
361
|
+
defaults: c = {},
|
|
362
|
+
submitText: u = "Pay with LonghoPay"
|
|
363
|
+
} = n, t = Array.from(document.querySelectorAll(e));
|
|
364
|
+
return t.forEach((r) => {
|
|
365
|
+
const o = r.dataset || {}, i = {
|
|
366
|
+
tag: o.lhpTag,
|
|
367
|
+
amount: o.lhpAmount,
|
|
368
|
+
name: o.lhpName || r.textContent?.trim(),
|
|
369
|
+
currency: o.lhpCurrency || c.currency
|
|
370
|
+
};
|
|
371
|
+
r.addEventListener("click", (h) => {
|
|
372
|
+
h.preventDefault(), this.renderPaymentForm({
|
|
373
|
+
product: i,
|
|
374
|
+
defaults: {
|
|
375
|
+
...c,
|
|
376
|
+
phone: o.lhpPhone || c.phone,
|
|
377
|
+
email: o.lhpEmail || c.email,
|
|
378
|
+
amount: i.amount
|
|
379
|
+
},
|
|
380
|
+
submitText: u,
|
|
381
|
+
onSuccess: a,
|
|
382
|
+
onError: (l) => {
|
|
383
|
+
typeof s == "function" && s(l), typeof d == "function" && d(l);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}), t;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const D = {
|
|
391
|
+
version: "0.3.0",
|
|
392
|
+
init: (b) => new q(b),
|
|
393
|
+
LonghoPaySDK: q
|
|
394
|
+
};
|
|
395
|
+
A.LonghoPaySDK = D;
|
|
396
|
+
})(typeof window < "u" ? window : globalThis);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
(function(){"use strict";(function(A){const P={apiBaseUrl:"",webBaseUrl:"",hostedPath:"/checkout/",routes:{pay:"/pay",checkoutSession:"/checkout/session"},timeoutMs:3e4},z=b=>{const n=Number(b);if(!Number.isFinite(n))throw new Error("Amount must be a valid number");return n},C=b=>typeof b=="string"&&b.trim().length>0,j=b=>{const n=A.LonghoPaySDKDefaults||{},e={...P,...n,...b||{}},a=s=>s?String(s).replace(/\/+$/,""):"";return{apiBaseUrl:a(e.apiBaseUrl),webBaseUrl:a(e.webBaseUrl),hostedPath:e.hostedPath||P.hostedPath,routes:{...P.routes,...e.routes||{}},publicKey:e.publicKey||null,authToken:e.authToken||null,storeCode:e.storeCode||null,timeoutMs:Number(e.timeoutMs)||P.timeoutMs}},F=(b={})=>{const n=[],e={...b};if(!C(e.tag)&&!C(e.productCode)&&n.push("tag is required (or provide productCode)"),e.amount===void 0||e.amount===null)n.push("amount is required");else try{if(e.amount=z(e.amount),e.amount<100)throw new Error("Amount must be at least 100");if(e.amount>1e6)throw new Error("Amount must be at most 1,000,000");if(e.amount%5!==0)throw new Error("Amount must be a multiple of 5")}catch(a){n.push(a.message||"Invalid amount")}if(C(e.phone)||n.push("phone is required"),C(e.name)||n.push("name is required"),n.length){const a=new Error(n.join("; "));throw a.details=n,a}return{productCode:e.productCode,tag:e.tag||e.productCode,amount:e.amount,phone:String(e.phone),name:e.name,email:e.email||"",address:e.address||"",currency:e.currency||"USD",trid:e.trid,metadata:e.metadata}};class q{constructor(n={}){this.configure(n)}configure(n={}){this.config=j(n)}buildHostedPaymentUrl(n){if(!C(n))throw new Error("productCode is required to build a payment URL");if(!C(this.config.webBaseUrl))throw new Error("webBaseUrl is required to build hosted checkout URLs");const e=this.config.webBaseUrl.replace(/\/+$/,""),a=this.config.hostedPath||"/";return`${e}${a}${n}`}async createPayment(n){const e=F({...n});if(!C(this.config.apiBaseUrl))throw new Error("apiBaseUrl is required to create a payment");const a=typeof AbortController<"u"?new AbortController:null,s=a?setTimeout(()=>a.abort(),this.config.timeoutMs):null;try{const d=typeof this.config.authToken=="function"?this.config.authToken():this.config.authToken?this.config.authToken:null,c=await fetch(`${this.config.apiBaseUrl}${this.config.routes.pay}`,{method:"POST",headers:{"Content-Type":"application/json",...this.config.publicKey?{"X-Public-Key":this.config.publicKey}:{},...d?{Authorization:`Bearer ${d}`}:{}},body:JSON.stringify(e),...a?{signal:a.signal}:{}}),u=await c.json().catch(()=>({}));if(!c.ok||u?.success===!1){const t=u?.message||"Payment failed.",r=new Error(t);throw r.response=u,r}return{success:!0,message:u?.message||"Payment created.",data:u?.data||u}}catch(d){const c=d?.message||"Payment request failed.";return Promise.reject({message:c,cause:d})}finally{s&&clearTimeout(s)}}async createCheckoutSession(n={}){const{payload:e={},redirect:a=!0,onRedirect:s}=n,d=F(e);if(!C(this.config.apiBaseUrl))throw new Error("apiBaseUrl is required to create a checkout session");const c=typeof this.config.authToken=="function"?this.config.authToken():this.config.authToken?this.config.authToken:null,u=await fetch(`${this.config.apiBaseUrl}${this.config.routes.checkoutSession}`,{method:"POST",headers:{"Content-Type":"application/json",...this.config.publicKey?{"X-Public-Key":this.config.publicKey}:{},...c?{Authorization:`Bearer ${c}`}:{}},body:JSON.stringify(d)}),t=await u.json().catch(()=>({}));if(!u.ok||t?.success===!1){const o=t?.message||"Unable to create checkout session.",i=new Error(o);throw i.response=t,i}const r=t?.data?.checkoutUrl||t?.checkoutUrl;return a&&r&&(typeof s=="function"&&s(r),window.location.href=r),{success:!0,message:t?.message||"Checkout session created.",data:t?.data||t,checkoutUrl:r}}mountPayButton(n={}){const{selector:e,buttonText:a="Pay with LonghoPay",payment:s={},onSuccess:d,onError:c,onFailure:u,reuseTarget:t}=n,r=typeof e=="string"?document.querySelector(e):e;if(!r)throw new Error("mountPayButton: target element not found");const o=t?r:document.createElement("button");t||(o.type="button",o.textContent=a,o.style.cursor="pointer",o.style.padding="10px 16px",o.style.backgroundColor="#1C6EC6",o.style.color="#ffffff",o.style.border="none",o.style.borderRadius="6px",o.style.fontWeight="600",r.appendChild(o));const i=l=>{o.disabled=l,o.style.opacity=l?"0.65":"1"},h=async()=>{i(!0),this.renderPaymentForm({product:{productCode:s.productCode,name:s.name,amount:s.amount,tag:s.tag,currency:s.currency},defaults:{phone:s.phone,email:s.email,address:s.address,amount:s.amount},onSuccess:l=>{typeof d=="function"&&d(l)},onError:l=>{typeof c=="function"&&c(l),typeof u=="function"&&u(l)}}),i(!1)};return o.addEventListener("click",h),o}renderPaymentForm(n={}){const{product:e={},defaults:a={},onSuccess:s,onError:d,onClose:c,title:u="Complete your payment",submitText:t="Pay now",fields:r={}}=n,o=(g,m,p={})=>({label:r[g]?.label||m,placeholder:r[g]?.placeholder||"",visible:r[g]?.visible!==!1,...p}),i={name:o("name","Your name"),phone:o("phone","Phone number"),email:o("email","Email"),address:o("address","Address"),amount:o("amount","Amount",{readonly:r.amount?.readonly??!a.allowAmountEdit})},h=document.createElement("div");h.style.cssText=`
|
|
2
|
+
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
|
3
|
+
display: flex; align-items: center; justify-content: center;
|
|
4
|
+
z-index: 9999; padding: 16px;
|
|
5
|
+
`;const l=document.createElement("div");l.style.cssText="width: 100%; max-width: 440px; background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.12); padding: 20px; position: relative; border: 1px solid #e5e7eb; border-top: 4px solid #1C6EC6;";const x=document.createElement("button");x.type="button",x.textContent="×",x.setAttribute("aria-label","Close"),x.style.cssText="position: absolute; top: 10px; right: 12px; border: none; background: transparent; font-size: 22px; cursor: pointer;";const k=document.createElement("h3");k.textContent=u,k.style.cssText="margin: 0 0 12px; font-size: 18px; font-weight: 700; color: #0b1f3a;";const S=document.createElement("p");S.textContent=e.name||e.productCode||e.tag||"",S.style.cssText="margin: 0 0 12px; color: #1C6EC6; font-size: 14px; font-weight: 600;";const w=document.createElement("form");w.style.cssText="display: flex; flex-direction: column; gap: 10px;";const B=(g,m="error")=>{let p=w.querySelector(".lhp-alert");p||(p=document.createElement("div"),p.className="lhp-alert",p.style.cssText="padding: 10px 12px; border-radius: 8px; font-size: 14px; border: 1px solid transparent;",w.prepend(p)),p.textContent=g,p.style.background=m==="error"?"#FDECEA":"#E8F5E9",p.style.borderColor=m==="error"?"#F5C6CB":"#C8E6C9",p.style.color=m==="error"?"#721C24":"#1B5E20"},K=({key:g,label:m,placeholder:p,value:L="",type:N="text",readonly:M=!1})=>{const T=document.createElement("label");T.style.cssText="display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #222;",T.textContent=m;const f=document.createElement("input");return f.name=g,f.type=N,f.value=L,f.placeholder=p||m,f.readOnly=M,f.style.cssText="padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 8px; font-size: 14px; outline: none;",f.addEventListener("focus",()=>{f.style.borderColor="#1C6EC6",f.style.boxShadow="0 0 0 3px rgba(28, 110, 198, 0.15)"}),f.addEventListener("blur",()=>{f.style.borderColor="#d0d5dd",f.style.boxShadow="none"}),T.appendChild(f),{wrapper:T,input:f}},y={},v=(g,m)=>{if(!i[g].visible)return;const{wrapper:p,input:L}=K(m);y[g]=L,w.appendChild(p)};v("name",{key:"name",label:i.name.label,placeholder:i.name.placeholder,value:a.name||""}),v("phone",{key:"phone",label:i.phone.label,placeholder:i.phone.placeholder,value:a.phone||""}),v("email",{key:"email",label:i.email.label,placeholder:i.email.placeholder,value:a.email||""}),v("address",{key:"address",label:i.address.label,placeholder:i.address.placeholder,value:a.address||""}),v("amount",{key:"amount",label:i.amount.label,placeholder:i.amount.placeholder,value:e.amount??a.amount??"",type:"number",readonly:i.amount.readonly});const E=document.createElement("button");E.type="submit",E.textContent=t,E.style.cssText="margin-top: 6px; padding: 12px; background: #1C6EC6; color: #fff; border: none; border-radius: 10px; font-weight: 700; cursor: pointer;";const U=document.createElement("div");U.style.cssText="display: flex; justify-content: flex-end; gap: 8px; align-items: center;",U.appendChild(E),w.appendChild(U);const $=()=>{h.remove(),typeof c=="function"&&c()};return x.addEventListener("click",$),w.addEventListener("submit",async g=>{g.preventDefault(),E.disabled=!0,E.style.opacity="0.7",B("Processing payment...","info");try{const m={productCode:e.productCode,tag:e.tag||e.productCode,amount:y.amount?y.amount.value:e.amount,currency:e.currency||a.currency,phone:y.phone?y.phone.value:"",name:y.name?y.name.value:"",email:y.email?y.email.value:"",address:y.address?y.address.value:""},p=await this.createPayment(m);B(p.message||"Payment successful","success"),typeof s=="function"&&s(p)}catch(m){B(m?.message||"Payment failed","error"),typeof d=="function"&&d(m)}finally{E.disabled=!1,E.style.opacity="1"}}),l.appendChild(x),l.appendChild(k),l.appendChild(S),l.appendChild(w),h.appendChild(l),document.body.appendChild(h),{close:$,overlay:h,form:w,inputs:y}}mountProductGrid(n={}){const{selector:e,products:a=[],renderCard:s,form:d={}}=n,c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error("mountProductGrid: container not found");const u=t=>{const r=document.createElement("div");if(r.style.cssText="border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; display: flex; gap: 12px; align-items: center; cursor: pointer; transition: box-shadow 0.2s;",r.addEventListener("mouseenter",()=>{r.style.boxShadow="0 8px 24px rgba(0,0,0,0.08)"}),r.addEventListener("mouseleave",()=>{r.style.boxShadow="none"}),t.imageUrl){const x=document.createElement("img");x.src=t.imageUrl,x.alt=t.name||t.productCode||"Product",x.style.cssText="width: 64px; height: 64px; object-fit: cover; border-radius: 10px;",r.appendChild(x)}const o=document.createElement("div");o.style.cssText="display: flex; flex-direction: column; gap: 4px;";const i=document.createElement("div");i.textContent=t.name||t.productCode||t.tag,i.style.cssText="font-weight: 700; color: #0b1f3a;";const h=document.createElement("div");h.textContent=t.description||"",h.style.cssText="font-size: 13px; color: #555;";const l=document.createElement("div");return l.textContent=t.amount!=null?`${t.currency||"USD"} ${t.amount}`:"",l.style.cssText="font-size: 13px; color: #1C6EC6; font-weight: 700;",o.appendChild(i),h.textContent&&o.appendChild(h),l.textContent&&o.appendChild(l),r.appendChild(o),r};return c.innerHTML="",a.forEach(t=>{const r=typeof s=="function"?s(t):u(t);r.addEventListener("click",()=>{this.renderPaymentForm({product:t,defaults:{amount:t.amount},...d})}),c.appendChild(r)}),c}autoBindPayButtons(n={}){const{selector:e="[data-lhp-pay]",onSuccess:a,onError:s,onFailure:d,defaults:c={},submitText:u="Pay with LonghoPay"}=n,t=Array.from(document.querySelectorAll(e));return t.forEach(r=>{const o=r.dataset||{},i={tag:o.lhpTag,amount:o.lhpAmount,name:o.lhpName||r.textContent?.trim(),currency:o.lhpCurrency||c.currency};r.addEventListener("click",h=>{h.preventDefault(),this.renderPaymentForm({product:i,defaults:{...c,phone:o.lhpPhone||c.phone,email:o.lhpEmail||c.email,amount:i.amount},submitText:u,onSuccess:a,onError:l=>{typeof s=="function"&&s(l),typeof d=="function"&&d(l)}})})}),t}}const D={version:"0.3.0",init:b=>new q(b),LonghoPaySDK:q};A.LonghoPaySDK=D})(typeof window<"u"?window:globalThis)})();
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Payment SDK JavaScript Plugin (LonghoPay-branded defaults)
|
|
2
|
+
|
|
3
|
+
A tiny browser-friendly helper with LonghoPay-branded defaults so merchants can trigger hosted checkout (Stripe-like) or direct payments from any website without React or bundlers. All defaults are configurable.
|
|
4
|
+
|
|
5
|
+
Receipts are not built by the SDK; handle receipt creation/formatting in your own code via the API response.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<script src="/sdk/payment-sdk.js"></script>
|
|
11
|
+
<script>
|
|
12
|
+
const sdk = window.LonghoPaySDK.init({
|
|
13
|
+
apiBaseUrl: 'https://api.example.com', // required API origin
|
|
14
|
+
routes: { pay: '/pay' }, // payment route
|
|
15
|
+
webBaseUrl: 'https://app.example.com', // used for hosted URLs
|
|
16
|
+
publicKey: 'pk_test_xxx', // optional header for your store if required
|
|
17
|
+
authToken: () => localStorage.getItem('t'), // Bearer token (string or function)
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
sdk
|
|
21
|
+
.createPayment({
|
|
22
|
+
tag: 'product-slug', // required (or use productCode)
|
|
23
|
+
amount: 1500, // required: numeric, 100–1,000,000, multiple of 5
|
|
24
|
+
phone: '670000000', // required
|
|
25
|
+
name: 'Jane Customer', // required
|
|
26
|
+
email: 'jane@example.com', // optional
|
|
27
|
+
address: 'Yaounde, CM', // optional
|
|
28
|
+
})
|
|
29
|
+
.then((res) => console.log('Payment created', res))
|
|
30
|
+
.catch((err) => console.error('Payment failed', err));
|
|
31
|
+
</script>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Mount a Button
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<div id="pay-here"></div>
|
|
38
|
+
<script>
|
|
39
|
+
const sdk = window.LonghoPaySDK.init({ apiBaseUrl: 'https://api.example.com' });
|
|
40
|
+
|
|
41
|
+
sdk.mountPayButton({
|
|
42
|
+
selector: '#pay-here', // CSS selector or DOM node
|
|
43
|
+
buttonText: 'Pay with LonghoPay',
|
|
44
|
+
payment: {
|
|
45
|
+
productCode: 'ABC123',
|
|
46
|
+
amount: 1500,
|
|
47
|
+
phone: '670000000',
|
|
48
|
+
name: 'Jane Customer',
|
|
49
|
+
},
|
|
50
|
+
onSuccess: ({ receipt }) => {
|
|
51
|
+
console.log('Payment success', receipt);
|
|
52
|
+
},
|
|
53
|
+
onError: (err) => {
|
|
54
|
+
alert(err.message || 'Payment failed');
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Hosted Checkout URL
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const sdk = window.LonghoPaySDK.init({ webBaseUrl: 'https://app.example.com', hostedPath: '/checkout/' });
|
|
64
|
+
const url = sdk.buildHostedPaymentUrl('ABC123'); // -> https://app.example.com/checkout/ABC123
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Product Grid + Click-to-Pay Form
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<div id="product-grid"></div>
|
|
71
|
+
<script>
|
|
72
|
+
const products = [
|
|
73
|
+
{ productCode: 'ABC123', tag: 'abc123', name: 'Red Shirt', amount: 1500, description: 'Cotton' },
|
|
74
|
+
{ productCode: 'XYZ789', tag: 'xyz789', name: 'Blue Hoodie', amount: 2500, description: 'Fleece' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const sdk = window.LonghoPaySDK.init({ apiBaseUrl: 'https://api.example.com' });
|
|
78
|
+
sdk.mountProductGrid({
|
|
79
|
+
selector: '#product-grid',
|
|
80
|
+
products,
|
|
81
|
+
form: {
|
|
82
|
+
title: 'Pay securely',
|
|
83
|
+
submitText: 'Pay with LonghoPay',
|
|
84
|
+
fields: {
|
|
85
|
+
amount: { readonly: true }, // keep product price locked
|
|
86
|
+
},
|
|
87
|
+
onSuccess: ({ receipt }) => console.log('Paid', receipt),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
</script>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Plug-and-Play (data attributes)
|
|
94
|
+
|
|
95
|
+
Add buttons with data attributes, then auto-bind:
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<button data-lhp-pay data-lhp-tag="product-slug" data-lhp-amount="1500" data-lhp-name="Red Shirt">
|
|
99
|
+
Pay with LonghoPay
|
|
100
|
+
</button>
|
|
101
|
+
<script>
|
|
102
|
+
const sdk = window.LonghoPaySDK.init({ apiBaseUrl: 'https://api.example.com', authToken: 'BEARER_TOKEN' });
|
|
103
|
+
sdk.autoBindPayButtons(); // opens branded modal with prefilled name/amount, hidden tag
|
|
104
|
+
</script>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API Reference
|
|
108
|
+
|
|
109
|
+
- `LonghoPaySDK.init(config)` → `LonghoPaySDK` instance
|
|
110
|
+
- `config.apiBaseUrl` (required): Base API origin, e.g., `https://api.example.com`.
|
|
111
|
+
- `config.webBaseUrl` (optional): Base web origin for hosted payment links.
|
|
112
|
+
- `config.hostedPath` (optional): Path prefix for hosted checkout links (default `/checkout/`).
|
|
113
|
+
- `config.routes` (optional): Override API paths (defaults `{ pay: '/pay', checkoutSession: '/checkout/session' }`).
|
|
114
|
+
- `config.publicKey` (optional): Added as `X-Public-Key` header.
|
|
115
|
+
- `config.authToken` (optional): String or function returning a Bearer token; adds `Authorization: Bearer <token>` to requests.
|
|
116
|
+
- `config.timeoutMs` (optional): Request timeout in milliseconds (default `30000`).
|
|
117
|
+
- `sdk.createPayment(payment)` → Promise resolving to `{ success, message, data }`. Throws with `{ message, cause }` on failure. Handle receipt creation yourself from the response payload.
|
|
118
|
+
- Required: `tag` (or `productCode`), `amount`, `phone`, `name`.
|
|
119
|
+
- Optional: `productCode`, `email`, `address`, `currency`, `metadata`, `trid`.
|
|
120
|
+
- Amount rules: must be numeric, min 100, max 1,000,000, and a multiple of 5.
|
|
121
|
+
- `sdk.createCheckoutSession({ payload, redirect, onRedirect })` → Creates hosted session, returns `{ checkoutUrl, ... }` and optionally redirects like Stripe Checkout.
|
|
122
|
+
- `sdk.mountPayButton(options)` → returns the created/enhanced button.
|
|
123
|
+
- `selector`: CSS selector or DOM element to attach to.
|
|
124
|
+
- `buttonText`: Text for the button (default: "Pay with LonghoPay").
|
|
125
|
+
- `payment`: Payload passed to `createPayment`.
|
|
126
|
+
- `onSuccess(result)`, `onError(error)`, `onFailure(error)`: Callbacks.
|
|
127
|
+
- `reuseTarget` (boolean): If `true`, the target element is used as the button instead of injecting a new one.
|
|
128
|
+
- `sdk.autoBindPayButtons(options)` → auto-attaches to elements with `data-lhp-pay`/`data-lhp-*` attributes and opens the branded modal.
|
|
129
|
+
- `sdk.buildHostedPaymentUrl(productCode)` → returns a hosted payment link using `webBaseUrl`.
|
|
130
|
+
- `sdk.renderPaymentForm(options)` → renders a modal overlay payment form.
|
|
131
|
+
- `product`: `{ productCode, name, amount, tag }` (prefills form and amount; tag is hidden but sent).
|
|
132
|
+
- `defaults`: `{ phone, name, email, address, allowAmountEdit }`.
|
|
133
|
+
- `fields`: per-field overrides `{ name: { label, placeholder, visible }, amount: { readonly } }`.
|
|
134
|
+
- `title`, `submitText`, `onSuccess`, `onError`, `onClose`.
|
|
135
|
+
- `sdk.mountProductGrid(options)` → builds a product list where clicking a card opens the payment form.
|
|
136
|
+
- `selector`: container to render in.
|
|
137
|
+
- `products`: array `{ productCode, name, amount, description, imageUrl, tag }`.
|
|
138
|
+
- `renderCard(product)`: custom renderer (defaults to a simple card).
|
|
139
|
+
- `form`: options forwarded to `renderPaymentForm` for customization.
|
|
140
|
+
|
|
141
|
+
## Global Defaults
|
|
142
|
+
|
|
143
|
+
Set `window.LonghoPaySDKDefaults = { apiBaseUrl: '...', webBaseUrl: '...' }` *before* loading the script if you want defaults for every `init()` call.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "longhopaysdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight browser SDK for LonghoPay checkout sessions and direct payments.",
|
|
5
|
+
"main": "vite.config.js",
|
|
6
|
+
"directories": {
|
|
7
|
+
"doc": "doc"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "vite build",
|
|
11
|
+
"deploy": "npm run build && npm publish --access public",
|
|
12
|
+
"prepublishOnly": "npm run build",
|
|
13
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"type": "commonjs",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"vite": "^7.3.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* LonghoPay Checkout SDK (browser-friendly)
|
|
3
|
+
*
|
|
4
|
+
* Lightweight helper for merchants to trigger hosted checkout flows from any webpage.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - createPayment(payload) // direct API payment
|
|
7
|
+
* - createCheckoutSession(opts) // Stripe-like hosted checkout creation + redirect
|
|
8
|
+
* - mountPayButton(options) // attach a button
|
|
9
|
+
* - renderPaymentForm(options) // modal, prefilled with product + price
|
|
10
|
+
* - mountProductGrid(options) // product cards → click to open payment form
|
|
11
|
+
*
|
|
12
|
+
* Example:
|
|
13
|
+
* <script src="/sdk/payment-sdk.js"></script>
|
|
14
|
+
* const sdk = window.LonghoPaySDK.init({ apiBaseUrl: 'https://api.example.com', webBaseUrl: 'https://app.example.com' });
|
|
15
|
+
* sdk.createCheckoutSession({ tag: 'product-slug', amount: 1000, phone: '670000000', name: 'Jane' });
|
|
16
|
+
*/
|
|
17
|
+
(function (global) {
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
apiBaseUrl: '',
|
|
20
|
+
webBaseUrl: '',
|
|
21
|
+
hostedPath: '/checkout/',
|
|
22
|
+
routes: {
|
|
23
|
+
pay: '/pay',
|
|
24
|
+
checkoutSession: '/checkout/session',
|
|
25
|
+
},
|
|
26
|
+
timeoutMs: 30_000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const toNumber = (v) => {
|
|
30
|
+
const num = Number(v);
|
|
31
|
+
if (!Number.isFinite(num)) {
|
|
32
|
+
throw new Error('Amount must be a valid number');
|
|
33
|
+
}
|
|
34
|
+
return num;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
38
|
+
|
|
39
|
+
const normalizeConfig = (config) => {
|
|
40
|
+
const base = global.LonghoPaySDKDefaults || {};
|
|
41
|
+
const merged = { ...DEFAULTS, ...base, ...(config || {}) };
|
|
42
|
+
|
|
43
|
+
const cleanBase = (val) => (val ? String(val).replace(/\/+$/, '') : '');
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
apiBaseUrl: cleanBase(merged.apiBaseUrl),
|
|
47
|
+
webBaseUrl: cleanBase(merged.webBaseUrl),
|
|
48
|
+
hostedPath: merged.hostedPath || DEFAULTS.hostedPath,
|
|
49
|
+
routes: { ...DEFAULTS.routes, ...(merged.routes || {}) },
|
|
50
|
+
publicKey: merged.publicKey || null,
|
|
51
|
+
authToken: merged.authToken || null,
|
|
52
|
+
storeCode: merged.storeCode || null,
|
|
53
|
+
timeoutMs: Number(merged.timeoutMs) || DEFAULTS.timeoutMs,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const validatePayment = (input = {}) => {
|
|
58
|
+
const errors = [];
|
|
59
|
+
const payload = { ...input };
|
|
60
|
+
|
|
61
|
+
if (!isNonEmptyString(payload.tag) && !isNonEmptyString(payload.productCode)) {
|
|
62
|
+
errors.push('tag is required (or provide productCode)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (payload.amount === undefined || payload.amount === null) {
|
|
66
|
+
errors.push('amount is required');
|
|
67
|
+
} else {
|
|
68
|
+
try {
|
|
69
|
+
payload.amount = toNumber(payload.amount);
|
|
70
|
+
if (payload.amount < 100) {
|
|
71
|
+
throw new Error('Amount must be at least 100');
|
|
72
|
+
}
|
|
73
|
+
if (payload.amount > 1_000_000) {
|
|
74
|
+
throw new Error('Amount must be at most 1,000,000');
|
|
75
|
+
}
|
|
76
|
+
if (payload.amount % 5 !== 0) {
|
|
77
|
+
throw new Error('Amount must be a multiple of 5');
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
errors.push(e.message || 'Invalid amount');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!isNonEmptyString(payload.phone)) {
|
|
85
|
+
errors.push('phone is required');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!isNonEmptyString(payload.name)) {
|
|
89
|
+
errors.push('name is required');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (errors.length) {
|
|
93
|
+
const error = new Error(errors.join('; '));
|
|
94
|
+
error.details = errors;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
productCode: payload.productCode,
|
|
100
|
+
tag: payload.tag || payload.productCode,
|
|
101
|
+
amount: payload.amount,
|
|
102
|
+
phone: String(payload.phone),
|
|
103
|
+
name: payload.name,
|
|
104
|
+
email: payload.email || '',
|
|
105
|
+
address: payload.address || '',
|
|
106
|
+
currency: payload.currency || 'USD',
|
|
107
|
+
trid: payload.trid,
|
|
108
|
+
metadata: payload.metadata,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
class LonghoPay {
|
|
113
|
+
constructor(config = {}) {
|
|
114
|
+
this.configure(config);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
configure(config = {}) {
|
|
118
|
+
this.config = normalizeConfig(config);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
buildHostedPaymentUrl(productCode) {
|
|
122
|
+
if (!isNonEmptyString(productCode)) {
|
|
123
|
+
throw new Error('productCode is required to build a payment URL');
|
|
124
|
+
}
|
|
125
|
+
if (!isNonEmptyString(this.config.webBaseUrl)) {
|
|
126
|
+
throw new Error('webBaseUrl is required to build hosted checkout URLs');
|
|
127
|
+
}
|
|
128
|
+
const base = this.config.webBaseUrl.replace(/\/+$/, '');
|
|
129
|
+
const path = this.config.hostedPath || '/';
|
|
130
|
+
return `${base}${path}${productCode}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async createPayment(payment) {
|
|
134
|
+
const payload = validatePayment({
|
|
135
|
+
...payment,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!isNonEmptyString(this.config.apiBaseUrl)) {
|
|
139
|
+
throw new Error('apiBaseUrl is required to create a payment');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
143
|
+
const timeout = controller ? setTimeout(() => controller.abort(), this.config.timeoutMs) : null;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const authHeader =
|
|
147
|
+
typeof this.config.authToken === 'function'
|
|
148
|
+
? this.config.authToken()
|
|
149
|
+
: this.config.authToken
|
|
150
|
+
? this.config.authToken
|
|
151
|
+
: null;
|
|
152
|
+
|
|
153
|
+
const res = await fetch(`${this.config.apiBaseUrl}${this.config.routes.pay}`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: {
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
...(this.config.publicKey ? { 'X-Public-Key': this.config.publicKey } : {}),
|
|
158
|
+
...(authHeader ? { Authorization: `Bearer ${authHeader}` } : {}),
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify(payload),
|
|
161
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const json = await res.json().catch(() => ({}));
|
|
165
|
+
|
|
166
|
+
if (!res.ok || json?.success === false) {
|
|
167
|
+
const message = json?.message || 'Payment failed.';
|
|
168
|
+
const error = new Error(message);
|
|
169
|
+
error.response = json;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
message: json?.message || 'Payment created.',
|
|
176
|
+
data: json?.data || json,
|
|
177
|
+
};
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err?.message || 'Payment request failed.';
|
|
180
|
+
return Promise.reject({
|
|
181
|
+
message,
|
|
182
|
+
cause: err,
|
|
183
|
+
});
|
|
184
|
+
} finally {
|
|
185
|
+
if (timeout) clearTimeout(timeout);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a hosted checkout session (Stripe-style) and optionally redirect.
|
|
191
|
+
* @param {Object} options - { payload, redirect: boolean, onRedirect }
|
|
192
|
+
*/
|
|
193
|
+
async createCheckoutSession(options = {}) {
|
|
194
|
+
const { payload = {}, redirect = true, onRedirect } = options;
|
|
195
|
+
const validated = validatePayment(payload);
|
|
196
|
+
|
|
197
|
+
if (!isNonEmptyString(this.config.apiBaseUrl)) {
|
|
198
|
+
throw new Error('apiBaseUrl is required to create a checkout session');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const authHeader =
|
|
202
|
+
typeof this.config.authToken === 'function'
|
|
203
|
+
? this.config.authToken()
|
|
204
|
+
: this.config.authToken
|
|
205
|
+
? this.config.authToken
|
|
206
|
+
: null;
|
|
207
|
+
|
|
208
|
+
const res = await fetch(`${this.config.apiBaseUrl}${this.config.routes.checkoutSession}`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
...(this.config.publicKey ? { 'X-Public-Key': this.config.publicKey } : {}),
|
|
213
|
+
...(authHeader ? { Authorization: `Bearer ${authHeader}` } : {}),
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(validated),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const json = await res.json().catch(() => ({}));
|
|
219
|
+
if (!res.ok || json?.success === false) {
|
|
220
|
+
const message = json?.message || 'Unable to create checkout session.';
|
|
221
|
+
const error = new Error(message);
|
|
222
|
+
error.response = json;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const checkoutUrl = json?.data?.checkoutUrl || json?.checkoutUrl;
|
|
227
|
+
if (redirect && checkoutUrl) {
|
|
228
|
+
if (typeof onRedirect === 'function') onRedirect(checkoutUrl);
|
|
229
|
+
window.location.href = checkoutUrl;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
success: true,
|
|
234
|
+
message: json?.message || 'Checkout session created.',
|
|
235
|
+
data: json?.data || json,
|
|
236
|
+
checkoutUrl,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
mountPayButton(options = {}) {
|
|
241
|
+
const {
|
|
242
|
+
selector,
|
|
243
|
+
buttonText = 'Pay with LonghoPay',
|
|
244
|
+
payment = {},
|
|
245
|
+
onSuccess,
|
|
246
|
+
onError,
|
|
247
|
+
onFailure,
|
|
248
|
+
reuseTarget,
|
|
249
|
+
} = options;
|
|
250
|
+
const target = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
|
251
|
+
|
|
252
|
+
if (!target) {
|
|
253
|
+
throw new Error('mountPayButton: target element not found');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const btn = reuseTarget ? target : document.createElement('button');
|
|
257
|
+
if (!reuseTarget) {
|
|
258
|
+
btn.type = 'button';
|
|
259
|
+
btn.textContent = buttonText;
|
|
260
|
+
btn.style.cursor = 'pointer';
|
|
261
|
+
btn.style.padding = '10px 16px';
|
|
262
|
+
btn.style.backgroundColor = '#1C6EC6';
|
|
263
|
+
btn.style.color = '#ffffff';
|
|
264
|
+
btn.style.border = 'none';
|
|
265
|
+
btn.style.borderRadius = '6px';
|
|
266
|
+
btn.style.fontWeight = '600';
|
|
267
|
+
target.appendChild(btn);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const setDisabled = (disabled) => {
|
|
271
|
+
btn.disabled = disabled;
|
|
272
|
+
btn.style.opacity = disabled ? '0.65' : '1';
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const clickHandler = async () => {
|
|
276
|
+
setDisabled(true);
|
|
277
|
+
this.renderPaymentForm({
|
|
278
|
+
product: {
|
|
279
|
+
productCode: payment.productCode,
|
|
280
|
+
name: payment.name,
|
|
281
|
+
amount: payment.amount,
|
|
282
|
+
tag: payment.tag,
|
|
283
|
+
currency: payment.currency,
|
|
284
|
+
},
|
|
285
|
+
defaults: {
|
|
286
|
+
phone: payment.phone,
|
|
287
|
+
email: payment.email,
|
|
288
|
+
address: payment.address,
|
|
289
|
+
amount: payment.amount,
|
|
290
|
+
},
|
|
291
|
+
onSuccess: (res) => {
|
|
292
|
+
if (typeof onSuccess === 'function') onSuccess(res);
|
|
293
|
+
},
|
|
294
|
+
onError: (err) => {
|
|
295
|
+
if (typeof onError === 'function') onError(err);
|
|
296
|
+
if (typeof onFailure === 'function') onFailure(err);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
setDisabled(false);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
btn.addEventListener('click', clickHandler);
|
|
303
|
+
return btn;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Render a lightweight payment form overlay.
|
|
308
|
+
* @param {Object} options
|
|
309
|
+
* @param {Object} options.product - { productCode, storeCode, name, amount, tag }
|
|
310
|
+
* @param {Object} [options.defaults] - { phone, name, email, address, allowAmountEdit }
|
|
311
|
+
* @param {Function} [options.onSuccess] - callback(result)
|
|
312
|
+
* @param {Function} [options.onError] - callback(error)
|
|
313
|
+
* @param {Function} [options.onClose] - callback()
|
|
314
|
+
* @param {string} [options.title] - form heading
|
|
315
|
+
* @param {string} [options.submitText] - button text
|
|
316
|
+
* @param {Object} [options.fields] - per-field config, e.g. { phone: { label, placeholder, visible } }
|
|
317
|
+
*/
|
|
318
|
+
renderPaymentForm(options = {}) {
|
|
319
|
+
const {
|
|
320
|
+
product = {},
|
|
321
|
+
defaults = {},
|
|
322
|
+
onSuccess,
|
|
323
|
+
onError,
|
|
324
|
+
onClose,
|
|
325
|
+
title = 'Complete your payment',
|
|
326
|
+
submitText = 'Pay now',
|
|
327
|
+
fields = {},
|
|
328
|
+
} = options;
|
|
329
|
+
|
|
330
|
+
const cfgField = (key, fallbackLabel, extra = {}) => ({
|
|
331
|
+
label: fields[key]?.label || fallbackLabel,
|
|
332
|
+
placeholder: fields[key]?.placeholder || '',
|
|
333
|
+
visible: fields[key]?.visible !== false,
|
|
334
|
+
...extra,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const fieldConfig = {
|
|
338
|
+
name: cfgField('name', 'Your name'),
|
|
339
|
+
phone: cfgField('phone', 'Phone number'),
|
|
340
|
+
email: cfgField('email', 'Email'),
|
|
341
|
+
address: cfgField('address', 'Address'),
|
|
342
|
+
amount: cfgField('amount', 'Amount', { readonly: fields.amount?.readonly ?? !defaults.allowAmountEdit }),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const overlay = document.createElement('div');
|
|
346
|
+
overlay.style.cssText = `
|
|
347
|
+
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
|
348
|
+
display: flex; align-items: center; justify-content: center;
|
|
349
|
+
z-index: 9999; padding: 16px;
|
|
350
|
+
`;
|
|
351
|
+
|
|
352
|
+
const panel = document.createElement('div');
|
|
353
|
+
panel.style.cssText =
|
|
354
|
+
'width: 100%; max-width: 440px; background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.12); padding: 20px; position: relative; border: 1px solid #e5e7eb; border-top: 4px solid #1C6EC6;';
|
|
355
|
+
|
|
356
|
+
const closeBtn = document.createElement('button');
|
|
357
|
+
closeBtn.type = 'button';
|
|
358
|
+
closeBtn.textContent = '×';
|
|
359
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
360
|
+
closeBtn.style.cssText =
|
|
361
|
+
'position: absolute; top: 10px; right: 12px; border: none; background: transparent; font-size: 22px; cursor: pointer;';
|
|
362
|
+
|
|
363
|
+
const heading = document.createElement('h3');
|
|
364
|
+
heading.textContent = title;
|
|
365
|
+
heading.style.cssText = 'margin: 0 0 12px; font-size: 18px; font-weight: 700; color: #0b1f3a;';
|
|
366
|
+
|
|
367
|
+
const sub = document.createElement('p');
|
|
368
|
+
sub.textContent = product.name || product.productCode || product.tag || '';
|
|
369
|
+
sub.style.cssText = 'margin: 0 0 12px; color: #1C6EC6; font-size: 14px; font-weight: 600;';
|
|
370
|
+
|
|
371
|
+
const form = document.createElement('form');
|
|
372
|
+
form.style.cssText = 'display: flex; flex-direction: column; gap: 10px;';
|
|
373
|
+
|
|
374
|
+
const showAlert = (msg, type = 'error') => {
|
|
375
|
+
let alertBox = form.querySelector('.lhp-alert');
|
|
376
|
+
if (!alertBox) {
|
|
377
|
+
alertBox = document.createElement('div');
|
|
378
|
+
alertBox.className = 'lhp-alert';
|
|
379
|
+
alertBox.style.cssText =
|
|
380
|
+
'padding: 10px 12px; border-radius: 8px; font-size: 14px; border: 1px solid transparent;';
|
|
381
|
+
form.prepend(alertBox);
|
|
382
|
+
}
|
|
383
|
+
alertBox.textContent = msg;
|
|
384
|
+
alertBox.style.background = type === 'error' ? '#FDECEA' : '#E8F5E9';
|
|
385
|
+
alertBox.style.borderColor = type === 'error' ? '#F5C6CB' : '#C8E6C9';
|
|
386
|
+
alertBox.style.color = type === 'error' ? '#721C24' : '#1B5E20';
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const inputFactory = ({ key, label, placeholder, value = '', type = 'text', readonly = false }) => {
|
|
390
|
+
const wrapper = document.createElement('label');
|
|
391
|
+
wrapper.style.cssText = 'display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #222;';
|
|
392
|
+
wrapper.textContent = label;
|
|
393
|
+
|
|
394
|
+
const input = document.createElement('input');
|
|
395
|
+
input.name = key;
|
|
396
|
+
input.type = type;
|
|
397
|
+
input.value = value;
|
|
398
|
+
input.placeholder = placeholder || label;
|
|
399
|
+
input.readOnly = readonly;
|
|
400
|
+
input.style.cssText =
|
|
401
|
+
'padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 8px; font-size: 14px; outline: none;';
|
|
402
|
+
input.addEventListener('focus', () => {
|
|
403
|
+
input.style.borderColor = '#1C6EC6';
|
|
404
|
+
input.style.boxShadow = '0 0 0 3px rgba(28, 110, 198, 0.15)';
|
|
405
|
+
});
|
|
406
|
+
input.addEventListener('blur', () => {
|
|
407
|
+
input.style.borderColor = '#d0d5dd';
|
|
408
|
+
input.style.boxShadow = 'none';
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
wrapper.appendChild(input);
|
|
412
|
+
return { wrapper, input };
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const inputs = {};
|
|
416
|
+
|
|
417
|
+
const addField = (key, opts) => {
|
|
418
|
+
if (!fieldConfig[key].visible) return;
|
|
419
|
+
const { wrapper, input } = inputFactory(opts);
|
|
420
|
+
inputs[key] = input;
|
|
421
|
+
form.appendChild(wrapper);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
addField('name', {
|
|
425
|
+
key: 'name',
|
|
426
|
+
label: fieldConfig.name.label,
|
|
427
|
+
placeholder: fieldConfig.name.placeholder,
|
|
428
|
+
value: defaults.name || '',
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
addField('phone', {
|
|
432
|
+
key: 'phone',
|
|
433
|
+
label: fieldConfig.phone.label,
|
|
434
|
+
placeholder: fieldConfig.phone.placeholder,
|
|
435
|
+
value: defaults.phone || '',
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
addField('email', {
|
|
439
|
+
key: 'email',
|
|
440
|
+
label: fieldConfig.email.label,
|
|
441
|
+
placeholder: fieldConfig.email.placeholder,
|
|
442
|
+
value: defaults.email || '',
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
addField('address', {
|
|
446
|
+
key: 'address',
|
|
447
|
+
label: fieldConfig.address.label,
|
|
448
|
+
placeholder: fieldConfig.address.placeholder,
|
|
449
|
+
value: defaults.address || '',
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
addField('amount', {
|
|
453
|
+
key: 'amount',
|
|
454
|
+
label: fieldConfig.amount.label,
|
|
455
|
+
placeholder: fieldConfig.amount.placeholder,
|
|
456
|
+
value: product.amount ?? defaults.amount ?? '',
|
|
457
|
+
type: 'number',
|
|
458
|
+
readonly: fieldConfig.amount.readonly,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const submitBtn = document.createElement('button');
|
|
462
|
+
submitBtn.type = 'submit';
|
|
463
|
+
submitBtn.textContent = submitText;
|
|
464
|
+
submitBtn.style.cssText =
|
|
465
|
+
'margin-top: 6px; padding: 12px; background: #1C6EC6; color: #fff; border: none; border-radius: 10px; font-weight: 700; cursor: pointer;';
|
|
466
|
+
|
|
467
|
+
const foot = document.createElement('div');
|
|
468
|
+
foot.style.cssText = 'display: flex; justify-content: flex-end; gap: 8px; align-items: center;';
|
|
469
|
+
|
|
470
|
+
foot.appendChild(submitBtn);
|
|
471
|
+
form.appendChild(foot);
|
|
472
|
+
|
|
473
|
+
const close = () => {
|
|
474
|
+
overlay.remove();
|
|
475
|
+
if (typeof onClose === 'function') onClose();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
closeBtn.addEventListener('click', close);
|
|
479
|
+
|
|
480
|
+
form.addEventListener('submit', async (e) => {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
submitBtn.disabled = true;
|
|
483
|
+
submitBtn.style.opacity = '0.7';
|
|
484
|
+
showAlert('Processing payment...', 'info');
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const payload = {
|
|
488
|
+
productCode: product.productCode,
|
|
489
|
+
tag: product.tag || product.productCode,
|
|
490
|
+
amount: inputs.amount ? inputs.amount.value : product.amount,
|
|
491
|
+
currency: product.currency || defaults.currency,
|
|
492
|
+
phone: inputs.phone ? inputs.phone.value : '',
|
|
493
|
+
name: inputs.name ? inputs.name.value : '',
|
|
494
|
+
email: inputs.email ? inputs.email.value : '',
|
|
495
|
+
address: inputs.address ? inputs.address.value : '',
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const res = await this.createPayment(payload);
|
|
499
|
+
showAlert(res.message || 'Payment successful', 'success');
|
|
500
|
+
if (typeof onSuccess === 'function') onSuccess(res);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
showAlert(err?.message || 'Payment failed', 'error');
|
|
503
|
+
if (typeof onError === 'function') onError(err);
|
|
504
|
+
} finally {
|
|
505
|
+
submitBtn.disabled = false;
|
|
506
|
+
submitBtn.style.opacity = '1';
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
panel.appendChild(closeBtn);
|
|
511
|
+
panel.appendChild(heading);
|
|
512
|
+
panel.appendChild(sub);
|
|
513
|
+
panel.appendChild(form);
|
|
514
|
+
overlay.appendChild(panel);
|
|
515
|
+
document.body.appendChild(overlay);
|
|
516
|
+
|
|
517
|
+
return { close, overlay, form, inputs };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Render a simple product grid and hook click-to-pay form.
|
|
522
|
+
* @param {Object} options
|
|
523
|
+
* @param {string|HTMLElement} options.selector - container
|
|
524
|
+
* @param {Array} options.products - [{ productCode, name, amount, description, imageUrl, tag }]
|
|
525
|
+
* @param {Function} [options.renderCard] - (product) => HTMLElement
|
|
526
|
+
* @param {Object} [options.form] - passed to renderPaymentForm
|
|
527
|
+
*/
|
|
528
|
+
mountProductGrid(options = {}) {
|
|
529
|
+
const { selector, products = [], renderCard, form = {} } = options;
|
|
530
|
+
const container = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
|
531
|
+
if (!container) {
|
|
532
|
+
throw new Error('mountProductGrid: container not found');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const defaultCard = (p) => {
|
|
536
|
+
const card = document.createElement('div');
|
|
537
|
+
card.style.cssText =
|
|
538
|
+
'border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; display: flex; gap: 12px; align-items: center; cursor: pointer; transition: box-shadow 0.2s;';
|
|
539
|
+
card.addEventListener('mouseenter', () => {
|
|
540
|
+
card.style.boxShadow = '0 8px 24px rgba(0,0,0,0.08)';
|
|
541
|
+
});
|
|
542
|
+
card.addEventListener('mouseleave', () => {
|
|
543
|
+
card.style.boxShadow = 'none';
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
if (p.imageUrl) {
|
|
547
|
+
const img = document.createElement('img');
|
|
548
|
+
img.src = p.imageUrl;
|
|
549
|
+
img.alt = p.name || p.productCode || 'Product';
|
|
550
|
+
img.style.cssText = 'width: 64px; height: 64px; object-fit: cover; border-radius: 10px;';
|
|
551
|
+
card.appendChild(img);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const body = document.createElement('div');
|
|
555
|
+
body.style.cssText = 'display: flex; flex-direction: column; gap: 4px;';
|
|
556
|
+
|
|
557
|
+
const title = document.createElement('div');
|
|
558
|
+
title.textContent = p.name || p.productCode || p.tag;
|
|
559
|
+
title.style.cssText = 'font-weight: 700; color: #0b1f3a;';
|
|
560
|
+
|
|
561
|
+
const desc = document.createElement('div');
|
|
562
|
+
desc.textContent = p.description || '';
|
|
563
|
+
desc.style.cssText = 'font-size: 13px; color: #555;';
|
|
564
|
+
|
|
565
|
+
const price = document.createElement('div');
|
|
566
|
+
price.textContent = p.amount != null ? `${p.currency || 'USD'} ${p.amount}` : '';
|
|
567
|
+
price.style.cssText = 'font-size: 13px; color: #1C6EC6; font-weight: 700;';
|
|
568
|
+
|
|
569
|
+
body.appendChild(title);
|
|
570
|
+
if (desc.textContent) body.appendChild(desc);
|
|
571
|
+
if (price.textContent) body.appendChild(price);
|
|
572
|
+
|
|
573
|
+
card.appendChild(body);
|
|
574
|
+
return card;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
container.innerHTML = '';
|
|
578
|
+
products.forEach((p) => {
|
|
579
|
+
const card = typeof renderCard === 'function' ? renderCard(p) : defaultCard(p);
|
|
580
|
+
card.addEventListener('click', () => {
|
|
581
|
+
this.renderPaymentForm({
|
|
582
|
+
product: p,
|
|
583
|
+
defaults: { amount: p.amount },
|
|
584
|
+
...form,
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
container.appendChild(card);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
return container;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Plug-and-play binding: attach to any element with data attributes.
|
|
595
|
+
* Expected data attributes:
|
|
596
|
+
* - data-lhp-tag (required)
|
|
597
|
+
* - data-lhp-amount (required)
|
|
598
|
+
* - data-lhp-name (optional, product name)
|
|
599
|
+
* - data-lhp-currency (optional)
|
|
600
|
+
* - data-lhp-phone / data-lhp-email (optional defaults)
|
|
601
|
+
*/
|
|
602
|
+
autoBindPayButtons(options = {}) {
|
|
603
|
+
const {
|
|
604
|
+
selector = '[data-lhp-pay]',
|
|
605
|
+
onSuccess,
|
|
606
|
+
onError,
|
|
607
|
+
onFailure,
|
|
608
|
+
defaults = {},
|
|
609
|
+
submitText = 'Pay with LonghoPay',
|
|
610
|
+
} = options;
|
|
611
|
+
|
|
612
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
613
|
+
nodes.forEach((node) => {
|
|
614
|
+
const dataset = node.dataset || {};
|
|
615
|
+
const product = {
|
|
616
|
+
tag: dataset.lhpTag,
|
|
617
|
+
amount: dataset.lhpAmount,
|
|
618
|
+
name: dataset.lhpName || node.textContent?.trim(),
|
|
619
|
+
currency: dataset.lhpCurrency || defaults.currency,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
node.addEventListener('click', (e) => {
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
this.renderPaymentForm({
|
|
625
|
+
product,
|
|
626
|
+
defaults: {
|
|
627
|
+
...defaults,
|
|
628
|
+
phone: dataset.lhpPhone || defaults.phone,
|
|
629
|
+
email: dataset.lhpEmail || defaults.email,
|
|
630
|
+
amount: product.amount,
|
|
631
|
+
},
|
|
632
|
+
submitText,
|
|
633
|
+
onSuccess,
|
|
634
|
+
onError: (err) => {
|
|
635
|
+
if (typeof onError === 'function') onError(err);
|
|
636
|
+
if (typeof onFailure === 'function') onFailure(err);
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
return nodes;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const api = {
|
|
647
|
+
version: '0.3.0',
|
|
648
|
+
init: (config) => new LonghoPay(config),
|
|
649
|
+
LonghoPaySDK: LonghoPay,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
global.LonghoPaySDK = api;
|
|
653
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
package/vite.config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
build: {
|
|
5
|
+
lib: {
|
|
6
|
+
entry: 'src/index.js',
|
|
7
|
+
name: 'LonghoPaySDK',
|
|
8
|
+
fileName: (format) => `payment-sdk.${format}.js`,
|
|
9
|
+
formats: ['iife', 'es'],
|
|
10
|
+
},
|
|
11
|
+
rollupOptions: {
|
|
12
|
+
output: {
|
|
13
|
+
globals: {},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|