A complete blockchain implementation featuring: - synord: Full node with GHOSTDAG consensus - explorer-web: Modern React blockchain explorer with 3D DAG visualization - CLI wallet and tools - Smart contract SDK and example contracts (DEX, NFT, token) - WASM crypto library for browser/mobile
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/**
|
|
* Transaction details page.
|
|
* Auto-refreshes when transaction is unconfirmed to detect confirmation.
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import {
|
|
ArrowRight,
|
|
Clock,
|
|
Hash,
|
|
Box,
|
|
Coins,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Gift,
|
|
RefreshCw,
|
|
Radio,
|
|
} from 'lucide-react';
|
|
import { useTransaction } from '../hooks/useApi';
|
|
import { useWebSocket } from '../contexts/WebSocketContext';
|
|
import CopyButton from '../components/CopyButton';
|
|
import TransactionFlowDiagram from '../components/TransactionFlowDiagram';
|
|
import ConnectionStatus from '../components/ConnectionStatus';
|
|
import { formatDateTime, formatSynor, truncateHash } from '../lib/utils';
|
|
|
|
const UNCONFIRMED_REFRESH_INTERVAL = 5000; // 5 seconds
|
|
|
|
export default function Transaction() {
|
|
const { txId } = useParams<{ txId: string }>();
|
|
const { data: tx, isLoading, error, refetch } = useTransaction(txId || '');
|
|
const { isConnected } = useWebSocket();
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [justConfirmed, setJustConfirmed] = useState(false);
|
|
|
|
// Auto-refresh for unconfirmed transactions
|
|
useEffect(() => {
|
|
if (!tx || tx.blockHash) return; // Already confirmed or no data
|
|
|
|
const interval = setInterval(async () => {
|
|
setIsRefreshing(true);
|
|
await refetch();
|
|
setIsRefreshing(false);
|
|
}, UNCONFIRMED_REFRESH_INTERVAL);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [tx, refetch]);
|
|
|
|
// Detect when transaction gets confirmed
|
|
useEffect(() => {
|
|
if (tx?.blockHash && !justConfirmed) {
|
|
// Check if it was previously unconfirmed (first load with blockHash won't trigger)
|
|
const wasUnconfirmed = sessionStorage.getItem(`tx-${txId}-unconfirmed`);
|
|
if (wasUnconfirmed === 'true') {
|
|
setJustConfirmed(true);
|
|
sessionStorage.removeItem(`tx-${txId}-unconfirmed`);
|
|
// Auto-dismiss after 5 seconds
|
|
setTimeout(() => setJustConfirmed(false), 5000);
|
|
}
|
|
} else if (tx && !tx.blockHash) {
|
|
// Mark as unconfirmed for later detection
|
|
sessionStorage.setItem(`tx-${txId}-unconfirmed`, 'true');
|
|
}
|
|
}, [tx, txId, justConfirmed]);
|
|
|
|
if (!txId) {
|
|
return <div className="card p-6 text-red-400">Transaction ID is required</div>;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <TransactionSkeleton />;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="card p-6 text-red-400">
|
|
Error loading transaction: {error.message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!tx) {
|
|
return <div className="card p-6 text-gray-400">Transaction not found</div>;
|
|
}
|
|
|
|
const isUnconfirmed = !tx.blockHash;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Just confirmed banner */}
|
|
{justConfirmed && (
|
|
<div className="bg-green-500/20 border border-green-500/30 rounded-xl p-4 flex items-center gap-3 animate-pulse">
|
|
<CheckCircle size={24} className="text-green-400" />
|
|
<div>
|
|
<div className="font-semibold text-green-400">Transaction Confirmed!</div>
|
|
<div className="text-sm text-green-400/80">
|
|
This transaction has been included in a block.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modern Header */}
|
|
<div className="relative">
|
|
{/* Background glow */}
|
|
<div className="absolute -top-10 left-0 w-[300px] h-[150px] bg-green-500/10 rounded-full blur-[80px] pointer-events-none" />
|
|
|
|
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 rounded-xl bg-gradient-to-br from-green-500/20 to-synor-500/20 border border-green-500/30">
|
|
<Hash size={28} className="text-green-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
|
Transaction Details
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{tx.isCoinbase && (
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-amber-500/20 text-amber-400 rounded-full border border-amber-500/30 flex items-center gap-1">
|
|
<Gift size={12} />
|
|
Coinbase
|
|
</span>
|
|
)}
|
|
{tx.blockHash ? (
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/20 text-green-400 rounded-full border border-green-500/30 flex items-center gap-1">
|
|
<CheckCircle size={12} />
|
|
Confirmed
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-amber-500/20 text-amber-400 rounded-full border border-amber-500/30 flex items-center gap-1">
|
|
{isRefreshing ? (
|
|
<RefreshCw size={12} className="animate-spin" />
|
|
) : (
|
|
<AlertCircle size={12} />
|
|
)}
|
|
Unconfirmed
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick stats and status */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Waiting for confirmation indicator */}
|
|
{isUnconfirmed && isConnected && (
|
|
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs">
|
|
<Radio size={12} className="animate-pulse" />
|
|
<span>Waiting for confirmation...</span>
|
|
</div>
|
|
)}
|
|
|
|
<ConnectionStatus size="sm" />
|
|
|
|
<div className="px-4 py-2 rounded-xl bg-gray-800/50 border border-gray-700/50">
|
|
<div className="text-xs text-gray-500">Total Value</div>
|
|
<div className="text-lg font-bold text-green-400 font-mono">
|
|
{formatSynor(tx.totalOutput, 4)}
|
|
</div>
|
|
</div>
|
|
{!tx.isCoinbase && tx.fee > 0 && (
|
|
<div className="px-4 py-2 rounded-xl bg-gray-800/50 border border-gray-700/50">
|
|
<div className="text-xs text-gray-500">Fee</div>
|
|
<div className="text-lg font-bold text-amber-400 font-mono">
|
|
{formatSynor(tx.fee, 4)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unconfirmed notice */}
|
|
{isUnconfirmed && (
|
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4 flex items-center gap-3">
|
|
<Clock size={20} className="text-amber-400 flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<div className="font-medium text-amber-400">Pending Confirmation</div>
|
|
<div className="text-sm text-amber-400/70">
|
|
This transaction is in the mempool and waiting to be included in a block.
|
|
{isConnected && ' Page will auto-update when confirmed.'}
|
|
</div>
|
|
</div>
|
|
{isRefreshing && (
|
|
<RefreshCw size={16} className="text-amber-400 animate-spin flex-shrink-0" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Transaction Flow Diagram */}
|
|
<TransactionFlowDiagram
|
|
inputs={tx.inputs}
|
|
outputs={tx.outputs}
|
|
isCoinbase={tx.isCoinbase}
|
|
totalInput={tx.totalInput}
|
|
totalOutput={tx.totalOutput}
|
|
fee={tx.fee}
|
|
/>
|
|
|
|
{/* Transaction Info */}
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h2 className="font-semibold">Transaction Information</h2>
|
|
</div>
|
|
<div className="divide-y divide-gray-800">
|
|
<InfoRow label="Transaction ID">
|
|
<div className="flex items-center gap-2">
|
|
<span className="hash text-synor-400">{tx.id}</span>
|
|
<CopyButton text={tx.id} />
|
|
</div>
|
|
</InfoRow>
|
|
{tx.blockHash && (
|
|
<InfoRow label="Block">
|
|
<Link
|
|
to={`/block/${tx.blockHash}`}
|
|
className="flex items-center gap-2 text-synor-400 hover:text-synor-300"
|
|
>
|
|
<Box size={16} />
|
|
<span className="hash">{truncateHash(tx.blockHash, 12, 12)}</span>
|
|
</Link>
|
|
</InfoRow>
|
|
)}
|
|
{tx.blockTime && (
|
|
<InfoRow label="Timestamp">
|
|
<div className="flex items-center gap-2">
|
|
<Clock size={16} className="text-gray-500" />
|
|
{formatDateTime(tx.blockTime)}
|
|
</div>
|
|
</InfoRow>
|
|
)}
|
|
<InfoRow label="Total Output">
|
|
<span className="text-green-400 font-mono">
|
|
{formatSynor(tx.totalOutput)}
|
|
</span>
|
|
</InfoRow>
|
|
{!tx.isCoinbase && (
|
|
<InfoRow label="Fee">
|
|
<span className="text-gray-300 font-mono">
|
|
{formatSynor(tx.fee)}
|
|
</span>
|
|
</InfoRow>
|
|
)}
|
|
<InfoRow label="Mass">{tx.mass.toLocaleString()}</InfoRow>
|
|
<InfoRow label="Version">{tx.version}</InfoRow>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inputs & Outputs */}
|
|
<div className="grid md:grid-cols-2 gap-6">
|
|
{/* Inputs */}
|
|
<div className="card">
|
|
<div className="card-header flex items-center gap-2">
|
|
<Coins size={18} className="text-synor-400" />
|
|
<h2 className="font-semibold">
|
|
Inputs ({tx.inputs.length})
|
|
</h2>
|
|
</div>
|
|
<div className="divide-y divide-gray-800 max-h-96 overflow-y-auto scrollbar-thin">
|
|
{tx.isCoinbase ? (
|
|
<div className="px-4 py-3 text-gray-400">
|
|
Coinbase (Block Reward)
|
|
</div>
|
|
) : (
|
|
tx.inputs.map((input, i) => (
|
|
<div key={i} className="px-4 py-3">
|
|
{input.address ? (
|
|
<Link
|
|
to={`/address/${input.address}`}
|
|
className="hash text-sm text-synor-400 hover:text-synor-300 block mb-1"
|
|
>
|
|
{truncateHash(input.address, 12, 12)}
|
|
</Link>
|
|
) : (
|
|
<span className="hash text-sm text-gray-500 block mb-1">
|
|
Unknown
|
|
</span>
|
|
)}
|
|
{input.value !== undefined && (
|
|
<span className="text-sm text-red-400 font-mono">
|
|
-{formatSynor(input.value, 4)}
|
|
</span>
|
|
)}
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{truncateHash(input.previousTxId)}:{input.previousIndex}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Outputs */}
|
|
<div className="card">
|
|
<div className="card-header flex items-center gap-2">
|
|
<ArrowRight size={18} className="text-synor-400" />
|
|
<h2 className="font-semibold">
|
|
Outputs ({tx.outputs.length})
|
|
</h2>
|
|
</div>
|
|
<div className="divide-y divide-gray-800 max-h-96 overflow-y-auto scrollbar-thin">
|
|
{tx.outputs.map((output, i) => (
|
|
<div key={i} className="px-4 py-3">
|
|
{output.address ? (
|
|
<Link
|
|
to={`/address/${output.address}`}
|
|
className="hash text-sm text-synor-400 hover:text-synor-300 block mb-1"
|
|
>
|
|
{truncateHash(output.address, 12, 12)}
|
|
</Link>
|
|
) : (
|
|
<span className="hash text-sm text-gray-500 block mb-1">
|
|
{output.scriptType}
|
|
</span>
|
|
)}
|
|
<span className="text-sm text-green-400 font-mono">
|
|
+{formatSynor(output.value, 4)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
|
|
<span className="text-sm text-gray-400 sm:w-32 flex-shrink-0">{label}</span>
|
|
<span className="text-gray-100 break-all">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TransactionSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-pulse">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-lg bg-gray-800" />
|
|
<div>
|
|
<div className="h-7 w-48 bg-gray-800 rounded mb-2" />
|
|
<div className="h-5 w-24 bg-gray-800 rounded" />
|
|
</div>
|
|
</div>
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="h-5 w-44 bg-gray-800 rounded" />
|
|
</div>
|
|
<div className="divide-y divide-gray-800">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="px-4 py-3 flex items-center gap-4">
|
|
<div className="h-4 w-28 bg-gray-800 rounded" />
|
|
<div className="h-4 w-48 bg-gray-800 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|