tsapay-checkout-js 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,313 @@
1
+ (function(){"use strict";/**
2
+ * TsaPay Checkout — Drop-in payment modal.
3
+ * Clean, Stripe-inspired design with real provider logos.
4
+ */
5
+ // ── Provider logos (base64 inline SVG) ──────
6
+ const MTN_LOGO = `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="10" fill="#FFCC00"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="900" font-size="14" fill="#003068">MTN</text></svg>`;
7
+ const ORANGE_LOGO = `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="10" fill="#FF6600"/><text x="50%" y="42%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="900" font-size="9.5" fill="#fff">Orange</text><text x="50%" y="62%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="700" font-size="7" fill="rgba(255,255,255,.85)">Money</text></svg>`;
8
+ const CSS = `
9
+ .tp-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
10
+ .tp-overlay.tp-show{opacity:1}
11
+ .tp-modal{background:#fff;border-radius:12px;width:400px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.3);transform:scale(.95);transition:transform .25s cubic-bezier(.4,0,.2,1)}
12
+ .tp-overlay.tp-show .tp-modal{transform:scale(1)}
13
+
14
+ /* Header */
15
+ .tp-head{padding:20px 24px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between}
16
+ .tp-head-left{display:flex;align-items:center;gap:10px}
17
+ .tp-head-logo{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,#eab308,#22c55e);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12px;color:#fff}
18
+ .tp-head-name{font-size:14px;font-weight:600;color:#111827}
19
+ .tp-close{width:28px;height:28px;border:none;background:none;color:#9ca3af;font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center}
20
+ .tp-close:hover{background:#f3f4f6;color:#374151}
21
+
22
+ /* Amount bar */
23
+ .tp-amount-bar{padding:20px 24px;background:#fafafa;border-bottom:1px solid #e5e7eb}
24
+ .tp-amount-label{font-size:12px;color:#6b7280;margin-bottom:2px}
25
+ .tp-amount-value{font-size:28px;font-weight:700;color:#111827;letter-spacing:-.5px}
26
+ .tp-amount-value small{font-size:14px;font-weight:500;color:#9ca3af;margin-left:4px}
27
+ .tp-desc{font-size:13px;color:#6b7280;margin-top:2px}
28
+
29
+ /* Body */
30
+ .tp-body{padding:20px 24px}
31
+ .tp-field{margin-bottom:16px}
32
+ .tp-label{display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px}
33
+ .tp-input{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;color:#111827;outline:none;box-sizing:border-box;font-family:inherit;transition:border-color .15s,box-shadow .15s}
34
+ .tp-input:focus{border-color:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.12)}
35
+ .tp-input::placeholder{color:#9ca3af}
36
+ .tp-input-error{border-color:#ef4444 !important}
37
+
38
+ /* Providers */
39
+ .tp-providers{display:grid;grid-template-columns:1fr 1fr;gap:10px}
40
+ .tp-prov{position:relative;cursor:pointer}
41
+ .tp-prov input{position:absolute;opacity:0;pointer-events:none}
42
+ .tp-prov-card{display:flex;align-items:center;gap:10px;padding:12px;border:1.5px solid #e5e7eb;border-radius:10px;transition:all .15s;background:#fff}
43
+ .tp-prov-card:hover{border-color:#d1d5db;background:#fafafa}
44
+ .tp-prov input:checked+.tp-prov-card{border-color:#22c55e;background:#f0fdf4}
45
+ .tp-prov-logo{width:36px;height:36px;border-radius:8px;flex-shrink:0}
46
+ .tp-prov-logo svg{width:100%;height:100%}
47
+ .tp-prov-info{line-height:1.3}
48
+ .tp-prov-name{font-size:13px;font-weight:600;color:#111827}
49
+ .tp-prov-sub{font-size:11px;color:#9ca3af}
50
+
51
+ /* Submit */
52
+ .tp-submit{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;color:#fff;background:#111827;cursor:pointer;font-family:inherit;transition:background .15s;display:flex;align-items:center;justify-content:center;gap:8px;margin-top:4px}
53
+ .tp-submit:hover{background:#1f2937}
54
+ .tp-submit:disabled{opacity:.5;cursor:not-allowed}
55
+
56
+ /* Spinner */
57
+ .tp-spin{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:tp-r .6s linear infinite}
58
+ @keyframes tp-r{to{transform:rotate(360deg)}}
59
+
60
+ /* Footer */
61
+ .tp-foot{padding:14px 24px;border-top:1px solid #e5e7eb;text-align:center}
62
+ .tp-foot span{font-size:11px;color:#9ca3af}
63
+ .tp-foot a{color:#22c55e;text-decoration:none;font-weight:600}
64
+
65
+ /* Result */
66
+ .tp-result{text-align:center;padding:40px 24px}
67
+ .tp-result-icon{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:24px;animation:tp-pop .4s cubic-bezier(.22,1,.36,1)}
68
+ @keyframes tp-pop{0%{transform:scale(0)}60%{transform:scale(1.1)}100%{transform:scale(1)}}
69
+ .tp-result-icon.ok{background:#dcfce7;color:#16a34a}
70
+ .tp-result-icon.ko{background:#fee2e2;color:#dc2626}
71
+ .tp-result-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:6px}
72
+ .tp-result-msg{font-size:13px;color:#6b7280;margin-bottom:24px;line-height:1.5}
73
+ .tp-result-btn{padding:10px 24px;border-radius:8px;border:none;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;background:#111827;color:#fff;transition:background .15s}
74
+ .tp-result-btn:hover{background:#1f2937}
75
+
76
+ /* Waiting */
77
+ .tp-wait{text-align:center;padding:40px 24px}
78
+ .tp-wait-phone{font-size:40px;animation:tp-ring 1.2s ease-in-out infinite}
79
+ @keyframes tp-ring{0%,100%{transform:rotate(0)}10%{transform:rotate(10deg)}20%{transform:rotate(-10deg)}30%{transform:rotate(6deg)}40%{transform:rotate(-6deg)}50%{transform:rotate(0)}}
80
+ .tp-wait-title{font-size:15px;font-weight:700;color:#111827;margin:16px 0 6px}
81
+ .tp-wait-msg{font-size:13px;color:#6b7280;line-height:1.6}
82
+ .tp-dots::after{content:'';animation:tp-d 1.5s steps(4) infinite}
83
+ @keyframes tp-d{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
84
+ `;
85
+ class TsaPayCheckout {
86
+ constructor(apiKey, baseUrl) {
87
+ this.el = null;
88
+ this.opts = null;
89
+ this.poll = null;
90
+ if (!apiKey)
91
+ throw new Error("TsaPay: API key required");
92
+ this.key = apiKey;
93
+ this.base = (baseUrl || "https://api.tsapay.com/v1").replace(/\/+$/, "");
94
+ }
95
+ open(opts) {
96
+ this.opts = opts;
97
+ this.css();
98
+ this.form(opts);
99
+ }
100
+ close() {
101
+ this.kill();
102
+ this.opts?.onClose?.();
103
+ }
104
+ // ── CSS ───────────────────────────────────
105
+ css() {
106
+ if (document.getElementById("tp-css"))
107
+ return;
108
+ const s = document.createElement("style");
109
+ s.id = "tp-css";
110
+ s.textContent = CSS;
111
+ document.head.appendChild(s);
112
+ }
113
+ // ── Helpers ───────────────────────────────
114
+ fmt(n) { return new Intl.NumberFormat("fr-FR").format(n); }
115
+ kill() {
116
+ if (this.poll) {
117
+ clearInterval(this.poll);
118
+ this.poll = null;
119
+ }
120
+ if (this.el) {
121
+ this.el.classList.remove("tp-show");
122
+ setTimeout(() => { this.el?.remove(); this.el = null; }, 250);
123
+ }
124
+ }
125
+ // ── API ───────────────────────────────────
126
+ async post(phone, prov) {
127
+ const o = this.opts;
128
+ const r = await fetch(`${this.base}/payments`, {
129
+ method: "POST",
130
+ headers: {
131
+ "Authorization": `Bearer ${this.key}`,
132
+ "Content-Type": "application/json",
133
+ "Idempotency-Key": crypto.randomUUID(),
134
+ },
135
+ body: JSON.stringify({
136
+ amount: o.amount, currency: o.currency || "XAF", provider: prov,
137
+ phone_number: phone, description: o.description || "",
138
+ reference: o.reference || "", callback_url: o.callbackUrl || "",
139
+ metadata: o.metadata || {},
140
+ }),
141
+ });
142
+ if (!r.ok) {
143
+ const e = await r.json().catch(() => ({ message: "Erreur réseau" }));
144
+ throw new Error(e.message || `HTTP ${r.status}`);
145
+ }
146
+ return r.json();
147
+ }
148
+ async get(id) {
149
+ const r = await fetch(`${this.base}/payments/${id}`, {
150
+ headers: { "Authorization": `Bearer ${this.key}` },
151
+ });
152
+ return r.json();
153
+ }
154
+ // ── Form ──────────────────────────────────
155
+ form(o) {
156
+ const cur = o.currency || "XAF";
157
+ const ov = document.createElement("div");
158
+ ov.className = "tp-overlay";
159
+ ov.innerHTML = `
160
+ <div class="tp-modal">
161
+ <div class="tp-head">
162
+ <div class="tp-head-left"><div class="tp-head-logo">T</div><span class="tp-head-name">TsaPay</span></div>
163
+ <button class="tp-close" id="tp-x">&times;</button>
164
+ </div>
165
+ <div class="tp-amount-bar">
166
+ <div class="tp-amount-label">Montant à payer</div>
167
+ <div class="tp-amount-value">${this.fmt(o.amount)}<small>${cur}</small></div>
168
+ ${o.description ? `<div class="tp-desc">${o.description}</div>` : ""}
169
+ </div>
170
+ <div class="tp-body">
171
+ <div class="tp-field">
172
+ <label class="tp-label">Numéro de téléphone</label>
173
+ <input class="tp-input" id="tp-phone" type="tel" placeholder="+237 6XX XXX XXX" value="${o.phoneNumber || ""}" />
174
+ </div>
175
+ <div class="tp-field">
176
+ <label class="tp-label">Moyen de paiement</label>
177
+ <div class="tp-providers">
178
+ <label class="tp-prov">
179
+ <input type="radio" name="tp-prov" value="mtn_momo" ${(!o.provider || o.provider === "mtn_momo") ? "checked" : ""} />
180
+ <div class="tp-prov-card">
181
+ <div class="tp-prov-logo">${MTN_LOGO}</div>
182
+ <div class="tp-prov-info"><div class="tp-prov-name">MTN MoMo</div><div class="tp-prov-sub">Mobile Money</div></div>
183
+ </div>
184
+ </label>
185
+ <label class="tp-prov">
186
+ <input type="radio" name="tp-prov" value="orange_money" ${o.provider === "orange_money" ? "checked" : ""} />
187
+ <div class="tp-prov-card">
188
+ <div class="tp-prov-logo">${ORANGE_LOGO}</div>
189
+ <div class="tp-prov-info"><div class="tp-prov-name">Orange Money</div><div class="tp-prov-sub">Mobile Money</div></div>
190
+ </div>
191
+ </label>
192
+ </div>
193
+ </div>
194
+ <button class="tp-submit" id="tp-pay">Payer ${this.fmt(o.amount)} ${cur}</button>
195
+ </div>
196
+ <div class="tp-foot"><span>Sécurisé par <a href="https://tsapay.com" target="_blank">TsaPay</a></span></div>
197
+ </div>`;
198
+ document.body.appendChild(ov);
199
+ this.el = ov;
200
+ requestAnimationFrame(() => ov.classList.add("tp-show"));
201
+ ov.querySelector("#tp-x").addEventListener("click", () => this.close());
202
+ ov.addEventListener("click", (e) => { if (e.target === ov)
203
+ this.close(); });
204
+ ov.querySelector("#tp-pay").addEventListener("click", () => this.submit());
205
+ }
206
+ // ── Submit ────────────────────────────────
207
+ async submit() {
208
+ const ph = this.el.querySelector("#tp-phone");
209
+ const prov = this.el.querySelector('input[name="tp-prov"]:checked');
210
+ const btn = this.el.querySelector("#tp-pay");
211
+ const phone = ph.value.trim();
212
+ if (!phone) {
213
+ ph.classList.add("tp-input-error");
214
+ ph.focus();
215
+ return;
216
+ }
217
+ ph.classList.remove("tp-input-error");
218
+ btn.disabled = true;
219
+ btn.innerHTML = `<span class="tp-spin"></span>Traitement…`;
220
+ try {
221
+ const d = await this.post(phone, prov.value);
222
+ const id = d.payment?.id || d.id;
223
+ if (id)
224
+ this.waiting(id, prov.value);
225
+ else {
226
+ this.ok(d.payment || d);
227
+ }
228
+ }
229
+ catch (e) {
230
+ this.ko(e.message || "Erreur inattendue");
231
+ }
232
+ }
233
+ // ── Waiting ───────────────────────────────
234
+ waiting(id, prov) {
235
+ const label = prov === "mtn_momo" ? "MTN MoMo" : "Orange Money";
236
+ const m = this.el.querySelector(".tp-modal");
237
+ m.innerHTML = `
238
+ <div class="tp-head">
239
+ <div class="tp-head-left"><div class="tp-head-logo">T</div><span class="tp-head-name">TsaPay</span></div>
240
+ <button class="tp-close" id="tp-x">&times;</button>
241
+ </div>
242
+ <div class="tp-wait">
243
+ <div class="tp-wait-phone">📱</div>
244
+ <div class="tp-wait-title">Confirmez sur votre téléphone</div>
245
+ <div class="tp-wait-msg">
246
+ Un message ${label} a été envoyé.<br/>Saisissez votre code PIN pour valider<span class="tp-dots"></span>
247
+ </div>
248
+ </div>
249
+ <div class="tp-foot"><span>Sécurisé par <a href="https://tsapay.com" target="_blank">TsaPay</a></span></div>`;
250
+ m.querySelector("#tp-x").addEventListener("click", () => this.close());
251
+ let n = 0;
252
+ this.poll = window.setInterval(async () => {
253
+ n++;
254
+ try {
255
+ const d = await this.get(id);
256
+ const s = d.payment?.status || d.status;
257
+ if (s === "success") {
258
+ clearInterval(this.poll);
259
+ this.poll = null;
260
+ this.ok(d.payment || d);
261
+ this.opts?.onSuccess?.(d.payment || d);
262
+ }
263
+ else if (s === "failed" || s === "expired") {
264
+ clearInterval(this.poll);
265
+ this.poll = null;
266
+ const r = d.payment?.failure_reason || "Le paiement a échoué.";
267
+ this.ko(r);
268
+ this.opts?.onError?.({ code: s, message: r });
269
+ }
270
+ else if (n >= 40) {
271
+ clearInterval(this.poll);
272
+ this.poll = null;
273
+ this.ko("Délai d'attente dépassé.");
274
+ this.opts?.onError?.({ code: "timeout", message: "timeout" });
275
+ }
276
+ }
277
+ catch { }
278
+ }, 3000);
279
+ }
280
+ // ── Success ───────────────────────────────
281
+ ok(p) {
282
+ const m = this.el.querySelector(".tp-modal");
283
+ m.innerHTML = `
284
+ <div class="tp-result">
285
+ <div class="tp-result-icon ok">✓</div>
286
+ <div class="tp-result-title">Paiement confirmé</div>
287
+ <div class="tp-result-msg">
288
+ ${this.fmt(p.amount || this.opts?.amount || 0)} ${p.currency || "XAF"} reçus avec succès.
289
+ ${p.reference ? `<br/>Réf: <strong>${p.reference}</strong>` : ""}
290
+ </div>
291
+ <button class="tp-result-btn" id="tp-done">Fermer</button>
292
+ </div>`;
293
+ m.querySelector("#tp-done").addEventListener("click", () => this.close());
294
+ }
295
+ // ── Error ─────────────────────────────────
296
+ ko(msg) {
297
+ const m = this.el.querySelector(".tp-modal");
298
+ m.innerHTML = `
299
+ <div class="tp-result">
300
+ <div class="tp-result-icon ko">✕</div>
301
+ <div class="tp-result-title">Paiement échoué</div>
302
+ <div class="tp-result-msg">${msg}</div>
303
+ <button class="tp-result-btn" id="tp-retry">Réessayer</button>
304
+ </div>`;
305
+ m.querySelector("#tp-retry").addEventListener("click", () => {
306
+ if (this.opts)
307
+ this.form(this.opts);
308
+ });
309
+ }
310
+ }
311
+ if (typeof window !== "undefined")
312
+ window.TsaPayCheckout = TsaPayCheckout;
313
+ })();
@@ -0,0 +1,313 @@
1
+ (function(){"use strict";/**
2
+ * TsaPay Checkout — Drop-in payment modal.
3
+ * Clean, Stripe-inspired design with real provider logos.
4
+ */
5
+ // ── Provider logos (base64 inline SVG) ──────
6
+ const MTN_LOGO = `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="10" fill="#FFCC00"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="900" font-size="14" fill="#003068">MTN</text></svg>`;
7
+ const ORANGE_LOGO = `<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="10" fill="#FF6600"/><text x="50%" y="42%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="900" font-size="9.5" fill="#fff">Orange</text><text x="50%" y="62%" dominant-baseline="middle" text-anchor="middle" font-family="Arial,sans-serif" font-weight="700" font-size="7" fill="rgba(255,255,255,.85)">Money</text></svg>`;
8
+ const CSS = `
9
+ .tp-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
10
+ .tp-overlay.tp-show{opacity:1}
11
+ .tp-modal{background:#fff;border-radius:12px;width:400px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.3);transform:scale(.95);transition:transform .25s cubic-bezier(.4,0,.2,1)}
12
+ .tp-overlay.tp-show .tp-modal{transform:scale(1)}
13
+
14
+ /* Header */
15
+ .tp-head{padding:20px 24px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between}
16
+ .tp-head-left{display:flex;align-items:center;gap:10px}
17
+ .tp-head-logo{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,#eab308,#22c55e);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12px;color:#fff}
18
+ .tp-head-name{font-size:14px;font-weight:600;color:#111827}
19
+ .tp-close{width:28px;height:28px;border:none;background:none;color:#9ca3af;font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center}
20
+ .tp-close:hover{background:#f3f4f6;color:#374151}
21
+
22
+ /* Amount bar */
23
+ .tp-amount-bar{padding:20px 24px;background:#fafafa;border-bottom:1px solid #e5e7eb}
24
+ .tp-amount-label{font-size:12px;color:#6b7280;margin-bottom:2px}
25
+ .tp-amount-value{font-size:28px;font-weight:700;color:#111827;letter-spacing:-.5px}
26
+ .tp-amount-value small{font-size:14px;font-weight:500;color:#9ca3af;margin-left:4px}
27
+ .tp-desc{font-size:13px;color:#6b7280;margin-top:2px}
28
+
29
+ /* Body */
30
+ .tp-body{padding:20px 24px}
31
+ .tp-field{margin-bottom:16px}
32
+ .tp-label{display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px}
33
+ .tp-input{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;color:#111827;outline:none;box-sizing:border-box;font-family:inherit;transition:border-color .15s,box-shadow .15s}
34
+ .tp-input:focus{border-color:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.12)}
35
+ .tp-input::placeholder{color:#9ca3af}
36
+ .tp-input-error{border-color:#ef4444 !important}
37
+
38
+ /* Providers */
39
+ .tp-providers{display:grid;grid-template-columns:1fr 1fr;gap:10px}
40
+ .tp-prov{position:relative;cursor:pointer}
41
+ .tp-prov input{position:absolute;opacity:0;pointer-events:none}
42
+ .tp-prov-card{display:flex;align-items:center;gap:10px;padding:12px;border:1.5px solid #e5e7eb;border-radius:10px;transition:all .15s;background:#fff}
43
+ .tp-prov-card:hover{border-color:#d1d5db;background:#fafafa}
44
+ .tp-prov input:checked+.tp-prov-card{border-color:#22c55e;background:#f0fdf4}
45
+ .tp-prov-logo{width:36px;height:36px;border-radius:8px;flex-shrink:0}
46
+ .tp-prov-logo svg{width:100%;height:100%}
47
+ .tp-prov-info{line-height:1.3}
48
+ .tp-prov-name{font-size:13px;font-weight:600;color:#111827}
49
+ .tp-prov-sub{font-size:11px;color:#9ca3af}
50
+
51
+ /* Submit */
52
+ .tp-submit{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;color:#fff;background:#111827;cursor:pointer;font-family:inherit;transition:background .15s;display:flex;align-items:center;justify-content:center;gap:8px;margin-top:4px}
53
+ .tp-submit:hover{background:#1f2937}
54
+ .tp-submit:disabled{opacity:.5;cursor:not-allowed}
55
+
56
+ /* Spinner */
57
+ .tp-spin{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:tp-r .6s linear infinite}
58
+ @keyframes tp-r{to{transform:rotate(360deg)}}
59
+
60
+ /* Footer */
61
+ .tp-foot{padding:14px 24px;border-top:1px solid #e5e7eb;text-align:center}
62
+ .tp-foot span{font-size:11px;color:#9ca3af}
63
+ .tp-foot a{color:#22c55e;text-decoration:none;font-weight:600}
64
+
65
+ /* Result */
66
+ .tp-result{text-align:center;padding:40px 24px}
67
+ .tp-result-icon{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:24px;animation:tp-pop .4s cubic-bezier(.22,1,.36,1)}
68
+ @keyframes tp-pop{0%{transform:scale(0)}60%{transform:scale(1.1)}100%{transform:scale(1)}}
69
+ .tp-result-icon.ok{background:#dcfce7;color:#16a34a}
70
+ .tp-result-icon.ko{background:#fee2e2;color:#dc2626}
71
+ .tp-result-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:6px}
72
+ .tp-result-msg{font-size:13px;color:#6b7280;margin-bottom:24px;line-height:1.5}
73
+ .tp-result-btn{padding:10px 24px;border-radius:8px;border:none;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;background:#111827;color:#fff;transition:background .15s}
74
+ .tp-result-btn:hover{background:#1f2937}
75
+
76
+ /* Waiting */
77
+ .tp-wait{text-align:center;padding:40px 24px}
78
+ .tp-wait-phone{font-size:40px;animation:tp-ring 1.2s ease-in-out infinite}
79
+ @keyframes tp-ring{0%,100%{transform:rotate(0)}10%{transform:rotate(10deg)}20%{transform:rotate(-10deg)}30%{transform:rotate(6deg)}40%{transform:rotate(-6deg)}50%{transform:rotate(0)}}
80
+ .tp-wait-title{font-size:15px;font-weight:700;color:#111827;margin:16px 0 6px}
81
+ .tp-wait-msg{font-size:13px;color:#6b7280;line-height:1.6}
82
+ .tp-dots::after{content:'';animation:tp-d 1.5s steps(4) infinite}
83
+ @keyframes tp-d{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
84
+ `;
85
+ class TsaPayCheckout {
86
+ constructor(apiKey, baseUrl) {
87
+ this.el = null;
88
+ this.opts = null;
89
+ this.poll = null;
90
+ if (!apiKey)
91
+ throw new Error("TsaPay: API key required");
92
+ this.key = apiKey;
93
+ this.base = (baseUrl || "https://api.tsapay.com/v1").replace(/\/+$/, "");
94
+ }
95
+ open(opts) {
96
+ this.opts = opts;
97
+ this.css();
98
+ this.form(opts);
99
+ }
100
+ close() {
101
+ this.kill();
102
+ this.opts?.onClose?.();
103
+ }
104
+ // ── CSS ───────────────────────────────────
105
+ css() {
106
+ if (document.getElementById("tp-css"))
107
+ return;
108
+ const s = document.createElement("style");
109
+ s.id = "tp-css";
110
+ s.textContent = CSS;
111
+ document.head.appendChild(s);
112
+ }
113
+ // ── Helpers ───────────────────────────────
114
+ fmt(n) { return new Intl.NumberFormat("fr-FR").format(n); }
115
+ kill() {
116
+ if (this.poll) {
117
+ clearInterval(this.poll);
118
+ this.poll = null;
119
+ }
120
+ if (this.el) {
121
+ this.el.classList.remove("tp-show");
122
+ setTimeout(() => { this.el?.remove(); this.el = null; }, 250);
123
+ }
124
+ }
125
+ // ── API ───────────────────────────────────
126
+ async post(phone, prov) {
127
+ const o = this.opts;
128
+ const r = await fetch(`${this.base}/payments`, {
129
+ method: "POST",
130
+ headers: {
131
+ "Authorization": `Bearer ${this.key}`,
132
+ "Content-Type": "application/json",
133
+ "Idempotency-Key": crypto.randomUUID(),
134
+ },
135
+ body: JSON.stringify({
136
+ amount: o.amount, currency: o.currency || "XAF", provider: prov,
137
+ phone_number: phone, description: o.description || "",
138
+ reference: o.reference || "", callback_url: o.callbackUrl || "",
139
+ metadata: o.metadata || {},
140
+ }),
141
+ });
142
+ if (!r.ok) {
143
+ const e = await r.json().catch(() => ({ message: "Erreur réseau" }));
144
+ throw new Error(e.message || `HTTP ${r.status}`);
145
+ }
146
+ return r.json();
147
+ }
148
+ async get(id) {
149
+ const r = await fetch(`${this.base}/payments/${id}`, {
150
+ headers: { "Authorization": `Bearer ${this.key}` },
151
+ });
152
+ return r.json();
153
+ }
154
+ // ── Form ──────────────────────────────────
155
+ form(o) {
156
+ const cur = o.currency || "XAF";
157
+ const ov = document.createElement("div");
158
+ ov.className = "tp-overlay";
159
+ ov.innerHTML = `
160
+ <div class="tp-modal">
161
+ <div class="tp-head">
162
+ <div class="tp-head-left"><div class="tp-head-logo">T</div><span class="tp-head-name">TsaPay</span></div>
163
+ <button class="tp-close" id="tp-x">&times;</button>
164
+ </div>
165
+ <div class="tp-amount-bar">
166
+ <div class="tp-amount-label">Montant à payer</div>
167
+ <div class="tp-amount-value">${this.fmt(o.amount)}<small>${cur}</small></div>
168
+ ${o.description ? `<div class="tp-desc">${o.description}</div>` : ""}
169
+ </div>
170
+ <div class="tp-body">
171
+ <div class="tp-field">
172
+ <label class="tp-label">Numéro de téléphone</label>
173
+ <input class="tp-input" id="tp-phone" type="tel" placeholder="+237 6XX XXX XXX" value="${o.phoneNumber || ""}" />
174
+ </div>
175
+ <div class="tp-field">
176
+ <label class="tp-label">Moyen de paiement</label>
177
+ <div class="tp-providers">
178
+ <label class="tp-prov">
179
+ <input type="radio" name="tp-prov" value="mtn_momo" ${(!o.provider || o.provider === "mtn_momo") ? "checked" : ""} />
180
+ <div class="tp-prov-card">
181
+ <div class="tp-prov-logo">${MTN_LOGO}</div>
182
+ <div class="tp-prov-info"><div class="tp-prov-name">MTN MoMo</div><div class="tp-prov-sub">Mobile Money</div></div>
183
+ </div>
184
+ </label>
185
+ <label class="tp-prov">
186
+ <input type="radio" name="tp-prov" value="orange_money" ${o.provider === "orange_money" ? "checked" : ""} />
187
+ <div class="tp-prov-card">
188
+ <div class="tp-prov-logo">${ORANGE_LOGO}</div>
189
+ <div class="tp-prov-info"><div class="tp-prov-name">Orange Money</div><div class="tp-prov-sub">Mobile Money</div></div>
190
+ </div>
191
+ </label>
192
+ </div>
193
+ </div>
194
+ <button class="tp-submit" id="tp-pay">Payer ${this.fmt(o.amount)} ${cur}</button>
195
+ </div>
196
+ <div class="tp-foot"><span>Sécurisé par <a href="https://tsapay.com" target="_blank">TsaPay</a></span></div>
197
+ </div>`;
198
+ document.body.appendChild(ov);
199
+ this.el = ov;
200
+ requestAnimationFrame(() => ov.classList.add("tp-show"));
201
+ ov.querySelector("#tp-x").addEventListener("click", () => this.close());
202
+ ov.addEventListener("click", (e) => { if (e.target === ov)
203
+ this.close(); });
204
+ ov.querySelector("#tp-pay").addEventListener("click", () => this.submit());
205
+ }
206
+ // ── Submit ────────────────────────────────
207
+ async submit() {
208
+ const ph = this.el.querySelector("#tp-phone");
209
+ const prov = this.el.querySelector('input[name="tp-prov"]:checked');
210
+ const btn = this.el.querySelector("#tp-pay");
211
+ const phone = ph.value.trim();
212
+ if (!phone) {
213
+ ph.classList.add("tp-input-error");
214
+ ph.focus();
215
+ return;
216
+ }
217
+ ph.classList.remove("tp-input-error");
218
+ btn.disabled = true;
219
+ btn.innerHTML = `<span class="tp-spin"></span>Traitement…`;
220
+ try {
221
+ const d = await this.post(phone, prov.value);
222
+ const id = d.payment?.id || d.id;
223
+ if (id)
224
+ this.waiting(id, prov.value);
225
+ else {
226
+ this.ok(d.payment || d);
227
+ }
228
+ }
229
+ catch (e) {
230
+ this.ko(e.message || "Erreur inattendue");
231
+ }
232
+ }
233
+ // ── Waiting ───────────────────────────────
234
+ waiting(id, prov) {
235
+ const label = prov === "mtn_momo" ? "MTN MoMo" : "Orange Money";
236
+ const m = this.el.querySelector(".tp-modal");
237
+ m.innerHTML = `
238
+ <div class="tp-head">
239
+ <div class="tp-head-left"><div class="tp-head-logo">T</div><span class="tp-head-name">TsaPay</span></div>
240
+ <button class="tp-close" id="tp-x">&times;</button>
241
+ </div>
242
+ <div class="tp-wait">
243
+ <div class="tp-wait-phone">📱</div>
244
+ <div class="tp-wait-title">Confirmez sur votre téléphone</div>
245
+ <div class="tp-wait-msg">
246
+ Un message ${label} a été envoyé.<br/>Saisissez votre code PIN pour valider<span class="tp-dots"></span>
247
+ </div>
248
+ </div>
249
+ <div class="tp-foot"><span>Sécurisé par <a href="https://tsapay.com" target="_blank">TsaPay</a></span></div>`;
250
+ m.querySelector("#tp-x").addEventListener("click", () => this.close());
251
+ let n = 0;
252
+ this.poll = window.setInterval(async () => {
253
+ n++;
254
+ try {
255
+ const d = await this.get(id);
256
+ const s = d.payment?.status || d.status;
257
+ if (s === "success") {
258
+ clearInterval(this.poll);
259
+ this.poll = null;
260
+ this.ok(d.payment || d);
261
+ this.opts?.onSuccess?.(d.payment || d);
262
+ }
263
+ else if (s === "failed" || s === "expired") {
264
+ clearInterval(this.poll);
265
+ this.poll = null;
266
+ const r = d.payment?.failure_reason || "Le paiement a échoué.";
267
+ this.ko(r);
268
+ this.opts?.onError?.({ code: s, message: r });
269
+ }
270
+ else if (n >= 40) {
271
+ clearInterval(this.poll);
272
+ this.poll = null;
273
+ this.ko("Délai d'attente dépassé.");
274
+ this.opts?.onError?.({ code: "timeout", message: "timeout" });
275
+ }
276
+ }
277
+ catch { }
278
+ }, 3000);
279
+ }
280
+ // ── Success ───────────────────────────────
281
+ ok(p) {
282
+ const m = this.el.querySelector(".tp-modal");
283
+ m.innerHTML = `
284
+ <div class="tp-result">
285
+ <div class="tp-result-icon ok">✓</div>
286
+ <div class="tp-result-title">Paiement confirmé</div>
287
+ <div class="tp-result-msg">
288
+ ${this.fmt(p.amount || this.opts?.amount || 0)} ${p.currency || "XAF"} reçus avec succès.
289
+ ${p.reference ? `<br/>Réf: <strong>${p.reference}</strong>` : ""}
290
+ </div>
291
+ <button class="tp-result-btn" id="tp-done">Fermer</button>
292
+ </div>`;
293
+ m.querySelector("#tp-done").addEventListener("click", () => this.close());
294
+ }
295
+ // ── Error ─────────────────────────────────
296
+ ko(msg) {
297
+ const m = this.el.querySelector(".tp-modal");
298
+ m.innerHTML = `
299
+ <div class="tp-result">
300
+ <div class="tp-result-icon ko">✕</div>
301
+ <div class="tp-result-title">Paiement échoué</div>
302
+ <div class="tp-result-msg">${msg}</div>
303
+ <button class="tp-result-btn" id="tp-retry">Réessayer</button>
304
+ </div>`;
305
+ m.querySelector("#tp-retry").addEventListener("click", () => {
306
+ if (this.opts)
307
+ this.form(this.opts);
308
+ });
309
+ }
310
+ }
311
+ if (typeof window !== "undefined")
312
+ window.TsaPayCheckout = TsaPayCheckout;
313
+ })();