|
|
|
@@ -0,0 +1,641 @@
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
import {
|
|
|
|
|
ArrowDownLeft,
|
|
|
|
|
ArrowRight,
|
|
|
|
|
ArrowUpRight,
|
|
|
|
|
Bitcoin,
|
|
|
|
|
Check,
|
|
|
|
|
Clock,
|
|
|
|
|
Copy,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
Hash,
|
|
|
|
|
Layers,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Weight,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
|
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
|
|
|
|
|
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
|
|
|
|
|
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
|
|
|
|
|
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Shared helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
|
|
|
|
|
if (str.length <= startLen + endLen + 3) return str;
|
|
|
|
|
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CopyButton({ text }: { text: string }) {
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
} catch {
|
|
|
|
|
// clipboard not available
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCopy}
|
|
|
|
|
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
|
|
|
|
|
title="Copy"
|
|
|
|
|
>
|
|
|
|
|
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Format a unix timestamp as a readable date string. */
|
|
|
|
|
function formatBlockTime(timestamp: number): string {
|
|
|
|
|
const date = new Date(timestamp * 1000);
|
|
|
|
|
return date.toLocaleString('en-US', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
hour12: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Format a large number with locale separators. */
|
|
|
|
|
function formatNumber(n: number): string {
|
|
|
|
|
return n.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Bitcoin Transaction Header
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function BitcoinTxHeader({ txid }: { txid: string }) {
|
|
|
|
|
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <TxSkeleton />;
|
|
|
|
|
|
|
|
|
|
if (error || !tx) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
|
|
|
|
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
|
|
|
|
<p className="text-sm text-destructive">Failed to load transaction</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
|
|
|
|
|
<a
|
|
|
|
|
href={`https://mempool.space/tx/${txid}`}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Bitcoin className="size-3.5" />
|
|
|
|
|
<span>View on mempool.space</span>
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="p-5 space-y-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className={`flex items-center justify-center size-10 rounded-full ${
|
|
|
|
|
tx.confirmed
|
|
|
|
|
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
|
|
|
|
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
|
|
|
|
}`}>
|
|
|
|
|
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-bold">
|
|
|
|
|
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
|
|
|
|
</h2>
|
|
|
|
|
{tx.blockTime && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Transaction ID */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
|
|
|
|
|
<CopyButton text={tx.txid} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats grid */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
{tx.confirmed && tx.blockHeight !== undefined && (
|
|
|
|
|
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
|
|
|
|
|
)}
|
|
|
|
|
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
|
|
|
|
|
<StatCard
|
|
|
|
|
icon={<Bitcoin className="size-3.5" />}
|
|
|
|
|
label="Fee"
|
|
|
|
|
value={`${formatSats(tx.fee)} sat`}
|
|
|
|
|
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
icon={<Hash className="size-3.5" />}
|
|
|
|
|
label="Amount"
|
|
|
|
|
value={`${formatBTC(tx.totalOutput)} BTC`}
|
|
|
|
|
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Inputs → Outputs flow */}
|
|
|
|
|
<div className="border-t border-border">
|
|
|
|
|
<TxFlow tx={tx} btcPrice={btcPrice} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer: link to mempool.space */}
|
|
|
|
|
<div className="border-t border-border px-5 py-2.5">
|
|
|
|
|
<a
|
|
|
|
|
href={`https://mempool.space/tx/${txid}`}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Bitcoin className="size-3.5" />
|
|
|
|
|
<span>View on mempool.space</span>
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
{icon}
|
|
|
|
|
<span>{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm font-semibold">{value}</p>
|
|
|
|
|
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Inputs → Outputs visualization, mempool.space-style. */
|
|
|
|
|
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4 space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
|
|
|
|
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
|
|
|
|
|
<ArrowRight className="size-3" />
|
|
|
|
|
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
|
|
|
{/* Inputs */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{tx.inputs.slice(0, 10).map((input, i) => (
|
|
|
|
|
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
|
|
|
|
|
))}
|
|
|
|
|
{tx.inputs.length > 10 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground text-center py-1">
|
|
|
|
|
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Outputs */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{tx.outputs.slice(0, 10).map((output, i) => (
|
|
|
|
|
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
|
|
|
|
|
))}
|
|
|
|
|
{tx.outputs.length > 10 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground text-center py-1">
|
|
|
|
|
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
|
|
|
|
|
if (input.isCoinbase) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
|
|
|
|
|
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
{input.address ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/i/bitcoin:address:${input.address}`}
|
|
|
|
|
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
|
|
|
|
|
>
|
|
|
|
|
{truncateMiddle(input.address, 10, 6)}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground">Unknown</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
|
|
|
|
|
</div>
|
|
|
|
|
{btcPrice !== undefined && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
|
|
|
|
|
const isOpReturn = output.scriptpubkeyType === 'op_return';
|
|
|
|
|
|
|
|
|
|
if (isOpReturn) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">OP_RETURN</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
{output.address ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/i/bitcoin:address:${output.address}`}
|
|
|
|
|
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
|
|
|
|
|
>
|
|
|
|
|
{truncateMiddle(output.address, 10, 6)}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground">Unknown</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
|
|
|
|
|
</div>
|
|
|
|
|
{btcPrice !== undefined && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TxSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
|
|
|
<div className="p-5 space-y-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Skeleton className="size-10 rounded-full" />
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-5 w-28" />
|
|
|
|
|
<Skeleton className="h-3.5 w-40" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-3 w-24" />
|
|
|
|
|
<Skeleton className="h-4 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="border-t border-border p-4 space-y-3">
|
|
|
|
|
<Skeleton className="h-3 w-32" />
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-12 rounded-lg" />
|
|
|
|
|
<Skeleton className="h-12 rounded-lg" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-12 rounded-lg" />
|
|
|
|
|
<Skeleton className="h-12 rounded-lg" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Bitcoin Address Header
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function BitcoinAddressHeader({ address }: { address: string }) {
|
|
|
|
|
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
|
|
|
|
|
|
|
|
|
|
if (isLoading) return <AddressSkeleton />;
|
|
|
|
|
|
|
|
|
|
if (error || !addressDetail) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
|
|
|
|
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
|
|
|
|
<p className="text-sm text-destructive">Failed to load address</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
|
|
|
<RefreshCw className="size-3.5 mr-1.5" />
|
|
|
|
|
Retry
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="p-5 space-y-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
|
|
|
|
|
<Bitcoin className="size-5" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-bold">Bitcoin Address</h2>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Address */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<p className="text-sm font-mono text-foreground break-all">{address}</p>
|
|
|
|
|
<CopyButton text={address} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Balance hero */}
|
|
|
|
|
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
|
|
|
|
|
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
|
|
|
|
|
<p className="text-3xl font-bold tracking-tight">
|
|
|
|
|
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{formatBTC(addressDetail.totalBalance)} BTC
|
|
|
|
|
</p>
|
|
|
|
|
{addressDetail.pendingBalance !== 0 && (
|
|
|
|
|
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
|
|
|
|
|
<RefreshCw className="size-3 animate-spin" />
|
|
|
|
|
{btcPrice
|
|
|
|
|
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
|
|
|
|
|
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats grid */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<StatCard
|
|
|
|
|
icon={<ArrowDownLeft className="size-3.5" />}
|
|
|
|
|
label="Total Received"
|
|
|
|
|
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
|
|
|
|
|
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
icon={<ArrowUpRight className="size-3.5" />}
|
|
|
|
|
label="Total Sent"
|
|
|
|
|
value={`${formatBTC(addressDetail.totalSent)} BTC`}
|
|
|
|
|
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Recent Transactions */}
|
|
|
|
|
{addressDetail.recentTxs.length > 0 && (
|
|
|
|
|
<div className="border-t border-border">
|
|
|
|
|
<div className="px-5 py-3">
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
|
|
|
Recent Transactions
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="divide-y divide-border">
|
|
|
|
|
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
|
|
|
|
|
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{addressDetail.recentTxs.length > 10 && (
|
|
|
|
|
<div className="px-5 py-3 text-center">
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Footer: link to mempool.space */}
|
|
|
|
|
<div className="border-t border-border px-5 py-2.5">
|
|
|
|
|
<a
|
|
|
|
|
href={`https://mempool.space/address/${address}`}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Bitcoin className="size-3.5" />
|
|
|
|
|
<span>View on mempool.space</span>
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
|
|
|
|
|
const isReceive = tx.type === 'receive';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/i/bitcoin:tx:${tx.txid}`}
|
|
|
|
|
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className={`flex items-center justify-center size-8 rounded-full ${
|
|
|
|
|
isReceive
|
|
|
|
|
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
|
|
|
|
: 'bg-red-500/10 text-red-600 dark:text-red-400'
|
|
|
|
|
}`}>
|
|
|
|
|
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
<p className={`text-sm font-medium ${
|
|
|
|
|
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
|
|
|
|
}`}>
|
|
|
|
|
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
|
|
|
|
|
</p>
|
|
|
|
|
{btcPrice && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{satsToUSD(tx.amount, btcPrice)}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AddressSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
|
|
|
<div className="p-5 space-y-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Skeleton className="size-10 rounded-full" />
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-5 w-32" />
|
|
|
|
|
<Skeleton className="h-3.5 w-24" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Skeleton className="h-3 w-16" />
|
|
|
|
|
<Skeleton className="h-4 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
|
|
|
|
|
<Skeleton className="h-3 w-12" />
|
|
|
|
|
<Skeleton className="h-9 w-40" />
|
|
|
|
|
<Skeleton className="h-4 w-28" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
<Skeleton className="h-16 rounded-xl" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Compact previews (used in NoteCard embeds, hover cards, etc.)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** Compact preview for a Bitcoin transaction — fetches real data. */
|
|
|
|
|
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
|
|
|
|
|
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="px-4 py-3 border-b border-border">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Skeleton className="size-12 rounded-lg shrink-0" />
|
|
|
|
|
<div className="flex-1 min-w-0 space-y-1.5">
|
|
|
|
|
<Skeleton className="h-3 w-32" />
|
|
|
|
|
<Skeleton className="h-4 w-3/4" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const amount = tx ? tx.totalOutput : 0;
|
|
|
|
|
const fee = tx?.fee ?? 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
to={link}
|
|
|
|
|
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
|
|
|
|
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
<Bitcoin className="size-3 shrink-0" />
|
|
|
|
|
<span>Bitcoin Transaction</span>
|
|
|
|
|
{tx && (
|
|
|
|
|
<span className={tx.confirmed
|
|
|
|
|
? 'text-green-600 dark:text-green-400'
|
|
|
|
|
: 'text-yellow-600 dark:text-yellow-400'
|
|
|
|
|
}>
|
|
|
|
|
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm font-medium truncate mt-0.5">
|
|
|
|
|
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
|
|
|
|
|
{tx && btcPrice ? (
|
|
|
|
|
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</p>
|
|
|
|
|
{tx && (
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
Fee {formatSats(fee)} sats
|
|
|
|
|
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Compact preview for a Bitcoin address — fetches real data. */
|
|
|
|
|
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
|
|
|
|
|
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="px-4 py-3 border-b border-border">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Skeleton className="size-12 rounded-lg shrink-0" />
|
|
|
|
|
<div className="flex-1 min-w-0 space-y-1.5">
|
|
|
|
|
<Skeleton className="h-3 w-28" />
|
|
|
|
|
<Skeleton className="h-4 w-3/4" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const balance = addressDetail?.totalBalance ?? 0;
|
|
|
|
|
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
to={link}
|
|
|
|
|
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
|
|
|
|
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
<Bitcoin className="size-3 shrink-0" />
|
|
|
|
|
<span>Bitcoin Address</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm font-medium truncate mt-0.5">
|
|
|
|
|
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
|
|
|
|
|
{addressDetail && btcPrice ? (
|
|
|
|
|
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</p>
|
|
|
|
|
{addressDetail && (
|
|
|
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
|
|
|
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
|
|
|
|
|
{' · '}
|
|
|
|
|
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|