Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 73 additions & 3 deletions src/components/ContractEventFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContractEvent[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<number>(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 <div className="animate-pulse">Loading...</div>;
}
if (error) {
return <div className="text-red">{error}</div>;
}
if (!events || events.length === 0) {
return <p className="text-ink-3">No events found</p>;
}

return (
<div className="space-y-4">
<div className="text-xs text-ink-4">
Last updated: <span data-testid="last-updated">{relativeTime()}</span>
</div>
<ul className="list-none p-0 m-0 space-y-2">
{events.map((ev) => (
<li
key={ev.id}
className="cursor-pointer hover:bg-surface-2 p-2 rounded"
onClick={() => onEventClick?.(ev)}
>
<div className="text-sm font-medium">{ev.type}</div>
<div className="text-xs text-ink-3">
{new Date(ev.createdAt).toLocaleString()}
</div>
</li>
))}
</ul>
</div>
);
}

export interface ContractEventFeedProps {
Expand Down
93 changes: 88 additions & 5 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ErrorBoundaryState>({
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 (
<div className="p-4 bg-red-50 text-red-800 rounded">
<h2>Something went wrong</h2>
<pre>{state.error?.message}</pre>
<button onClick={reset} className="mt-2 btn-primary">
Try again
</button>
</div>
);
}

const content = (
<Boundary key={state.resetKey}>
{children}
</Boundary>
);

return isolate ? (
<div className="overflow-hidden rounded-xl">{content}</div>
) : (
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;
Expand Down
12 changes: 11 additions & 1 deletion src/components/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -52,13 +52,23 @@ function TxRow({ tx }: { tx: Transaction }) {
<span className="text-[10px] text-ink-3">· {tx.memo}</span>
)}
</div>
{/* New feePaid display */}
{tx.feePaid && (
<span className="text-[10px] text-ink-4">Fee paid: {tx.feePaid}</span>
)}
</div>
</div>

<div className="flex flex-col items-end gap-0.5 shrink-0">
<Badge variant={tx.successful ? "success" : "error"} live>
{tx.successful ? "Success" : "Failed"}
</Badge>
{/* New operationCount badge */}
{tx.operationCount > 1 && (
<Badge variant="primary" className="mt-1">
{tx.operationCount} ops
</Badge>
)}
<span className="text-[10px] text-ink-3">
{dateStr} {timeStr}
</span>
Expand Down
39 changes: 39 additions & 0 deletions src/components/TxRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TxRow tx={tx} />);
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(<TxRow tx={txMany} />);
expect(screen.getByText(/3 ops/)).toBeInTheDocument();

const txOne: Transaction = {
...txMany,
operationCount: 1,
} as Transaction;
rerender(<TxRow tx={txOne} />);
expect(screen.queryByText(/1 ops/)).not.toBeInTheDocument();
});
});
12 changes: 11 additions & 1 deletion src/screens/AccountScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function RefreshIcon() {
}

export function AccountScreen() {
const { isConnected, isLoadingAccount, refreshAccount } = useSorokit();
const { isConnected, isLoadingAccount, refreshAccount, network, balances } = useSorokit();

return (
<div className="flex flex-col gap-5">
Expand All @@ -43,6 +43,16 @@ export function AccountScreen() {
</Button>
</div>
)}
{isConnected && network?.name === "testnet" && balances.length === 0 && (
<a
href="https://friendbot.stellar.org"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
Fund with Friendbot
</a>
)}
<AccountCard />
<BalanceList />
<ClaimableBalanceCard />
Expand Down