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
6,398 changes: 5,487 additions & 911 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
}
],
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^0.0.0-beta.0",
"@creit.tech/stellar-wallets-kit": "^0.1.5",
"@hugeicons/core-free-icons": "^4.2.2",
"@hugeicons/react": "^1.1.9",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-slot": "^1.3.0",
"@stellar/stellar-sdk": "^13.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand All @@ -63,10 +67,16 @@
},
"devDependencies": {
"@size-limit/file": "^11.2.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"clsx": "^2.1.1",
"jsdom": "^24.1.3",
"size-limit": "^11.2.0",
"tailwind-merge": "^2.6.1",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
Expand Down
53 changes: 44 additions & 9 deletions src/components/BalanceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,58 @@ import { AssetBadge } from "@/components/AssetBadge";
import { AssetRowSkeleton } from "@/components/ui/Skeleton";
import type { Balance } from "@/lib/client";

/**
* Sort balances: XLM (native) always first, then alphabetically by asset code.
*/
function sortBalances(balances: Balance[]): Balance[] {
return [...balances].sort((a, b) => {
const aIsNative = a.assetType === "native";
const bIsNative = b.assetType === "native";
if (aIsNative && !bIsNative) return -1;
if (!aIsNative && bIsNative) return 1;
const aCode = a.assetType === "native" ? "XLM" : (a.assetCode ?? a.asset);
const bCode = b.assetType === "native" ? "XLM" : (b.assetCode ?? b.asset);
return aCode.localeCompare(bCode);
});
}

function isZeroBalance(balance: string): boolean {
return parseFloat(balance) === 0;
}

function AssetRow({ b }: { b: Balance }) {
const zero = isZeroBalance(b.balance);

return (
<div className="flex items-center justify-between px-5 py-4 border-b border-line last:border-0">
<div
className={`flex items-center justify-between px-5 py-4 border-b border-line last:border-0${zero ? " opacity-50" : ""}`}
aria-label={zero ? "zero balance trustline" : undefined}
>
<AssetBadge balance={b} />
<span className="text-[14px] font-semibold text-ink tabular-nums">
{parseFloat(b.balance).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
})}
</span>
<div className="flex flex-col items-end gap-0.5">
<span
className={`text-[14px] font-semibold tabular-nums${zero ? " text-ink-3" : " text-ink"}`}
>
{parseFloat(b.balance).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
})}
</span>
{zero && (
<span className="text-[11px] text-ink-3">Empty trustline</span>
)}
</div>
</div>
);
}

export function BalanceList() {
const { balances, isLoadingAccount, isConnected } = useSorokit();

// Use balance count for skeleton rows so the loading state matches the real list
const skeletonCount = balances.length > 0 ? balances.length : 3;
const sorted = sortBalances(balances);

return (
<div className="rounded-xl border border-line bg-surface overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-line">
Expand All @@ -39,7 +74,7 @@ export function BalanceList() {
</p>
) : isLoadingAccount ? (
<div>
{[1, 2, 3].map((i) => (
{Array.from({ length: skeletonCount }).map((_, i) => (
<AssetRowSkeleton key={i} />
))}
</div>
Expand All @@ -49,7 +84,7 @@ export function BalanceList() {
</p>
) : (
<div>
{balances.map((b) => (
{sorted.map((b) => (
<AssetRow key={b.asset} b={b} />
))}
</div>
Expand Down
178 changes: 127 additions & 51 deletions src/components/ContractEventFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,134 @@
/**
* ContractEventFeed Component
*
* Real-time feed of contract events from Soroban smart contracts.
* Displays event history, filtering, and search capabilities.
*
* @component
* @example
* ```tsx
* import { ContractEventFeed } from 'sorokit-ui';
*
* export function Dashboard() {
* return (
* <ContractEventFeed
* contractId="CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"
* limit={50}
* />
* );
* }
* ```
*
* @param props - Component props
* @param props.contractId - Smart contract ID to monitor
* @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 type { ContractEvent } from '@/lib/client';

export function ContractEventFeed({
contractId,
limit = 100,
autoRefresh = 5000,
onEventClick
}: ContractEventFeedProps) {
// Component implementation
}
import { useState, useEffect, useCallback, useRef } from "react";
import { getClient } from "@/lib/client";
import type { ContractEvent } from "@/lib/client";

export interface ContractEventFeedProps {
/** The Soroban contract ID to monitor */
contractId: string;
/** Maximum number of events to fetch (default: 10) */
limit?: number;
/** Polling interval in ms; 0 = no polling (default: 0) */
pollInterval?: number;
/** @deprecated use pollInterval instead */
autoRefresh?: number;
/** Callback when an event row is clicked */
onEventClick?: (event: ContractEvent) => void;
className?: string;
}

export function ContractEventFeed({
contractId,
limit = 10,
pollInterval = 0,
onEventClick,
className,
}: ContractEventFeedProps) {
const [events, setEvents] = useState<ContractEvent[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState(pollInterval > 0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const fetchEvents = useCallback(async () => {
try {
const { data, error: err } = await getClient().soroban.getEvents(
contractId,
limit,
);
if (err) {
setError(err);
setEvents(null);
} else {
setEvents(data ?? []);
setError(null);
}
} finally {
setLoading(false);
}
}, [contractId, limit]);

// Initial fetch + re-fetch when contractId changes
useEffect(() => {
setLoading(true);
setEvents(null);
setError(null);
fetchEvents();
}, [contractId, fetchEvents]);

// Polling
useEffect(() => {
if (!live || pollInterval <= 0) {
if (timerRef.current) clearInterval(timerRef.current);
return;
}
timerRef.current = setInterval(fetchEvents, pollInterval);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [live, pollInterval, fetchEvents]);

function toggleLive() {
setLive((prev) => !prev);
}

return (
<div className={className}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-[14px] font-semibold text-ink">Contract Events</h3>
{pollInterval > 0 && (
<button
type="button"
aria-pressed={live}
onClick={toggleLive}
className="text-[12px] text-ink-2 hover:text-ink"
>
{live ? "Live" : "Paused"}
</button>
)}
</div>

{/* Live region for accessibility */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{events !== null && !loading && !error
? `${events.length} events loaded`
: null}
</div>

{/* Content */}
{loading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-surface-2 rounded-lg" />
))}
</div>
) : error ? (
<p className="text-[13px] text-red">{error}</p>
) : events && events.length === 0 ? (
<p className="text-[13px] text-ink-3 text-center py-8">
No events found
</p>
) : (
<div className="space-y-1">
{(events ?? []).map((event) => (
<button
key={event.id}
type="button"
onClick={() => onEventClick?.(event)}
className="w-full text-left rounded-lg px-4 py-3 bg-surface-2 hover:bg-surface transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium text-ink">
{event.type}
</span>
<span className="text-[11px] text-ink-3">
{new Date(event.createdAt).toLocaleTimeString()}
</span>
</div>
</button>
))}
</div>
)}
</div>
);
}
Loading