Redesign transaction list and add transaction detail view
All checks were successful
check / check (push) Successful in 13s
All checks were successful
check / check (push) Successful in 13s
Transaction list entries are now two lines with more spacing: - Line 1: humanized age (hover for ISO datetime) + direction (Sent/Received) - Line 2: counterparty address + amount with symbol - Clickable rows navigate to transaction detail view Transaction detail view (placeholder) shows: - Status, time, amount, from, to, transaction hash - Back button returns to address detail Also added "transaction" to VIEWS list in helpers.
This commit is contained in:
@@ -519,6 +519,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ TRANSACTION DETAIL ============ -->
|
||||||
|
<div id="view-transaction" class="view hidden">
|
||||||
|
<button
|
||||||
|
id="btn-tx-back"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
|
||||||
|
>
|
||||||
|
< Back
|
||||||
|
</button>
|
||||||
|
<h2 class="font-bold mb-2">Transaction</h2>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">Status</div>
|
||||||
|
<div id="tx-detail-status" class="text-xs"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">Time</div>
|
||||||
|
<div id="tx-detail-time" class="text-xs"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">Amount</div>
|
||||||
|
<div id="tx-detail-value" class="text-xs font-bold"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">From</div>
|
||||||
|
<div id="tx-detail-from" class="text-xs break-all"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">To</div>
|
||||||
|
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-xs text-muted">Transaction hash</div>
|
||||||
|
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ APPROVAL ============ -->
|
<!-- ============ APPROVAL ============ -->
|
||||||
<div id="view-approve" class="view hidden">
|
<div id="view-approve" class="view hidden">
|
||||||
<h2 class="font-bold mb-2">A website is requesting access</h2>
|
<h2 class="font-bold mb-2">A website is requesting access</h2>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function show() {
|
|||||||
loadTransactions(addr.address);
|
loadTransactions(addr.address);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
function isoDate(timestamp) {
|
||||||
const d = new Date(timestamp * 1000);
|
const d = new Date(timestamp * 1000);
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
return (
|
return (
|
||||||
@@ -42,20 +42,42 @@ function formatDate(timestamp) {
|
|||||||
" " +
|
" " +
|
||||||
pad(d.getHours()) +
|
pad(d.getHours()) +
|
||||||
":" +
|
":" +
|
||||||
pad(d.getMinutes())
|
pad(d.getMinutes()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getSeconds())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeAgo(timestamp) {
|
||||||
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
|
if (seconds < 60) return seconds + " seconds ago";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60)
|
||||||
|
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12)
|
||||||
|
return months + " month" + (months !== 1 ? "s" : "") + " ago";
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return years + " year" + (years !== 1 ? "s" : "") + " ago";
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = s;
|
div.textContent = s;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loadedTxs = [];
|
||||||
|
|
||||||
async function loadTransactions(address) {
|
async function loadTransactions(address) {
|
||||||
try {
|
try {
|
||||||
const txs = await fetchRecentTransactions(address, state.blockscoutUrl);
|
const txs = await fetchRecentTransactions(address, state.blockscoutUrl);
|
||||||
renderTransactions(txs, address);
|
loadedTxs = txs;
|
||||||
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
$("tx-list").innerHTML =
|
$("tx-list").innerHTML =
|
||||||
@@ -63,30 +85,65 @@ async function loadTransactions(address) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTransactions(txs, address) {
|
function renderTransactions(txs) {
|
||||||
const list = $("tx-list");
|
const list = $("tx-list");
|
||||||
if (txs.length === 0) {
|
if (txs.length === 0) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addrLower = address.toLowerCase();
|
list.innerHTML = "";
|
||||||
let html = "";
|
txs.forEach((tx, i) => {
|
||||||
for (const tx of txs) {
|
|
||||||
const arrow = tx.direction === "sent" ? "\u2192" : "\u2190";
|
|
||||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
||||||
const label = tx.direction === "sent" ? "to" : "from";
|
const dirLabel = tx.direction === "sent" ? "Sent" : "Received";
|
||||||
const errorClass = tx.isError ? ' style="opacity:0.5"' : "";
|
const errorStyle = tx.isError ? " opacity:0.5" : "";
|
||||||
const errorTag = tx.isError
|
|
||||||
? ' <span class="text-muted">[failed]</span>'
|
const row = document.createElement("div");
|
||||||
: "";
|
row.className =
|
||||||
html += `<div class="py-1 border-b border-border-light text-xs"${errorClass}>`;
|
"py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover";
|
||||||
html += `<div>${formatDate(tx.timestamp)} ${arrow} ${escapeHtml(tx.value)} ${escapeHtml(tx.symbol)}${errorTag}</div>`;
|
if (errorStyle) row.style.cssText = errorStyle;
|
||||||
html += `<div class="text-muted break-all">${label}: ${escapeHtml(counterparty)}</div>`;
|
|
||||||
html += `<div class="break-all"><a href="https://etherscan.io/tx/${escapeHtml(tx.hash)}" target="_blank" class="underline decoration-dashed">${escapeHtml(tx.hash)}</a></div>`;
|
const line1 = document.createElement("div");
|
||||||
html += `</div>`;
|
line1.className = "flex justify-between";
|
||||||
}
|
const age = document.createElement("span");
|
||||||
list.innerHTML = html;
|
age.className = "text-muted";
|
||||||
|
age.textContent = timeAgo(tx.timestamp);
|
||||||
|
age.title = isoDate(tx.timestamp);
|
||||||
|
const dir = document.createElement("span");
|
||||||
|
dir.className = tx.isError ? "text-muted" : "";
|
||||||
|
dir.textContent = dirLabel + (tx.isError ? " (failed)" : "");
|
||||||
|
line1.appendChild(age);
|
||||||
|
line1.appendChild(dir);
|
||||||
|
|
||||||
|
const line2 = document.createElement("div");
|
||||||
|
line2.className = "flex justify-between";
|
||||||
|
const addr = document.createElement("span");
|
||||||
|
addr.className = "break-all pr-2";
|
||||||
|
addr.textContent = counterparty;
|
||||||
|
const amount = document.createElement("span");
|
||||||
|
amount.className = "shrink-0";
|
||||||
|
amount.textContent = tx.value + " " + tx.symbol;
|
||||||
|
line2.appendChild(addr);
|
||||||
|
line2.appendChild(amount);
|
||||||
|
|
||||||
|
row.appendChild(line1);
|
||||||
|
row.appendChild(line2);
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
state.selectedTx = i;
|
||||||
|
showTxDetail(tx);
|
||||||
|
});
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTxDetail(tx) {
|
||||||
|
$("tx-detail-hash").textContent = tx.hash;
|
||||||
|
$("tx-detail-from").textContent = tx.from;
|
||||||
|
$("tx-detail-to").textContent = tx.to;
|
||||||
|
$("tx-detail-value").textContent = tx.value + " " + tx.symbol;
|
||||||
|
$("tx-detail-time").textContent = isoDate(tx.timestamp);
|
||||||
|
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
||||||
|
showView("transaction");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSendTokenSelect(addr) {
|
function renderSendTokenSelect(addr) {
|
||||||
@@ -144,6 +201,10 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
||||||
|
|
||||||
|
$("btn-tx-back").addEventListener("click", () => {
|
||||||
|
show();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const VIEWS = [
|
|||||||
"receive",
|
"receive",
|
||||||
"add-token",
|
"add-token",
|
||||||
"settings",
|
"settings",
|
||||||
|
"transaction",
|
||||||
"approve",
|
"approve",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user