suioutkit 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +277 -0
- package/assets/flutterwave.png +0 -0
- package/assets/opay.png +0 -0
- package/assets/stripe.png +0 -0
- package/assets/stripe_c.jpeg +0 -0
- package/assets/sui.png +0 -0
- package/assets/suioutkit.png +0 -0
- package/dist/components/PaymentStatusUI.d.ts +7 -0
- package/dist/components/ProgressStepper.d.ts +10 -0
- package/dist/components/StatusBadge.d.ts +7 -0
- package/dist/components/modal.d.ts +50 -0
- package/dist/config/api.d.ts +5 -0
- package/dist/hooks/usePaymentStatus.d.ts +12 -0
- package/dist/hooks/usePolling.d.ts +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51124 -0
- package/dist/types/index.d.ts +57 -0
- package/dist/utils/format.d.ts +12 -0
- package/dist/utils/http.d.ts +11 -0
- package/package.json +40 -0
- package/src/components/PaymentStatusUI.tsx +58 -0
- package/src/components/ProgressStepper.tsx +23 -0
- package/src/components/StatusBadge.tsx +22 -0
- package/src/components/modal.ts +992 -0
- package/src/components/style.css +751 -0
- package/src/config/api.ts +16 -0
- package/src/declarations.d.ts +1 -0
- package/src/hooks/usePaymentStatus.ts +40 -0
- package/src/hooks/usePolling.ts +46 -0
- package/src/index.ts +139 -0
- package/src/types/index.ts +69 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/http.ts +64 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0
|
|
2
|
+
// Copyright (c) 2026 The3rdWebLabs (https://github.com/the3rdweblabs)
|
|
3
|
+
// Author: @CYBWithFlourish (https://github.com/CYBWithFlourish)
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { createRoot } from "react-dom/client";
|
|
7
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
8
|
+
import { Transaction } from "@mysten/sui/transactions";
|
|
9
|
+
import { createPaymentTransactionUri } from "@mysten/payment-kit";
|
|
10
|
+
import { paymentKit } from "@mysten/payment-kit";
|
|
11
|
+
import { createDAppKit } from "@mysten/dapp-kit-core";
|
|
12
|
+
import "@mysten/dapp-kit-core/web";
|
|
13
|
+
import { loadStripe, StripeElements, Stripe } from "@stripe/stripe-js";
|
|
14
|
+
import { CheckoutSession, ChargeResponse, CheckoutStatusResponse, CryptoIntentResponse } from "../types/index.js";
|
|
15
|
+
import PaymentStatusUI from "./PaymentStatusUI";
|
|
16
|
+
import { joinApiPath } from "../config/api.js";
|
|
17
|
+
|
|
18
|
+
const SUI_GRPC_URLS = {
|
|
19
|
+
mainnet: "https://fullnode.mainnet.sui.io:443",
|
|
20
|
+
testnet: "https://fullnode.testnet.sui.io:443"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type SupportedNetwork = keyof typeof SUI_GRPC_URLS;
|
|
24
|
+
|
|
25
|
+
function getExplorerNetworkPath() {
|
|
26
|
+
const requestedNetwork = (window as any).SuiOutKitNetwork as string | undefined;
|
|
27
|
+
return requestedNetwork === "mainnet" ? "mainnet" : "testnet";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SuiOutKitModal {
|
|
31
|
+
private overlay: HTMLDivElement | null = null;
|
|
32
|
+
private session: CheckoutSession;
|
|
33
|
+
private backendUrl: string;
|
|
34
|
+
private pollInterval: any = null;
|
|
35
|
+
private walletConnectionUnsubscribe: (() => void) | null = null;
|
|
36
|
+
private onCloseCallback: () => void;
|
|
37
|
+
private cryptoIntent: CryptoIntentResponse | null = null;
|
|
38
|
+
private dAppKit: any | null = null;
|
|
39
|
+
private paymentClient: any | null = null;
|
|
40
|
+
private stripeInstance: Stripe | null = null;
|
|
41
|
+
private stripeElements: StripeElements | null = null;
|
|
42
|
+
|
|
43
|
+
constructor(session: CheckoutSession, backendUrl: string, onClose: () => void) {
|
|
44
|
+
this.session = session;
|
|
45
|
+
this.backendUrl = backendUrl;
|
|
46
|
+
this.onCloseCallback = onClose;
|
|
47
|
+
this.ensureDAppKit(); // Initialize early so wallets have time to inject
|
|
48
|
+
this.injectStyles();
|
|
49
|
+
this.createModal();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private injectStyles() {
|
|
53
|
+
if (!document.getElementById("suioutkit-styles")) {
|
|
54
|
+
const link = document.createElement("link");
|
|
55
|
+
link.id = "suioutkit-styles";
|
|
56
|
+
link.rel = "stylesheet";
|
|
57
|
+
link.href = `${this.backendUrl}/style.css`;
|
|
58
|
+
document.head.appendChild(link);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!document.getElementById("suioutkit-lucide")) {
|
|
62
|
+
const script = document.createElement("script");
|
|
63
|
+
script.id = "suioutkit-lucide";
|
|
64
|
+
script.src = "https://unpkg.com/lucide@latest";
|
|
65
|
+
script.onload = () => this.renderIcons();
|
|
66
|
+
document.head.appendChild(script);
|
|
67
|
+
} else {
|
|
68
|
+
this.renderIcons();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private renderIcons() {
|
|
73
|
+
const globalWindow = window as any;
|
|
74
|
+
if (globalWindow.lucide) {
|
|
75
|
+
globalWindow.lucide.createIcons();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private createModal() {
|
|
80
|
+
this.overlay = document.createElement("div");
|
|
81
|
+
this.overlay.className = "suioutkit-overlay";
|
|
82
|
+
this.overlay.innerHTML = `
|
|
83
|
+
<div class="suioutkit-card">
|
|
84
|
+
<button class="suioutkit-close" id="sok-close-btn">×</button>
|
|
85
|
+
<div class="suioutkit-content" id="sok-content-panel"></div>
|
|
86
|
+
</div>
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
document.body.appendChild(this.overlay);
|
|
90
|
+
|
|
91
|
+
const closeBtn = this.overlay.querySelector("#sok-close-btn");
|
|
92
|
+
closeBtn?.addEventListener("click", () => this.destroy());
|
|
93
|
+
|
|
94
|
+
const card = this.overlay.querySelector(".suioutkit-card");
|
|
95
|
+
card?.addEventListener("click", (e) => e.stopPropagation());
|
|
96
|
+
|
|
97
|
+
this.overlay.addEventListener("click", () => this.destroy());
|
|
98
|
+
|
|
99
|
+
this.renderSelectionPanel();
|
|
100
|
+
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
this.overlay?.classList.add("active");
|
|
103
|
+
}, 50);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private renderSelectionPanel() {
|
|
107
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
108
|
+
if (!container) return;
|
|
109
|
+
|
|
110
|
+
const currencySymbol = this.session.currency === "NGN" ? "₦" : "";
|
|
111
|
+
const formattedAmount = `${currencySymbol}${this.session.amount.toLocaleString()}`;
|
|
112
|
+
|
|
113
|
+
container.innerHTML = `
|
|
114
|
+
<div class="suioutkit-header">
|
|
115
|
+
<h2 class="suioutkit-title">Checkout</h2>
|
|
116
|
+
<p class="suioutkit-subtitle">Select payment method to settle ${formattedAmount}</p>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="suioutkit-body">
|
|
119
|
+
<button class="suioutkit-option" id="sok-method-bank">
|
|
120
|
+
<div class="suioutkit-option-content">
|
|
121
|
+
<img src="${this.backendUrl}/assets/flutterwave.png" class="suioutkit-option-img" alt="Bank Transfer" />
|
|
122
|
+
<span class="suioutkit-option-name">Bank Transfer</span>
|
|
123
|
+
</div>
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
<button class="suioutkit-option" id="sok-method-stripe">
|
|
127
|
+
<div class="suioutkit-option-content">
|
|
128
|
+
<img src="${this.backendUrl}/assets/stripe_c.jpeg" class="suioutkit-option-img" alt="Card / Global" />
|
|
129
|
+
<span class="suioutkit-option-name">Card / Global</span>
|
|
130
|
+
</div>
|
|
131
|
+
</button>
|
|
132
|
+
|
|
133
|
+
<button class="suioutkit-option" id="sok-method-opay">
|
|
134
|
+
<div class="suioutkit-option-content">
|
|
135
|
+
<img src="${this.backendUrl}/assets/opay.png" class="suioutkit-option-img" alt="OPay Account" />
|
|
136
|
+
<span class="suioutkit-option-name">OPay Account</span>
|
|
137
|
+
</div>
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
<button class="suioutkit-option" id="sok-method-crypto">
|
|
141
|
+
<div class="suioutkit-option-content">
|
|
142
|
+
<img src="${this.backendUrl}/assets/sui.png" class="suioutkit-option-img" alt="Sui Wallet" />
|
|
143
|
+
<span class="suioutkit-option-name">Sui Wallet</span>
|
|
144
|
+
</div>
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
this.renderIcons();
|
|
150
|
+
|
|
151
|
+
container.querySelector("#sok-method-bank")?.addEventListener("click", () => this.handleCharge("bank_transfer"));
|
|
152
|
+
container.querySelector("#sok-method-stripe")?.addEventListener("click", () => void this.handleStripePaymentPanel());
|
|
153
|
+
container.querySelector("#sok-method-opay")?.addEventListener("click", () => this.renderOPayFormPanel());
|
|
154
|
+
container.querySelector("#sok-method-crypto")?.addEventListener("click", () => void this.handleCryptoPaymentPanel());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async handleCharge(method: "bank_transfer" | "opay", phoneNumber?: string) {
|
|
158
|
+
this.renderLoadingPanel("Allocating checkout session...");
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(joinApiPath(this.backendUrl, "checkout", "charge"), {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
token: this.session.token,
|
|
166
|
+
method,
|
|
167
|
+
phoneNumber
|
|
168
|
+
})
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result: ChargeResponse = await response.json();
|
|
172
|
+
|
|
173
|
+
if (result.status === "success") {
|
|
174
|
+
if (method === "bank_transfer" && result.virtualAccount) {
|
|
175
|
+
this.renderBankTransferPanel(result.virtualAccount);
|
|
176
|
+
} else if (method === "opay") {
|
|
177
|
+
this.renderOPayInstructionsPanel(result.opayPrompt || "Approve OPay payment prompt on your phone.");
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
this.renderErrorPanel(result.message || "Failed to process charge.");
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.renderErrorPanel("Connection to payment server failed.");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private renderLoadingPanel(message: string) {
|
|
188
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
189
|
+
if (!container) return;
|
|
190
|
+
|
|
191
|
+
container.innerHTML = `
|
|
192
|
+
<div class="suioutkit-panel">
|
|
193
|
+
<div class="sok-spinner"></div>
|
|
194
|
+
<p class="sok-status-text">${message}</p>
|
|
195
|
+
</div>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private renderBankTransferPanel(va: any) {
|
|
200
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
201
|
+
if (!container) return;
|
|
202
|
+
|
|
203
|
+
container.innerHTML = `
|
|
204
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
205
|
+
<div class="suioutkit-panel">
|
|
206
|
+
<div class="suioutkit-amount-box">
|
|
207
|
+
<p class="suioutkit-subtitle">Please transfer exactly</p>
|
|
208
|
+
<h2 class="sok-fiat-amt">₦${va.amount.toLocaleString()}</h2>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div class="sok-va-card">
|
|
212
|
+
<div class="sok-copied-alert" id="sok-copy-bubble">Copied!</div>
|
|
213
|
+
|
|
214
|
+
<div class="sok-va-row">
|
|
215
|
+
<div class="sok-va-lbl">Bank Name</div>
|
|
216
|
+
<div class="sok-va-val">${va.bankName}</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="sok-va-row">
|
|
220
|
+
<div class="sok-va-lbl">Account Number</div>
|
|
221
|
+
<div class="sok-va-val">
|
|
222
|
+
<span id="sok-acct-num">${va.accountNumber}</span>
|
|
223
|
+
<button class="sok-copy-btn" id="sok-copy-acct">Copy</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div id="sok-status-react"></div>
|
|
229
|
+
<p class="sok-status-text">Waiting for your bank transfer alert...</p>
|
|
230
|
+
</div>
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => {
|
|
234
|
+
this.stopPolling();
|
|
235
|
+
this.renderSelectionPanel();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
container.querySelector("#sok-copy-acct")?.addEventListener("click", () => {
|
|
239
|
+
const numSpan = container.querySelector("#sok-acct-num");
|
|
240
|
+
if (numSpan) {
|
|
241
|
+
navigator.clipboard.writeText(numSpan.textContent || "");
|
|
242
|
+
const bubble = container.querySelector("#sok-copy-bubble");
|
|
243
|
+
bubble?.classList.add("show");
|
|
244
|
+
setTimeout(() => bubble?.classList.remove("show"), 2000);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this.mountPaymentStatus(container as HTMLElement);
|
|
249
|
+
this.startPolling();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private mountPaymentStatus(container: HTMLElement) {
|
|
253
|
+
const statusDiv = container.querySelector("#sok-status-react");
|
|
254
|
+
if (!statusDiv) return;
|
|
255
|
+
const root = createRoot(statusDiv as HTMLElement);
|
|
256
|
+
root.render(
|
|
257
|
+
React.createElement(PaymentStatusUI, {
|
|
258
|
+
backendUrl: this.backendUrl,
|
|
259
|
+
nonce: this.session.nonce,
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private renderOPayFormPanel() {
|
|
265
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
266
|
+
if (!container) return;
|
|
267
|
+
|
|
268
|
+
container.innerHTML = `
|
|
269
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
270
|
+
<div class="suioutkit-header">
|
|
271
|
+
<h2 class="suioutkit-title">OPay Direct</h2>
|
|
272
|
+
<p class="suioutkit-subtitle">Enter your OPay registered phone number</p>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="sok-panel">
|
|
275
|
+
<form class="sok-form" id="sok-opay-form">
|
|
276
|
+
<input type="tel" class="sok-input" placeholder="e.g. 08012345678" id="sok-phone-input" required />
|
|
277
|
+
<button type="submit" class="sok-btn">Send Prompt</button>
|
|
278
|
+
</form>
|
|
279
|
+
</div>
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.renderSelectionPanel());
|
|
283
|
+
|
|
284
|
+
container.querySelector("#sok-opay-form")?.addEventListener("submit", (e) => {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
const phoneInput = container.querySelector("#sok-phone-input") as HTMLInputElement;
|
|
287
|
+
if (phoneInput) {
|
|
288
|
+
this.handleCharge("opay", phoneInput.value.trim());
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private renderOPayInstructionsPanel(promptText: string) {
|
|
294
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
295
|
+
if (!container) return;
|
|
296
|
+
|
|
297
|
+
container.innerHTML = `
|
|
298
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
299
|
+
<div class="suioutkit-panel">
|
|
300
|
+
<div class="suioutkit-amount-box">
|
|
301
|
+
<p class="suioutkit-subtitle">Check your phone to approve</p>
|
|
302
|
+
<h2 class="sok-fiat-amt">OPay Prompt</h2>
|
|
303
|
+
</div>
|
|
304
|
+
<p class="sok-status-text" style="margin-bottom: 20px; font-weight:600;">${promptText}</p>
|
|
305
|
+
<div class="sok-spinner"></div>
|
|
306
|
+
<p class="sok-status-text">Waiting for your OPay confirmation...</p>
|
|
307
|
+
</div>
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => {
|
|
311
|
+
this.stopPolling();
|
|
312
|
+
this.renderSelectionPanel();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
this.startPolling();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async handleStripePaymentPanel() {
|
|
319
|
+
this.renderLoadingPanel("Initializing secure global checkout...");
|
|
320
|
+
try {
|
|
321
|
+
const response = await fetch(joinApiPath(this.backendUrl, "checkout", "charge"), {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Content-Type": "application/json" },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
token: this.session.token,
|
|
326
|
+
method: "stripe"
|
|
327
|
+
})
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const result: any = await response.json();
|
|
331
|
+
if (result.status === "success" && result.clientSecret && result.stripePublicKey) {
|
|
332
|
+
this.renderStripeElementsPanel(result.clientSecret, result.stripePublicKey, result.validatedRate);
|
|
333
|
+
} else {
|
|
334
|
+
this.renderErrorPanel(result.message || "Failed to initialize Stripe checkout.");
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
this.renderErrorPanel("Connection to payment server failed.");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async renderStripeElementsPanel(clientSecret: string, publicKey: string, rate: number) {
|
|
342
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
343
|
+
if (!container) return;
|
|
344
|
+
|
|
345
|
+
container.innerHTML = `
|
|
346
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
347
|
+
<div class="suioutkit-header">
|
|
348
|
+
<h2 class="suioutkit-title">Global Checkout</h2>
|
|
349
|
+
<p class="suioutkit-subtitle">Secured by Stripe</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="suioutkit-panel" style="gap: 16px; display: flex; flex-direction: column; width: 100%;">
|
|
352
|
+
<form id="payment-form" style="width: 100%;">
|
|
353
|
+
<div id="payment-element" style="min-height: 200px; margin-bottom: 16px;">
|
|
354
|
+
<div class="sok-spinner" style="margin: 0 auto;"></div>
|
|
355
|
+
</div>
|
|
356
|
+
<button class="sok-btn" id="submit-stripe-btn" style="background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); width: 100%;">
|
|
357
|
+
Pay Now
|
|
358
|
+
</button>
|
|
359
|
+
<div id="payment-message" style="color: #ef4444; font-size: 13px; margin-top: 8px; text-align: center; display: none;"></div>
|
|
360
|
+
</form>
|
|
361
|
+
</div>
|
|
362
|
+
`;
|
|
363
|
+
|
|
364
|
+
this.renderIcons();
|
|
365
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.renderSelectionPanel());
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
if (!this.stripeInstance) {
|
|
369
|
+
this.stripeInstance = await loadStripe(publicKey);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!this.stripeInstance) throw new Error("Stripe failed to load");
|
|
373
|
+
|
|
374
|
+
const appearance = { theme: 'night' as const, variables: { colorPrimary: '#6366f1', colorBackground: 'rgba(15,23,42,0.6)' } };
|
|
375
|
+
this.stripeElements = this.stripeInstance.elements({ appearance, clientSecret });
|
|
376
|
+
const paymentElement = this.stripeElements.create("payment");
|
|
377
|
+
paymentElement.mount("#payment-element");
|
|
378
|
+
|
|
379
|
+
const form = document.getElementById("payment-form");
|
|
380
|
+
form?.addEventListener("submit", async (e) => {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
const submitBtn = document.getElementById("submit-stripe-btn") as HTMLButtonElement;
|
|
383
|
+
submitBtn.disabled = true;
|
|
384
|
+
submitBtn.textContent = "Processing...";
|
|
385
|
+
|
|
386
|
+
const { error } = await this.stripeInstance!.confirmPayment({
|
|
387
|
+
elements: this.stripeElements!,
|
|
388
|
+
confirmParams: {
|
|
389
|
+
return_url: window.location.href, // Fallback, we use 'if_required' for cards
|
|
390
|
+
},
|
|
391
|
+
redirect: "if_required"
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (error) {
|
|
395
|
+
const msg = document.getElementById("payment-message");
|
|
396
|
+
if (msg) {
|
|
397
|
+
msg.textContent = error.message || "An unexpected error occurred.";
|
|
398
|
+
msg.style.display = "block";
|
|
399
|
+
}
|
|
400
|
+
submitBtn.disabled = false;
|
|
401
|
+
submitBtn.textContent = "Pay Now";
|
|
402
|
+
} else {
|
|
403
|
+
this.renderLoadingPanel("Payment approved! Waiting for settlement...");
|
|
404
|
+
this.startPolling();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
} catch (e: any) {
|
|
408
|
+
this.renderErrorPanel("Failed to load Stripe: " + e.message);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async handleCryptoPaymentPanel() {
|
|
413
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
414
|
+
if (!container) return;
|
|
415
|
+
|
|
416
|
+
this.renderLoadingPanel("Preparing crypto payment...");
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
this.cryptoIntent = await this.loadCryptoIntent("sui_wallet");
|
|
420
|
+
} catch (err: any) {
|
|
421
|
+
this.renderErrorPanel(err.message || "Failed to prepare crypto payment.");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
container.innerHTML = `
|
|
426
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
427
|
+
<div class="suioutkit-header">
|
|
428
|
+
<h2 class="suioutkit-title">Pay with Sui Wallet</h2>
|
|
429
|
+
<p class="suioutkit-subtitle">Choose SUI payment channel</p>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="suioutkit-panel" style="gap: 12px; display: flex; flex-direction: column; width: 100%;">
|
|
432
|
+
<p class="sok-status-text" style="margin-bottom: 12px;">
|
|
433
|
+
Choose whether to pay via a desktop extension wallet or scan a dynamic QR Code with your mobile wallet.
|
|
434
|
+
</p>
|
|
435
|
+
<button class="sok-btn" id="sok-connect-extension-btn" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); margin-bottom: 4px;">
|
|
436
|
+
Standard Connect Wallet
|
|
437
|
+
</button>
|
|
438
|
+
<button class="sok-btn" id="sok-outpay-qr-btn" style="background: linear-gradient(135deg, #10b981 0%, #047857 100%);">
|
|
439
|
+
outPay (Scan QR Code)
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
`;
|
|
443
|
+
|
|
444
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.renderSelectionPanel());
|
|
445
|
+
|
|
446
|
+
container.querySelector("#sok-connect-extension-btn")?.addEventListener("click", () => {
|
|
447
|
+
if (!this.cryptoIntent) {
|
|
448
|
+
this.renderErrorPanel("Crypto intent not ready.");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
void this.openStandardConnectWallet();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
container.querySelector("#sok-outpay-qr-btn")?.addEventListener("click", () => void this.renderOutPayQRPanel());
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async renderCustomWalletListPanel() {
|
|
458
|
+
await this.openStandardConnectWallet();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private async openStandardConnectWallet() {
|
|
462
|
+
if (!this.cryptoIntent) {
|
|
463
|
+
this.renderErrorPanel("Crypto intent not ready.");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (this.isFileOrigin()) {
|
|
468
|
+
this.renderUnsupportedOriginPanel();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const wallets = await this.getCompatibleWallets();
|
|
473
|
+
|
|
474
|
+
if (wallets.length === 0) {
|
|
475
|
+
this.renderNoSupportedWalletsPanel();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
this.renderWalletPickerPanel(wallets);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async getCompatibleWallets() {
|
|
483
|
+
const dAppKit = this.ensureDAppKit();
|
|
484
|
+
const getWallets = () => (dAppKit.stores as any)?.$wallets?.get?.() || [];
|
|
485
|
+
|
|
486
|
+
let wallets: any[] = getWallets();
|
|
487
|
+
if (wallets.length === 0) {
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
489
|
+
wallets = getWallets();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return wallets
|
|
493
|
+
.filter((wallet) => wallet?.name && wallet?.icon)
|
|
494
|
+
.sort((a, b) => {
|
|
495
|
+
const rank = (name: string) => {
|
|
496
|
+
const normalized = name.toLowerCase();
|
|
497
|
+
if (normalized.includes("slush")) return 0;
|
|
498
|
+
if (normalized.includes("phantom")) return 1;
|
|
499
|
+
return 2;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
return rank(String(a.name)) - rank(String(b.name)) || String(a.name).localeCompare(String(b.name));
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private renderWalletPickerPanel(wallets: any[]) {
|
|
507
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
508
|
+
if (!container) return;
|
|
509
|
+
|
|
510
|
+
const walletCardsHtml = wallets
|
|
511
|
+
.map((wallet, index) => this.renderWalletCard(wallet, index))
|
|
512
|
+
.join("");
|
|
513
|
+
|
|
514
|
+
container.innerHTML = `
|
|
515
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to Sui options</button>
|
|
516
|
+
<div class="suioutkit-header">
|
|
517
|
+
<h2 class="suioutkit-title">Connect Wallet</h2>
|
|
518
|
+
<p class="suioutkit-subtitle">Choose the extension you want to use</p>
|
|
519
|
+
</div>
|
|
520
|
+
<div style="display: grid; grid-template-columns: 1fr; gap: 12px; width: 100%;">
|
|
521
|
+
${walletCardsHtml}
|
|
522
|
+
</div>
|
|
523
|
+
<p class="sok-status-text" style="font-size: 12px; opacity: 0.75; margin-top: 14px; text-align: center;">
|
|
524
|
+
Wallets are filtered from the browser extensions detected by dApp Kit.
|
|
525
|
+
</p>
|
|
526
|
+
`;
|
|
527
|
+
|
|
528
|
+
this.renderIcons();
|
|
529
|
+
|
|
530
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.handleCryptoPaymentPanel());
|
|
531
|
+
|
|
532
|
+
wallets.forEach((wallet, index) => {
|
|
533
|
+
const btn = container.querySelector(`[data-wallet-index="${index}"]`);
|
|
534
|
+
btn?.addEventListener("click", async () => {
|
|
535
|
+
const dAppKit = this.ensureDAppKit();
|
|
536
|
+
this.renderLoadingPanel(`Connecting to ${wallet.name}...`);
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const result = await dAppKit.connectWallet({ wallet });
|
|
540
|
+
const connection = (dAppKit.stores as any)?.$connection?.get?.() || {};
|
|
541
|
+
const account = result.accounts?.[0] || connection.currentAccount || connection.account;
|
|
542
|
+
|
|
543
|
+
if (!account) {
|
|
544
|
+
this.renderErrorPanel("Wallet connected, but no account was returned. Please unlock the wallet and try again.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.renderPaymentConfirmPanel(account);
|
|
549
|
+
} catch (err: any) {
|
|
550
|
+
const errMsg = err?.message || "Failed to connect wallet.";
|
|
551
|
+
if (errMsg.toLowerCase().includes("no accounts were authorized") || errMsg.toLowerCase().includes("rejected")) {
|
|
552
|
+
this.renderErrorPanel("Connection rejected or wallet is locked. Please unlock your wallet and try again.");
|
|
553
|
+
} else {
|
|
554
|
+
this.renderErrorPanel(errMsg);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private ensureDAppKit() {
|
|
562
|
+
if (this.dAppKit) {
|
|
563
|
+
return this.dAppKit;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const requestedNetwork = (window as any).SuiOutKitNetwork as string | undefined;
|
|
567
|
+
const network: SupportedNetwork = requestedNetwork === "mainnet" || requestedNetwork === "testnet" ? requestedNetwork : "testnet";
|
|
568
|
+
|
|
569
|
+
this.dAppKit = createDAppKit({
|
|
570
|
+
networks: [network],
|
|
571
|
+
defaultNetwork: network,
|
|
572
|
+
autoConnect: false,
|
|
573
|
+
slushWalletConfig: null,
|
|
574
|
+
createClient: (selectedNetwork) =>
|
|
575
|
+
new SuiGrpcClient({
|
|
576
|
+
network: selectedNetwork,
|
|
577
|
+
baseUrl: SUI_GRPC_URLS[selectedNetwork as keyof typeof SUI_GRPC_URLS] || SUI_GRPC_URLS.testnet
|
|
578
|
+
})
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
return this.dAppKit;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private clearWalletConnectionWaiter() {
|
|
585
|
+
if (this.walletConnectionUnsubscribe) {
|
|
586
|
+
this.walletConnectionUnsubscribe();
|
|
587
|
+
this.walletConnectionUnsubscribe = null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private isFileOrigin() {
|
|
592
|
+
return window.location.protocol === "file:";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private renderUnsupportedOriginPanel() {
|
|
596
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
597
|
+
if (!container) return;
|
|
598
|
+
|
|
599
|
+
container.innerHTML = `
|
|
600
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to Sui options</button>
|
|
601
|
+
<div class="suioutkit-panel" style="gap: 12px; display: flex; flex-direction: column; align-items: center; text-align: center;">
|
|
602
|
+
<div class="sok-success-icon" style="color: #f59e0b; display: flex; align-items: center; justify-content: center; margin-bottom: 8px;">
|
|
603
|
+
<i data-lucide="alert-circle" style="width: 48px; height: 48px;"></i>
|
|
604
|
+
</div>
|
|
605
|
+
<h2 class="sok-success-title">Open this demo from localhost</h2>
|
|
606
|
+
<p class="sok-status-text" style="max-width: 320px;">
|
|
607
|
+
This page is running from a local file URL. Browser extension wallets like Slush and Phantom do not reliably inject into file:// pages, so dApp Kit cannot list them here.
|
|
608
|
+
</p>
|
|
609
|
+
<p class="sok-status-text" style="max-width: 320px; font-size: 12px; opacity: 0.78;">
|
|
610
|
+
Open the demo over http://localhost or another web server, then reload. That is the supported origin for wallet detection and connection.
|
|
611
|
+
</p>
|
|
612
|
+
</div>
|
|
613
|
+
`;
|
|
614
|
+
|
|
615
|
+
this.renderIcons();
|
|
616
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.handleCryptoPaymentPanel());
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private renderWalletCard(wallet: any, index: number): string {
|
|
620
|
+
const walletName = wallet.name || "Unknown Wallet";
|
|
621
|
+
const icon = wallet.icon || "https://via.placeholder.com/48";
|
|
622
|
+
|
|
623
|
+
return `
|
|
624
|
+
<button
|
|
625
|
+
class="sok-wallet-card"
|
|
626
|
+
data-wallet-index="${index}"
|
|
627
|
+
style="display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 16px; border-radius: 18px; border: 1px solid rgba(255,255,255,0.08); background: linear-gradient(135deg, rgba(17,24,39,0.88), rgba(15,23,42,0.96)); color: #fff; text-align: left; box-shadow: 0 18px 40px rgba(0,0,0,0.22);"
|
|
628
|
+
>
|
|
629
|
+
<img src="${icon}" alt="${walletName}" class="sok-wallet-icon" style="width: 44px; height: 44px; border-radius: 14px; flex: none; background: rgba(255,255,255,0.08); padding: 4px;" />
|
|
630
|
+
<span style="display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0;">
|
|
631
|
+
<span class="sok-wallet-name" style="font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${walletName}</span>
|
|
632
|
+
<span style="font-size: 12px; opacity: 0.74;">Detected browser wallet</span>
|
|
633
|
+
</span>
|
|
634
|
+
<span style="font-size: 12px; font-weight: 700; color: #93c5fd;">Connect</span>
|
|
635
|
+
</button>
|
|
636
|
+
`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private renderNoSupportedWalletsPanel() {
|
|
640
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
641
|
+
if (!container) return;
|
|
642
|
+
|
|
643
|
+
container.innerHTML = `
|
|
644
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to Sui options</button>
|
|
645
|
+
<div class="suioutkit-panel">
|
|
646
|
+
<div class="sok-success-icon" style="color: #f59e0b; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
|
647
|
+
<i data-lucide="alert-circle" style="width: 48px; height: 48px;"></i>
|
|
648
|
+
</div>
|
|
649
|
+
<h2 class="sok-success-title">No Wallets Detected</h2>
|
|
650
|
+
<p class="sok-status-text" style="margin-top: 16px;">
|
|
651
|
+
We couldn't find any installed Sui wallets. Please install a wallet extension like Phantom, Slush, or others from the app store and refresh the page.
|
|
652
|
+
</p>
|
|
653
|
+
<p class="sok-status-text" style="font-size: 12px; opacity: 0.7; margin-top: 12px;">
|
|
654
|
+
Alternatively, you can use the outPay QR option to pay from a mobile wallet.
|
|
655
|
+
</p>
|
|
656
|
+
</div>
|
|
657
|
+
`;
|
|
658
|
+
|
|
659
|
+
this.renderIcons();
|
|
660
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.handleCryptoPaymentPanel());
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Step 2 of crypto flow: show payment summary after wallet connected
|
|
664
|
+
private renderPaymentConfirmPanel(account: any) {
|
|
665
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
666
|
+
if (!container) return;
|
|
667
|
+
|
|
668
|
+
const currencySymbol = this.session.currency === "NGN" ? "₦" : "";
|
|
669
|
+
const formattedAmount = `${currencySymbol}${this.session.amount.toLocaleString()}`;
|
|
670
|
+
const shortAddress = `${account.address.substring(0, 6)}...${account.address.slice(-4)}`;
|
|
671
|
+
const network = ((window as any).SuiOutKitNetwork as string) || "testnet";
|
|
672
|
+
|
|
673
|
+
container.innerHTML = `
|
|
674
|
+
<button class="suioutkit-back" id="sok-back-btn">← Change wallet</button>
|
|
675
|
+
<div class="suioutkit-header">
|
|
676
|
+
<h2 class="suioutkit-title">Confirm Payment</h2>
|
|
677
|
+
<p class="suioutkit-subtitle">Review and approve this transaction</p>
|
|
678
|
+
</div>
|
|
679
|
+
<div class="suioutkit-panel" style="gap: 12px; display: flex; flex-direction: column; width: 100%;">
|
|
680
|
+
<div class="sok-va-card">
|
|
681
|
+
<div class="sok-va-row">
|
|
682
|
+
<div class="sok-va-lbl">Amount</div>
|
|
683
|
+
<div class="sok-va-val" style="color: #10b981; font-weight: 700;">${formattedAmount}</div>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="sok-va-row">
|
|
686
|
+
<div class="sok-va-lbl">From Wallet</div>
|
|
687
|
+
<div class="sok-va-val">${shortAddress}</div>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="sok-va-row">
|
|
690
|
+
<div class="sok-va-lbl">Network</div>
|
|
691
|
+
<div class="sok-va-val">${network}</div>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
<button class="sok-btn" id="sok-confirm-pay-btn" style="background: linear-gradient(135deg, #10b981 0%, #047857 100%);">
|
|
695
|
+
Confirm & Pay
|
|
696
|
+
</button>
|
|
697
|
+
</div>
|
|
698
|
+
`;
|
|
699
|
+
|
|
700
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => void this.openStandardConnectWallet());
|
|
701
|
+
container.querySelector("#sok-confirm-pay-btn")?.addEventListener("click", () => void this.executeWalletPayment());
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Step 3 of crypto flow: sign and submit the transaction
|
|
705
|
+
private async executeWalletPayment() {
|
|
706
|
+
if (!this.cryptoIntent) {
|
|
707
|
+
this.renderErrorPanel("Crypto intent not ready.");
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
this.renderLoadingPanel("Waiting for wallet approval...");
|
|
712
|
+
|
|
713
|
+
const dAppKit = this.ensureDAppKit();
|
|
714
|
+
const connection = (dAppKit.stores as any)?.$connection?.get?.() || {};
|
|
715
|
+
const account = connection.currentAccount || connection.account;
|
|
716
|
+
|
|
717
|
+
if (!account) {
|
|
718
|
+
this.renderErrorPanel("No connected wallet account found.");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const baseUnits = this.cryptoIntent.amountBaseUnits;
|
|
723
|
+
const walrusBlobId = this.cryptoIntent.walrusBlobId;
|
|
724
|
+
|
|
725
|
+
if (!this.cryptoIntent.packageId) {
|
|
726
|
+
this.renderErrorPanel("Crypto intent is missing the contract package id.");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!walrusBlobId) {
|
|
731
|
+
this.renderErrorPanel("Crypto intent is missing the Walrus receipt blob id.");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
const paymentClient = this.ensurePaymentClient();
|
|
737
|
+
const tx = new Transaction();
|
|
738
|
+
const paymentReceipt = tx.add(paymentClient.paymentKit.calls.processRegistryPayment({
|
|
739
|
+
nonce: this.cryptoIntent.nonce,
|
|
740
|
+
coinType: this.cryptoIntent.coinType,
|
|
741
|
+
amount: BigInt(baseUnits),
|
|
742
|
+
receiver: this.cryptoIntent.receiverAddress,
|
|
743
|
+
sender: account.address,
|
|
744
|
+
...(this.cryptoIntent.registryName ? { registryName: this.cryptoIntent.registryName } : {})
|
|
745
|
+
}));
|
|
746
|
+
|
|
747
|
+
const [suioutkitReceipt] = tx.moveCall({
|
|
748
|
+
target: `${this.cryptoIntent.packageId}::checkout::mint_suioutkit_receipt`,
|
|
749
|
+
arguments: [
|
|
750
|
+
paymentReceipt,
|
|
751
|
+
tx.pure.address(this.cryptoIntent.receiverAddress),
|
|
752
|
+
tx.pure.u64(BigInt(baseUnits)),
|
|
753
|
+
tx.pure.string(this.cryptoIntent.nonce),
|
|
754
|
+
tx.pure.string(this.cryptoIntent.coinType),
|
|
755
|
+
tx.pure.string("sui_wallet"),
|
|
756
|
+
tx.pure.string(walrusBlobId)
|
|
757
|
+
]
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
tx.transferObjects([suioutkitReceipt], this.cryptoIntent.receiverAddress);
|
|
761
|
+
|
|
762
|
+
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx });
|
|
763
|
+
|
|
764
|
+
if ((result as any).FailedTransaction) {
|
|
765
|
+
this.renderErrorPanel(
|
|
766
|
+
`Transaction failed: ${(result as any).FailedTransaction?.status?.error?.message || "Unknown error"}`
|
|
767
|
+
);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const txDigest = (result as any).Transaction?.digest || "";
|
|
772
|
+
|
|
773
|
+
// Notify backend to verify on-chain and store Walrus receipt
|
|
774
|
+
this.renderLoadingPanel("Confirming payment on-chain...");
|
|
775
|
+
const confirmResponse = await fetch(joinApiPath(this.backendUrl, "checkout", "crypto", "confirm"), {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: { "Content-Type": "application/json" },
|
|
778
|
+
body: JSON.stringify({
|
|
779
|
+
nonce: this.session.nonce,
|
|
780
|
+
txDigest,
|
|
781
|
+
method: "sui_wallet"
|
|
782
|
+
})
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const confirmResult: any = await confirmResponse.json().catch(() => ({}));
|
|
786
|
+
if (!confirmResponse.ok) {
|
|
787
|
+
this.renderErrorPanel(confirmResult.error || confirmResult.message || "Unable to confirm payment on-chain.");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Poll for SETTLED status (backend verifies + emits Walrus receipt)
|
|
792
|
+
this.startPolling();
|
|
793
|
+
} catch (err) {
|
|
794
|
+
this.renderErrorPanel(`Payment failed: ${(err as any)?.message || String(err)}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private async renderOutPayQRPanel() {
|
|
799
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
800
|
+
if (!container) return;
|
|
801
|
+
|
|
802
|
+
this.renderLoadingPanel("Preparing outPay QR...");
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
this.cryptoIntent = await this.loadCryptoIntent("outpay");
|
|
806
|
+
} catch (err: any) {
|
|
807
|
+
this.renderErrorPanel(err.message || "Failed to prepare outPay QR.");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const paymentUri = this.buildPaymentUri(this.cryptoIntent);
|
|
812
|
+
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(paymentUri)}`;
|
|
813
|
+
|
|
814
|
+
container.innerHTML = `
|
|
815
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to Sui options</button>
|
|
816
|
+
<div class="suioutkit-panel">
|
|
817
|
+
<div class="suioutkit-amount-box" style="margin-bottom: 12px;">
|
|
818
|
+
<p class="suioutkit-subtitle">Scan to approve and pay SUI/Tokens</p>
|
|
819
|
+
<h2 class="sok-fiat-amt" style="font-size: 24px; color: #10b981;">outPay Mobile</h2>
|
|
820
|
+
</div>
|
|
821
|
+
|
|
822
|
+
<div class="sok-qr-card">
|
|
823
|
+
<div class="sok-qr-frame">
|
|
824
|
+
<img src="${qrCodeUrl}" alt="outPay QR Code" class="sok-qr-img" />
|
|
825
|
+
<div class="sok-qr-logo-badge">
|
|
826
|
+
<i data-lucide="droplet" style="width: 16px; height: 16px; color: white;"></i>
|
|
827
|
+
</div>
|
|
828
|
+
<div class="sok-qr-scan-pulse"></div>
|
|
829
|
+
</div>
|
|
830
|
+
<p class="sok-status-text" style="font-size: 11px; word-break: break-all; opacity: 0.8; margin-bottom: 4px;">
|
|
831
|
+
${paymentUri.substring(0, 60)}...
|
|
832
|
+
</p>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<div class="sok-spinner"></div>
|
|
836
|
+
<p class="sok-status-text">Awaiting scan & on-chain verification...</p>
|
|
837
|
+
</div>
|
|
838
|
+
`;
|
|
839
|
+
|
|
840
|
+
this.renderIcons();
|
|
841
|
+
|
|
842
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.handleCryptoPaymentPanel());
|
|
843
|
+
|
|
844
|
+
this.startPolling();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private buildPaymentUri(intent: CryptoIntentResponse): string {
|
|
848
|
+
return createPaymentTransactionUri({
|
|
849
|
+
receiverAddress: intent.receiverAddress,
|
|
850
|
+
amount: BigInt(intent.amountBaseUnits),
|
|
851
|
+
coinType: intent.coinType,
|
|
852
|
+
nonce: intent.nonce,
|
|
853
|
+
registryName: intent.registryName,
|
|
854
|
+
label: "SuiOutKit Payment",
|
|
855
|
+
message: `Payment for ${intent.nonce.substring(0, 8)}`,
|
|
856
|
+
iconUrl: "https://raw.githubusercontent.com/MystenLabs/sui/refs/heads/main/docs/site/static/img/logo.svg"
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private async loadCryptoIntent(method: "sui_wallet" | "outpay"): Promise<CryptoIntentResponse> {
|
|
861
|
+
const response = await fetch(joinApiPath(this.backendUrl, "checkout", "crypto", "intent"), {
|
|
862
|
+
method: "POST",
|
|
863
|
+
headers: { "Content-Type": "application/json" },
|
|
864
|
+
body: JSON.stringify({
|
|
865
|
+
token: this.session.token,
|
|
866
|
+
method
|
|
867
|
+
})
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
const result: any = await response.json();
|
|
871
|
+
if (!response.ok) {
|
|
872
|
+
throw new Error(result.error || "Failed to prepare crypto intent.");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return result as CryptoIntentResponse;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private ensurePaymentClient() {
|
|
879
|
+
if (this.paymentClient) {
|
|
880
|
+
return this.paymentClient;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const requestedNetwork = (window as any).SuiOutKitNetwork as string | undefined;
|
|
884
|
+
const network: SupportedNetwork = requestedNetwork === "mainnet" || requestedNetwork === "testnet" ? requestedNetwork : "testnet";
|
|
885
|
+
|
|
886
|
+
this.paymentClient = new SuiGrpcClient({
|
|
887
|
+
network,
|
|
888
|
+
baseUrl: SUI_GRPC_URLS[network]
|
|
889
|
+
}).$extend(paymentKit());
|
|
890
|
+
|
|
891
|
+
return this.paymentClient;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private renderSuccessPanel(txDigest: string, walrusBlobId: string) {
|
|
895
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
896
|
+
if (!container) return;
|
|
897
|
+
|
|
898
|
+
this.stopPolling();
|
|
899
|
+
const walrusNetworkPath = getExplorerNetworkPath();
|
|
900
|
+
|
|
901
|
+
container.innerHTML = `
|
|
902
|
+
<div class="suioutkit-panel">
|
|
903
|
+
<div class="sok-success-icon" style="color: #10b981; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
|
904
|
+
<i data-lucide="check-circle" style="width: 48px; height: 48px;"></i>
|
|
905
|
+
</div>
|
|
906
|
+
<h2 class="sok-success-title">Payment Successful!</h2>
|
|
907
|
+
<p class="sok-success-desc">The merchant has been paid on-chain.</p>
|
|
908
|
+
|
|
909
|
+
<div class="sok-success-details">
|
|
910
|
+
<div class="sok-receipt-row">
|
|
911
|
+
<span class="sok-receipt-lbl">Amount Paid</span>
|
|
912
|
+
<span class="sok-receipt-val" style="color: #10b981; font-weight:700;">
|
|
913
|
+
${this.session.currency === "NGN" ? "₦" : ""}${this.session.amount.toLocaleString()}
|
|
914
|
+
</span>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
<div class="sok-receipt-row">
|
|
918
|
+
<span class="sok-receipt-lbl">Sui Transaction</span>
|
|
919
|
+
<span class="sok-receipt-val">
|
|
920
|
+
<a href="https://suiscan.xyz/testnet/tx/${txDigest}" target="_blank">${txDigest.substring(0, 10)}...</a>
|
|
921
|
+
</span>
|
|
922
|
+
</div>
|
|
923
|
+
|
|
924
|
+
<div class="sok-receipt-row">
|
|
925
|
+
<span class="sok-receipt-lbl">Walrus Invoice ID</span>
|
|
926
|
+
<span class="sok-receipt-val">
|
|
927
|
+
<a href="https://walruscan.com/${walrusNetworkPath}/blob/${walrusBlobId}" target="_blank">${walrusBlobId.substring(0, 10)}...</a>
|
|
928
|
+
</span>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
`;
|
|
933
|
+
|
|
934
|
+
this.renderIcons();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private renderErrorPanel(message: string) {
|
|
938
|
+
const container = this.overlay?.querySelector("#sok-content-panel");
|
|
939
|
+
if (!container) return;
|
|
940
|
+
|
|
941
|
+
container.innerHTML = `
|
|
942
|
+
<button class="suioutkit-back" id="sok-back-btn">← Back to methods</button>
|
|
943
|
+
<div class="suioutkit-panel">
|
|
944
|
+
<div class="sok-success-icon" style="color: #ef4444; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
|
945
|
+
<i data-lucide="x-circle" style="width: 48px; height: 48px;"></i>
|
|
946
|
+
</div>
|
|
947
|
+
<h2 class="sok-success-title">Payment Failed</h2>
|
|
948
|
+
<p class="sok-status-text" style="color: #ef4444; margin-bottom: 20px;">${message}</p>
|
|
949
|
+
</div>
|
|
950
|
+
`;
|
|
951
|
+
|
|
952
|
+
this.renderIcons();
|
|
953
|
+
|
|
954
|
+
container.querySelector("#sok-back-btn")?.addEventListener("click", () => this.renderSelectionPanel());
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private startPolling() {
|
|
958
|
+
this.stopPolling();
|
|
959
|
+
this.pollInterval = setInterval(async () => {
|
|
960
|
+
try {
|
|
961
|
+
const response = await fetch(joinApiPath(this.backendUrl, "checkout", "status", this.session.nonce));
|
|
962
|
+
const result: CheckoutStatusResponse = await response.json();
|
|
963
|
+
|
|
964
|
+
if (result.status === "SETTLED" && result.txDigest && result.walrusBlobId) {
|
|
965
|
+
this.renderSuccessPanel(result.txDigest, result.walrusBlobId);
|
|
966
|
+
}
|
|
967
|
+
} catch (err) {
|
|
968
|
+
// Soft fail on polling connectivity issues, keep retrying
|
|
969
|
+
}
|
|
970
|
+
}, 3000);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private stopPolling() {
|
|
974
|
+
if (this.pollInterval) {
|
|
975
|
+
clearInterval(this.pollInterval);
|
|
976
|
+
this.pollInterval = null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
public destroy() {
|
|
981
|
+
this.stopPolling();
|
|
982
|
+
this.clearWalletConnectionWaiter();
|
|
983
|
+
if (this.dAppKit) {
|
|
984
|
+
this.dAppKit.disconnectWallet().catch(() => { });
|
|
985
|
+
}
|
|
986
|
+
this.overlay?.classList.remove("active");
|
|
987
|
+
setTimeout(() => {
|
|
988
|
+
this.overlay?.remove();
|
|
989
|
+
this.onCloseCallback();
|
|
990
|
+
}, 300);
|
|
991
|
+
}
|
|
992
|
+
}
|