diff --git a/src/components/ClaimableBalanceCard.test.tsx b/src/components/ClaimableBalanceCard.test.tsx index c150839..2efca45 100644 --- a/src/components/ClaimableBalanceCard.test.tsx +++ b/src/components/ClaimableBalanceCard.test.tsx @@ -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"; @@ -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, @@ -62,6 +62,9 @@ describe("ClaimableBalanceCard", () => { render(); 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 () => { @@ -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); + + 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); + + render(); + + // 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); + + 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); + + render(); + + // Should display a "Claimable until ..." string derived from the abs_before epoch + expect(await screen.findByText(/claimable until/i)).toBeInTheDocument(); + }); }); diff --git a/src/components/ClaimableBalanceCard.tsx b/src/components/ClaimableBalanceCard.tsx index 8e075e5..222de80 100644 --- a/src/components/ClaimableBalanceCard.tsx +++ b/src/components/ClaimableBalanceCard.tsx @@ -1,12 +1,71 @@ -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; + +/** 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(null); @@ -14,6 +73,12 @@ function BalanceRow({ cb }: { cb: ClaimableBalance }) { 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); @@ -21,6 +86,7 @@ function BalanceRow({ cb }: { cb: ClaimableBalance }) { const { error } = await getClient().account.claimBalance(cb.id); if (!error) { setClaimed(true); + onClaimed(); } else { setClaimError(error); } @@ -52,6 +118,9 @@ function BalanceRow({ cb }: { cb: ClaimableBalance }) { {truncateAddress(cb.sponsor, 8, 6)} + {predicateText && ( + {predicateText} + )} {!claimed && ( @@ -80,6 +149,25 @@ export function ClaimableBalanceCard() { const [loading, setLoading] = useState(false); const [error, setError] = useState(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; @@ -140,13 +228,21 @@ export function ClaimableBalanceCard() { ) : error ? ( {error} ) : balances.length === 0 ? ( - - No claimable balances - + + + + No claimable balances + + ) : ( {balances.map((cb) => ( - + ))} )} diff --git a/src/components/index.ts b/src/components/index.ts index 9cec7eb..52b0458 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/src/components/ui/Skeleton.test.tsx b/src/components/ui/Skeleton.test.tsx index df4460d..e98dbdb 100644 --- a/src/components/ui/Skeleton.test.tsx +++ b/src/components/ui/Skeleton.test.tsx @@ -18,6 +18,24 @@ describe("Skeleton", () => { const { container } = render(); expect(container.firstElementChild).toHaveClass("rounded-full"); }); + + it("uses animate-pulse by default (no variant prop)", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("animate-pulse"); + }); + + it("applies skeleton-shimmer class when variant='shimmer'", () => { + const { container } = render(); + 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(); + expect(container.firstElementChild).toHaveClass("animate-pulse"); + expect(container.firstElementChild).not.toHaveClass("skeleton-shimmer"); + }); }); describe("SkeletonRow", () => { @@ -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(); + const before = Array.from( + container.querySelectorAll('[role="presentation"]'), + ).map((el) => el.getAttribute("data-key")); + + rerender(); + 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", () => { diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index f6bac40..1b0c2c6 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -4,15 +4,22 @@ import { cn } from "@/lib/utils"; interface SkeletonProps extends React.HTMLAttributes { /** 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 ( {Array.from({ length: rows }).map((_, i) => ( - + ))} > diff --git a/src/index.css b/src/index.css index 6ddd9ae..a78cf90 100644 --- a/src/index.css +++ b/src/index.css @@ -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.
{error}
- No claimable balances -
+ No claimable balances +