@three-ws/x402-payment-modal 1.1.0 → 1.2.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,506 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>x402 Crypto Paywall — Solana</title>
7
+ <meta name="description" content="Pay $0.01 USDC on Solana to unlock live crypto prices — a demo of the @three-ws/x402-payment-modal npm package." />
8
+
9
+ <!--
10
+ The payment modal is loaded straight from npm (unpkg) so this page exercises
11
+ the EXACT artifact we publish. Pinned to a version per the package's own
12
+ security guidance; bump it to test a new release.
13
+ -->
14
+ <script
15
+ type="module"
16
+ src="https://unpkg.com/@three-ws/x402-payment-modal@1.2.0"
17
+ data-x402-brand-name="Crypto Prices"
18
+ data-x402-brand-url="https://example.com"
19
+ data-x402-footer-note="x402 · Solana · settled onchain"></script>
20
+
21
+ <style>
22
+ :root {
23
+ --bg: #07080c;
24
+ --bg-glow: #15233f;
25
+ --card: #11131b;
26
+ --card-2: #0c0e15;
27
+ --border: #232838;
28
+ --border-hi: #313a52;
29
+ --text: #eef1f7;
30
+ --muted: #99a1b5;
31
+ --faint: #6b7280;
32
+ --accent: #7b8cff;
33
+ --accent-press: #6072f5;
34
+ --sol: #14f195;
35
+ --sol-2: #9945ff;
36
+ --ok: #2fd6a0;
37
+ --err: #ff6b6b;
38
+ --warn: #ffcf5c;
39
+ --radius: 16px;
40
+ }
41
+ * { box-sizing: border-box; }
42
+ html, body { min-height: 100%; }
43
+ body {
44
+ margin: 0;
45
+ background:
46
+ radial-gradient(900px 480px at 15% -10%, rgba(153,69,255,0.14) 0%, transparent 60%),
47
+ radial-gradient(900px 520px at 100% 0%, rgba(20,241,149,0.10) 0%, transparent 55%),
48
+ var(--bg);
49
+ color: var(--text);
50
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
51
+ -webkit-font-smoothing: antialiased;
52
+ padding: 40px 18px 64px;
53
+ }
54
+ .wrap { max-width: 760px; margin: 0 auto; }
55
+
56
+ header.hero { margin-bottom: 26px; }
57
+ .brandline { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
58
+ .dot { width: 10px; height: 10px; border-radius: 99px; background: linear-gradient(135deg, var(--sol) 0%, var(--sol-2) 100%); box-shadow: 0 0 16px rgba(20,241,149,0.55); }
59
+ .brandline b { letter-spacing: -0.01em; }
60
+ .pill {
61
+ margin-left: auto; font-size: 11.5px; color: var(--muted);
62
+ border: 1px solid var(--border); border-radius: 99px; padding: 4px 10px;
63
+ display: inline-flex; align-items: center; gap: 6px; background: var(--card-2);
64
+ }
65
+ .pill .live { width: 6px; height: 6px; border-radius: 99px; background: var(--muted); }
66
+ .pill.ready .live { background: var(--sol); box-shadow: 0 0 8px var(--sol); }
67
+ h1 { font-size: 30px; line-height: 1.12; margin: 0 0 10px; letter-spacing: -0.025em; }
68
+ h1 .grad { background: linear-gradient(100deg, var(--sol) 0%, var(--accent) 55%, var(--sol-2) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
69
+ p.lede { margin: 0; color: var(--muted); font-size: 16px; max-width: 56ch; }
70
+
71
+ .card {
72
+ background: linear-gradient(180deg, var(--card) 0%, var(--card-2) 100%);
73
+ border: 1px solid var(--border);
74
+ border-radius: var(--radius);
75
+ padding: 22px;
76
+ margin-top: 18px;
77
+ }
78
+ .step { display: flex; align-items: baseline; gap: 10px; margin: 0 0 14px; }
79
+ .step .n {
80
+ flex: none; width: 22px; height: 22px; border-radius: 99px; font-size: 12px; font-weight: 700;
81
+ display: grid; place-items: center; background: #1b2030; color: var(--muted); border: 1px solid var(--border-hi);
82
+ }
83
+ .step.done .n { background: rgba(47,214,160,0.15); color: var(--ok); border-color: rgba(47,214,160,0.4); }
84
+ .step h2 { font-size: 14px; margin: 0; letter-spacing: 0.01em; }
85
+ .step .sub { font-size: 12.5px; color: var(--muted); margin: 2px 0 0; }
86
+
87
+ label.fld { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
88
+ .inrow { display: flex; gap: 10px; flex-wrap: wrap; }
89
+ input[type=text] {
90
+ flex: 1 1 320px; min-width: 0;
91
+ background: #0a0c12; border: 1px solid var(--border-hi); border-radius: 10px;
92
+ color: var(--text); font: inherit; font-size: 14px; padding: 11px 13px;
93
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: -0.01em;
94
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
95
+ }
96
+ input[type=text]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(123,140,255,0.18); }
97
+ input[type=text].bad { border-color: var(--err); box-shadow: 0 0 0 3px rgba(255,107,107,0.16); }
98
+
99
+ button {
100
+ appearance: none; border: 0; border-radius: 10px; padding: 11px 16px;
101
+ font: inherit; font-weight: 600; color: #fff; background: var(--accent);
102
+ cursor: pointer; transition: transform 0.07s ease, background 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
103
+ }
104
+ button:hover { background: var(--accent-press); }
105
+ button:active { transform: translateY(1px); }
106
+ button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
107
+ button.ghost { background: #1a1f2e; color: var(--text); border: 1px solid var(--border-hi); }
108
+ button.ghost:hover { background: #222838; }
109
+ button[disabled] { opacity: 0.45; cursor: not-allowed; }
110
+
111
+ .cfg-status { margin-top: 12px; font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; min-height: 20px; }
112
+ .cfg-status .tick { color: var(--ok); }
113
+ .cfg-status code { background: #0a0c12; border: 1px solid var(--border); border-radius: 6px; padding: 1px 7px; font-size: 12px; }
114
+ .cfg-status.err { color: var(--err); }
115
+
116
+ .chips { display: flex; gap: 8px; flex-wrap: wrap; }
117
+ .chip {
118
+ appearance: none; border: 1px solid var(--border-hi); background: #0c0f17; color: var(--muted);
119
+ border-radius: 99px; padding: 8px 13px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
120
+ display: inline-flex; align-items: center; gap: 7px; transition: all 0.13s ease;
121
+ }
122
+ .chip .tk { color: var(--faint); font-size: 11px; font-weight: 500; }
123
+ .chip:hover { border-color: var(--accent); color: var(--text); }
124
+ .chip[aria-pressed=true] { background: rgba(123,140,255,0.16); border-color: var(--accent); color: #fff; }
125
+ .chip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
126
+
127
+ .pay {
128
+ width: 100%; margin-top: 4px; padding: 15px 18px; font-size: 15.5px;
129
+ background: linear-gradient(100deg, var(--sol-2) 0%, var(--accent) 100%);
130
+ display: flex; align-items: center; justify-content: center; gap: 10px;
131
+ }
132
+ .pay:hover { background: linear-gradient(100deg, #8a3bff 0%, var(--accent-press) 100%); }
133
+ .pay .price { font-variant-numeric: tabular-nums; opacity: 0.95; }
134
+ .paynote { text-align: center; font-size: 12px; color: var(--muted); margin: 10px 0 0; }
135
+
136
+ /* results */
137
+ #results { margin-top: 18px; }
138
+ .res-shell {
139
+ border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--card-2);
140
+ }
141
+ .res-empty, .res-error { padding: 34px 22px; text-align: center; color: var(--muted); }
142
+ .res-empty .big { font-size: 30px; margin-bottom: 8px; opacity: 0.65; }
143
+ .res-error { color: var(--err); }
144
+ .res-error .retry { margin-top: 14px; }
145
+
146
+ table { width: 100%; border-collapse: collapse; }
147
+ thead th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); padding: 13px 18px; border-bottom: 1px solid var(--border); font-weight: 600; }
148
+ thead th.num, tbody td.num { text-align: right; }
149
+ tbody td { padding: 14px 18px; border-bottom: 1px solid #181b25; font-variant-numeric: tabular-nums; }
150
+ tbody tr:last-child td { border-bottom: 0; }
151
+ .coin { display: flex; align-items: center; gap: 11px; }
152
+ .coin .sym { width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center; font-size: 11px; font-weight: 700; background: #1a1f2e; color: var(--text); border: 1px solid var(--border-hi); }
153
+ .coin .nm { font-weight: 600; }
154
+ .coin .id { font-size: 11.5px; color: var(--faint); }
155
+ .price-cell { font-weight: 600; }
156
+ .chg.up { color: var(--ok); }
157
+ .chg.down { color: var(--err); }
158
+ .cap { color: var(--muted); }
159
+
160
+ .receipt { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 13px 18px; background: rgba(47,214,160,0.07); border-top: 1px solid var(--border); font-size: 13px; }
161
+ .receipt .ok { color: var(--ok); font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
162
+ .receipt .kv { color: var(--muted); }
163
+ .receipt .kv b { color: var(--text); font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
164
+ .receipt a { color: var(--sol); text-decoration: none; margin-left: auto; }
165
+ .receipt a:hover { text-decoration: underline; }
166
+
167
+ /* skeleton */
168
+ .sk-row { display: flex; align-items: center; gap: 11px; padding: 14px 18px; border-bottom: 1px solid #181b25; }
169
+ .sk { background: linear-gradient(90deg, #161a25 25%, #1f2433 50%, #161a25 75%); background-size: 200% 100%; animation: sh 1.2s infinite linear; border-radius: 6px; }
170
+ @keyframes sh { to { background-position: -200% 0; } }
171
+ .sk.circle { width: 34px; height: 34px; border-radius: 99px; flex: none; }
172
+ .sk.line { height: 12px; }
173
+
174
+ footer { margin-top: 30px; color: var(--muted); font-size: 12.5px; }
175
+ footer .how { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 16px; }
176
+ footer .how div { background: var(--card-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; }
177
+ footer .how b { color: var(--text); display: block; font-size: 12px; margin-bottom: 3px; }
178
+ footer a { color: var(--accent); text-decoration: none; }
179
+ footer a:hover { text-decoration: underline; }
180
+ footer .fine { line-height: 1.7; }
181
+
182
+ @media (max-width: 560px) {
183
+ h1 { font-size: 24px; }
184
+ thead th.cap-col, tbody td.cap-col { display: none; }
185
+ .receipt a { margin-left: 0; }
186
+ }
187
+ @media (prefers-reduced-motion: reduce) { .sk { animation: none; } }
188
+ </style>
189
+ </head>
190
+
191
+ <body>
192
+ <div class="wrap">
193
+ <header class="hero">
194
+ <div class="brandline">
195
+ <span class="dot" aria-hidden="true"></span>
196
+ <b>x402 demo</b>
197
+ <span class="pill" id="verPill"><span class="live"></span><span id="verText">loading modal…</span></span>
198
+ </div>
199
+ <h1>Pay-per-call <span class="grad">crypto prices</span>,<br/>settled on Solana.</h1>
200
+ <p class="lede">
201
+ A live demo of <code>@three-ws/x402-payment-modal</code> — the modal we ship on npm.
202
+ Set a payout wallet, pick coins, and pay $0.01 USDC with Phantom to unlock real
203
+ CoinGecko prices. One click; no wallet code.
204
+ </p>
205
+ </header>
206
+
207
+ <!-- Step 1 — runtime payout -->
208
+ <section class="card" id="cfgCard">
209
+ <div class="step" id="step1">
210
+ <span class="n">1</span>
211
+ <div>
212
+ <h2>Set your payout wallet</h2>
213
+ <p class="sub">Set at runtime — never from a <code>.env</code> or the source. This is where the USDC lands.</p>
214
+ </div>
215
+ </div>
216
+ <label class="fld" for="payTo">Solana address (base58)</label>
217
+ <div class="inrow">
218
+ <input id="payTo" type="text" inputmode="text" autocomplete="off" spellcheck="false"
219
+ placeholder="e.g. 5xY…your Solana wallet…Jpump" aria-describedby="cfgStatus" />
220
+ <button id="saveCfg" class="ghost" type="button">Save</button>
221
+ </div>
222
+ <div class="cfg-status" id="cfgStatus" role="status" aria-live="polite">No payout wallet set yet.</div>
223
+ </section>
224
+
225
+ <!-- Step 2 — pick coins -->
226
+ <section class="card">
227
+ <div class="step">
228
+ <span class="n">2</span>
229
+ <div>
230
+ <h2>Pick coins to price</h2>
231
+ <p class="sub">Pulled live from CoinGecko (free, no key) once payment settles.</p>
232
+ </div>
233
+ </div>
234
+ <div class="chips" id="chips" role="group" aria-label="Coins to price"></div>
235
+ </section>
236
+
237
+ <!-- Step 3 — pay -->
238
+ <section class="card">
239
+ <div class="step">
240
+ <span class="n">3</span>
241
+ <div>
242
+ <h2>Pay &amp; unlock</h2>
243
+ <p class="sub">The modal handles connect → sign → settle. USDC moves on Solana mainnet.</p>
244
+ </div>
245
+ </div>
246
+ <button id="payBtn" class="pay" type="button" disabled>
247
+ <span>Pay &amp; get live prices</span>
248
+ <span class="price">· $0.01 USDC</span>
249
+ </button>
250
+ <p class="paynote" id="payNote">Set a payout wallet and pick at least one coin to enable.</p>
251
+ </section>
252
+
253
+ <!-- Results -->
254
+ <section id="results" aria-live="polite"></section>
255
+
256
+ <footer>
257
+ <div class="how">
258
+ <div><b>402 challenge</b>The endpoint answers unpaid calls with an x402 challenge listing the Solana price.</div>
259
+ <div><b>Phantom signs</b>The modal builds the USDC transfer server-side and Phantom signs it.</div>
260
+ <div><b>PayAI settles</b>A facilitator co-signs the fee &amp; broadcasts — you hold no keys, pay no gas.</div>
261
+ <div><b>Data unlocked</b>On settle, the server returns live CoinGecko prices + an on-chain receipt.</div>
262
+ </div>
263
+ <p class="fine">
264
+ Modal loaded from <a href="https://www.npmjs.com/package/@three-ws/x402-payment-modal" target="_blank" rel="noopener">npm</a> ·
265
+ prices from <a href="https://www.coingecko.com" target="_blank" rel="noopener">CoinGecko</a> ·
266
+ settled via <a href="https://facilitator.payai.network" target="_blank" rel="noopener">PayAI</a>.
267
+ Real micropayment on Solana mainnet — needs Phantom with a little USDC.
268
+ </p>
269
+ </footer>
270
+ </div>
271
+
272
+ <script>
273
+ (function () {
274
+ 'use strict';
275
+ const $ = (id) => document.getElementById(id);
276
+ const SOLSCAN = 'https://solscan.io/tx/';
277
+ const ADDR_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
278
+
279
+ const state = { configured: false, coins: [], selected: new Set(), busy: false, last: null };
280
+
281
+ // ── wait for the npm modal to attach window.X402 ──────────────────────
282
+ function whenModalReady() {
283
+ return new Promise((resolve, reject) => {
284
+ if (window.X402) return resolve(window.X402);
285
+ let waited = 0;
286
+ const t = setInterval(() => {
287
+ if (window.X402) { clearInterval(t); resolve(window.X402); }
288
+ else if ((waited += 50) > 10000) { clearInterval(t); reject(new Error('payment modal failed to load from npm')); }
289
+ }, 50);
290
+ });
291
+ }
292
+
293
+ // ── helpers ───────────────────────────────────────────────────────────
294
+ const trunc = (a) => (a ? a.slice(0, 5) + '…' + a.slice(-5) : '');
295
+ function fmtUsd(n) {
296
+ if (n == null) return '—';
297
+ if (n >= 1000) return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
298
+ if (n >= 1) return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
299
+ return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 6 });
300
+ }
301
+ function fmtCap(n) {
302
+ if (n == null) return '—';
303
+ if (n >= 1e12) return '$' + (n / 1e12).toFixed(2) + 'T';
304
+ if (n >= 1e9) return '$' + (n / 1e9).toFixed(2) + 'B';
305
+ if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M';
306
+ return fmtUsd(n);
307
+ }
308
+ function fmtPct(n) {
309
+ if (n == null) return { txt: '—', cls: '' };
310
+ const s = (n >= 0 ? '+' : '') + n.toFixed(2) + '%';
311
+ return { txt: s, cls: n >= 0 ? 'up' : 'down' };
312
+ }
313
+ function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
314
+
315
+ // ── config (step 1) ─────────────────────────────────────────────────────
316
+ function setCfgStatus(html, cls) {
317
+ const el = $('cfgStatus');
318
+ el.className = 'cfg-status' + (cls ? ' ' + cls : '');
319
+ el.innerHTML = html;
320
+ }
321
+ function markConfigured(payTo) {
322
+ state.configured = true;
323
+ $('step1').classList.add('done');
324
+ setCfgStatus('<span class="tick">✓</span> Paying out to <code>' + esc(trunc(payTo)) + '</code>');
325
+ refreshPayBtn();
326
+ }
327
+ async function saveConfig() {
328
+ const v = $('payTo').value.trim();
329
+ const input = $('payTo');
330
+ if (!ADDR_RE.test(v)) {
331
+ input.classList.add('bad');
332
+ setCfgStatus('That doesn’t look like a Solana address (base58, 32–44 chars).', 'err');
333
+ return;
334
+ }
335
+ input.classList.remove('bad');
336
+ $('saveCfg').disabled = true;
337
+ setCfgStatus('Saving…');
338
+ try {
339
+ const r = await fetch('/api/config', {
340
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ payTo: v }),
341
+ });
342
+ const d = await r.json();
343
+ if (!r.ok) throw new Error(d.error_description || 'could not save');
344
+ localStorage.setItem('x402demo.payTo', v);
345
+ markConfigured(d.payTo);
346
+ } catch (err) {
347
+ setCfgStatus(esc(err.message), 'err');
348
+ } finally {
349
+ $('saveCfg').disabled = false;
350
+ }
351
+ }
352
+
353
+ // ── coin chips (step 2) ──────────────────────────────────────────────────
354
+ function renderChips() {
355
+ const host = $('chips');
356
+ host.innerHTML = '';
357
+ const defaults = new Set(['bitcoin', 'ethereum', 'solana']);
358
+ state.coins.forEach((c) => {
359
+ const on = defaults.has(c.id);
360
+ if (on) state.selected.add(c.id);
361
+ const b = document.createElement('button');
362
+ b.type = 'button';
363
+ b.className = 'chip';
364
+ b.setAttribute('aria-pressed', String(on));
365
+ b.dataset.id = c.id;
366
+ b.innerHTML = esc(c.name) + ' <span class="tk">' + esc(c.symbol) + '</span>';
367
+ b.addEventListener('click', () => {
368
+ const sel = b.getAttribute('aria-pressed') === 'true';
369
+ b.setAttribute('aria-pressed', String(!sel));
370
+ if (sel) state.selected.delete(c.id); else state.selected.add(c.id);
371
+ refreshPayBtn();
372
+ });
373
+ host.appendChild(b);
374
+ });
375
+ refreshPayBtn();
376
+ }
377
+
378
+ // ── pay button gating (step 3) ───────────────────────────────────────────
379
+ function refreshPayBtn() {
380
+ const ready = state.configured && state.selected.size > 0 && !state.busy;
381
+ $('payBtn').disabled = !ready;
382
+ const note = $('payNote');
383
+ if (state.busy) note.textContent = 'Payment in progress — finish in the modal…';
384
+ else if (!state.configured) note.textContent = 'Set a payout wallet (step 1) to enable.';
385
+ else if (state.selected.size === 0) note.textContent = 'Pick at least one coin (step 2) to enable.';
386
+ else note.textContent = 'Pays $0.01 USDC on Solana mainnet · needs Phantom.';
387
+ }
388
+
389
+ // ── results rendering ────────────────────────────────────────────────────
390
+ function renderEmpty() {
391
+ $('results').innerHTML =
392
+ '<div class="res-shell"><div class="res-empty"><div class="big">🔒</div>' +
393
+ 'Pick coins and pay to reveal live prices.</div></div>';
394
+ }
395
+ function renderLoading() {
396
+ const rows = Array.from(state.selected).slice(0, 6).map(() =>
397
+ '<div class="sk-row"><div class="sk circle"></div>' +
398
+ '<div style="flex:1"><div class="sk line" style="width:38%;margin-bottom:7px"></div>' +
399
+ '<div class="sk line" style="width:22%;height:10px"></div></div>' +
400
+ '<div class="sk line" style="width:64px"></div></div>').join('');
401
+ $('results').innerHTML = '<div class="res-shell">' + rows + '</div>';
402
+ }
403
+ function renderError(msg) {
404
+ $('results').innerHTML =
405
+ '<div class="res-shell"><div class="res-error">⚠ ' + esc(msg) +
406
+ '<div class="retry"><button class="ghost" id="retryBtn" type="button">Try again</button></div></div></div>';
407
+ const rb = $('retryBtn');
408
+ if (rb) rb.addEventListener('click', startPayment);
409
+ }
410
+ function renderResults(data, payment) {
411
+ const rows = data.coins.map((c) => {
412
+ const pct = fmtPct(c.change_24h);
413
+ return '<tr><td><div class="coin">' +
414
+ '<span class="sym">' + esc(c.symbol.slice(0, 4)) + '</span>' +
415
+ '<span><span class="nm">' + esc(c.name) + '</span><br/><span class="id">' + esc(c.id) + '</span></span>' +
416
+ '</div></td>' +
417
+ '<td class="num price-cell">' + fmtUsd(c.price_usd) + '</td>' +
418
+ '<td class="num chg ' + pct.cls + '">' + pct.txt + '</td>' +
419
+ '<td class="num cap cap-col">' + fmtCap(c.market_cap) + '</td></tr>';
420
+ }).join('');
421
+
422
+ let receipt = '';
423
+ if (payment && (payment.transaction || payment.payer)) {
424
+ const tx = payment.transaction;
425
+ receipt = '<div class="receipt"><span class="ok">✓ Paid</span>' +
426
+ '<span class="kv">network <b>' + esc(payment.network || 'solana') + '</b></span>' +
427
+ (payment.payer ? '<span class="kv">payer <b>' + esc(trunc(payment.payer)) + '</b></span>' : '') +
428
+ (tx ? '<a href="' + SOLSCAN + esc(tx) + '" target="_blank" rel="noopener">view tx ↗</a>' : '') +
429
+ '</div>';
430
+ }
431
+
432
+ $('results').innerHTML =
433
+ '<div class="res-shell"><table><thead><tr>' +
434
+ '<th>Coin</th><th class="num">Price</th><th class="num">24h</th><th class="num cap-col">Mkt cap</th>' +
435
+ '</tr></thead><tbody>' + rows + '</tbody></table>' + receipt +
436
+ '<div style="padding:10px 18px;font-size:11.5px;color:var(--muted);border-top:1px solid var(--border)">' +
437
+ 'as of ' + esc(new Date(data.asof).toLocaleTimeString()) + ' · source: ' + esc(data.source) + '</div></div>';
438
+ }
439
+
440
+ // ── payment flow ─────────────────────────────────────────────────────────
441
+ async function startPayment() {
442
+ if (!state.configured || state.selected.size === 0 || state.busy) return;
443
+ const ids = Array.from(state.selected);
444
+ state.busy = true;
445
+ refreshPayBtn();
446
+ renderLoading();
447
+ try {
448
+ const out = await window.X402.pay({
449
+ endpoint: '/api/paid/crypto',
450
+ method: 'POST',
451
+ body: { ids },
452
+ merchant: 'Crypto Prices',
453
+ action: 'Live prices · ' + ids.length + ' coin' + (ids.length > 1 ? 's' : ''),
454
+ });
455
+ state.last = { data: out.result, payment: out.payment };
456
+ renderResults(out.result, out.payment);
457
+ } catch (err) {
458
+ // Dismissing the modal rejects with code 'cancelled' — restore whatever
459
+ // was on screen before instead of showing a scary error.
460
+ if (err && err.code === 'cancelled') {
461
+ if (state.last) renderResults(state.last.data, state.last.payment); else renderEmpty();
462
+ } else {
463
+ renderError((err && err.message) || 'payment failed');
464
+ }
465
+ } finally {
466
+ state.busy = false;
467
+ refreshPayBtn();
468
+ }
469
+ }
470
+
471
+ // ── boot ──────────────────────────────────────────────────────────────────
472
+ async function boot() {
473
+ renderEmpty();
474
+ $('saveCfg').addEventListener('click', saveConfig);
475
+ $('payTo').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveConfig(); });
476
+ $('payBtn').addEventListener('click', startPayment);
477
+
478
+ // prefill last-used address (convenience only; server is source of truth)
479
+ const saved = localStorage.getItem('x402demo.payTo');
480
+ if (saved) $('payTo').value = saved;
481
+
482
+ // load server config + coin catalog
483
+ try {
484
+ const cfg = await (await fetch('/api/config')).json();
485
+ state.coins = cfg.coins || [];
486
+ renderChips();
487
+ if (cfg.configured && cfg.payTo) { $('payTo').value = cfg.payTo; markConfigured(cfg.payTo); }
488
+ } catch (_) { /* offline — chips stay empty, page still explains itself */ }
489
+
490
+ // confirm the npm modal is live
491
+ try {
492
+ const X = await whenModalReady();
493
+ X.configure({ checkoutOrigin: location.origin, brand: { name: 'Crypto Prices', url: 'https://example.com' } });
494
+ $('verPill').classList.add('ready');
495
+ $('verText').textContent = 'modal v' + (X.version || '?') + ' · live from npm';
496
+ } catch (err) {
497
+ $('verText').textContent = 'modal failed to load';
498
+ renderError(err.message);
499
+ }
500
+ }
501
+
502
+ boot();
503
+ })();
504
+ </script>
505
+ </body>
506
+ </html>