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
8,361 changes: 6,153 additions & 2,208 deletions package-lock.json

Large diffs are not rendered by default.

51 changes: 40 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,28 @@
}
],
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^0.0.0-beta.0",
"@creit.tech/stellar-wallets-kit": "^1.9.5",
"@hugeicons/core-free-icons": "^4.1.2",
"@hugeicons/react": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@stellar/stellar-sdk": "^13.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sorokit-core": "^0.1.0"
"@tailwindcss/vite": "^4.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"qrcode": "1.5.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sorokit-core": "^0.1.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4"
},
"peerDependencies": {
"tailwindcss": "^3.0.0"
Expand All @@ -62,14 +79,26 @@
}
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@size-limit/file": "^11.2.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.2",
"@types/qrcode": "1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.1",
"size-limit": "^11.2.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vitest": "^1.0.0"
"terser": "^5.48.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10",
"vitest": "^3.0.5"
}
}
4 changes: 2 additions & 2 deletions src/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('Library Build', () => {
const cjsPath = path.resolve(__dirname, '../../dist/sorokit-ui.cjs.js');
if (fs.existsSync(cjsPath)) {
const content = fs.readFileSync(cjsPath, 'utf-8');
expect(content).toContain('module.exports');
expect(content).toMatch(/exports|module\.exports/);
}
});

Expand All @@ -24,7 +24,7 @@ describe('Library Build', () => {
if (fs.existsSync(esPath)) {
const content = fs.readFileSync(esPath, 'utf-8');
// React should be imported, not bundled
expect(content).toMatch(/from ['"]react['"]/);
expect(content).toMatch(/from\s*['"]react['"]/);
// But React internals should not be bundled
expect(content).not.toContain('ReactDOM.createRoot');
}
Expand Down
193 changes: 182 additions & 11 deletions src/components/ContractEventFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient } from "@/lib/client";
import { Badge } from "@/components/ui/Badge";
import { truncateAddress } from "@/lib/utils";
import type { ContractEvent } from "@/lib/client";
import { HugeiconsIcon } from "@hugeicons/react";
import { Refresh01Icon } from "@hugeicons/core-free-icons";

const EVENT_TYPE_VARIANT: Record<
string,
"success" | "warning" | "teal" | "purple" | "default"
> = {
transfer: "teal",
mint: "success",
burn: "warning",
approve: "purple",
};

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",
});

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>
</div>
);
}

/**
* ContractEventFeed Component
*
Expand Down Expand Up @@ -39,20 +102,128 @@
* @see {@link SorokitProvider} for setup
* @see GitHub issue #8 for QR code scanner limitation
*/
import type { ContractEvent } from '@/lib/client';
export interface ContractEventFeedProps {
contractId: string;
/** Auto-poll interval in ms. 0 = manual only. */
pollInterval?: number;
limit?: number;
}

export function ContractEventFeed({
contractId,
limit = 100,
autoRefresh = 5000,
onEventClick
pollInterval = 0,
limit = 10,
}: ContractEventFeedProps) {
// Component implementation
}
const [events, setEvents] = useState<ContractEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState(pollInterval > 0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

export interface ContractEventFeedProps {
contractId: string;
limit?: number;
autoRefresh?: number;
onEventClick?: (event: ContractEvent) => void;
const load = useCallback(async () => {
if (!contractId.trim()) return;
setLoading(true);
try {
const { data, error: err } = await getClient().soroban.getEvents(
contractId,
limit,
);
if (err) {
setError(err);
return;
}
setEvents(data ?? []);
setError(null);
} finally {
setLoading(false);
}
}, [contractId, limit]);

useEffect(() => {
const timerId = window.setTimeout(() => {
void load();
}, 0);

return () => {
window.clearTimeout(timerId);
};
}, [load]);

useEffect(() => {
if (live && pollInterval > 0) {
intervalRef.current = setInterval(() => {
void load();
}, pollInterval);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [live, pollInterval, load]);

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">
<div>
<h3 className="text-[14px] font-semibold text-ink">
Contract Events
</h3>
<p className="text-[12px] text-ink-3 mt-0.5 font-mono">
{truncateAddress(contractId, 10, 6)}
</p>
</div>
<div className="flex items-center gap-2">
{pollInterval > 0 && (
<button
onClick={() => setLive((l) => !l)}
aria-pressed={live}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border transition-colors ${live ? "bg-success-dim text-green border-success-dim-strong" : "bg-surface-2 text-ink-3 border-line-2"}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${live ? "bg-green animate-pulse" : "bg-ink-3"}`}
/>
{live ? "Live" : "Paused"}
</button>
)}
<button
onClick={() => void load()}
disabled={loading}
className="p-1.5 rounded-lg hover:bg-surface-2 text-ink-3 hover:text-ink-2 transition-colors disabled:opacity-40"
>
<HugeiconsIcon
icon={Refresh01Icon}
size={14}
color="currentColor"
strokeWidth={1.5}
className={loading ? "animate-spin" : ""}
/>
</button>
</div>
</div>

{error ? (
<p className="text-[13px] text-red text-center py-10">{error}</p>
) : loading && events.length === 0 ? (
<div className="px-5 py-4 flex flex-col gap-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-12 rounded-lg bg-surface-2 animate-pulse"
/>
))}
</div>
) : events.length === 0 ? (
<p className="text-[13px] text-ink-3 text-center py-10">
No events found
</p>
) : (
<div aria-live="polite">
{events.map((e) => (
<EventRow key={e.id} event={e} />
))}
</div>
)}
</div>
);
}
Loading
Loading