rspace-online/modules/wallet/components/folk-wallet-viewer.ts

315 lines
10 KiB
TypeScript

/**
* <folk-wallet-viewer> — multichain Safe wallet visualization.
*
* Enter a Safe address to see balances across chains, transfer history,
* and flow visualizations.
*/
interface ChainInfo {
chainId: string;
name: string;
prefix: string;
color: string;
}
interface BalanceItem {
tokenAddress: string | null;
token: { name: string; symbol: string; decimals: number } | null;
balance: string;
fiatBalance: string;
fiatConversion: string;
}
const CHAIN_COLORS: Record<string, string> = {
"1": "#627eea",
"10": "#ff0420",
"100": "#04795b",
"137": "#8247e5",
"8453": "#0052ff",
"42161": "#28a0f0",
"42220": "#35d07f",
"43114": "#e84142",
"56": "#f3ba2f",
"324": "#8c8dfc",
};
class FolkWalletViewer extends HTMLElement {
private shadow: ShadowRoot;
private address = "";
private detectedChains: ChainInfo[] = [];
private selectedChain: string | null = null;
private balances: BalanceItem[] = [];
private loading = false;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
// Check URL params for initial address
const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || "";
this.render();
if (this.address) this.detectChains();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/wallet/);
return match ? `/${match[1]}/wallet` : "";
}
private async detectChains() {
if (!this.address || !/^0x[a-fA-F0-9]{40}$/.test(this.address)) {
this.error = "Please enter a valid Ethereum address (0x...)";
this.render();
return;
}
this.loading = true;
this.error = "";
this.detectedChains = [];
this.balances = [];
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/safe/detect/${this.address}`);
const data = await res.json();
this.detectedChains = (data.chains || []).map((c: any) => ({
...c,
color: CHAIN_COLORS[c.chainId] || "#888",
}));
if (this.detectedChains.length === 0) {
this.error = "No Safe wallets found for this address on any supported chain.";
} else {
// Auto-select first chain
this.selectedChain = this.detectedChains[0].chainId;
await this.loadBalances();
}
} catch (e) {
this.error = "Failed to detect chains. Check the address and try again.";
}
this.loading = false;
this.render();
}
private async loadBalances() {
if (!this.selectedChain) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/safe/${this.selectedChain}/${this.address}/balances`);
if (res.ok) {
this.balances = await res.json();
}
} catch {
this.balances = [];
}
}
private formatBalance(balance: string, decimals: number): string {
const val = Number(balance) / Math.pow(10, decimals);
if (val >= 1000000) return `${(val / 1000000).toFixed(2)}M`;
if (val >= 1000) return `${(val / 1000).toFixed(2)}K`;
if (val >= 1) return val.toFixed(2);
if (val >= 0.0001) return val.toFixed(4);
return val.toExponential(2);
}
private formatUSD(val: string): string {
const n = parseFloat(val);
if (isNaN(n) || n === 0) return "$0";
if (n >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (n >= 1000) return `$${(n / 1000).toFixed(1)}K`;
return `$${n.toFixed(2)}`;
}
private shortenAddress(addr: string): string {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
private handleSubmit(e: Event) {
e.preventDefault();
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) {
this.address = input.value.trim();
const url = new URL(window.location.href);
url.searchParams.set("address", this.address);
window.history.replaceState({}, "", url.toString());
this.detectChains();
}
}
private async handleChainSelect(chainId: string) {
this.selectedChain = chainId;
this.loading = true;
this.render();
await this.loadBalances();
this.loading = false;
this.render();
}
private render() {
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.address-bar { display: flex; gap: 8px; margin-bottom: 24px; }
.address-bar input {
flex: 1; padding: 10px 14px; border-radius: 8px;
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
font-family: monospace; font-size: 13px;
}
.address-bar input:focus { border-color: #00d4ff; outline: none; }
.address-bar button {
padding: 10px 20px; border-radius: 8px; border: none;
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer;
}
.address-bar button:hover { background: #00b8d9; }
.chains { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.chain-btn {
padding: 6px 14px; border-radius: 8px; border: 2px solid #333;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 6px; transition: all 0.2s;
}
.chain-btn:hover { border-color: #555; }
.chain-btn.active { border-color: var(--chain-color); background: rgba(255,255,255,0.05); }
.chain-dot { width: 8px; height: 8px; border-radius: 50%; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.stat-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 14px; text-align: center;
}
.stat-label { font-size: 11px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
.stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; }
.balance-table { width: 100%; border-collapse: collapse; }
.balance-table th {
text-align: left; padding: 10px 8px; border-bottom: 2px solid #333;
color: #888; font-size: 11px; text-transform: uppercase;
}
.balance-table td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
.balance-table tr:hover td { background: rgba(255,255,255,0.02); }
.token-symbol { font-weight: 600; color: #e0e0e0; }
.token-name { font-size: 12px; color: #888; }
.amount-cell { text-align: right; font-family: monospace; }
.fiat { color: #4ade80; }
.empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; }
.error { text-align: center; color: #ef5350; padding: 20px; }
.demo-link { color: #00d4ff; cursor: pointer; text-decoration: underline; }
</style>
<form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter Safe address (0x...)"
value="${this.address}" spellcheck="false">
<button type="submit">Load</button>
</form>
${!this.address && !this.loading ? `
<div class="empty">
<p style="font-size:16px;margin-bottom:8px">Enter a Safe wallet address to visualize</p>
<p>Try: <span class="demo-link" data-address="0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1">TEC Commons Fund</span></p>
</div>
` : ""}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading">Detecting Safe wallets across chains...</div>' : ""}
${!this.loading && this.detectedChains.length > 0 ? `
<div class="chains">
${this.detectedChains.map((ch) => `
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}"
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
<div class="chain-dot" style="background: ${ch.color}"></div>
${ch.name}
</div>
`).join("")}
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Total Value</div>
<div class="stat-value">${this.formatUSD(String(totalUSD))}</div>
</div>
<div class="stat-card">
<div class="stat-label">Tokens</div>
<div class="stat-value">${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0).length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Chains</div>
<div class="stat-value">${this.detectedChains.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Address</div>
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
</div>
</div>
${this.balances.length > 0 ? `
<table class="balance-table">
<thead>
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
</thead>
<tbody>
${this.balances
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01)
.sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0"))
.map((b) => `
<tr>
<td>
<span class="token-symbol">${b.token?.symbol || "ETH"}</span>
<span class="token-name">${b.token?.name || "Ether"}</span>
</td>
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
</tr>
`).join("")}
</tbody>
</table>
` : '<div class="empty">No balances found on this chain.</div>'}
` : ""}
`;
// Event listeners
const form = this.shadow.querySelector("#address-form");
form?.addEventListener("submit", (e) => this.handleSubmit(e));
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const chainId = (btn as HTMLElement).dataset.chain!;
this.handleChainSelect(chainId);
});
});
this.shadow.querySelectorAll(".demo-link").forEach((link) => {
link.addEventListener("click", () => {
const addr = (link as HTMLElement).dataset.address!;
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) input.value = addr;
this.address = addr;
this.detectChains();
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-wallet-viewer", FolkWalletViewer);