Blockchain added
This commit is contained in:
532
app/(dashboard)/blockchain/page.tsx
Normal file
532
app/(dashboard)/blockchain/page.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Link2,
|
||||
Shield,
|
||||
Activity,
|
||||
Hash,
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Blocks,
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
getBlockchainTransactions,
|
||||
getBlockchainStats,
|
||||
verifyDocumentHashOnBlockchain,
|
||||
registerContractOnBlockchain,
|
||||
} from "@/features/blockchain/api/blockchain.action";
|
||||
import { getContracts } from "@/features/contracts/api/contract.action";
|
||||
import type {
|
||||
BlockchainTransactionView,
|
||||
BlockchainStats,
|
||||
} from "@/lib/services/blockchain.types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Blockchain Explorer Page
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export default function BlockchainExplorerPage() {
|
||||
const [transactions, setTransactions] = useState<BlockchainTransactionView[]>([]);
|
||||
const [stats, setStats] = useState<BlockchainStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [verifyHash, setVerifyHash] = useState("");
|
||||
const [verifyResult, setVerifyResult] = useState<{
|
||||
exists: boolean;
|
||||
timestamp: number;
|
||||
depositor: string;
|
||||
} | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [copiedTx, setCopiedTx] = useState<string | null>(null);
|
||||
const [unregisteredContracts, setUnregisteredContracts] = useState<
|
||||
Array<{ id: string; title: string | null; fileName: string }>
|
||||
>([]);
|
||||
const [registeringId, setRegisteringId] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [txResult, statsResult, contractsResult] = await Promise.all([
|
||||
getBlockchainTransactions(),
|
||||
getBlockchainStats(),
|
||||
getContracts({ status: "COMPLETED" }),
|
||||
]);
|
||||
|
||||
if (txResult.success && txResult.transactions) {
|
||||
setTransactions(txResult.transactions);
|
||||
}
|
||||
if (statsResult.success && statsResult.stats) {
|
||||
setStats(statsResult.stats);
|
||||
}
|
||||
|
||||
// Find contracts not yet on blockchain
|
||||
if (contractsResult.success && contractsResult.contracts) {
|
||||
const registered = new Set(
|
||||
txResult.transactions?.map((tx) => tx.contractId) ?? []
|
||||
);
|
||||
const unregistered = contractsResult.contracts
|
||||
.filter(
|
||||
(c: { id: string; txHash?: string | null; status: string }) =>
|
||||
!c.txHash && !registered.has(c.id) && c.status === "COMPLETED"
|
||||
)
|
||||
.map((c: { id: string; title: string | null; fileName: string }) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
fileName: c.fileName,
|
||||
}));
|
||||
setUnregisteredContracts(unregistered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load blockchain data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verifyHash.trim()) return;
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const result = await verifyDocumentHashOnBlockchain(verifyHash.trim());
|
||||
if (result.success && result.verification) {
|
||||
setVerifyResult(result.verification);
|
||||
} else {
|
||||
toast.error(result.error || "Verification failed");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to verify hash");
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (contractId: string) => {
|
||||
setRegisteringId(contractId);
|
||||
try {
|
||||
const result = await registerContractOnBlockchain(contractId);
|
||||
if (result.success) {
|
||||
toast.success("Contract registered on blockchain!");
|
||||
await loadData();
|
||||
} else {
|
||||
toast.error(result.error || "Registration failed");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to register on blockchain");
|
||||
} finally {
|
||||
setRegisteringId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedTx(text);
|
||||
setTimeout(() => setCopiedTx(null), 2000);
|
||||
};
|
||||
|
||||
const formatTimestamp = (iso: string) => {
|
||||
return new Date(iso).toLocaleString();
|
||||
};
|
||||
|
||||
const truncateHash = (hash: string, chars = 8) => {
|
||||
if (!hash || hash.length <= chars * 2 + 3) return hash;
|
||||
return `${hash.slice(0, chars + 2)}...${hash.slice(-chars)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
{/* Page Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<Blocks className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
Blockchain Explorer
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
View on-chain proofs and verify document integrity
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<StatsCard
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
label="Verified Documents"
|
||||
value={stats?.totalVerified?.toString() ?? "0"}
|
||||
color="emerald"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={<Blocks className="w-5 h-5" />}
|
||||
label="Latest Block"
|
||||
value={stats?.latestBlockNumber ? `#${stats.latestBlockNumber.toLocaleString()}` : "—"}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={<Activity className="w-5 h-5" />}
|
||||
label="Network"
|
||||
value={stats?.networkName ? `${stats.networkName} ${stats.chainId ? `(Chain ${stats.chainId})` : ""}` : "Not Configured"}
|
||||
color={stats?.networkStatus === "connected" ? "emerald" : "red"}
|
||||
badge={stats?.networkStatus === "connected" ? "● Live" : "● Offline"}
|
||||
/>
|
||||
<StatsCard
|
||||
icon={<Hash className="w-5 h-5" />}
|
||||
label="Wallet"
|
||||
value={stats?.walletAddress ? truncateHash(stats.walletAddress, 6) : "—"}
|
||||
color="violet"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Unregistered contracts */}
|
||||
{unregisteredContracts.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Upload className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold text-amber-600 dark:text-amber-400">
|
||||
{unregisteredContracts.length} contract{unregisteredContracts.length > 1 ? "s" : ""} not yet on blockchain
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{unregisteredContracts.map((contract) => (
|
||||
<div
|
||||
key={contract.id}
|
||||
className="flex items-center justify-between rounded-xl bg-background/60 border border-border/40 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm truncate">
|
||||
{contract.title || contract.fileName}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 text-xs shrink-0 ml-3"
|
||||
disabled={registeringId === contract.id}
|
||||
onClick={() => handleRegister(contract.id)}
|
||||
>
|
||||
{registeringId === contract.id ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="w-3 h-3" />
|
||||
)}
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Transactions List */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="lg:col-span-2 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl overflow-hidden"
|
||||
>
|
||||
<div className="p-5 border-b border-border/40">
|
||||
<h2 className="font-semibold text-foreground flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-primary" />
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
All documents registered on the blockchain
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||
Loading transactions...
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
<Blocks className="w-8 h-8 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">No transactions yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Upload and analyze a contract to register it on-chain
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{transactions.map((tx, idx) => (
|
||||
<motion.div
|
||||
key={tx.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="p-4 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{tx.contractTitle || tx.contractFileName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mt-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground group">
|
||||
<Shield className="w-3 h-3 shrink-0 text-emerald-500/70" />
|
||||
<span className="font-mono text-[10px]">Fingerprint: {truncateHash(tx.documentHash, 12)}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(tx.documentHash)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground"
|
||||
title="Copy Document Fingerprint"
|
||||
>
|
||||
{copiedTx === tx.documentHash ? (
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Hash className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono">Tx: {truncateHash(tx.txHash, 12)}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(tx.txHash)}
|
||||
className="hover:text-foreground transition-colors"
|
||||
title="Copy Transaction Hash"
|
||||
>
|
||||
{copiedTx === tx.txHash ? (
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Blocks className="w-3 h-3 shrink-0" />
|
||||
Block #{tx.blockNumber.toLocaleString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
{formatTimestamp(tx.blockTimestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tx.network === "sepolia" ? "Sepolia" : "Hardhat"}
|
||||
</span>
|
||||
{tx.explorerUrl && (
|
||||
<a
|
||||
href={tx.explorerUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
Etherscan
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Verification Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl h-fit"
|
||||
>
|
||||
<div className="p-5 border-b border-border/40">
|
||||
<h2 className="font-semibold text-foreground flex items-center gap-2">
|
||||
<Search className="w-4 h-4 text-primary" />
|
||||
Verify Document
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Check if a document hash exists on-chain
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||
Document Hash (SHA-256)
|
||||
</label>
|
||||
<textarea
|
||||
value={verifyHash}
|
||||
onChange={(e) => setVerifyHash(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full rounded-xl border border-border/60 bg-muted/20 px-3 py-2.5 text-xs font-mono resize-none h-20 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!verifyHash.trim() || verifying}
|
||||
className="w-full gap-2"
|
||||
size="sm"
|
||||
>
|
||||
{verifying ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
Verify On-Chain
|
||||
</Button>
|
||||
|
||||
{/* Verification Result */}
|
||||
{verifyResult !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`rounded-xl border p-4 ${
|
||||
verifyResult.exists
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-red-500/30 bg-red-500/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{verifyResult.exists ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
verifyResult.exists
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{verifyResult.exists
|
||||
? "✓ Document Verified"
|
||||
: "✗ Not Found"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{verifyResult.exists && (
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Timestamp</span>
|
||||
<span className="font-mono">
|
||||
{new Date(verifyResult.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Depositor</span>
|
||||
<span className="font-mono">
|
||||
{truncateHash(verifyResult.depositor, 6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Stats Card Sub-Component
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
function StatsCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
badge,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||
blue: "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20",
|
||||
violet: "text-violet-600 dark:text-violet-400 bg-violet-500/10 border-violet-500/20",
|
||||
red: "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20",
|
||||
};
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
emerald: "text-emerald-500",
|
||||
blue: "text-blue-500",
|
||||
violet: "text-violet-500",
|
||||
red: "text-red-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={`p-2 rounded-lg border ${colorMap[color]}`}>
|
||||
<span className={iconColors[color]}>{icon}</span>
|
||||
</div>
|
||||
{badge && (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
badge.includes("Live")
|
||||
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "text-red-600 dark:text-red-400 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-bold text-foreground mt-0.5 truncate">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user