diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index cd3e37b..d7258f3 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -182,6 +182,10 @@ class FolkWalletViewer extends HTMLElement { private sandboxDays = 365; private sandboxComparisons: ProtocolComparison[] = []; + // UX state + private addressBarExpanded = false; + private detailsModalOpen = false; + // Visualization state private activeView: ViewTab = "balances"; private transfers: Map | null = null; @@ -609,7 +613,6 @@ class FolkWalletViewer extends HTMLElement { this.allChainBalances = new Map(); this.chainFilter = null; this.walletType = ""; - this.activeView = "balances"; this.transfers = null; this.vizData = {}; this.render(); @@ -642,6 +645,19 @@ class FolkWalletViewer extends HTMLElement { } this.loading = false; + this.addressBarExpanded = false; + + // Default to timeline view and auto-load transfers when wallet data is available + if (this.hasData()) { + const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0); + if (showViz) { + this.activeView = "timeline"; + this.render(); + this.loadTransfers(); + return; + } + } + this.render(); } @@ -997,6 +1013,7 @@ class FolkWalletViewer extends HTMLElement { const input = this.shadow.querySelector("#address-input") as HTMLInputElement; if (input) { this.address = input.value.trim(); + this.addressBarExpanded = false; const url = new URL(window.location.href); url.searchParams.set("address", this.address); window.history.replaceState({}, "", url.toString()); @@ -1947,6 +1964,85 @@ class FolkWalletViewer extends HTMLElement { .sandbox-balance { display: none; } } + /* ── Compact address bar ── */ + .compact-address-bar { + display: flex; align-items: center; gap: 8px; margin-bottom: 12px; + max-width: 640px; margin-left: auto; margin-right: auto; + padding: 8px 14px; border-radius: 10px; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + } + .compact-addr-chip { + display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; + font-family: monospace; font-size: 14px; font-weight: 600; color: var(--rs-text-primary); + } + .compact-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; font-weight: 500; transition: all 0.2s; white-space: nowrap; + } + .compact-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); } + + /* ── Watchlist chips ── */ + .watchlist-bar { + display: flex; flex-wrap: wrap; gap: 6px; align-items: center; + margin-bottom: 16px; max-width: 640px; margin-left: auto; margin-right: auto; + } + .watchlist-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 4px 10px; border-radius: 16px; + border: 1px solid var(--rs-border-subtle); background: var(--rs-bg-surface); + color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; + transition: all 0.2s; white-space: nowrap; + } + .watchlist-chip:hover { border-color: var(--rs-accent); color: var(--rs-accent); } + .watchlist-chip.active { border-color: var(--rs-accent); background: rgba(20,184,166,0.1); color: var(--rs-accent); } + .watchlist-chip.suggested { border-style: dashed; color: var(--rs-text-muted); } + .watchlist-chip.suggested:hover { border-style: solid; } + .watchlist-chip.add-chip { padding: 4px 8px; font-size: 14px; font-weight: 700; } + .chip-label { font-weight: 500; } + .chip-addr { font-family: monospace; font-size: 10px; color: var(--rs-text-muted); } + .chip-remove { + font-size: 14px; line-height: 1; padding: 0 2px; margin-left: 2px; + color: var(--rs-text-muted); cursor: pointer; + } + .chip-remove:hover { color: var(--rs-error); } + + /* ── Details modal ── */ + .details-modal-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + padding: 20px; + } + .details-modal { + background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border-subtle); + border-radius: 16px; max-width: 900px; width: 100%; + max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + } + .details-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle); + } + .details-modal-header h3 { margin: 0; font-size: 16px; color: var(--rs-text-primary); } + .details-modal-header button { + background: none; border: none; color: var(--rs-text-muted); cursor: pointer; + font-size: 20px; padding: 4px 8px; border-radius: 6px; + } + .details-modal-header button:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); } + .details-modal-body { overflow-y: auto; padding: 20px; } + + /* ── Viz wrapper with details button ── */ + .viz-wrapper { position: relative; } + .details-btn { + position: absolute; top: 8px; right: 8px; z-index: 5; + padding: 5px 14px; border-radius: 16px; + border: 1px solid var(--rs-border); background: var(--rs-bg-surface); + color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; + font-weight: 500; transition: all 0.2s; backdrop-filter: blur(4px); + } + .details-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); } + @media (max-width: 768px) { .hero-title { font-size: 22px; } .balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -1959,6 +2055,8 @@ class FolkWalletViewer extends HTMLElement { } @media (max-width: 640px) { .view-tab { padding: 6px 10px; font-size: 12px; } + .compact-address-bar { flex-wrap: wrap; } + .watchlist-bar { gap: 4px; } } @media (max-width: 480px) { .features { grid-template-columns: 1fr; } @@ -2813,6 +2911,8 @@ class FolkWalletViewer extends HTMLElement { Local ` : ""; + const isVizView = this.activeView !== "balances" && this.activeView !== "yield"; + return `
-
+ ${!isVizView ? `
Total Value
${this.formatUSD(String(totalUSD))}
@@ -2840,7 +2940,7 @@ class FolkWalletViewer extends HTMLElement {
Address
${this.shortenAddress(this.address)}
-
+
` : ""} ${this.renderViewTabs()} @@ -2848,50 +2948,115 @@ class FolkWalletViewer extends HTMLElement { ? this.renderBalanceTable() + this.renderPaymentActions() : this.activeView === "yield" ? this.renderYieldTab() - : `
- ${this.transfersLoading ? '
Loading transfer data...
' : ""} -
- ${this.renderTransactionTables()}` + : `
+ +
+ ${this.transfersLoading ? '
Loading transfer data...
' : ""} +
+
` }`; } + private renderCompactAddressBar(): string { + const typeLabel = this.walletType === "safe" ? "Safe Multisig" : this.walletType === "eoa" ? "EOA" : ""; + const isWatched = this.watchedAddresses.some(w => w.address.toLowerCase() === this.address.toLowerCase()); + return ` +
+ + ${this.shortenAddress(this.address)} + ${typeLabel ? `${typeLabel}` : ""} + + ${!isWatched ? `` : ""} + +
`; + } + + private renderWatchlistChips(): string { + const chips = this.watchedAddresses.map(w => { + const isActive = this.address && w.address.toLowerCase() === this.address.toLowerCase(); + return ``; + }).join(''); + + // Show example wallets as suggestions when watchlist is empty and no wallet loaded + const showSuggestions = this.watchedAddresses.length === 0 && !this.hasData() && !this.loading; + const suggestions = showSuggestions ? EXAMPLE_WALLETS.map(w => ` + + `).join('') : ''; + + if (!chips && !suggestions) { + if (!this.hasData()) return `
Save wallets to quickly switch between them
`; + return ''; + } + + return ` +
+ ${chips}${suggestions} + +
`; + } + + private renderDetailsModal(): string { + if (!this.detailsModalOpen) return ''; + return ` +
+
+
+

Wallet Details

+ +
+
+ ${this.renderBalanceTable()} + ${this.renderTransactionTables()} +
+
+
`; + } + private renderVisualizerTab(): string { // Yield view is standalone — skip wallet UI entirely if (this.activeView === "yield") { return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`; } + const hasData = this.hasData(); + const showFullAddressBar = !hasData || this.addressBarExpanded; + return ` ${this.renderHero()} -
- - -
+ ${showFullAddressBar ? ` +
+ + +
-
-
-
- Include testnets -
- - ${this.walletType ? ` -
- ${this.walletType === "safe" ? "⛓ Safe Multisig" : "👤 EOA Wallet"} +
+
+
+ Include testnets
- ` : ""} -
+ +
+ ` : this.renderCompactAddressBar()} + + ${this.renderWatchlistChips()} ${this.renderSupportedChains()} ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Detecting wallet across chains...
' : ""} - ${this.renderFeatures()} - ${this.renderExamples()} - ${this.renderWatchlist()} + ${!hasData && !this.loading && this.watchedAddresses.length === 0 ? this.renderFeatures() : ""} ${this.renderDashboard()} + ${this.renderDetailsModal()} `; } @@ -2918,19 +3083,66 @@ class FolkWalletViewer extends HTMLElement { }); }); - this.shadow.querySelectorAll(".example-item").forEach((item) => { - item.addEventListener("click", () => { - const addr = (item as HTMLElement).dataset.address!; - const input = this.shadow.querySelector("#address-input") as HTMLInputElement; - if (input) input.value = addr; + // Compact address bar actions + this.shadow.querySelector('[data-action="change-wallet"]')?.addEventListener('click', () => { + this.addressBarExpanded = true; + this.render(); + const input = this.shadow.querySelector("#address-input") as HTMLInputElement; + if (input) { input.value = this.address; input.focus(); input.select(); } + }); + + this.shadow.querySelector('[data-action="save-wallet"]')?.addEventListener('click', () => { + const label = prompt('Label (optional):') || ''; + const chain = this.selectedChain || '1'; + this.addToWatchlist(this.address, chain, label); + }); + + // Watchlist chips: click to load address + this.shadow.querySelectorAll('.watchlist-chip[data-watch-address]').forEach(el => { + el.addEventListener('click', (e) => { + // Don't navigate if clicking the remove button + if ((e.target as HTMLElement).classList.contains('chip-remove')) return; + const addr = el.dataset.watchAddress; + if (!addr) return; this.address = addr; const url = new URL(window.location.href); - url.searchParams.set("address", addr); - window.history.replaceState({}, "", url.toString()); + url.searchParams.set('address', addr); + window.history.replaceState({}, '', url.toString()); this.detectChains(); }); }); + // Watchlist chips: suggested examples + this.shadow.querySelectorAll('.watchlist-chip.suggested[data-address]').forEach(el => { + el.addEventListener('click', () => { + const addr = el.dataset.address; + if (!addr) return; + this.address = addr; + const url = new URL(window.location.href); + url.searchParams.set('address', addr); + window.history.replaceState({}, '', url.toString()); + this.detectChains(); + }); + }); + + // Watchlist: remove chip + this.shadow.querySelectorAll('.chip-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const key = btn.dataset.watchKey; + if (key) this.removeFromWatchlist(key); + }); + }); + + // Watchlist: add + this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => { + const addr = prompt('Wallet address to watch:'); + if (!addr) return; + const label = prompt('Label (optional):') || ''; + const chain = this.selectedChain || '1'; + this.addToWatchlist(addr, chain, label); + }); + // View tab listeners (skip tour button which has no data-view) this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => { tab.addEventListener("click", () => { @@ -2941,6 +3153,20 @@ class FolkWalletViewer extends HTMLElement { this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Details modal + this.shadow.querySelector('[data-action="open-details"]')?.addEventListener('click', () => { + this.detailsModalOpen = true; + this.render(); + }); + this.shadow.querySelectorAll('[data-action="close-details"]').forEach(el => { + el.addEventListener('click', (e) => { + // Don't close when clicking inside the modal body + if ((e.target as HTMLElement).closest('[data-stop-propagation]') && !(e.target as HTMLElement).hasAttribute('data-action')) return; + this.detailsModalOpen = false; + this.render(); + }); + }); + // Sandbox listeners this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => { this.sandboxActive = !this.sandboxActive; @@ -2980,39 +3206,6 @@ class FolkWalletViewer extends HTMLElement { }); }); - // Watchlist: add - this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => { - const addr = prompt('Wallet address to watch:'); - if (!addr) return; - const label = prompt('Label (optional):') || ''; - const chain = this.selectedChain || '1'; - this.addToWatchlist(addr, chain, label); - }); - - // Watchlist: click to load - this.shadow.querySelectorAll('.watch-item').forEach(el => { - el.addEventListener('click', () => { - const addr = el.dataset.watchAddress; - if (!addr) return; - const input = this.shadow.querySelector('#address-input') as HTMLInputElement; - if (input) input.value = addr; - this.address = addr; - const url = new URL(window.location.href); - url.searchParams.set('address', addr); - window.history.replaceState({}, '', url.toString()); - this.detectChains(); - }); - }); - - // Watchlist: remove - this.shadow.querySelectorAll('.watch-remove').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const key = btn.dataset.watchKey; - if (key) this.removeFromWatchlist(key); - }); - }); - // Draw visualization if active if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) { requestAnimationFrame(() => this.drawActiveVisualization()); diff --git a/modules/rwallet/lib/data-transform.ts b/modules/rwallet/lib/data-transform.ts index 76eb9d3..712f375 100644 --- a/modules/rwallet/lib/data-transform.ts +++ b/modules/rwallet/lib/data-transform.ts @@ -134,8 +134,20 @@ const STABLECOINS = new Set([ "GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD", ]); +// Approximate USD prices for major non-stablecoin tokens (updated periodically) +const NATIVE_APPROX_USD: Record = { + ETH: 2500, WETH: 2500, stETH: 2500, cbETH: 2500, rETH: 2800, wstETH: 2900, + MATIC: 0.40, POL: 0.40, WMATIC: 0.40, + BNB: 600, WBNB: 600, + AVAX: 35, WAVAX: 35, + xDAI: 1, WXDAI: 1, + CELO: 0.50, GNO: 250, +}; + export function estimateUSD(value: number, symbol: string): number | null { if (STABLECOINS.has(symbol)) return value; + const price = NATIVE_APPROX_USD[symbol]; + if (price !== undefined) return value * price; return null; } @@ -172,7 +184,7 @@ export function transformToTimelineData( type: "in", amount: value, token: symbol, - usd: usd !== null ? usd : value, + usd: usd !== null ? usd : 0, hasUsdEstimate: usd !== null, chain: chainName, chainId, @@ -239,7 +251,7 @@ export function transformToTimelineData( } for (const t of txTransfers) { - const usd = t.usd !== null ? t.usd : t.value; + const usd = t.usd !== null ? t.usd : 0; timeline.push({ date: tx.executionDate, type: "out", @@ -445,7 +457,7 @@ export function transformToMultichainData( if (value <= 0) continue; const usd = estimateUSD(value, symbol); - const usdVal = usd !== null ? usd : value; + const usdVal = usd !== null ? usd : 0; chainTransfers++; chainInflow += usdVal; if (transfer.from) { @@ -546,7 +558,7 @@ export function transformToMultichainData( for (const t of outTransfers) { const usd = estimateUSD(t.value, t.symbol); - const usdVal = usd !== null ? usd : t.value; + const usdVal = usd !== null ? usd : 0; chainOutflow += usdVal; if (t.to) { chainAddresses.add(t.to.toLowerCase()); diff --git a/modules/rwallet/lib/wallet-viz.ts b/modules/rwallet/lib/wallet-viz.ts index 682d243..db0d82f 100644 --- a/modules/rwallet/lib/wallet-viz.ts +++ b/modules/rwallet/lib/wallet-viz.ts @@ -400,20 +400,15 @@ export function renderTimeline( .on("end", () => svg.style("cursor", "grab")); svg.on("wheel", function(event: any) { + if (!event.ctrlKey) return; // let normal scroll pass through to page event.preventDefault(); const currentTransform = d3.zoomTransform(svg.node()); - if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) { - const panAmount = event.deltaX !== 0 ? event.deltaX : event.deltaY; - const newTransform = currentTransform.translate(-panAmount * 0.5, 0); - svg.call(zoom.transform, newTransform); - } else { - const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; - const [mouseX] = d3.pointer(event); - const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor)); - const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k); - const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale); - svg.call(zoom.transform, newTransform); - } + const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; + const [mouseX] = d3.pointer(event); + const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor)); + const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k); + const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale); + svg.call(zoom.transform, newTransform); }, { passive: false }); svg.call(zoom); @@ -537,9 +532,10 @@ export function renderFlowChart( .attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`); }); - // Zoom + // Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through) const flowZoom = d3.zoom() .scaleExtent([0.3, 5]) + .filter((event: any) => event.type !== "wheel" || event.ctrlKey) .on("zoom", (event: any) => g.attr("transform", event.transform)) .on("start", () => svg.style("cursor", "grabbing")) .on("end", () => svg.style("cursor", "grab")); @@ -641,9 +637,10 @@ export function renderSankey( .style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal") .style("fill", "#e0e0e0"); - // Zoom + // Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through) const sankeyZoom = d3.zoom() .scaleExtent([0.3, 5]) + .filter((event: any) => event.type !== "wheel" || event.ctrlKey) .on("zoom", (event: any) => zoomGroup.attr("transform", event.transform)) .on("start", () => svg.style("cursor", "grabbing")) .on("end", () => svg.style("cursor", "grab"));