synor/apps/explorer-web/src/pages/Transaction.tsx
Gulshan Yadav 48949ebb3f Initial commit: Synor blockchain monorepo
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
2026-01-08 05:22:17 +05:30

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>
);
}