From a4a4175e9fb7d0968f9941e86584bdba28d164a2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 11:36:23 -0700 Subject: [PATCH] feat(rwallet): add My Wallets tab with per-wallet balance breakdowns Restructure rWallet with a top-level tab system: "My Wallets" (default for authenticated users) shows wallet cards with on-chain balances and CRDT tokens, while "Wallet Visualizer" preserves existing explore-any- address functionality. View Flows button bridges the two tabs. Co-Authored-By: Claude Opus 4.6 --- .../rwallet/components/folk-wallet-viewer.ts | 372 ++++++++++++++++-- modules/rwallet/mod.ts | 2 +- 2 files changed, 345 insertions(+), 29 deletions(-) diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index be8b660..2a32e90 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -76,6 +76,7 @@ const EXAMPLE_WALLETS = [ ]; type ViewTab = "balances" | "timeline" | "flow" | "sankey"; +type TopTab = "my-wallets" | "visualizer"; interface AllChainBalanceEntry { chainId: string; @@ -110,6 +111,11 @@ class FolkWalletViewer extends HTMLElement { private crdtBalances: Array<{ tokenId: string; name: string; symbol: string; decimals: number; icon: string; color: string; balance: number }> = []; private crdtLoading = false; + // Top-level tab + private topTab: TopTab = "visualizer"; + private myWalletBalances: Map> = new Map(); + private myWalletsLoading = false; + // Visualization state private activeView: ViewTab = "balances"; private transfers: Map | null = null; @@ -156,18 +162,22 @@ class FolkWalletViewer extends HTMLElement { this.address = params.get("address") || ""; this.checkAuthState(); - // Auto-load: use session wallet, or fall back to demo EOA - if (!this.address) { + // If address in URL, show visualizer regardless of auth + if (this.address) { + this.topTab = "visualizer"; + } + + // For visualizer tab: auto-load address or demo + if (this.topTab === "visualizer" && !this.address) { if (this.passKeyEOA) { this.address = this.passKeyEOA; } else { - // Demo EOA — Vitalik.eth this.address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; } } this.render(); - if (this.address) this.detectChains(); + if (this.topTab === "visualizer" && this.address) this.detectChains(); } if (!localStorage.getItem("rwallet_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -181,8 +191,9 @@ class FolkWalletViewer extends HTMLElement { const parsed = JSON.parse(session); if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) { this.isAuthenticated = true; + this.topTab = "my-wallets"; this.passKeyEOA = parsed.claims?.eid?.walletAddress || ""; - this.loadLinkedWallets(); + this.loadLinkedWallets().then(() => this.loadMyWalletBalances()); this.loadCRDTBalances(); } } @@ -242,6 +253,44 @@ class FolkWalletViewer extends HTMLElement { this.render(); } + private async loadMyWalletBalances() { + const addresses: Array<{ address: string; type: "eoa" | "safe" }> = []; + + if (this.passKeyEOA) { + addresses.push({ address: this.passKeyEOA, type: "eoa" }); + } + for (const w of this.linkedWallets) { + addresses.push({ address: w.address, type: w.type }); + } + + if (addresses.length === 0) return; + + this.myWalletsLoading = true; + this.render(); + + const base = this.getApiBase(); + const tn = this.includeTestnets ? "" : "?testnets=false"; + + await Promise.allSettled( + addresses.map(async ({ address, type }) => { + try { + const endpoint = type === "safe" + ? `${base}/api/safe/${address}/all-balances${tn}` + : `${base}/api/eoa/${address}/all-balances${tn}`; + const res = await fetch(endpoint); + if (!res.ok) return; + const data = await res.json(); + if (data.chains && data.chains.length > 0) { + this.myWalletBalances.set(address.toLowerCase(), data.chains); + } + } catch {} + }), + ); + + this.myWalletsLoading = false; + this.render(); + } + private renderLocalTokens(): string { if (!this.isAuthenticated) return ""; if (this.crdtLoading) { @@ -1129,6 +1178,74 @@ class FolkWalletViewer extends HTMLElement { padding: 6px 10px; background: rgba(239,83,80,0.08); border-radius: 6px; } + /* ── Top-level tabs ── */ + .top-tabs { + display: flex; gap: 0; border-bottom: 2px solid var(--rs-border-subtle); + margin-bottom: 24px; max-width: 640px; margin-left: auto; margin-right: auto; + } + .top-tab { + padding: 12px 24px; border: none; background: transparent; + color: var(--rs-text-secondary); cursor: pointer; font-size: 14px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; + } + .top-tab:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); } + .top-tab.active { color: var(--rs-accent); border-bottom-color: var(--rs-accent); } + + /* ── Wallet cards (My Wallets tab) ── */ + .my-wallets-grid { display: flex; flex-direction: column; gap: 16px; max-width: 720px; margin: 0 auto; } + .wallet-card { + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + border-radius: 12px; padding: 16px; transition: border-color 0.2s; + } + .wallet-card:hover { border-color: var(--rs-border-strong); } + .wallet-card-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; flex-wrap: wrap; gap: 8px; + } + .wallet-card-addr { + font-family: monospace; font-size: 12px; color: var(--rs-text-muted); margin-left: 8px; + } + .wallet-card-actions { display: flex; align-items: center; gap: 8px; } + .wallet-card-total { + font-size: 18px; font-weight: 700; color: var(--rs-accent); font-family: monospace; + } + .view-flows-btn { + padding: 5px 12px; border-radius: 6px; border: 1px solid var(--rs-border); + background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; + transition: all 0.2s; white-space: nowrap; + } + .view-flows-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); background: rgba(20,184,166,0.05); } + .unlink-btn-card { + padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; + font-size: 12px; background: transparent; color: var(--rs-text-muted); transition: all 0.15s; + } + .unlink-btn-card:hover { color: var(--rs-error); } + .balance-table.compact th { padding: 6px 8px; font-size: 10px; } + .balance-table.compact td { padding: 6px 8px; font-size: 12px; } + + /* ── CRDT section in wallet card ── */ + .crdt-section { + margin-top: 12px; padding: 10px 12px; border-radius: 8px; + background: rgba(39,117,202,0.06); border: 1px solid rgba(39,117,202,0.15); + } + .crdt-label { font-size: 11px; color: #5b9bd5; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } + .crdt-row { + display: flex; align-items: center; gap: 6px; padding: 4px 0; + font-size: 13px; color: var(--rs-text-primary); + } + + /* ── Aggregate stats ── */ + .aggregate-stats { + display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; max-width: 720px; margin: 20px auto 0; + } + + /* ── My wallets tab link button ── */ + .my-wallets-tab-link-btn { + display: block; max-width: 720px; margin: 20px auto 0; text-align: center; + } + @media (max-width: 768px) { .hero-title { font-size: 22px; } .balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -1137,6 +1254,8 @@ class FolkWalletViewer extends HTMLElement { .chains { flex-wrap: wrap; } .features { grid-template-columns: 1fr 1fr; } .view-tabs { overflow-x: auto; } + .top-tabs { max-width: 100%; } + .wallet-card-header { flex-direction: column; align-items: flex-start; } } @media (max-width: 480px) { .features { grid-template-columns: 1fr; } @@ -1280,6 +1399,174 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderTopTabBar(): string { + return ` +
+ + +
`; + } + + private renderMyWalletsTab(): string { + if (!this.isAuthenticated) { + return `
+

Sign in to view your wallets

+ Sign in with EncryptID → +
`; + } + + if (this.myWalletsLoading) { + return `
Loading wallet balances...
`; + } + + let html = '
'; + + // EncryptID wallet card + if (this.passKeyEOA) { + html += this.renderWalletCard(this.passKeyEOA, "EncryptID", "encryptid", true); + } + + // Linked wallet cards + for (const w of this.linkedWallets) { + html += this.renderWalletCard(w.address, w.providerName || w.type.toUpperCase(), w.type, false, w.id); + } + + if (!this.passKeyEOA && this.linkedWallets.length === 0) { + html += '
No wallets linked yet. Link a browser wallet to get started.
'; + } + + html += '
'; + + // Aggregate total + html += this.renderAggregateTotal(); + + // Link wallet button + provider picker + html += ` + + ${this.renderProviderPicker()}`; + + return html; + } + + private renderWalletCard(address: string, label: string, badgeClass: string, isEncryptId: boolean, walletId?: string): string { + const chainBalances = this.myWalletBalances.get(address.toLowerCase()) || []; + + // Flatten all balances for this wallet + const allBals: Array = []; + for (const ch of chainBalances) { + for (const b of ch.balances) { + allBals.push({ ...b, chainId: ch.chainId, chainName: ch.chainName }); + } + } + + const sorted = allBals + .filter(b => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) + .sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0")); + + const totalUSD = sorted.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); + + let balanceRows = ""; + if (sorted.length > 0) { + balanceRows = sorted.slice(0, 10).map(b => { + const color = CHAIN_COLORS[b.chainId] || "#888"; + return ` +
${this.esc(b.chainName)}
+ ${this.esc(b.token?.symbol || "ETH")} + ${this.formatBalance(b.balance, b.token?.decimals || 18)} + ${this.formatUSD(b.fiatBalance)} + `; + }).join(""); + if (sorted.length > 10) { + balanceRows += `+ ${sorted.length - 10} more tokens`; + } + } + + // CRDT tokens for EncryptID wallet + let crdtSection = ""; + if (isEncryptId && this.crdtBalances.length > 0) { + const crdtRows = this.crdtBalances.map(t => { + const formatted = (t.balance / Math.pow(10, t.decimals)).toFixed(2); + return `
+ ${t.icon || '\u{1FA99}'} + ${this.esc(t.symbol)} + ${formatted} +
`; + }).join(""); + crdtSection = ` +
+
Local Tokens (CRDT)
+ ${crdtRows} +
`; + } + + return ` +
+
+
+ ${this.esc(label)} + ${this.shortenAddress(address)} +
+
+ ${this.formatUSD(String(totalUSD))} + + ${walletId ? `` : ""} +
+
+ ${sorted.length > 0 ? ` + + + + + ${balanceRows} +
ChainTokenBalanceUSD
` : `
No on-chain balances found
`} + ${crdtSection} +
`; + } + + private renderAggregateTotal(): string { + let grandTotal = 0; + let totalTokens = 0; + const totalChains = new Set(); + + for (const [, chains] of this.myWalletBalances) { + for (const ch of chains) { + totalChains.add(ch.chainId); + for (const b of ch.balances) { + if (parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) { + grandTotal += parseFloat(b.fiatBalance || "0"); + totalTokens++; + } + } + } + } + + if (grandTotal === 0 && totalTokens === 0) return ""; + + const walletCount = (this.passKeyEOA ? 1 : 0) + this.linkedWallets.length; + + return ` +
+
+
Total Portfolio
+
${this.formatUSD(String(grandTotal))}
+
+
+
Wallets
+
${walletCount}
+
+
+
Tokens
+
${totalTokens}
+
+
+
Chains
+
${totalChains.size}
+
+
`; + } + private renderViewTabs(): string { if (!this.hasData()) return ""; const tabs: { id: ViewTab; label: string }[] = [ @@ -1403,14 +1690,10 @@ class FolkWalletViewer extends HTMLElement { }`; } - private render() { - this.shadow.innerHTML = ` - ${this.renderStyles()} - + private renderVisualizerTab(): string { + return ` ${this.renderHero()} - ${this.renderMyWallets()} -
@@ -1439,8 +1722,9 @@ class FolkWalletViewer extends HTMLElement { ${this.renderExamples()} ${this.renderDashboard()} `; + } - // Event listeners + private attachVisualizerListeners() { const form = this.shadow.querySelector("#address-form"); form?.addEventListener("submit", (e) => this.handleSubmit(e)); @@ -1476,15 +1760,38 @@ class FolkWalletViewer extends HTMLElement { }); }); - // View tab listeners - this.shadow.querySelectorAll(".view-tab").forEach((tab) => { + // View tab listeners (skip tour button which has no data-view) + this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => { tab.addEventListener("click", () => { const view = (tab as HTMLElement).dataset.view as ViewTab; this.handleViewTabClick(view); }); }); - // Linked wallet event listeners + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + + // Draw visualization if active + if (this.activeView !== "balances" && this.hasData()) { + requestAnimationFrame(() => this.drawActiveVisualization()); + } + } + + private attachMyWalletsListeners() { + // "View Flows →" buttons + this.shadow.querySelectorAll("[data-view-in-viz]").forEach((btn) => { + btn.addEventListener("click", () => { + const addr = (btn as HTMLElement).dataset.viewInViz!; + this.topTab = "visualizer"; + this.address = addr; + const url = new URL(window.location.href); + url.searchParams.set("address", addr); + window.history.replaceState({}, "", url.toString()); + this.render(); + this.detectChains(); + }); + }); + + // Link wallet this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => { this.startProviderDiscovery(); }); @@ -1501,15 +1808,7 @@ class FolkWalletViewer extends HTMLElement { }); }); - this.shadow.querySelectorAll("[data-view-address]").forEach((item) => { - item.addEventListener("click", (e) => { - // Don't navigate if clicking the unlink button - if ((e.target as HTMLElement).closest(".unlink-btn")) return; - const addr = (item as HTMLElement).dataset.viewAddress!; - this.handleViewLinkedWallet(addr); - }); - }); - + // Unlink buttons this.shadow.querySelectorAll("[data-unlink]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); @@ -1519,12 +1818,29 @@ class FolkWalletViewer extends HTMLElement { } }); }); + } - this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + private render() { + const isMyWallets = this.topTab === "my-wallets" && this.isAuthenticated; - // Draw visualization if active - if (this.activeView !== "balances" && this.hasData()) { - requestAnimationFrame(() => this.drawActiveVisualization()); + this.shadow.innerHTML = ` + ${this.renderStyles()} + ${this.isAuthenticated ? this.renderTopTabBar() : ''} + ${isMyWallets ? this.renderMyWalletsTab() : this.renderVisualizerTab()} + `; + + // Top tab listeners + this.shadow.querySelectorAll(".top-tab").forEach((tab) => { + tab.addEventListener("click", () => { + this.topTab = (tab as HTMLElement).dataset.topTab as TopTab; + this.render(); + }); + }); + + if (isMyWallets) { + this.attachMyWalletsListeners(); + } else { + this.attachVisualizerListeners(); } this._tour.renderOverlay(); diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 9f3109a..6cf9633 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -840,7 +840,7 @@ function renderWallet(spaceSlug: string, initialView?: string) { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }); }