2025-10-20 15:42:12 +02:00

342 lines
11 KiB
JavaScript

// static/pages/TransactionDetail.js
import { h, Fragment } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { API } from '../lib/api.js?dev=1';
// ————— helpers —————
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
const toLocaleNum = (n, opts = {}) => Number(n ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8, ...opts });
// Try to render bytes in a readable way without guessing too hard
function renderBytes(value) {
if (typeof value === 'string') return value; // hex/base64/etc.
if (Array.isArray(value) && value.every((x) => Number.isInteger(x) && x >= 0 && x <= 255)) {
return '0x' + value.map((b) => b.toString(16).padStart(2, '0')).join('');
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
// ————— normalizer (robust to partial data) —————
function normalizeTransaction(raw) {
const ops = Array.isArray(raw?.operations) ? raw.operations : [];
const lt = raw?.ledger_transaction ?? {};
const inputs = Array.isArray(lt?.inputs) ? lt.inputs : [];
const outputs = Array.isArray(lt?.outputs) ? lt.outputs : [];
const totalOutputValue = outputs.reduce((sum, note) => sum + Number(note?.value ?? 0), 0);
return {
id: raw?.id ?? '',
blockId: raw?.block_id ?? null,
operations: ops.map(String),
executionGasPrice: isNumber(raw?.execution_gas_price)
? raw.execution_gas_price
: Number(raw?.execution_gas_price ?? 0),
storageGasPrice: isNumber(raw?.storage_gas_price) ? raw.storage_gas_price : Number(raw?.storage_gas_price ?? 0),
ledger: { inputs, outputs, totalOutputValue },
};
}
// ————— UI bits —————
function SectionCard({ title, children, style }) {
return h(
'div',
{ class: 'card', style: `margin-top:12px; ${style ?? ''}` },
h('div', { class: 'card-header' }, h('strong', null, title)),
h('div', { style: 'padding:12px 14px' }, children),
);
}
function Summary({ tx }) {
return h(
SectionCard,
{ title: 'Summary' },
h(
'div',
{ style: 'display:grid; gap:8px;' },
// (ID removed)
tx.blockId != null &&
h(
'div',
null,
h('b', null, 'Block: '),
h(
'a',
{ class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) },
String(tx.blockId),
),
),
h(
'div',
null,
h('b', null, 'Execution Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.executionGasPrice)),
),
h(
'div',
null,
h('b', null, 'Storage Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.storageGasPrice)),
),
h(
'div',
null,
h('b', null, 'Operations: '),
tx.operations?.length
? h(
'span',
{ style: 'display:inline-flex; gap:6px; flex-wrap:wrap; vertical-align:middle;' },
...tx.operations.map((op, i) => h('span', { key: i, class: 'pill', title: op }, op)),
)
: h('span', { style: 'color:var(--muted)' }, '—'),
),
),
);
}
function InputsTable({ inputs }) {
if (!inputs?.length) {
return h('div', { style: 'color:var(--muted)' }, '—');
}
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // #
h('col', null), // Value (fills)
),
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))),
h(
'tbody',
null,
...inputs.map((fr, i) =>
h(
'tr',
{ key: i },
h('td', { style: 'text-align:center;' }, String(i)),
h(
'td',
null,
h(
'span',
{ class: 'mono', style: 'overflow-wrap:anywhere; word-break:break-word;' },
String(fr),
),
),
),
),
),
),
);
}
function OutputsTable({ outputs }) {
if (!outputs?.length) {
return h('div', { style: 'color:var(--muted)' }, '—');
}
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // # (compact, centered)
h('col', null), // Public Key (fills)
h('col', { style: 'width:180px' }), // Value (compact, right)
),
h(
'thead',
null,
h(
'tr',
null,
h('th', { style: 'text-align:center;' }, '#'),
h('th', null, 'Public Key'), // ← back to Public Key second
h('th', { style: 'text-align:right;' }, 'Value'), // ← Value last
),
),
h(
'tbody',
null,
...outputs.map((note, idx) =>
h(
'tr',
{ key: idx },
// # (index)
h('td', { style: 'text-align:center;' }, String(idx)),
// Public Key (fills, wraps)
h(
'td',
null,
h(
'span',
{
class: 'mono',
style: 'display:inline-block; overflow-wrap:anywhere; word-break:break-word;',
title: renderBytes(note?.public_key),
},
renderBytes(note?.public_key),
),
),
// Value (right-aligned)
h('td', { class: 'amount', style: 'text-align:right;' }, toLocaleNum(note?.value)),
),
),
),
),
);
}
function Ledger({ ledger }) {
const { inputs, outputs, totalOutputValue } = ledger;
// Sum inputs as integers (Fr is declared as int in your schema)
const totalInputValue = inputs.reduce((sum, v) => sum + Number(v ?? 0), 0);
return h(
SectionCard,
{ title: 'Ledger Transaction' },
h(
'div',
{ style: 'display:grid; gap:16px;' },
// Inputs (with Total on the right)
h(
'div',
null,
h(
'div',
{ style: 'display:flex; align-items:center; gap:8px;' },
h('b', null, 'Inputs'),
h('span', { class: 'pill' }, String(inputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalInputValue)}`,
),
),
h(InputsTable, { inputs }),
),
// Outputs (unchanged header total)
h(
'div',
null,
h(
'div',
{ style: 'display:flex; align-items:center; gap:8px;' },
h('b', null, 'Outputs'),
h('span', { class: 'pill' }, String(outputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalOutputValue)}`,
),
),
h(OutputsTable, { outputs }),
),
),
);
}
// ————— page —————
export default function TransactionDetail({ parameters }) {
const idParam = parameters?.[0];
const id = Number.parseInt(String(idParam), 10);
const isValidId = Number.isInteger(id) && id >= 0;
const [tx, setTx] = useState(null);
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
const pageTitle = useMemo(() => `Transaction ${String(idParam)}`, [idParam]);
useEffect(() => {
document.title = pageTitle;
}, [pageTitle]);
useEffect(() => {
setTx(null);
setErr(null);
if (!isValidId) {
setErr({ kind: 'invalid', msg: 'Invalid transaction id.' });
return;
}
let alive = true;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), {
cache: 'no-cache',
signal: controller.signal,
});
if (res.status === 404 || res.status === 410) {
if (alive) setErr({ kind: 'not-found', msg: 'Transaction not found.' });
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (!alive) return;
setTx(normalizeTransaction(payload));
} catch (e) {
if (!alive || e?.name === 'AbortError') return;
setErr({ kind: 'network', msg: e?.message ?? 'Failed to load transaction' });
}
})();
return () => {
alive = false;
controller.abort();
};
}, [id, isValidId]);
return h(
'main',
{ class: 'wrap' },
h(
'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, pageTitle),
),
// Errors
err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg),
err?.kind === 'not-found' &&
h(
SectionCard,
{ title: 'Transaction not found' },
h('p', null, 'We could not find a transaction with that identifier.'),
),
err?.kind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${err.msg}`),
// Loading
!tx && !err && h('p', null, 'Loading…'),
// Success
tx && h(Fragment, null, h(Summary, { tx }), h(Ledger, { ledger: tx.ledger })),
);
}