Skip to content
Merged
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
138 changes: 88 additions & 50 deletions src/components/ContractEventFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* ContractEventFeed Component
*
*
* Real-time feed of contract events from Soroban smart contracts.
* Displays event history, filtering, and search capabilities.
*
*
* @component
* @example
* ```tsx
Expand All @@ -24,6 +24,22 @@
* @param props.limit - Maximum number of events to display (default: 100)
* @param props.autoRefresh - Auto-refresh interval in ms (default: 5000, 0 to disable)
* @param props.onEventClick - Callback when event is clicked
*
* @returns The rendered ContractEventFeed component
*
* @throws Error if contractId is invalid format
*
* @remarks
* - Auto-refreshes every 5 seconds by default
* - Shows timestamp, topics, and event data
* - Searchable event topics
* - Requires SorokitProvider context
* - Known issue: QR code scanner doesn't work with complex metadata (issue #8)
*
* @see {@link SorokitProvider} for setup
* @see GitHub issue #8 for QR code scanner limitation
*/
import { useEffect, useState } from "react";
*
* @returns The rendered ContractEventFeed component
*
Expand Down Expand Up @@ -130,59 +146,81 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/Badge";
import type { ContractEvent } from "@/lib/client";
import { getClient } from "@/lib/client";
import { truncateAddress } from "@/lib/utils";
import type { ContractEvent } from "@/lib/client";

export function ContractEventFeed({
contractId,
limit = 100,
autoRefresh = 5000,
onEventClick,
}: ContractEventFeedProps) {
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 EVENT_TYPE_VARIANT: Record<
string,
"success" | "warning" | "teal" | "purple" | "default"
> = {
transfer: "teal",
mint: "success",
burn: "warning",
approve: "purple",
};
const relativeTime = () => {
const diffM = Math.floor((Date.now() - lastUpdated) / 60000);
return diffM === 0 ? "just now" : `${diffM}m ago`;
};

function EventRow({ event }: { event: ContractEvent }) {
const variant = EVENT_TYPE_VARIANT[event.type] ?? "default";
const time = new Date(event.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
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="flex items-start gap-3 px-5 py-3.5 border-b border-line last:border-0">
<div className="flex flex-col items-center gap-1 shrink-0 mt-0.5">
<Badge variant={variant}>{event.type}</Badge>
<span className="text-[10px] text-ink-4 font-mono">{time}</span>
</div>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-ink-4">
Ledger
</span>
<span className="text-[11px] text-ink-2 font-mono">
{event.ledger}
</span>
</div>
{event.topics.length > 0 && (
<div className="flex flex-wrap gap-1">
{event.topics.map((t, i) => (
<span
key={i}
className="text-[10px] font-mono text-ink-3 bg-surface-2 rounded px-1.5 py-0.5 border border-line"
>
{t.length > 20 ? truncateAddress(t, 8, 4) : t}
</span>
))}
</div>
)}
{event.value !== null && event.value !== undefined && (
<pre className="text-[10px] font-mono text-ink-3 bg-surface-2 rounded-lg px-3 py-2 border border-line whitespace-pre-wrap break-all mt-0.5">
{JSON.stringify(event.value, null, 2)}
</pre>
)}
<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>
);
}
Expand Down
194 changes: 117 additions & 77 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,131 @@
import { AlertCircleIcon, Refresh01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Component, type ErrorInfo, type ReactNode } from "react";
/**
* ErrorBoundary Component
*
* React error boundary that catches errors in child components and displays
* a user-friendly error message instead of crashing the entire application.
*
* @component
* @example
* ```tsx
* import { ErrorBoundary } from 'sorokit-ui';
* import { MyComponent } from './MyComponent';
*
* export function App() {
* return (
* <ErrorBoundary>
* <MyComponent />
* </ErrorBoundary>
* );
* }
* ```
*
* @param props - Component props
* @param props.children - Child components to protect
* @param props.fallback - Optional custom fallback UI (default: error message)
* @param props.onError - Optional callback when error occurs
*
* @returns The rendered ErrorBoundary or fallback UI on error
*
* @remarks
* - Only catches errors in child component render and lifecycle
* - Event handlers should use try/catch
* - Async errors won't be caught (use Promise catch blocks)
*
* @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
import React from "react";

interface Props {
children: ReactNode;
/** Custom fallback UI. Receives the error and a reset callback. */
fallback?: (error: Error, reset: () => void) => ReactNode;
}

interface State {
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
resetKey: number;
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
export function ErrorBoundary({
children,
fallback,
onError,
isolate,
}: ErrorBoundaryProps) {
const [state, setState] = React.useState<ErrorBoundaryState>({
hasError: false,
error: null,
errorInfo: null,
resetKey: 0,
});

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[sorokit-ui] Uncaught error:", error, info.componentStack);
}
const reset = React.useCallback(() => {
setState((prev) => ({
hasError: false,
error: null,
errorInfo: null,
resetKey: prev.resetKey + 1,
}));
}, []);

reset = () => this.setState({ error: null });

render() {
const { error } = this.state;
if (!error) return this.props.children;
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 });
};

if (this.props.fallback) {
return this.props.fallback(error, this.reset);
// 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="min-h-screen flex items-center justify-center bg-base px-4">
<div className="w-full max-w-[400px] flex flex-col items-center gap-6 text-center">
<div className="w-14 h-14 rounded-2xl bg-error-dim border border-error-dim-strong flex items-center justify-center">
<HugeiconsIcon
icon={AlertCircleIcon}
size={24}
color="currentColor"
strokeWidth={1.5}
className="text-red"
/>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-ink">
Something went wrong
</h2>
<p className="text-[13px] text-ink-3 leading-relaxed">
An unexpected error occurred. You can try reloading the page or
resetting the component.
</p>
</div>
<div className="w-full rounded-xl border border-error-dim bg-error-dim-subtle px-5 py-4 text-left">
<p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-ink-4 mb-2">
Error
</p>
<p className="text-[12px] font-mono text-red break-all">
{import.meta.env.DEV
? error.message
: "See the browser console for details."}
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={this.reset}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg bg-surface-2 border border-line hover:border-line-2 text-[13px] text-ink-2 transition-colors cursor-pointer"
>
<HugeiconsIcon
icon={Refresh01Icon}
size={14}
color="currentColor"
strokeWidth={1.5}
/>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg bg-brand text-white text-[13px] font-medium hover:bg-brand-hover transition-colors cursor-pointer"
>
Reload page
</button>
</div>
</div>
<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;
onError?: (error: Error) => void;
}
Loading
Loading