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.
@@ -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
+ });