diff --git a/package-lock.json b/package-lock.json
index ff4af88..d3097d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"clsx": "^2.1.1",
"framer-motion": "^12.5.0",
"lucide-react": "^0.482.0",
- "next": "15.3.6",
+ "next": "15.3.8",
"next-themes": "^0.4.6",
"react": "^19.1.2",
"react-dom": "^19.1.2",
@@ -808,9 +808,9 @@
}
},
"node_modules/@next/env": {
- "version": "15.3.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.6.tgz",
- "integrity": "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==",
+ "version": "15.3.8",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz",
+ "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -6802,12 +6802,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "15.3.6",
- "resolved": "https://registry.npmjs.org/next/-/next-15.3.6.tgz",
- "integrity": "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==",
+ "version": "15.3.8",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz",
+ "integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.3.6",
+ "@next/env": "15.3.8",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index bbe7d3d..c26201b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Suspense } from 'react'
import { NetworkProvider } from "@/contexts/NetworkContext";
+import { ThemeProvider } from "next-themes";
const geistSans = Geist({
@@ -33,15 +34,22 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
-
+
Loading...}>
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 987c070..49c4af4 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -12,17 +12,27 @@ import { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants";
import type { NextPage } from "next";
import { useRouter } from "next/navigation";
import { useNetwork } from "@/contexts/NetworkContext";
+import { getNetworkConfig } from "@/lib/network-config";
const inter = Inter({ subsets: ["latin"] });
const Home: NextPage = () => {
const router = useRouter();
- const { currentNetwork } = useNetwork();
+ const { currentNetwork, setNetwork } = useNetwork();
const [contractId, setContractId] = useState("");
const [error, setError] = useState(null);
const [isSearchFocused, setIsSearchFocused] = useState(false);
+ // Network switching for error recovery
+ const otherNetwork = currentNetwork === 'testnet' ? 'mainnet' : 'testnet';
+ const switchNetworkLabel = `Try ${getNetworkConfig(otherNetwork).name}`;
+ const handleSwitchNetwork = () => {
+ setNetwork(otherNetwork);
+ // Clear error when switching networks
+ setError(null);
+ };
+
// Responsive: detect mobile for SearchCard behaviour
const handleNavigate = async () => {
@@ -86,7 +96,11 @@ const Home: NextPage = () => {
handleUseExample={handleUseExample}
/>
-
+
diff --git a/src/components/escrow/EscrowDetails.tsx b/src/components/escrow/EscrowDetails.tsx
index 776fe90..8a7c78f 100644
--- a/src/components/escrow/EscrowDetails.tsx
+++ b/src/components/escrow/EscrowDetails.tsx
@@ -6,8 +6,10 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { motion } from "framer-motion";
import { NavbarSimple } from "@/components/Navbar";
import { TooltipProvider } from "@/components/ui/tooltip";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LoadingLogo } from "@/components/shared/loading-logo";
import { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants";
+import { getNetworkConfig } from "@/lib/network-config";
import { useRouter } from "next/navigation";
import { useNetwork } from "@/contexts/NetworkContext";
@@ -16,11 +18,16 @@ import { SearchCard } from "@/components/escrow/search-card";
import { ErrorDisplay } from "@/components/escrow/error-display";
import { EscrowContent } from "@/components/escrow/escrow-content";
import { TransactionTable } from "@/components/escrow/TransactionTable";
+import { EventTable } from "@/components/escrow/EventTable";
import { TransactionDetailModal } from "@/components/escrow/TransactionDetailModal";
+import { TransactionHistoryModal } from "@/components/escrow/TransactionHistoryModal";
import {
fetchTransactions,
+ fetchEvents,
type TransactionMetadata,
type TransactionResponse,
+ type EventMetadata,
+ type EventResponse,
} from "@/utils/transactionFetcher";
import { LedgerBalancePanel } from "@/components/escrow/LedgerBalancePanel";
import { useIsMobile } from "@/hooks/useIsMobile";
@@ -63,6 +70,16 @@ const isMobile = useIsMobile();
currentNetwork
);
+ // Network switching for error recovery
+ const { setNetwork } = useNetwork();
+ const otherNetwork = currentNetwork === 'testnet' ? 'mainnet' : 'testnet';
+ const switchNetworkLabel = `Try ${getNetworkConfig(otherNetwork).name}`;
+ const handleSwitchNetwork = () => {
+ setNetwork(otherNetwork);
+ router.push(`/${contractId}`);
+ // The network change will trigger a re-fetch automatically
+ };
+
const organizedWithLive = useMemo(() => {
if (!organized) return null;
if (!ledgerBalance) return organized; // nothing to override
@@ -83,8 +100,13 @@ const isMobile = useIsMobile();
const [transactionResponse, setTransactionResponse] = useState(null);
const [selectedTxHash, setSelectedTxHash] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
- const [showOnlyTransactions, setShowOnlyTransactions] = useState(false);
- const txRef = useRef(null);
+ const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
+
+ // Event-related state
+ const [events, setEvents] = useState([]);
+ const [eventLoading, setEventLoading] = useState(false);
+ const [eventError, setEventError] = useState(null);
+ const [eventResponse, setEventResponse] = useState(null);
// Fetch transaction data
@@ -94,7 +116,7 @@ const isMobile = useIsMobile();
setTransactionLoading(true);
setTransactionError(null);
try {
- const response = await fetchTransactions(id, { cursor, limit: 20 });
+ const response = await fetchTransactions(id, currentNetwork, { cursor, limit: 20 });
setTransactionResponse(response);
if (cursor) {
setTransactions((prev) => [...prev, ...response.transactions]);
@@ -107,15 +129,59 @@ const isMobile = useIsMobile();
setTransactionLoading(false);
}
},
+ [currentNetwork]
+ );
+
+ // Fetch event data
+ const fetchEventData = useCallback(
+ async (id: string, rpcUrl: string, cursor?: string) => {
+ if (!id) return;
+
+ // Basic validation for contract ID format
+ if (!/^C[A-Z0-9]{55}$/.test(id)) {
+ setEventError("Invalid contract ID format for event fetching.");
+ return;
+ }
+
+ setEventLoading(true);
+ setEventError(null);
+ try {
+ const response = await fetchEvents(id, rpcUrl, cursor, 20);
+ setEventResponse(response);
+ if (cursor) {
+ setEvents((prev) => [...prev, ...response.events]);
+ } else {
+ setEvents(response.events);
+ }
+ } catch (err: any) {
+ let errorMessage = "Failed to fetch contract events";
+
+ if (err instanceof Error) {
+ if (err.message.includes("startLedger must be within")) {
+ errorMessage = "Unable to fetch recent events due to RPC retention limits.";
+ } else if (err.message.includes("HTTP error")) {
+ errorMessage = "Network error while fetching events. Please try again.";
+ } else {
+ errorMessage = err.message;
+ }
+ }
+
+ setEventError(errorMessage);
+ } finally {
+ setEventLoading(false);
+ }
+ },
[]
);
- // Initial + network-change fetch (escrow + txs)
+ // Initial + network-change fetch (escrow + txs + events)
useEffect(() => {
if (!contractId) return;
- // useEscrowData auto-refreshes on contractId change; just ensure txs loaded:
+ // useEscrowData auto-refreshes on contractId change; just ensure txs and events loaded:
fetchTransactionData(contractId);
- }, [contractId, currentNetwork, fetchTransactionData]);
+ const { rpcUrl } = getNetworkConfig(currentNetwork);
+ fetchEventData(contractId, rpcUrl);
+ }, [contractId, currentNetwork, fetchTransactionData, fetchEventData]);
// Enter key in search
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -126,6 +192,8 @@ const isMobile = useIsMobile();
}
void refresh();
fetchTransactionData(contractId);
+ const { rpcUrl } = getNetworkConfig(currentNetwork);
+ fetchEventData(contractId, rpcUrl);
}
};
@@ -139,12 +207,13 @@ const isMobile = useIsMobile();
// Fetch button click
const handleFetch = async () => {
- if (!contractId) return;
if (contractId !== initialEscrowId) {
router.push(`/${contractId}`);
}
await refresh();
fetchTransactionData(contractId);
+ const { rpcUrl } = getNetworkConfig(currentNetwork);
+ fetchEventData(contractId, rpcUrl);
};
// Transactions UI handlers
@@ -162,16 +231,13 @@ const isMobile = useIsMobile();
}
};
- // When user toggles to show transactions, scroll the section into view
- useEffect(() => {
- if (showOnlyTransactions && txRef.current) {
- try {
- txRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
- } catch {
- // ignore scroll failures
- }
+ const handleLoadMoreEvents = () => {
+ if (eventResponse?.cursor && contractId) {
+ const { rpcUrl } = getNetworkConfig(currentNetwork);
+ fetchEventData(contractId, rpcUrl, eventResponse.cursor);
}
- }, [showOnlyTransactions]);
+ };
+
// === DEBUG LOGGING (EscrowDetails) ===
const DEBUG = true;
@@ -180,17 +246,17 @@ useEffect(() => {
if (!DEBUG) return;
console.log("[DBG][EscrowDetails] network:", currentNetwork);
console.log("[DBG][EscrowDetails] contractId:", contractId);
-}, [currentNetwork, contractId]);
+}, [currentNetwork, contractId, DEBUG]);
useEffect(() => {
if (!DEBUG) return;
console.log("[DBG][EscrowDetails] raw escrow map:", raw);
-}, [raw]);
+}, [raw, DEBUG]);
useEffect(() => {
if (!DEBUG) return;
console.log("[DBG][EscrowDetails] organized data:", organized);
-}, [organized]);
+}, [organized, DEBUG]);
useEffect(() => {
if (!DEBUG) return;
@@ -199,7 +265,7 @@ useEffect(() => {
decimals,
mismatch,
});
-}, [ledgerBalance, decimals, mismatch]);
+}, [ledgerBalance, decimals, mismatch, DEBUG]);
return (
@@ -224,102 +290,48 @@ useEffect(() => {
)}
{/* Search Card + View Transactions button (flexed together) */}
- {!showOnlyTransactions && (
-
-
-
-
-
- {raw && (
-
-
-
- )}
+
+
+
- )}
+
+ {raw && (
+
+
+
+ )}
+
{/* Error Display */}
-
+
- {/* Content Section (hidden when showing transactions as a page) */}
- {!showOnlyTransactions && (
-
- )}
+ {/* Content Section */}
+
{/* Live ledger balance (from token contract) */}
- {!showOnlyTransactions && raw && ledgerBalance && (
+ {raw && ledgerBalance && (
)}
- {/* Transaction History Section (renders only when requested) */}
- {raw && showOnlyTransactions && (
-
-
-
Transaction History
-
-
-
-
-
-
-
Complete blockchain activity record for this escrow contract
-
-
-
-
- )}
{/* Transaction Detail Modal */}
{
isOpen={isModalOpen}
onClose={handleModalClose}
isMobile={isMobile}
+ network={currentNetwork}
+ />
+
+ {/* Transaction History Modal */}
+ setIsHistoryModalOpen(false)}
+ isMobile={isMobile}
+ transactions={transactions}
+ transactionLoading={transactionLoading}
+ transactionError={transactionError}
+ transactionResponse={transactionResponse}
+ onLoadMoreTransactions={handleLoadMoreTransactions}
+ onTransactionClick={handleTransactionClick}
+ events={events}
+ eventLoading={eventLoading}
+ eventError={eventError}
+ eventResponse={eventResponse}
+ onLoadMoreEvents={handleLoadMoreEvents}
/>
diff --git a/src/components/escrow/EventTable.tsx b/src/components/escrow/EventTable.tsx
new file mode 100644
index 0000000..7ef5d23
--- /dev/null
+++ b/src/components/escrow/EventTable.tsx
@@ -0,0 +1,264 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { InfoTooltip } from "@/components/shared/info-tooltip";
+import {
+ Zap,
+ AlertCircle
+} from "lucide-react";
+import {
+ type EventMetadata,
+} from "@/utils/transactionFetcher";
+
+interface EventTableProps {
+ events: EventMetadata[];
+ loading: boolean;
+ error?: string | null;
+ hasMore: boolean;
+ onLoadMore: () => void;
+ isMobile: boolean;
+}
+
+export const EventTable: React.FC = ({
+ events,
+ loading,
+ error,
+ hasMore,
+ onLoadMore,
+ isMobile,
+}) => {
+ const getEventTypeColor = () => {
+ return "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/30 dark:text-purple-200 dark:border-purple-700/40";
+ };
+
+ if (loading && events.length === 0) {
+ return (
+
+
+
+
+
Recent Contract Events
+
+
+
+
+
+
Loading contract events...
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
Recent Contract Events
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Recent Contract Events
+
+
+
+
Last 7 days (RPC-limited)
+
+
+ {events.length === 0 ? (
+
+
+
+
+
No Contract Events Found
+
+ This contract has not emitted any events in the last 7 days, or events are not yet available via RPC.
+
+
+
Note: Contract events are only available for the last ~7 days due to RPC retention limits.
+
+
+ ) : (
+
+ {isMobile ? (
+ // Mobile: Card layout
+
+ {events.map((event, index) => (
+
+
+
+
+
+
+ Event {event.id}
+
+
+
+ {event.type}
+
+
+
+
+ Ledger:
+ {event.ledger.toLocaleString()}
+
+
+ {event.topics.length > 0 && (
+
+
Topics:
+
+ {event.topics.slice(0, 2).map((topic, idx) => (
+
+ {topic.length > 20 ? `${topic.substring(0, 20)}...` : topic}
+
+ ))}
+ {event.topics.length > 2 && (
+
+{event.topics.length - 2} more
+ )}
+
+
+ )}
+ {event.value && (
+
+
Value:
+
+ {event.value.length > 50 ? `${event.value.substring(0, 50)}...` : event.value}
+
+
+ )}
+
+
+ ))}
+
+ ) : (
+ // Desktop: Table layout
+
+
+
+
+
+ |
+ Event ID
+ |
+
+ Type
+ |
+
+ Ledger
+ |
+
+ Topics
+ |
+
+ Value
+ |
+
+
+
+ {events.map((event, index) => (
+
+
+
+ |
+
+
+ {event.type}
+
+ |
+
+ {event.ledger.toLocaleString()}
+ |
+
+ {event.topics.length > 0 ? (
+
+ {event.topics.slice(0, 1).map((topic, idx) => (
+
+ {topic}
+
+ ))}
+ {event.topics.length > 1 && (
+ +{event.topics.length - 1} more
+ )}
+
+ ) : (
+ None
+ )}
+ |
+
+ {event.value ? (
+
+ {event.value}
+
+ ) : (
+ None
+ )}
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ {hasMore && (
+
+
+
+ )}
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/escrow/TransactionDetailModal.tsx b/src/components/escrow/TransactionDetailModal.tsx
index 34cd8d2..dee8f07 100644
--- a/src/components/escrow/TransactionDetailModal.tsx
+++ b/src/components/escrow/TransactionDetailModal.tsx
@@ -29,12 +29,14 @@ import {
formatTransactionTime,
truncateHash,
} from "@/utils/transactionFetcher";
+import { type NetworkType } from "@/lib/network-config";
interface TransactionDetailModalProps {
txHash: string | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
+ network: NetworkType;
}
export const TransactionDetailModal: React.FC = ({
@@ -42,6 +44,7 @@ export const TransactionDetailModal: React.FC = ({
isOpen,
onClose,
isMobile,
+ network,
}) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false);
@@ -54,7 +57,7 @@ export const TransactionDetailModal: React.FC = ({
setError(null);
try {
- const transactionDetails = await fetchTransactionDetails(txHash);
+ const transactionDetails = await fetchTransactionDetails(txHash, network);
setDetails(transactionDetails);
} catch (err) {
setError("Failed to fetch transaction details");
@@ -62,7 +65,7 @@ export const TransactionDetailModal: React.FC = ({
} finally {
setLoading(false);
}
- }, [txHash]);
+ }, [txHash, network]);
const copyToClipboard = async (text: string) => {
try {
diff --git a/src/components/escrow/TransactionHistoryModal.tsx b/src/components/escrow/TransactionHistoryModal.tsx
new file mode 100644
index 0000000..c73862e
--- /dev/null
+++ b/src/components/escrow/TransactionHistoryModal.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useState } from "react";
+import { motion } from "framer-motion";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { TransactionTable } from "@/components/escrow/TransactionTable";
+import { EventTable } from "@/components/escrow/EventTable";
+import {
+ type TransactionMetadata,
+ type TransactionResponse,
+ type EventMetadata,
+ type EventResponse,
+} from "@/utils/transactionFetcher";
+import { type NetworkType } from "@/lib/network-config";
+
+interface TransactionHistoryModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ isMobile: boolean;
+ // Transaction data
+ transactions: TransactionMetadata[];
+ transactionLoading: boolean;
+ transactionError: string | null;
+ transactionResponse: TransactionResponse | null;
+ onLoadMoreTransactions: () => void;
+ onTransactionClick: (txHash: string) => void;
+ // Event data
+ events: EventMetadata[];
+ eventLoading: boolean;
+ eventError: string | null;
+ eventResponse: EventResponse | null;
+ onLoadMoreEvents: () => void;
+}
+
+export const TransactionHistoryModal: React.FC = ({
+ isOpen,
+ onClose,
+ isMobile,
+ transactions,
+ transactionLoading,
+ transactionError,
+ transactionResponse,
+ onLoadMoreTransactions,
+ onTransactionClick,
+ events,
+ eventLoading,
+ eventError,
+ eventResponse,
+ onLoadMoreEvents,
+}) => {
+ const [activeTab, setActiveTab] = useState<'transactions' | 'events'>('transactions');
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/escrow/error-display.tsx b/src/components/escrow/error-display.tsx
index 2a4e452..dccb5fe 100644
--- a/src/components/escrow/error-display.tsx
+++ b/src/components/escrow/error-display.tsx
@@ -1,11 +1,16 @@
import { motion, AnimatePresence } from "framer-motion"
-import { AlertCircle } from "lucide-react"
+import { AlertCircle, RefreshCw } from "lucide-react"
+import { Button } from "@/components/ui/button"
interface ErrorDisplayProps {
error: string | null
+ onSwitchNetwork?: () => void
+ switchNetworkLabel?: string
}
-export const ErrorDisplay = ({ error }: ErrorDisplayProps) => {
+export const ErrorDisplay = ({ error, onSwitchNetwork, switchNetworkLabel }: ErrorDisplayProps) => {
+ const isContractNotFound = error?.includes("Contract not found") && error?.includes("Try switching to")
+
return (
{error && (
@@ -13,10 +18,27 @@ export const ErrorDisplay = ({ error }: ErrorDisplayProps) => {
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
- className="max-w-2xl mx-auto mb-6 p-4 bg-red-50 text-red-800 rounded-md flex items-center gap-2 shadow-sm border border-red-100"
+ className="max-w-2xl mx-auto mb-6 p-4 bg-red-50 text-red-800 rounded-md shadow-sm border border-red-100"
>
-
- {error}
+
+
+
+
{error}
+ {isContractNotFound && onSwitchNetwork && switchNetworkLabel && (
+
+
+
+ )}
+
+
)}
diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx
index 4f255a8..3eafb6d 100644
--- a/src/components/ui/theme-toggle.tsx
+++ b/src/components/ui/theme-toggle.tsx
@@ -1,31 +1,41 @@
"use client";
+import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
- const [isDark, setIsDark] = useState(() => {
- try {
- if (typeof window === "undefined") return false;
- const stored = localStorage.getItem("theme");
- if (stored) return stored === "dark";
- return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
- } catch {
- return false;
- }
- });
+ const { theme, setTheme, resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
useEffect(() => {
- const root = document.documentElement;
- if (isDark) root.classList.add("dark");
- else root.classList.remove("dark");
- try {
- localStorage.setItem("theme", isDark ? "dark" : "light");
- } catch {
- // ignore
- }
- }, [isDark]);
+ setMounted(true);
+ }, []);
- const toggle = () => setIsDark((v) => !v);
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ const isDark = resolvedTheme === "dark";
+ const toggle = () => setTheme(theme === "dark" ? "light" : "dark");
return (