/* ========================================================================= billing.js — the credits & wallet console • fetches /auth/me for billing state (enabled, balance, deposit, key) • renders the key-reveal banner on first signup (?new=1) • renders balance / deposit / key-prefix cards • polls /api/me/balance every 10s for a live feel • gracefully hides if billing isn't configured (unmetered mode) ========================================================================= */ (async function () { 'use strict'; var grid = document.getElementById('billing-grid'); var section = document.getElementById('billing-section'); if (!grid || !section) return; // not on the dashboard page var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var pollTimer = null; var lastBalance = -1; /* ---- escape helpers --------------------------------------------------- */ function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } /* ---- inline SVG icons (no external dep, mobile-robust) ---------------- */ var ICON = { wallet: '', deposit: '', key: '' /* (kept for the reveal banner; key card moved to the hero) */, alert: '' }; /* ---- copy-to-clipboard with feedback ---------------------------------- */ function copyButton(text) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'copy-btn'; btn.textContent = 'Copy'; btn.addEventListener('click', function () { navigator.clipboard.writeText(text).then(function () { btn.classList.add('copied'); btn.textContent = 'Copied!'; setTimeout(function () { btn.classList.remove('copied'); btn.textContent = 'Copy'; }, 1500); }).catch(function () { // fallback for non-secure contexts var ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); btn.classList.add('copied'); btn.textContent = 'Copied!'; } catch (e) {} document.body.removeChild(ta); setTimeout(function () { btn.classList.remove('copied'); btn.textContent = 'Copy'; }, 1500); }); }); return btn; } /* ---- render the key-reveal banner (first signup only) --------------- */ function renderKeyReveal(plaintextKey) { var banner = document.createElement('div'); banner.className = 'key-reveal'; banner.setAttribute('role', 'alert'); banner.innerHTML = '' + '
' + '

Your API key — welcome aboard

' + '

Here’s your key. It’s sk- + your deposit address, so you can always find it again on the dashboard — no need to panic if you lose it. Use it as Authorization: Bearer for API calls outside the browser.

' + '
' + '' + esc(plaintextKey) + '' + '
' + '
' + ''; section.parentNode.insertBefore(banner, section); // insert before the grid section // copy button var keyWrap = banner.querySelector('.kr-key'); keyWrap.appendChild(copyButton(plaintextKey)); // dismiss → clear the one-time reveal server-side + remove the banner banner.querySelector('.kr-dismiss').addEventListener('click', function () { fetch('/auth/clear-reveal', { method: 'POST' }).catch(function () {}); banner.remove(); }); } /* ---- render the billing cards (balance + deposit; key lives in the hero) */ function renderCards(billing) { var bal = billing.balance || 0; var lowCls = bal === 0 ? 'is-zero' : (bal < 50 ? 'is-low' : ''); var flash = (lastBalance >= 0 && lastBalance !== bal) ? ' flash' : ''; lastBalance = bal; // balance card var balCard = document.createElement('div'); balCard.className = 'bill-card ' + lowCls; balCard.innerHTML = ICON.wallet + 'credits' + '' + bal.toLocaleString() + '' + '\u2248 $' + (bal / 10000).toFixed(4) + ' USD'; // deposit card (the wallet address you send ETH to) var depCard = document.createElement('div'); depCard.className = 'bill-card'; depCard.innerHTML = ICON.deposit + 'deposit (Base L2)' + '' + esc(billing.deposit_address) + ''; var depCopy = copyButton(billing.deposit_address); depCopy.style.marginTop = '.4rem'; depCard.appendChild(depCopy); grid.innerHTML = ''; grid.appendChild(balCard); grid.appendChild(depCard); // trigger the flash animation if (flash && !reduceMotion) { var val = balCard.querySelector('.bc-value'); void val.offsetWidth; // force reflow val.classList.add('flash'); } } /* ---- render the "out of credits" state -------------------------------- */ function renderEmpty() { grid.innerHTML = '
' + ICON.alert + '
No credits remaining
' + '
Send ETH to your deposit address above to top up. Credits appear within 60 seconds.
' + '
'; } /* ---- render the transaction history table (ledger) --------------- */ function fmtTime(iso) { if (!iso) return '\u2014'; try { var d = new Date(iso); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch (e) { return iso; } } function renderLedger(entries) { var wrap = document.getElementById('ledger-wrap'); var body = document.getElementById('ledger-body'); var empty = document.getElementById('ledger-empty'); if (!wrap || !body) return; if (!entries || !entries.length) { body.innerHTML = ''; wrap.hidden = false; if (empty) empty.hidden = false; return; } if (empty) empty.hidden = true; // newest first var rows = entries.slice().reverse().slice(0, 50); var html = ''; for (var i = 0; i < rows.length; i++) { var e = rows[i]; var isDeposit = e.type === 'deposit'; var cls = isDeposit ? 'is-deposit' : 'is-burn'; var sign = isDeposit ? '+' : '\u2212'; var detail; if (isDeposit) { detail = (typeof e.eth === 'number' ? e.eth.toFixed(6) : e.eth) + ' ETH' + (e.usd ? ' (\u2248 $' + e.usd + ')' : ''); } else { detail = esc(e.model || 'model') + ' \u00b7 ' + (e.tok_in || 0) + ' in / ' + (e.tok_out || 0) + ' out'; } html += '' + '' + esc(fmtTime(e.ts)) + '' + '' + (isDeposit ? 'deposit' : 'usage') + '' + '' + detail + '' + '' + sign + (e.credits || e.cost || 0).toLocaleString() + '' + ''; } body.innerHTML = html; wrap.hidden = false; } /* ---- poll balance every 10s (live feel without websockets) ------------ */ async function pollBalance() { try { var r = await fetch('/api/me/balance'); if (!r.ok) return; var d = await r.json(); if (d.balance !== lastBalance) { renderCards({ balance: d.balance, deposit_address: grid._deposit || '' }); if (d.balance === 0) renderEmpty(); } } catch (e) { /* network blip — retry next cycle */ } } /* ---- bootstrap -------------------------------------------------------- */ try { var res = await fetch('/auth/me'); if (!res.ok) { section.hidden = true; return; } var me = await res.json(); // billing not configured → hide the section (unmetered mode) if (!me.billing || !me.billing.enabled) { section.hidden = true; return; } // not provisioned yet (shouldn't happen post-callback, but be safe) if (!me.billing.provisioned) { section.hidden = true; return; } // reveal the key on first signup if (me.reveal_key) { renderKeyReveal(me.reveal_key); } // stash deposit + prefix for the poller grid._deposit = me.billing.deposit_address; grid._prefix = me.billing.key_prefix; // render + show renderCards(me.billing); if (me.billing.balance === 0) renderEmpty(); // transaction history renderLedger((me.billing.ledger && me.billing.ledger.entries) || []); section.hidden = false; // start polling pollTimer = setInterval(pollBalance, 10000); } catch (e) { section.hidden = true; // network error — hide gracefully } // stop polling when the page unloads window.addEventListener('beforeunload', function () { if (pollTimer) clearInterval(pollTimer); }); })();