main
html 287 lines 12 KB
Raw
1 <!DOCTYPE html>
2 <html lang="en">
3
4 <head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
7 <title>{{.PageTitle}}</title>
8 <meta name="description" content="{{.PageDescription}}">
9 <meta property="og:type" content="website">
10 <meta property="og:title" content="{{.PageTitle}}">
11 <meta property="og:description" content="{{.PageDescription}}">
12 <meta property="og:image" content="{{.OGImage}}">
13 <meta property="og:url" content="{{.URL}}">
14 <meta name="twitter:card" content="summary_large_image">
15 <meta name="twitter:title" content="{{.PageTitle}}">
16 <meta name="twitter:description" content="{{.PageDescription}}">
17 <meta name="twitter:image" content="{{.OGImage}}">
18 <link rel="stylesheet" href="/static/style.css">
19 <script id="payment-config" type="application/json">{{.ConfigJSON}}</script>
20 </head>
21
22 <body>
23 <main class="shell">
24 <section class="app-surface" aria-label="Payment app">
25 <div class="summary">
26 <div>
27 <div class="kicker">
28 <span>Sui wallet</span>
29 <span>{{.NetworkName}}</span>
30 <span>USDC</span>
31 </div>
32 <h1>Paid image delivery</h1>
33 <p class="lede">Connect a Sui wallet, sign the x402 payment transaction, and reveal the protected image in the viewer.</p>
34
35 <dl class="facts">
36 <div>
37 <dt>Amount</dt>
38 <dd>{{.Amount}}</dd>
39 </div>
40 <div>
41 <dt>Network</dt>
42 <dd>{{.Network}}</dd>
43 </div>
44 <div>
45 <dt>Recipient</dt>
46 <dd>{{.RecipientAddress}}</dd>
47 </div>
48 </dl>
49 </div>
50
51 <div class="checkout">
52 <label class="wallet-label" for="walletSelect">Wallet</label>
53 <select id="walletSelect" class="wallet-select"></select>
54 <label class="wallet-label" for="accountSelect">Address</label>
55 <select id="accountSelect" class="wallet-select" disabled></select>
56 <div class="actions">
57 <button id="unlockButton" class="primary-button" type="button" disabled>Connect and pay</button>
58 </div>
59 <p id="status" class="status">Looking for Sui wallets</p>
60 </div>
61 </div>
62
63 <div id="viewer" class="preview" aria-label="Protected image viewer">
64 <div class="preview-topbar">
65 <div class="window-controls" aria-hidden="true">
66 <span></span>
67 <span></span>
68 <span></span>
69 </div>
70 <span id="viewerTitle" class="viewer-title">Protected image</span>
71 <button id="resetViewer" class="viewer-reset" type="button" hidden>Reset</button>
72 </div>
73 <div id="lockedPreview" class="preview-body">
74 <div class="lock-mark">402</div>
75 <div>
76 <p class="preview-label">Protected image</p>
77 <p class="preview-title">Wallet payment opens here</p>
78 </div>
79 </div>
80 <iframe id="paymentFrame" class="payment-frame" title="Unlocked protected image" hidden></iframe>
81 </div>
82 </section>
83 </main>
84
85 <script type="module">
86 import { getSuiWallets, onSuiWalletChange, x402Fetch } from '/x402/client.js';
87
88 const config = JSON.parse(document.getElementById('payment-config').textContent || '{}');
89 const walletSelect = document.getElementById('walletSelect');
90 const accountSelect = document.getElementById('accountSelect');
91 const unlockButton = document.getElementById('unlockButton');
92 const statusEl = document.getElementById('status');
93 const viewer = document.getElementById('viewer');
94 const lockedPreview = document.getElementById('lockedPreview');
95 const paymentFrame = document.getElementById('paymentFrame');
96 const resetViewer = document.getElementById('resetViewer');
97 const viewerTitle = document.getElementById('viewerTitle');
98 let wallets = [];
99 let connectedWalletIndex = '';
100 let connectedAccounts = [];
101
102 function setStatus(value) {
103 statusEl.textContent = value;
104 }
105
106 function setPaymentEvent(event) {
107 setStatus(event.message);
108 }
109
110 function currentWallets() {
111 wallets = getSuiWallets({ network: config.network });
112 return wallets;
113 }
114
115 function resetAccountSelect() {
116 const option = document.createElement('option');
117 option.value = '';
118 option.textContent = 'Connect wallet first';
119 accountSelect.replaceChildren(option);
120 accountSelect.disabled = true;
121 }
122
123 function refreshWallets() {
124 const selectedWalletIndex = walletSelect.value;
125 const nextWallets = currentWallets();
126 walletSelect.replaceChildren(...nextWallets.map((wallet, index) => {
127 const option = document.createElement('option');
128 option.value = String(index);
129 option.textContent = wallet.name;
130 return option;
131 }));
132 if (selectedWalletIndex && Number(selectedWalletIndex) < nextWallets.length) {
133 walletSelect.value = selectedWalletIndex;
134 }
135 const hasWallets = nextWallets.length > 0;
136 walletSelect.disabled = !hasWallets;
137 unlockButton.disabled = !hasWallets;
138 unlockButton.textContent = 'Connect and pay';
139 connectedWalletIndex = '';
140 connectedAccounts = [];
141 resetAccountSelect();
142 setStatus(hasWallets ? 'Select a wallet and continue' : 'Install a Sui wallet extension');
143 }
144
145 function accountKey(account) {
146 return String(account?.address || '').trim().toLowerCase();
147 }
148
149 function shortAddress(value) {
150 const address = String(value || '').trim();
151 if (address.length <= 18) {
152 return address;
153 }
154 return `${address.slice(0, 10)}...${address.slice(-6)}`;
155 }
156
157 function accountByAddress(accounts, address) {
158 const key = String(address || '').trim().toLowerCase();
159 return accounts.find((account) => account.address === address) ||
160 accounts.find((account) => accountKey(account) === key) ||
161 null;
162 }
163
164 function setAccountOptions(accounts, preferredAddress) {
165 const seen = new Set();
166 connectedAccounts = accounts.filter((account) => {
167 const key = accountKey(account);
168 if (!key || seen.has(key)) {
169 return false;
170 }
171 seen.add(key);
172 return true;
173 });
174 accountSelect.replaceChildren(...connectedAccounts.map((account) => {
175 const option = document.createElement('option');
176 option.value = account.address;
177 option.textContent = shortAddress(account.address);
178 return option;
179 }));
180 const preferred = accountByAddress(connectedAccounts, preferredAddress);
181 if (preferred) {
182 accountSelect.value = preferred.address;
183 }
184 accountSelect.disabled = connectedAccounts.length === 0;
185 }
186
187 async function connectWallet() {
188 const selectedWalletIndex = walletSelect.value;
189 const wallet = wallets[Number(selectedWalletIndex)] || currentWallets()[Number(selectedWalletIndex)];
190 if (!wallet) {
191 throw new Error('No Sui wallet selected');
192 }
193
194 const hadAccountSelection = connectedWalletIndex === selectedWalletIndex && accountSelect.value !== '';
195 const accounts = await wallet.accounts(config.network);
196 connectedWalletIndex = selectedWalletIndex;
197 setAccountOptions(accounts, accountSelect.value);
198
199 if (connectedAccounts.length > 1 && !hadAccountSelection) {
200 unlockButton.textContent = 'Pay with selected address';
201 setStatus('Select an address and continue');
202 return null;
203 }
204
205 const account = accountByAddress(connectedAccounts, accountSelect.value) ||
206 (connectedAccounts.length === 1 ? connectedAccounts[0] : null);
207 if (!account) {
208 throw new Error('Connected wallet did not return an account');
209 }
210 if (Array.isArray(account.chains) && !account.chains.includes(config.network)) {
211 throw new Error(`Connected account does not advertise ${config.network}`);
212 }
213 return { wallet, account };
214 }
215
216 async function unlock() {
217 unlockButton.disabled = true;
218 walletSelect.disabled = true;
219 accountSelect.disabled = true;
220 paymentFrame.removeAttribute('srcdoc');
221 try {
222 setStatus('Connecting wallet');
223 const connection = await connectWallet();
224 if (!connection) {
225 return;
226 }
227 const { wallet, account } = connection;
228
229 viewer.classList.add('is-active');
230 resetViewer.hidden = false;
231 lockedPreview.hidden = true;
232 paymentFrame.hidden = false;
233 viewerTitle.textContent = 'Sui wallet payment';
234
235 const protectedResponse = await x402Fetch(config.protectedPath, { method: 'GET' }, {
236 wallet,
237 account,
238 network: config.network,
239 preparePath: config.preparePath,
240 onEvent: setPaymentEvent,
241 });
242 if (!protectedResponse.ok) {
243 throw new Error(await protectedResponse.text());
244 }
245 paymentFrame.srcdoc = await protectedResponse.text();
246 viewerTitle.textContent = 'Image unlocked';
247 setStatus('Payment complete');
248 } catch (error) {
249 lockedPreview.hidden = false;
250 paymentFrame.hidden = true;
251 paymentFrame.removeAttribute('srcdoc');
252 viewerTitle.textContent = 'Protected image';
253 setStatus(error instanceof Error ? error.message.trim() : String(error));
254 } finally {
255 const hasWallets = currentWallets().length > 0;
256 walletSelect.disabled = !hasWallets;
257 accountSelect.disabled = connectedAccounts.length === 0;
258 unlockButton.disabled = !hasWallets;
259 unlockButton.textContent = connectedAccounts.length > 0 ? 'Pay with selected address' : 'Connect and pay';
260 }
261 }
262
263 unlockButton.addEventListener('click', unlock);
264 walletSelect.addEventListener('change', () => {
265 connectedWalletIndex = '';
266 connectedAccounts = [];
267 resetAccountSelect();
268 unlockButton.textContent = 'Connect and pay';
269 setStatus('Select a wallet and continue');
270 });
271
272 resetViewer.addEventListener('click', () => {
273 viewer.classList.remove('is-active');
274 lockedPreview.hidden = false;
275 paymentFrame.hidden = true;
276 paymentFrame.removeAttribute('srcdoc');
277 resetViewer.hidden = true;
278 viewerTitle.textContent = 'Protected image';
279 setStatus('Ready');
280 });
281
282 refreshWallets();
283 onSuiWalletChange(refreshWallets);
284 </script>
285 </body>
286
287 </html>