labs / static /js /billing.js
3v324v23's picture
deploy: unified router + dreamy website (2026-06-16T09:46:52Z)
c1a683f
Raw
History Blame Contribute Delete
10.8 kB
/* =========================================================================
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/* ---- inline SVG icons (no external dep, mobile-robust) ---------------- */
var ICON = {
wallet: '<svg class="bc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>',
deposit: '<svg class="bc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>',
key: '<svg class="bc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3"/></svg>' /* (kept for the reveal banner; key card moved to the hero) */,
alert: '<svg class="bc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
};
/* ---- 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 =
'<svg class="kr-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3"/></svg>' +
'<div class="kr-body">' +
'<h3>Your API key — welcome aboard</h3>' +
'<p>Here&rsquo;s your key. It&rsquo;s <code>sk-</code> + your deposit address, so you can always find it again on the dashboard — no need to panic if you lose it. Use it as <code>Authorization: Bearer</code> for API calls outside the browser.</p>' +
'<div class="kr-key">' +
'<code>' + esc(plaintextKey) + '</code>' +
'</div>' +
'</div>' +
'<button type="button" class="kr-dismiss" aria-label="Dismiss" title="Got it">\u00d7</button>';
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 +
'<span class="bc-label">credits</span>' +
'<span class="bc-value' + flash + '">' + bal.toLocaleString() + '</span>' +
'<span class="bc-sub">\u2248 $' + (bal / 10000).toFixed(4) + ' USD</span>';
// deposit card (the wallet address you send ETH to)
var depCard = document.createElement('div');
depCard.className = 'bill-card';
depCard.innerHTML =
ICON.deposit +
'<span class="bc-label">deposit (Base L2)</span>' +
'<span class="bc-sub" style="margin-top:.2rem;"><code>' + esc(billing.deposit_address) + '</code></span>';
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 =
'<div class="bill-card is-zero" style="grid-column:1/-1;flex-direction:row;align-items:center;gap:1rem;">' +
ICON.alert +
'<div><div class="bc-value" style="font-size:1.1rem;">No credits remaining</div>' +
'<div class="bc-sub" style="text-transform:none;letter-spacing:0;">Send ETH to your deposit address above to top up. Credits appear within 60 seconds.</div></div>' +
'</div>';
}
/* ---- 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 +=
'<tr class="' + cls + '">' +
'<td>' + esc(fmtTime(e.ts)) + '</td>' +
'<td><span class="tag ' + cls + '">' + (isDeposit ? 'deposit' : 'usage') + '</span></td>' +
'<td class="ledger-detail">' + detail + '</td>' +
'<td class="num">' + sign + (e.credits || e.cost || 0).toLocaleString() + '</td>' +
'</tr>';
}
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);
});
})();