feat(rwallet): rich landing UI, testnet toggle, and wallet type badges

Replaces the bare address-only empty state with a full dashboard landing:
hero branding, supported chain chips, feature cards, clickable example
wallets (TEC, Gitcoin, Vitalik), and animated loading spinner.

Adds testnet toggle (off by default) that filters Sepolia/Base Sepolia
from detection endpoints via ?testnets=true query param. Shows Safe
Multisig / EOA Wallet badge when a wallet is loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-07 11:42:23 -08:00
parent be8982f160
commit bfb3a588ed
2 changed files with 316 additions and 84 deletions

View File

@ -31,8 +31,22 @@ const CHAIN_COLORS: Record<string, string> = {
"43114": "#e84142", "43114": "#e84142",
"56": "#f3ba2f", "56": "#f3ba2f",
"324": "#8c8dfc", "324": "#8c8dfc",
"11155111": "#f59e0b",
"84532": "#f59e0b",
}; };
const CHAIN_NAMES: Record<string, string> = {
"1": "Ethereum", "10": "Optimism", "100": "Gnosis", "137": "Polygon",
"8453": "Base", "42161": "Arbitrum", "42220": "Celo", "43114": "Avalanche",
"56": "BSC", "324": "zkSync",
};
const EXAMPLE_WALLETS = [
{ name: "TEC Commons Fund", address: "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1", type: "Safe" },
{ name: "Gitcoin Treasury", address: "0xde21F729137C5Af1b01d73aF1dC21eFfa2B8a0d6", type: "Safe" },
{ name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" },
];
class FolkWalletViewer extends HTMLElement { class FolkWalletViewer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private address = ""; private address = "";
@ -43,6 +57,7 @@ class FolkWalletViewer extends HTMLElement {
private error = ""; private error = "";
private isDemo = false; private isDemo = false;
private walletType: "safe" | "eoa" | "" = ""; private walletType: "safe" | "eoa" | "" = "";
private includeTestnets = false;
constructor() { constructor() {
super(); super();
@ -108,9 +123,10 @@ class FolkWalletViewer extends HTMLElement {
try { try {
const base = this.getApiBase(); const base = this.getApiBase();
const tn = this.includeTestnets ? "?testnets=true" : "";
// Try Safe detection first // Try Safe detection first
const res = await fetch(`${base}/api/safe/detect/${this.address}`); const res = await fetch(`${base}/api/safe/detect/${this.address}${tn}`);
const data = await res.json(); const data = await res.json();
this.detectedChains = (data.chains || []).map((c: any) => ({ this.detectedChains = (data.chains || []).map((c: any) => ({
@ -124,7 +140,7 @@ class FolkWalletViewer extends HTMLElement {
await this.loadBalances(); await this.loadBalances();
} else { } else {
// Fall back to EOA detection (any wallet) // Fall back to EOA detection (any wallet)
const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}`); const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}${tn}`);
const eoaData = await eoaRes.json(); const eoaData = await eoaRes.json();
this.detectedChains = (eoaData.chains || []).map((c: any) => ({ this.detectedChains = (eoaData.chains || []).map((c: any) => ({
@ -211,27 +227,120 @@ class FolkWalletViewer extends HTMLElement {
this.render(); this.render();
} }
private render() { private hasData(): boolean {
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); return this.detectedChains.length > 0;
}
this.shadow.innerHTML = ` private renderStyles(): string {
return `
<style> <style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; } :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
.address-bar { display: flex; gap: 8px; margin-bottom: 24px; } /* ── Hero ── */
.address-bar input { .hero {
flex: 1; padding: 10px 14px; border-radius: 8px; text-align: center; padding: 32px 16px 24px;
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0; border-bottom: 1px solid #2a2a3e; margin-bottom: 24px;
font-family: monospace; font-size: 13px;
} }
.address-bar input:focus { border-color: #00d4ff; outline: none; } .hero-title {
.address-bar button { font-size: 28px; font-weight: 700; margin: 0 0 6px;
padding: 10px 20px; border-radius: 8px; border: none; background: linear-gradient(135deg, #00d4ff, #4ade80);
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
} }
.address-bar button:hover { background: #00b8d9; } .hero-subtitle { font-size: 14px; color: #888; margin: 0 0 20px; }
/* ── Address bar ── */
.address-bar { display: flex; gap: 8px; margin-bottom: 12px; max-width: 640px; margin-left: auto; margin-right: auto; }
.address-bar input {
flex: 1; padding: 12px 16px; border-radius: 10px;
border: 1px solid #444; background: #1e1e2e; color: #e0e0e0;
font-family: monospace; font-size: 14px;
}
.address-bar input:focus { border-color: #00d4ff; outline: none; box-shadow: 0 0 0 2px rgba(0,212,255,0.15); }
.address-bar button {
padding: 12px 24px; border-radius: 10px; border: none;
background: linear-gradient(135deg, #00d4ff, #00b8d9); color: #000;
font-weight: 600; cursor: pointer; font-size: 14px; transition: opacity 0.2s;
}
.address-bar button:hover { opacity: 0.9; }
/* ── Controls row ── */
.controls-row {
display: flex; align-items: center; justify-content: space-between;
max-width: 640px; margin: 0 auto 24px; flex-wrap: wrap; gap: 8px;
}
.testnet-toggle {
display: flex; align-items: center; gap: 8px;
cursor: pointer; user-select: none; font-size: 13px; color: #888;
}
.testnet-toggle:hover { color: #bbb; }
.toggle-track {
width: 34px; height: 18px; border-radius: 9px; background: #333;
position: relative; transition: background 0.2s;
}
.testnet-toggle.active .toggle-track { background: #f59e0b; }
.toggle-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #666;
position: absolute; top: 2px; left: 2px; transition: all 0.2s;
}
.testnet-toggle.active .toggle-thumb { left: 18px; background: #fff; }
.wallet-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
}
.wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
.wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); }
/* ── Supported chains ── */
.supported-chains {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-bottom: 28px; max-width: 640px; margin-left: auto; margin-right: auto;
}
.supported-chain {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 6px; background: #1e1e2e;
border: 1px solid #2a2a3e; font-size: 12px; color: #999;
}
.supported-chain .dot { width: 6px; height: 6px; border-radius: 50%; }
/* ── Feature cards ── */
.features {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; margin-bottom: 28px;
}
.feature-card {
background: #1e1e2e; border: 1px solid #2a2a3e; border-radius: 12px;
padding: 20px; text-align: center; transition: border-color 0.2s;
}
.feature-card:hover { border-color: #444; }
.feature-icon { font-size: 28px; margin-bottom: 10px; }
.feature-card h3 { font-size: 14px; font-weight: 600; margin: 0 0 6px; color: #e0e0e0; }
.feature-card p { font-size: 12px; color: #888; margin: 0; line-height: 1.5; }
/* ── Example wallets ── */
.examples {
max-width: 640px; margin: 0 auto 20px;
}
.examples-label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.example-list { display: flex; flex-direction: column; gap: 6px; }
.example-item {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
background: #1e1e2e; border: 1px solid #2a2a3e; border-radius: 10px;
cursor: pointer; transition: all 0.2s;
}
.example-item:hover { border-color: #00d4ff; background: #252540; }
.example-name { font-size: 13px; font-weight: 500; color: #e0e0e0; }
.example-addr { font-size: 11px; color: #666; font-family: monospace; }
.example-type {
margin-left: auto; font-size: 10px; font-weight: 600;
padding: 2px 8px; border-radius: 4px; text-transform: uppercase;
}
.example-type.safe { background: rgba(74,222,128,0.1); color: #4ade80; }
.example-type.eoa { background: rgba(0,212,255,0.1); color: #00d4ff; }
/* ── Dashboard: chains ── */
.chains { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; } .chains { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.chain-btn { .chain-btn {
padding: 6px 14px; border-radius: 8px; border: 2px solid #333; padding: 6px 14px; border-radius: 8px; border: 2px solid #333;
@ -242,6 +351,7 @@ class FolkWalletViewer extends HTMLElement {
.chain-btn.active { border-color: var(--chain-color); background: rgba(255,255,255,0.05); } .chain-btn.active { border-color: var(--chain-color); background: rgba(255,255,255,0.05); }
.chain-dot { width: 8px; height: 8px; border-radius: 50%; } .chain-dot { width: 8px; height: 8px; border-radius: 50%; }
/* ── Dashboard: stats ── */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.stat-card { .stat-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px; background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
@ -250,6 +360,7 @@ class FolkWalletViewer extends HTMLElement {
.stat-label { font-size: 11px; color: #888; text-transform: uppercase; margin-bottom: 6px; } .stat-label { font-size: 11px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
.stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; } .stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; }
/* ── Dashboard: balance table ── */
.balance-table { width: 100%; border-collapse: collapse; } .balance-table { width: 100%; border-collapse: collapse; }
.balance-table th { .balance-table th {
text-align: left; padding: 10px 8px; border-bottom: 2px solid #333; text-align: left; padding: 10px 8px; border-bottom: 2px solid #333;
@ -262,37 +373,108 @@ class FolkWalletViewer extends HTMLElement {
.amount-cell { text-align: right; font-family: monospace; } .amount-cell { text-align: right; font-family: monospace; }
.fiat { color: #4ade80; } .fiat { color: #4ade80; }
/* ── States ── */
.empty { text-align: center; color: #666; padding: 40px; } .empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; } .loading { text-align: center; color: #888; padding: 40px; }
.error { text-align: center; color: #ef5350; padding: 20px; } .loading .spinner {
.demo-link { color: #00d4ff; cursor: pointer; text-decoration: underline; } display: inline-block; width: 20px; height: 20px;
border: 2px solid #333; border-top-color: #00d4ff; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error {
text-align: center; color: #ef5350; padding: 16px;
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
border-radius: 10px; margin-bottom: 16px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hero-title { font-size: 22px; }
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } .balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.address-bar { flex-wrap: wrap; } .address-bar { flex-wrap: wrap; }
.address-bar input { min-width: 0; } .address-bar input { min-width: 0; }
.chains { flex-wrap: wrap; } .chains { flex-wrap: wrap; }
.features { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.features { grid-template-columns: 1fr; }
}
</style>`;
} }
</style>
<form class="address-bar" id="address-form"> private renderHero(): string {
<input id="address-input" type="text" placeholder="Enter wallet address (0x...)" if (this.hasData()) return "";
value="${this.address}" spellcheck="false"> return `
<button type="submit">Load</button> <div class="hero">
</form> <div class="hero-title">rWallet</div>
<div class="hero-subtitle">Multichain treasury visualization Safe multisigs and EOA wallets</div>
</div>`;
}
${!this.address && !this.loading ? ` private renderSupportedChains(): string {
<div class="empty"> if (this.hasData() || this.loading) return "";
<p style="font-size:16px;margin-bottom:8px">Enter any wallet address to visualize</p> return `
<p>Supports Safe multisigs and regular wallets (EOA)</p> <div class="supported-chains">
<p style="margin-top:8px">Try: <span class="demo-link" data-address="0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1">TEC Commons Fund (Safe)</span></p> ${Object.entries(CHAIN_NAMES).map(([id, name]) => `
<div class="supported-chain">
<div class="dot" style="background: ${CHAIN_COLORS[id]}"></div>
${name}
</div> </div>
` : ""} `).join("")}
</div>`;
}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""} private renderFeatures(): string {
${this.loading ? '<div class="loading">Detecting wallet across chains...</div>' : ""} if (this.hasData() || this.loading) return "";
return `
<div class="features">
<div class="feature-card">
<div class="feature-icon">&#9939;</div>
<h3>Safe Multisig</h3>
<p>Visualize Gnosis Safe balances, signers, and thresholds across all chains.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128279;</div>
<h3>Any Wallet (EOA)</h3>
<p>Paste any 0x address works for regular wallets too, not just Safes.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#127760;</div>
<h3>10+ Chains</h3>
<p>Ethereum, Base, Polygon, Gnosis, Arbitrum, Optimism, and more in one view.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128274;</div>
<h3>No Custody Risk</h3>
<p>Read-only. rWallet never holds keys or moves funds just visualizes.</p>
</div>
</div>`;
}
${!this.loading && this.detectedChains.length > 0 ? ` private renderExamples(): string {
if (this.hasData() || this.loading) return "";
return `
<div class="examples">
<div class="examples-label">Try an example</div>
<div class="example-list">
${EXAMPLE_WALLETS.map((w) => `
<div class="example-item" data-address="${w.address}">
<div>
<div class="example-name">${w.name}</div>
<div class="example-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</div>
</div>
<div class="example-type ${w.type.toLowerCase()}">${w.type}</div>
</div>
`).join("")}
</div>
</div>`;
}
private renderDashboard(): string {
if (!this.hasData()) return "";
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
return `
<div class="chains"> <div class="chains">
${this.detectedChains.map((ch) => ` ${this.detectedChains.map((ch) => `
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}" <div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}"
@ -343,14 +525,53 @@ class FolkWalletViewer extends HTMLElement {
`).join("")} `).join("")}
</tbody> </tbody>
</table> </table>
` : '<div class="empty">No balances found on this chain.</div>'} ` : '<div class="empty">No token balances found on this chain.</div>'}`;
}
private render() {
this.shadow.innerHTML = `
${this.renderStyles()}
${this.renderHero()}
<form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
value="${this.address}" spellcheck="false">
<button type="submit">Load</button>
</form>
<div class="controls-row">
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
<div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span>
</div>
${this.walletType ? `
<div class="wallet-badge ${this.walletType}">
${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"}
</div>
` : ""} ` : ""}
</div>
${this.renderSupportedChains()}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${this.renderFeatures()}
${this.renderExamples()}
${this.renderDashboard()}
`; `;
// Event listeners // Event listeners
const form = this.shadow.querySelector("#address-form"); const form = this.shadow.querySelector("#address-form");
form?.addEventListener("submit", (e) => this.handleSubmit(e)); form?.addEventListener("submit", (e) => this.handleSubmit(e));
this.shadow.querySelector("#testnet-toggle")?.addEventListener("click", () => {
this.includeTestnets = !this.includeTestnets;
if (this.address) this.detectChains();
else this.render();
});
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => { this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const chainId = (btn as HTMLElement).dataset.chain!; const chainId = (btn as HTMLElement).dataset.chain!;
@ -358,12 +579,15 @@ class FolkWalletViewer extends HTMLElement {
}); });
}); });
this.shadow.querySelectorAll(".demo-link").forEach((link) => { this.shadow.querySelectorAll(".example-item").forEach((item) => {
link.addEventListener("click", () => { item.addEventListener("click", () => {
const addr = (link as HTMLElement).dataset.address!; const addr = (item as HTMLElement).dataset.address!;
const input = this.shadow.querySelector("#address-input") as HTMLInputElement; const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) input.value = addr; if (input) input.value = addr;
this.address = addr; this.address = addr;
const url = new URL(window.location.href);
url.searchParams.set("address", addr);
window.history.replaceState({}, "", url.toString());
this.detectChains(); this.detectChains();
}); });
}); });

View File

@ -55,7 +55,8 @@ routes.get("/api/safe/:chainId/:address/info", async (c) => {
// Detect which chains have a Safe for this address // Detect which chains have a Safe for this address
routes.get("/api/safe/detect/:address", async (c) => { routes.get("/api/safe/detect/:address", async (c) => {
const address = c.req.param("address"); const address = c.req.param("address");
const chains = Object.entries(CHAIN_MAP); const includeTestnets = c.req.query("testnets") === "true";
const chains = getChains(includeTestnets);
const results: Array<{ chainId: string; name: string; prefix: string }> = []; const results: Array<{ chainId: string; name: string; prefix: string }> = [];
await Promise.allSettled( await Promise.allSettled(
@ -88,6 +89,12 @@ const CHAIN_MAP: Record<string, { name: string; prefix: string }> = {
"84532": { name: "Base Sepolia", prefix: "base-sepolia" }, "84532": { name: "Base Sepolia", prefix: "base-sepolia" },
}; };
const TESTNET_CHAIN_IDS = new Set(["11155111", "84532"]);
function getChains(includeTestnets: boolean): [string, { name: string; prefix: string }][] {
return Object.entries(CHAIN_MAP).filter(([id]) => includeTestnets || !TESTNET_CHAIN_IDS.has(id));
}
function getSafePrefix(chainId: string): string | null { function getSafePrefix(chainId: string): string | null {
return CHAIN_MAP[chainId]?.prefix || null; return CHAIN_MAP[chainId]?.prefix || null;
} }
@ -318,10 +325,11 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => {
// Detect which chains have a non-zero native balance for any address // Detect which chains have a non-zero native balance for any address
routes.get("/api/eoa/detect/:address", async (c) => { routes.get("/api/eoa/detect/:address", async (c) => {
const address = c.req.param("address"); const address = c.req.param("address");
const includeTestnets = c.req.query("testnets") === "true";
const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = []; const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = [];
await Promise.allSettled( await Promise.allSettled(
Object.entries(CHAIN_MAP).map(async ([chainId, info]) => { getChains(includeTestnets).map(async ([chainId, info]) => {
const rpcUrl = RPC_URLS[chainId]; const rpcUrl = RPC_URLS[chainId];
if (!rpcUrl) return; if (!rpcUrl) return;
try { try {