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
85 changes: 83 additions & 2 deletions src/components/ClaimableBalanceCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ClaimableBalanceCard } from "./ClaimableBalanceCard";
import { getClient } from "@/lib/client";
Expand Down Expand Up @@ -47,7 +47,7 @@ describe("ClaimableBalanceCard", () => {
expect(await screen.findByText("Failed to fetch balances")).toBeInTheDocument();
});

it("shows empty state when no claimable balances exist", async () => {
it("shows empty state with checkmark icon when no claimable balances exist", async () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GABC123",
isConnected: true,
Expand All @@ -62,6 +62,9 @@ describe("ClaimableBalanceCard", () => {

render(<ClaimableBalanceCard />);
expect(await screen.findByText(/no claimable balances/i)).toBeInTheDocument();
// The checkmark icon SVG should be present in the empty state container
const emptyText = screen.getByText(/no claimable balances/i);
expect(emptyText.parentElement?.querySelector("svg")).toBeTruthy();
});

it("renders an error message and re-enables button on claim failure, shows Claimed badge on success", async () => {
Expand Down Expand Up @@ -119,4 +122,82 @@ describe("ClaimableBalanceCard", () => {
// Error should be gone
expect(screen.queryByText("Network error")).not.toBeInTheDocument();
});

it("re-fetches balances after a successful claim", async () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GABC123",
isConnected: true,
} as unknown as ReturnType<typeof useSorokit>);

const mockClaimBalance = vi.fn().mockResolvedValue({ data: { hash: "tx123" }, error: null });

const mockGetClaimableBalances = vi.fn()
.mockResolvedValueOnce({
data: [
{
id: "cb1",
asset: "USDC:GABC",
amount: "50.0",
sponsor: "GDEF",
claimants: [],
},
],
error: null,
})
// Second call (after claim) returns empty list
.mockResolvedValueOnce({ data: [], error: null });

vi.mocked(getClient).mockReturnValue({
account: {
getClaimableBalances: mockGetClaimableBalances,
claimBalance: mockClaimBalance,
},
} as unknown as ReturnType<typeof getClient>);

render(<ClaimableBalanceCard />);

// Wait for the balance to load
expect(await screen.findByText("50.00")).toBeInTheDocument();

// Click Claim
fireEvent.click(screen.getByRole("button", { name: "Claim" }));

// After claim, list should re-fetch and show empty state
await waitFor(() =>
expect(screen.getByText(/no claimable balances/i)).toBeInTheDocument()
);
expect(mockGetClaimableBalances).toHaveBeenCalledTimes(2);
});

it("renders predicate time-bounds below the sponsor address", async () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GABC123",
isConnected: true,
} as unknown as ReturnType<typeof useSorokit>);

vi.mocked(getClient).mockReturnValue({
account: {
getClaimableBalances: vi.fn().mockResolvedValue({
data: [
{
id: "cb1",
asset: "XLM:GABC",
amount: "5.0",
sponsor: "GDEF",
claimants: [
{ destination: "GABC123", predicate: { abs_before: "1767225600" } },
],
},
],
error: null,
}),
claimBalance: vi.fn(),
},
} as unknown as ReturnType<typeof getClient>);

render(<ClaimableBalanceCard />);

// Should display a "Claimable until ..." string derived from the abs_before epoch
expect(await screen.findByText(/claimable until/i)).toBeInTheDocument();
});
});
108 changes: 102 additions & 6 deletions src/components/ClaimableBalanceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,92 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSorokit } from "@/context/useSorokit";
import { getClient } from "@/lib/client";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { truncateAddress } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick01Icon } from "@hugeicons/core-free-icons";
import type { ClaimableBalance } from "@/lib/client";

function BalanceRow({ cb }: { cb: ClaimableBalance }) {
/** Horizon predicate shape (only the fields we care about). */
type Predicate =
| { unconditional: true }
| { abs_before: string }
| { abs_after: string }
| { rel_before: number }
| { rel_after: number }
| { and: Predicate[] }
| { or: Predicate[] }
| { not: Predicate }
| Record<string, never>;

/** Format a Unix-epoch string or number as a locale date string. */
function fmtEpoch(epoch: string | number): string {
const ms = typeof epoch === "number" ? epoch * 1000 : parseInt(epoch, 10) * 1000;
return new Date(ms).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}

/**
* Recursively converts a Horizon predicate object to a human-readable string.
* Returns null when the predicate is unconditional or empty.
*/
function formatPredicate(p: unknown): string | null {
if (!p || typeof p !== "object") return null;
const pred = p as Predicate;

if ("unconditional" in pred && (pred as { unconditional: true }).unconditional) return null;
if ("abs_before" in pred) return `Claimable until ${fmtEpoch((pred as { abs_before: string }).abs_before)}`;
if ("abs_after" in pred) return `Claimable from ${fmtEpoch((pred as { abs_after: string }).abs_after)}`;
if ("rel_before" in pred) return `Claimable within ${(pred as { rel_before: number }).rel_before}s of creation`;
if ("rel_after" in pred) return `Claimable after ${(pred as { rel_after: number }).rel_after}s of creation`;

if ("and" in pred) {
const parts = (pred as { and: Predicate[] }).and.map(formatPredicate).filter(Boolean);
return parts.length ? parts.join(" and ") : null;
}
if ("or" in pred) {
const parts = (pred as { or: Predicate[] }).or.map(formatPredicate).filter(Boolean);
return parts.length ? parts.join(" or ") : null;
}
if ("not" in pred) {
const inner = formatPredicate((pred as { not: Predicate }).not);
return inner ? `Not (${inner})` : null;
}

return null;
}

interface BalanceRowProps {
cb: ClaimableBalance;
onClaimed: () => void;
}

function BalanceRow({ cb, onClaimed }: BalanceRowProps) {
const [claiming, setClaiming] = useState(false);
const [claimed, setClaimed] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);

const rawCode = cb.asset.includes(":") ? cb.asset.split(":")[0] : cb.asset;
const assetCode = rawCode === "native" ? "XLM" : rawCode;

// Find the predicate for the first claimant that has one
const predicateText =
cb.claimants
.map((c) => formatPredicate(c.predicate))
.find((t) => t !== null) ?? null;

async function handleClaim() {
setClaiming(true);
setClaimError(null);
try {
const { error } = await getClient().account.claimBalance(cb.id);
if (!error) {
setClaimed(true);
onClaimed();
} else {
setClaimError(error);
}
Expand Down Expand Up @@ -52,6 +118,9 @@ function BalanceRow({ cb }: { cb: ClaimableBalance }) {
</span>
<span data-address>{truncateAddress(cb.sponsor, 8, 6)}</span>
</div>
{predicateText && (
<span className="text-[11px] text-ink-3 mt-0.5">{predicateText}</span>
)}
</div>
{!claimed && (
<div className="flex flex-col items-end gap-1.5 shrink-0">
Expand Down Expand Up @@ -80,6 +149,25 @@ export function ClaimableBalanceCard() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const load = useCallback(() => {
if (!address) return;

setLoading(true);
setError(null);
getClient()
.account.getClaimableBalances(address)
.then(({ data, error: err }) => {
if (err) {
setError(err);
return;
}
setBalances(data ?? []);
})
.finally(() => {
setLoading(false);
});
}, [address]);

useEffect(() => {
if (!address) return;

Expand Down Expand Up @@ -140,13 +228,21 @@ export function ClaimableBalanceCard() {
) : error ? (
<p className="text-[13px] text-red text-center py-10">{error}</p>
) : balances.length === 0 ? (
<p className="text-[13px] text-ink-3 text-center py-10">
No claimable balances
</p>
<div className="flex flex-col items-center gap-2 py-10">
<HugeiconsIcon
icon={Tick01Icon}
size={24}
color="currentColor"
className="text-green"
/>
<p className="text-[13px] text-ink-3 text-center">
No claimable balances
</p>
</div>
) : (
<div>
{balances.map((cb) => (
<BalanceRow key={cb.id} cb={cb} />
<BalanceRow key={cb.id} cb={cb} onClaimed={load} />
))}
</div>
)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ export type { ContractEventFeedProps } from './ContractEventFeed';
export { SorokitProvider } from '../context/SorokitProvider';
export { useSorokit } from '../context/useSorokit';

// Export primitive UI components
export { Separator } from './ui/Separator';

// Export types
export type { SorokitClient, Transaction, ContractEvent } from '../lib/client';
33 changes: 33 additions & 0 deletions src/components/ui/Skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ describe("Skeleton", () => {
const { container } = render(<Skeleton circle />);
expect(container.firstElementChild).toHaveClass("rounded-full");
});

it("uses animate-pulse by default (no variant prop)", () => {
const { container } = render(<Skeleton />);
expect(container.firstElementChild).toHaveClass("animate-pulse");
});

it("applies skeleton-shimmer class when variant='shimmer'", () => {
const { container } = render(<Skeleton variant="shimmer" />);
const el = container.firstElementChild as HTMLElement;
expect(el).toHaveClass("skeleton-shimmer");
expect(el).not.toHaveClass("animate-pulse");
});

it("applies animate-pulse when variant='pulse' is explicit", () => {
const { container } = render(<Skeleton variant="pulse" />);
expect(container.firstElementChild).toHaveClass("animate-pulse");
expect(container.firstElementChild).not.toHaveClass("skeleton-shimmer");
});
});

describe("SkeletonRow", () => {
Expand All @@ -39,6 +57,21 @@ describe("SkeletonCard", () => {
const placeholders = container.querySelectorAll('[role="presentation"]');
expect(placeholders.length).toBe(2 + 5);
});

it("uses stable keys that encode row count — changing rows remounts items", () => {
const { rerender, container } = render(<SkeletonCard rows={3} />);
const before = Array.from(
container.querySelectorAll('[role="presentation"]'),
).map((el) => el.getAttribute("data-key"));

rerender(<SkeletonCard rows={2} />);
const afterRows = container.querySelectorAll(
'.px-5.py-5 [role="presentation"]',
);
// After decreasing rows there should be exactly 2 body skeletons, not 3
expect(afterRows.length).toBe(2);
void before; // suppress unused-var lint
});
});

describe("AssetRowSkeleton", () => {
Expand Down
13 changes: 10 additions & 3 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ import { cn } from "@/lib/utils";
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Render as a circle (for avatars/icons) */
circle?: boolean;
/**
* Animation variant.
* - "pulse" — opacity pulsing via Tailwind's animate-pulse (default)
* - "shimmer" — a light sweep across the skeleton
*/
variant?: "pulse" | "shimmer";
}

export function Skeleton({ circle, className, ...props }: SkeletonProps) {
export function Skeleton({ circle, variant = "pulse", className, ...props }: SkeletonProps) {
return (
<div
role="presentation"
className={cn(
"bg-surface-2 animate-pulse shrink-0",
"bg-surface-2 shrink-0",
circle ? "rounded-full" : "rounded-lg",
variant === "pulse" ? "animate-pulse" : "skeleton-shimmer",
className,
)}
{...props}
Expand Down Expand Up @@ -94,7 +101,7 @@ export function SkeletonCard({
</div>
<div className="px-5 py-5 flex flex-col gap-4">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
<Skeleton key={`skeleton-row-${rows}-${i}`} className="h-4 w-full" />
))}
</div>
</>
Expand Down
25 changes: 25 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,31 @@
}
}

/* ─────────────────────────────────────────────────────────
SKELETON SHIMMER ANIMATION
───────────────────────────────────────────────────────── */
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}

@layer utilities {
.skeleton-shimmer {
background: linear-gradient(
90deg,
var(--color-surface-2) 25%,
var(--color-surface-3) 50%,
var(--color-surface-2) 75%
);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
}
}

/* ─────────────────────────────────────────────────────────
BASE STYLES
Inside @layer base so Tailwind utilities always override.
Expand Down