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 <noreply@anthropic.com>
This commit is contained in:
parent
2cb1ff092b
commit
a4a4175e9f
|
|
@ -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<string, Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>> = new Map();
|
||||
private myWalletsLoading = false;
|
||||
|
||||
// Visualization state
|
||||
private activeView: ViewTab = "balances";
|
||||
private transfers: Map<string, any> | 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 {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
private renderTopTabBar(): string {
|
||||
return `
|
||||
<div class="top-tabs">
|
||||
<button class="top-tab ${this.topTab === "my-wallets" ? "active" : ""}" data-top-tab="my-wallets">My Wallets</button>
|
||||
<button class="top-tab ${this.topTab === "visualizer" ? "active" : ""}" data-top-tab="visualizer">Wallet Visualizer</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderMyWalletsTab(): string {
|
||||
if (!this.isAuthenticated) {
|
||||
return `<div class="empty">
|
||||
<p>Sign in to view your wallets</p>
|
||||
<a href="/auth" style="color:var(--rs-accent)">Sign in with EncryptID →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (this.myWalletsLoading) {
|
||||
return `<div class="loading"><span class="spinner"></span> Loading wallet balances...</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="my-wallets-grid">';
|
||||
|
||||
// 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 += '<div style="text-align:center;color:var(--rs-text-muted);padding:24px;font-size:13px">No wallets linked yet. Link a browser wallet to get started.</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Aggregate total
|
||||
html += this.renderAggregateTotal();
|
||||
|
||||
// Link wallet button + provider picker
|
||||
html += `
|
||||
<div class="my-wallets-tab-link-btn">
|
||||
<button class="link-wallet-btn" id="link-wallet-btn" style="padding:10px 24px;font-size:14px">+ Link Wallet</button>
|
||||
</div>
|
||||
${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<BalanceItem & { chainId: string; chainName: string }> = [];
|
||||
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 `<tr>
|
||||
<td><div class="chain-cell"><span class="chain-dot-sm" style="background:${color}"></span>${this.esc(b.chainName)}</div></td>
|
||||
<td><span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</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("");
|
||||
if (sorted.length > 10) {
|
||||
balanceRows += `<tr><td colspan="4" style="text-align:center;color:var(--rs-text-muted);font-size:11px;padding:8px">+ ${sorted.length - 10} more tokens</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 `<div class="crdt-row">
|
||||
<span style="font-size:1.1em">${t.icon || '\u{1FA99}'}</span>
|
||||
<strong>${this.esc(t.symbol)}</strong>
|
||||
<span style="margin-left:auto;font-family:monospace;font-weight:600">${formatted}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
crdtSection = `
|
||||
<div class="crdt-section">
|
||||
<div class="crdt-label">Local Tokens (CRDT)</div>
|
||||
${crdtRows}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="wallet-card">
|
||||
<div class="wallet-card-header">
|
||||
<div>
|
||||
<span class="wallet-badge ${badgeClass}">${this.esc(label)}</span>
|
||||
<span class="wallet-card-addr">${this.shortenAddress(address)}</span>
|
||||
</div>
|
||||
<div class="wallet-card-actions">
|
||||
<span class="wallet-card-total">${this.formatUSD(String(totalUSD))}</span>
|
||||
<button class="view-flows-btn" data-view-in-viz="${this.esc(address)}">View Flows →</button>
|
||||
${walletId ? `<button class="unlink-btn-card" data-unlink="${this.esc(walletId)}" title="Unlink">✕</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${sorted.length > 0 ? `
|
||||
<table class="balance-table compact">
|
||||
<thead>
|
||||
<tr><th>Chain</th><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD</th></tr>
|
||||
</thead>
|
||||
<tbody>${balanceRows}</tbody>
|
||||
</table>` : `<div style="padding:12px;text-align:center;color:var(--rs-text-muted);font-size:12px">No on-chain balances found</div>`}
|
||||
${crdtSection}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderAggregateTotal(): string {
|
||||
let grandTotal = 0;
|
||||
let totalTokens = 0;
|
||||
const totalChains = new Set<string>();
|
||||
|
||||
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 `
|
||||
<div class="aggregate-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Portfolio</div>
|
||||
<div class="stat-value">${this.formatUSD(String(grandTotal))}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Wallets</div>
|
||||
<div class="stat-value">${walletCount}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Tokens</div>
|
||||
<div class="stat-value">${totalTokens}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Chains</div>
|
||||
<div class="stat-value">${totalChains.size}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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()}
|
||||
|
||||
<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">
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -840,7 +840,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-wallet-viewer${viewAttr}></folk-wallet-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=7"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue