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
87 changes: 86 additions & 1 deletion src/components/ClaimableBalanceCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";

Check failure on line 1 in src/components/ClaimableBalanceCard.test.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

Run autofix to sort these imports!

Check failure on line 1 in src/components/ClaimableBalanceCard.test.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

Run autofix to sort these imports!
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ClaimableBalanceCard } from "./ClaimableBalanceCard";
import { getClient } from "@/lib/client";
import { fireEvent,render, screen } from "@testing-library/react";
import { beforeEach,describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -49,7 +53,7 @@
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 @@ -64,6 +68,9 @@

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 @@ -121,4 +128,82 @@
// 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: 103 additions & 5 deletions src/components/ClaimableBalanceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useState } from "react";

Check failure on line 1 in src/components/ClaimableBalanceCard.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

Run autofix to sort these imports!

Check failure on line 1 in src/components/ClaimableBalanceCard.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

Run autofix to sort these imports!
import { useEffect, useState } from "react";

import { Badge } from "@/components/ui/Badge";
Expand All @@ -6,22 +7,89 @@
import type { ClaimableBalance } from "@/lib/client";
import { getClient } from "@/lib/client";
import { truncateAddress } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick01Icon } from "@hugeicons/core-free-icons";
import type { ClaimableBalance } from "@/lib/client";

/** 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 }: { cb: ClaimableBalance }) {
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 @@ -53,6 +121,9 @@
</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 @@ -81,6 +152,25 @@
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 @@ -141,13 +231,21 @@
) : 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
Loading