diff --git a/package.json b/package.json index e3ae8da..b98d0b4 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ } ], "dependencies": { - "@creit.tech/stellar-wallets-kit": "^0.0.0-beta.0", + "@stellar/stellar-sdk": "^13.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/ContractEventFeed.tsx b/src/components/ContractEventFeed.tsx index 6276844..d79f202 100644 --- a/src/components/ContractEventFeed.tsx +++ b/src/components/ContractEventFeed.tsx @@ -39,15 +39,85 @@ * @see {@link SorokitProvider} for setup * @see GitHub issue #8 for QR code scanner limitation */ -import type { ContractEvent } from '@/lib/client'; +import { useEffect, useState } from "react"; +import { getClient } from "@/lib/client"; +import type { ContractEvent } from "@/lib/client"; export function ContractEventFeed({ contractId, limit = 100, autoRefresh = 5000, - onEventClick + onEventClick, }: ContractEventFeedProps) { - // Component implementation + const [events, setEvents] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(Date.now()); + + const fetchEvents = async () => { + setLoading(true); + try { + const { data, error: err } = await getClient().soroban.getEvents(contractId, limit); + if (err) { + setError(err); + setEvents(null); + } else { + setEvents(data); + setError(null); + setLastUpdated(Date.now()); + } + } catch (e) { + setError((e as Error).message); + setEvents(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEvents(); + if (autoRefresh > 0) { + const interval = setInterval(fetchEvents, autoRefresh); + return () => clearInterval(interval); + } + }, [contractId, limit, autoRefresh]); + + const relativeTime = () => { + const diffM = Math.floor((Date.now() - lastUpdated) / 60000); + return diffM === 0 ? "just now" : `${diffM}m ago`; + }; + + if (loading && !events) { + return
Loading...
; + } + if (error) { + return
{error}
; + } + if (!events || events.length === 0) { + return

No events found

; + } + + return ( +
+
+ Last updated: {relativeTime()} +
+
    + {events.map((ev) => ( +
  • onEventClick?.(ev)} + > +
    {ev.type}
    +
    + {new Date(ev.createdAt).toLocaleString()} +
    +
  • + ))} +
+
+ ); } export interface ContractEventFeedProps { diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index aa8f935..4518f5b 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -33,14 +33,97 @@ * * @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary */ -export function ErrorBoundary({ - children, - fallback, - onError +import React from "react"; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; + resetKey: number; +} + +export function ErrorBoundary({ + children, + fallback, + onError, + isolate, }: ErrorBoundaryProps) { - // Component implementation + const [state, setState] = React.useState({ + hasError: false, + error: null, + errorInfo: null, + resetKey: 0, + }); + + const reset = React.useCallback(() => { + setState((prev) => ({ + hasError: false, + error: null, + errorInfo: null, + resetKey: prev.resetKey + 1, + })); + }, []); + + const componentDidCatch = (error: Error, errorInfo: React.ErrorInfo) => { + // Log in development, otherwise delegate to onError if provided + if (process.env.NODE_ENV === "development") { + console.error("[sorokit-ui] Uncaught error:", error, errorInfo.componentStack); + } else if (onError) { + onError(error, errorInfo); + } + setState({ hasError: true, error, errorInfo, resetKey: state.resetKey }); + }; + + // Use a class-less pattern: we need lifecycle hook, so use useEffect with error boundary via React error handling is not possible. + // Instead, create an inner class component to leverage componentDidCatch. + class Boundary extends React.Component<{ children: React.ReactNode }> { + componentDidCatch(error: Error, info: React.ErrorInfo) { + componentDidCatch(error, info); + } + render() { + return this.props.children; + } + } + + if (state.hasError) { + if (fallback) { + if (typeof fallback === "function") { + return (fallback as any)(state.error, reset); + } + return <>{fallback}; + } + return ( +
+

Something went wrong

+
{state.error?.message}
+ +
+ ); + } + + const content = ( + + {children} + + ); + + return isolate ? ( +
{content}
+ ) : ( + content + ); } +export interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode); + onError?: (error: Error, info: React.ErrorInfo) => void; + isolate?: boolean; +} + + export interface ErrorBoundaryProps { children: React.ReactNode; fallback?: React.ReactNode; diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 18813ea..acb9511 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -15,7 +15,7 @@ import { const PAGE_SIZE = 10; -function TxRow({ tx }: { tx: Transaction }) { +export function TxRow({ tx }: { tx: Transaction }) { const date = new Date(tx.createdAt); const timeStr = date.toLocaleTimeString([], { hour: "2-digit", @@ -52,6 +52,10 @@ function TxRow({ tx }: { tx: Transaction }) { ยท {tx.memo} )} + {/* New feePaid display */} + {tx.feePaid && ( + Fee paid: {tx.feePaid} + )} @@ -59,6 +63,12 @@ function TxRow({ tx }: { tx: Transaction }) { {tx.successful ? "Success" : "Failed"} + {/* New operationCount badge */} + {tx.operationCount > 1 && ( + + {tx.operationCount} ops + + )} {dateStr} {timeStr} diff --git a/src/components/TxRow.test.tsx b/src/components/TxRow.test.tsx new file mode 100644 index 0000000..f450049 --- /dev/null +++ b/src/components/TxRow.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { TxRow } from "./TransactionHistory"; +import type { Transaction } from "@/lib/client"; + +describe("TxRow component", () => { + it("displays feePaid when provided", () => { + const tx: Transaction = { + hash: "hash123", + ledger: 1000, + createdAt: new Date().toISOString(), + successful: true, + operationCount: 1, + feePaid: "100", + } as Transaction; + render(); + expect(screen.getByText(/Fee paid:/i)).toBeInTheDocument(); + expect(screen.getByText(/100/)).toBeInTheDocument(); + }); + + it("shows operationCount badge when >1 and hides when =1", () => { + const txMany: Transaction = { + hash: "hash456", + ledger: 1001, + createdAt: new Date().toISOString(), + successful: true, + operationCount: 3, + feePaid: "0", + } as Transaction; + const { rerender } = render(); + expect(screen.getByText(/3 ops/)).toBeInTheDocument(); + + const txOne: Transaction = { + ...txMany, + operationCount: 1, + } as Transaction; + rerender(); + expect(screen.queryByText(/1 ops/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/screens/AccountScreen.tsx b/src/screens/AccountScreen.tsx index 36a2683..d003186 100644 --- a/src/screens/AccountScreen.tsx +++ b/src/screens/AccountScreen.tsx @@ -25,7 +25,7 @@ function RefreshIcon() { } export function AccountScreen() { - const { isConnected, isLoadingAccount, refreshAccount } = useSorokit(); + const { isConnected, isLoadingAccount, refreshAccount, network, balances } = useSorokit(); return (
@@ -43,6 +43,16 @@ export function AccountScreen() {
)} + {isConnected && network?.name === "testnet" && balances.length === 0 && ( + + Fund with Friendbot + + )}