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

+
+
+ + {error} +
+
+
+ ); + } + + 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 +
+
+ + + + + + + + + + + + {events.map((event, index) => ( + + + + + + + + ))} + +
+ Event ID + + Type + + Ledger + + Topics + + Value +
+
+
+ + {event.id} + +
+
+ + {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 ( + + + + Contract Activity + + Recent transactions and contract events for this escrow + + + +
+ setActiveTab(value as 'transactions' | 'events')} className="w-full h-full"> + + Transactions + Recent Events + + + +
+ +
+
+ + +
+ +
+
+
+
+
+
+ ); +}; \ 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 (