sirius/js/profile-menu.js
Local Dev b5eea9422b feat(sirius-x): prices in BCH, amounts in bits, live BCH/USD rate from several exchanges
Every dollar figure on the site came from a hard-coded 250,000 sats per
dollar, which implies $400 per BCH; the market is near $250, so name
prices, TLD fees and sale listings were shown about 60% too cheap in
dollars. The gateway now serves /api/price: the median of Coinbase,
Kraken, Bitstamp, Binance and CoinGecko public tickers, no keys, cached
60 s, last good answer kept if every source fails. The site reads it
first, queries the same tickers itself if the gateway is unreachable,
remembers the last rate per browser, and only falls back to a constant
for the very first paint. Pages repaint when the rate arrives, and the
rate line says where it came from and how old it is.

Units: satoshi no longer appear anywhere. Sale prices, the seller's
input and the buy dialog are in BCH with the dollar figure beside
them; balances, fees and dust are in bits (1 bit = 100 satoshi).
2026-09-20 18:18:43 +02:00

343 lines
18 KiB
JavaScript

// Transforms the "🔑 Sign in" nav pill on every Sirius.X page into a wallet
// dropdown. Reads siriusProfile from localStorage; when null shows the three
// onboarding actions (New wallet / Import a phrase / WizardConnect), when
// present shows the signed-in menu (address, My names, Admin, Sign out).
//
// Each onboarding action deep-links portal.html?mode=new|import|wc; portal.html
// picks that up and immediately runs the matching flow — no extra click on
// arrival. Signed-in state changes flip the button label without a reload:
// the storage event covers cross-tab, a custom 'siriusProfileChanged' event
// covers same-tab (portal.html and tld.html both dispatch it on write/clear).
(() => {
const anchor = document.querySelector(".topnav a.portal");
if (!anchor) return;
// Base URL for local links — the nav's brand link is already set with the
// right relative path per page (./ at root, ../ in subdirs). Reuse it so
// absolute /sirius-x/... paths (which break under the BCNR route) never
// slip in.
const PORTAL_URL = anchor.getAttribute("href") || "./portal.html";
const BASE = PORTAL_URL.replace(/portal\.html.*$/, "");
const ADMIN_URL = BASE + "admin/";
const readProfile = () => {
try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; }
};
// Build wrapper + button + (empty) menu around the existing pill.
const wrap = document.createElement("div");
wrap.className = "profile-wrap";
const btn = document.createElement("button");
btn.type = "button";
btn.className = anchor.className; // reuse .portal (and .here if present)
btn.setAttribute("aria-haspopup", "menu");
btn.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.className = "profile-menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
// Header "Dashboard" button — sits left of the wallet pill and is the one
// way into names, TLDs, the web-builder and (for the operator) the admin
// panel. Always visible: signed out it lands on the portal's sign-in.
const dash = document.createElement("a");
dash.className = "dash-link" + (/\/portal\.html(?:$|[?#])/.test(location.pathname) ? " here" : "");
dash.href = PORTAL_URL;
dash.innerHTML = `<span aria-hidden="true">▦</span> Dashboard`;
const readWalletInfo = () => {
try { return JSON.parse(localStorage.getItem("siriusWalletInfo") || "null"); } catch { return null; }
};
const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
// ---------- render ----------
// Re-renders the button label AND the menu innerHTML from the current
// profile in localStorage. Called at load, on every open(), and whenever
// another tab or this tab updates the storage key.
function render() {
const p = readProfile();
if (p) {
const dot = `<span class="wallet-dot ok" aria-hidden="true"></span>`;
const label = shortAddr(p.address);
const sourceBadge = p.source === "wc"
? `<span class="wallet-src">WC</span>`
: `<span class="wallet-src">seed</span>`;
btn.innerHTML = `${dot}<span class="wallet-label">${escapeAttr(label)}</span>${sourceBadge}<span class="wallet-caret" aria-hidden="true">▾</span>`;
btn.setAttribute("aria-label", `Wallet ${label}, signed in via ${p.source || "seed"}`);
// Wallet-only menu: identity, balance, receive, settings, sign out.
// Names/TLDs/admin live behind the Dashboard button next to this one.
const info = readWalletInfo();
const bal = info?.sats != null ? (window.siriusPricing?.formatBits ? window.siriusPricing.formatBits(info.sats) : `${(Number(info.sats) / 100).toLocaleString("en-US")} bits`) : "—";
const held = info ? `${info.names ?? 0} name${info.names === 1 ? "" : "s"} · ${info.tlds ?? 0} TLD${info.tlds === 1 ? "" : "s"}` : "open the dashboard to load";
menu.innerHTML = `
<div class="profile-header">
<span class="profile-hint">Signed in via ${escapeAttr(p.source === "wc" ? "WizardConnect" : "seed phrase")} · chipnet</span>
<code>${escapeAttr(p.address)}</code>
</div>
<div class="wallet-stats">
<div><span class="ws-k">Balance</span><span class="ws-v">${escapeAttr(bal)}</span></div>
<div><span class="ws-k">Holdings</span><span class="ws-v">${escapeAttr(held)}</span></div>
</div>
<a href="#" role="menuitem" data-action="copy" data-copy="${escapeAttr(p.address)}">
<span class="mi-ic">⧉</span>
<span class="mi-body"><b>Copy address</b><span class="mi-hint">receive coins or names here</span></span>
</a>
${p.tokenAddress ? `<a href="#" role="menuitem" data-action="copy" data-copy="${escapeAttr(p.tokenAddress)}">
<span class="mi-ic">🎟</span>
<span class="mi-body"><b>Copy token address</b><span class="mi-hint">where name certificates are minted</span></span>
</a>` : ""}
<a href="https://tbch.googol.cash/" target="_blank" rel="noopener" role="menuitem">
<span class="mi-ic">🚰</span>
<span class="mi-body"><b>Get test coins</b><span class="mi-hint">chipnet faucet</span></span>
</a>
<a href="${PORTAL_URL}#settings" role="menuitem">
<span class="mi-ic">⚙</span>
<span class="mi-body"><b>Wallet settings</b><span class="mi-hint">session, PIN, forget device</span></span>
</a>
<hr>
<a href="#" role="menuitem" data-action="signout">
<span class="mi-ic">🚪</span>
<span class="mi-body"><b>Sign out</b><span class="mi-hint">clear this browser</span></span>
</a>
`;
} else {
const dot = `<span class="wallet-dot" aria-hidden="true"></span>`;
btn.innerHTML = `${dot}<span class="wallet-label">Wallet</span><span class="wallet-caret" aria-hidden="true">▾</span>`;
btn.setAttribute("aria-label", "Wallet — not signed in");
// If a saved encrypted wallet already exists in this browser (created
// in a previous session), surface an Unlock option as the first item so
// users can sign back into the same wallet the mint flow would use —
// instead of hunting for it inside the Register modal's wallet-choice.
const hasSaved = (() => {
try { return !!localStorage.getItem("bns.wallet.v1"); } catch { return false; }
})();
const unlockItem = hasSaved ? `
<a href="#" role="menuitem" data-action="unlock">
<span class="mi-ic">🔓</span>
<span class="mi-body"><b>Unlock my wallet</b><span class="mi-hint">one you already created on this device</span></span>
</a>
<hr>
` : "";
const headerHint = hasSaved
? `<span class="profile-hint">Locked wallet on this device</span><span class="profile-sub">Unlock or pick another action</span>`
: `<span class="profile-hint">No wallet yet</span><span class="profile-sub">Pick how to sign in</span>`;
// Four direct actions — each opens the shared modal at the matching
// step (Unlock/Create/Import/WizardConnect) with tabs across the top
// of the modal so the user can switch between them without going back
// to a landing screen.
menu.innerHTML = `
<div class="profile-header">${headerHint}</div>
${unlockItem}
<a href="#" role="menuitem" data-action="new">
<span class="mi-ic">🆕</span>
<span class="mi-body"><b>Create a wallet</b><span class="mi-hint">generate a fresh 12-word phrase (sign up)</span></span>
</a>
<a href="#" role="menuitem" data-action="import">
<span class="mi-ic">📥</span>
<span class="mi-body"><b>Import a wallet</b><span class="mi-hint">seed phrase you already have (sign in)</span></span>
</a>
<a href="#" role="menuitem" data-action="wc">
<span class="mi-ic">🔗</span>
<span class="mi-body"><b>WizardConnect</b><span class="mi-hint">Cashonize / Paytaca via QR</span></span>
</a>
`;
}
}
render();
// ---------- open/close ----------
// Opening the menu asks the dashboard (when it is the current page) for a
// fresh electrum balance; the numbers repaint when it arrives. Other pages
// show the last figure the dashboard or a sign-in wrote, with its age.
const open = () => {
render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true");
if (typeof window.siriusRefreshBalance === "function") { try { window.siriusRefreshBalance(); } catch {} }
};
window.addEventListener("sirius:balance", () => { if (!menu.hidden) render(); });
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) close(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
// Cross-tab (storage event fires only in other tabs) + same-tab (portal.html
// dispatches this after writing/clearing) — either wakes render().
window.addEventListener("storage", (e) => { if (e.key === "siriusProfile") render(); });
window.addEventListener("siriusProfileChanged", render);
// ---------- menu actions ----------
// Sign-out clears siriusProfile; the wallet-onboarding items (new/import/wc)
// open the shared mint modal inline (register-flow.js). If the page did not
// ship register-flow.js in its own <script>, load it on demand.
const FLOW_URL = "js/register-flow.js?v=20260907signin";
async function ensureFlow() {
if (typeof window.siriusSignInWallet === "function") return true;
try {
// Same base as the nav portal anchor — works from root pages and
// subdirs equally (../ vs ./ handled by BASE).
await import(BASE + FLOW_URL);
// Its top-level code exposes the global synchronously after the
// module graph resolves; still, wait a tick for definiteness.
for (let i = 0; i < 50 && typeof window.siriusSignInWallet !== "function"; i++) {
await new Promise((r) => setTimeout(r, 30));
}
return typeof window.siriusSignInWallet === "function";
} catch { return false; }
}
menu.addEventListener("click", async (e) => {
const t = e.target.closest("[data-action]");
if (!t) return;
e.preventDefault();
const action = t.dataset.action;
if (action === "copy") {
try { await navigator.clipboard?.writeText(t.dataset.copy || ""); } catch {}
const b = t.querySelector("b"); const orig = b?.textContent;
if (b) { b.textContent = "Copied"; setTimeout(() => { b.textContent = orig; }, 1200); }
return;
}
if (action === "signout") {
try { localStorage.removeItem("siriusProfile"); } catch {}
// Also wipe the PIN blob so a signed-out device can't be quick-
// unlocked with a stale PIN. The password-encrypted BuiltInWallet
// blob (bns.wallet.v1) stays — the user's device, the user's data;
// they'd remove it explicitly via 'forget wallet' in a future
// settings screen.
try { window.siriusPin?.clear(); } catch {}
try { delete window.siriusWallet; } catch { window.siriusWallet = null; }
// End the persistent device session too (js/session.js) — otherwise
// the next visit would sign straight back in.
try { await window.siriusSession?.forget?.(); } catch {}
window.dispatchEvent(new Event("siriusProfileChanged"));
// Reload so any signed-in section on the current page (portal, tld) resets.
location.reload();
return;
}
if (["new", "import", "wc", "unlock"].includes(action)) {
close();
// Always route to the portal — it renders the tabbed sign-in INLINE
// as page content (?mode=X picks the starting tab). Prevents the
// "modal opens on top of the page" symptom the launcher used to have.
// Only stay on-page if we're already on portal.html.
const onPortal = /\/portal\.html(?:$|[?#])/.test(location.pathname + location.search);
if (onPortal && typeof window.siriusRenderSignInInline === "function") {
const hasSaved = (() => { try { return !!localStorage.getItem("bns.wallet.v1"); } catch { return false; } })();
const mode = (action === "unlock" && !hasSaved) ? "import" : action;
const mount = document.getElementById("signin-inline");
if (mount) {
window.siriusRenderSignInInline(mount, mode);
document.getElementById("signin")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
}
// Not on portal (or the inline mount is missing) — take the user
// there. portal.html?mode=X renders the tabbed sign-in as its main
// content, no modal.
location.href = PORTAL_URL + "?mode=" + action;
}
});
wrap.appendChild(btn);
wrap.appendChild(menu);
anchor.replaceWith(wrap);
wrap.parentNode.insertBefore(dash, wrap);
render(); // dash visibility depends on the profile
// ---------- inline CSS ----------
if (!document.getElementById("profile-menu-css")) {
const s = document.createElement("style");
s.id = "profile-menu-css";
s.textContent = `
.topnav .profile-wrap{position:relative;margin-left:auto}
/* Phones: the bar was wrapping into four rows. Drop the font-comparison
pill, tighten link padding, and shrink the two buttons. */
@media (max-width:640px){
.topnav{padding:8px 10px;gap:0}
.topnav .font-picker{display:none !important}
.topnav > a{padding:5px 8px;font-size:13px}
.topnav .brand{margin-right:6px}
.topnav .dash-link{padding:5px 9px;font-size:12.5px}
.topnav .profile-wrap button.portal{padding:5px 9px;font-size:12.5px}
.topnav .profile-wrap .wallet-label{max-width:88px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
}
.topnav .dash-link{margin-left:auto;display:inline-flex;align-items:center;gap:6px;
padding:6px 12px;border-radius:999px;font-size:13.5px;font-weight:500;text-decoration:none;
color:var(--ink,#f1f4fa);background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12)}
.topnav .dash-link:hover{background:rgba(255,255,255,.1)}
.topnav .dash-link.here{color:var(--acid,#d6ff3d);border-color:rgba(214,255,61,.35);background:rgba(214,255,61,.08)}
.topnav .dash-link[hidden]{display:none}
.topnav .dash-link + .profile-wrap{margin-left:8px}
.topnav .lang-picker + .dash-link{margin-left:8px}
.topnav .profile-menu .wallet-stats{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:6px 4px 10px;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
.topnav .profile-menu .wallet-stats > div{background:rgba(255,255,255,.04);border-radius:9px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.topnav .profile-menu .ws-k{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#8b98a9;font-weight:600}
.topnav .profile-menu .ws-v{font-size:12.5px;color:#e7eaf1;font-family:ui-monospace,monospace}
.topnav .profile-wrap button.portal{
display:inline-flex;align-items:center;gap:8px;
cursor:pointer;font-family:inherit;font-size:13.5px;font-weight:500;
padding:6px 12px 6px 10px;border-radius:999px;
background:rgba(214,255,61,.10);color:var(--acid);
border:1px solid rgba(214,255,61,.22);transition:background .1s,border-color .1s
}
.topnav .profile-wrap button.portal:hover{background:rgba(214,255,61,.16);border-color:rgba(214,255,61,.35)}
.topnav .profile-wrap button.portal[aria-expanded="true"]{background:rgba(214,255,61,.20)}
.topnav .profile-wrap .wallet-dot{
width:7px;height:7px;border-radius:50%;background:rgba(139,152,169,.45);
box-shadow:0 0 0 2px rgba(139,152,169,.10)
}
.topnav .profile-wrap .wallet-dot.ok{
background:#4fd1a5;box-shadow:0 0 0 2px rgba(79,209,165,.20)
}
.topnav .profile-wrap .wallet-label{font-family:ui-monospace,monospace;font-size:12.5px;letter-spacing:.2px}
.topnav .profile-wrap .wallet-src{
font-size:9.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;
padding:2px 6px;border-radius:4px;background:rgba(214,255,61,.15);color:var(--acid);
margin-left:2px
}
.topnav .profile-wrap .wallet-caret{opacity:.6;font-size:11px;margin-left:2px}
.topnav .profile-menu{
position:absolute;right:0;top:calc(100% + 8px);
min-width:280px;z-index:30;
background:rgba(18,24,34,.98);backdrop-filter:blur(12px);
border:1px solid rgba(255,255,255,.12);border-radius:14px;
padding:8px;display:flex;flex-direction:column;gap:2px;
box-shadow:0 12px 40px rgba(0,0,0,.55),0 0 0 1px rgba(0,0,0,.4)
}
.topnav .profile-menu[hidden]{display:none}
.topnav .profile-menu > a{
display:grid;grid-template-columns:24px 1fr;gap:12px;align-items:center;
padding:9px 11px;border-radius:9px;
color:#e7eaf1;text-decoration:none;transition:background .08s
}
.topnav .profile-menu > a:hover{background:rgba(214,255,61,.10)}
.topnav .profile-menu > a:hover .mi-ic{filter:brightness(1.2)}
.topnav .profile-menu .mi-ic{font-size:17px;line-height:1;text-align:center}
.topnav .profile-menu .mi-body{display:flex;flex-direction:column;gap:1px;font-size:13.5px;font-weight:600;color:#e7eaf1}
.topnav .profile-menu .mi-hint{font-weight:400;font-size:11.5px;color:#8b98a9;letter-spacing:.1px}
.topnav .profile-menu hr{border:0;border-top:1px solid rgba(255,255,255,.08);margin:4px 2px}
.topnav .profile-menu .profile-header{
padding:8px 11px 12px;display:flex;flex-direction:column;gap:4px;
border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px
}
.topnav .profile-menu .profile-hint{
color:#8b98a9;font-size:11px;text-transform:uppercase;letter-spacing:.4px;font-weight:600
}
.topnav .profile-menu .profile-sub{color:#5e6678;font-size:12px}
.topnav .profile-menu code{
font-family:ui-monospace,monospace;font-size:11.5px;
color:#e7eaf1;word-break:break-all;background:transparent;padding:0;border:0
}
`;
document.head.appendChild(s);
}
// ---------- helpers ----------
function shortAddr(a) {
if (!a) return "wallet";
const i = a.indexOf(":");
const body = i > 0 ? a.slice(i + 1) : a;
return body.slice(0, 6) + "…" + body.slice(-4);
}
function escapeAttr(s) {
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
}
})();