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
24 changes: 15 additions & 9 deletions frontend/src/app/activity/activity-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatAmount } from "@/utils/amount";
import { downloadCSV } from "@/utils/csvExport";
import { getApiBaseUrl } from "@/lib/api/_shared";
import { logger } from "@/lib/logger";
import { useAsyncPageData } from "@/hooks/useAsyncPageData";

const PAGE_SIZE = 10;
const API_BASE_URL = getApiBaseUrl();
Expand All @@ -31,6 +32,11 @@ export default function ActivityContent() {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

const pageState = useAsyncPageData({
isLoading: loading && events.length === 0,
data: events,
});

const fetchActivity = useCallback(
async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => {
if (!session?.publicKey) return;
Expand Down Expand Up @@ -89,18 +95,18 @@ export default function ActivityContent() {
};

const handleExportCSV = () => {
const csvData = events.map(event => ({
'Stream ID': event.streamId,
'Event Type': event.eventType,
'Amount': event.amount ? formatAmount(BigInt(event.amount), 7) : '0',
'Timestamp': new Date(event.timestamp * 1000).toLocaleString(),
'Transaction Hash': event.transactionHash,
'Ledger': event.ledgerSequence,
const csvData = events.map((event) => ({
"Stream ID": event.streamId,
"Event Type": event.eventType,
Amount: event.amount ? formatAmount(BigInt(event.amount), 7) : "0",
Timestamp: new Date(event.timestamp * 1000).toLocaleString(),
"Transaction Hash": event.transactionHash,
Ledger: event.ledgerSequence,
}));
downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`);
};

if (status !== "connected") {
if (!pageState.isConnected) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<h1 className="text-2xl font-bold mb-2 text-white">Access Denied</h1>
Expand Down Expand Up @@ -152,7 +158,7 @@ export default function ActivityContent() {
))}
</div>

<ActivityHistory events={events} isLoading={loading && events.length === 0} />
<ActivityHistory events={events} isLoading={pageState.isLoading} />

{hasMore && (
<div className="mt-12 flex justify-center">
Expand Down
28 changes: 16 additions & 12 deletions frontend/src/app/incoming/incoming-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useIncomingStreams,
useWithdrawIncomingStream,
} from "@/hooks/useIncomingStreams";
import { useAsyncPageData } from "@/hooks/useAsyncPageData";

interface TrackerState {
status: TransactionStatus;
Expand All @@ -40,12 +41,20 @@ function LoadingCard() {
}

export default function IncomingContent() {
const { session, status, isHydrated } = useWallet();
const { session, status } = useWallet();
const [tracker, setTracker] = React.useState<TrackerState>({
status: "idle",
});

const incomingStreamsQuery = useIncomingStreams(session?.publicKey);

const pageState = useAsyncPageData({
isLoading: incomingStreamsQuery.isLoading,
isError: incomingStreamsQuery.isError,
error: incomingStreamsQuery.error,
data: incomingStreamsQuery.data ?? [],
});

const withdrawMutation = useWithdrawIncomingStream(
session,
session?.publicKey,
Expand Down Expand Up @@ -91,9 +100,6 @@ export default function IncomingContent() {
}
};

const isLoading =
!isHydrated ||
(status === "connected" && incomingStreamsQuery.isLoading);
const streams = incomingStreamsQuery.data ?? [];

return (
Expand Down Expand Up @@ -124,13 +130,13 @@ export default function IncomingContent() {
</div>
</div>

{!isHydrated ? (
{!pageState.isHydrated ? (
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<LoadingCard />
<LoadingCard />
<LoadingCard />
</section>
) : status !== "connected" ? (
) : !pageState.isConnected ? (
<section className="mt-8 rounded-[2rem] border border-white/45 bg-white/75 p-10 text-center shadow-[0_24px_50px_rgba(15,23,42,0.07)]">
<h2 className="text-2xl font-semibold text-slate-950">
Connect a wallet to view incoming streams
Expand All @@ -140,24 +146,22 @@ export default function IncomingContent() {
where you are the recipient and keep the claimable balance fresh.
</p>
</section>
) : incomingStreamsQuery.isError ? (
) : pageState.isError ? (
<section className="mt-8 rounded-[2rem] border border-rose-200 bg-rose-50/80 p-8 shadow-[0_18px_36px_rgba(190,24,93,0.08)]">
<h2 className="text-xl font-semibold text-rose-900">
We couldn&apos;t load your incoming streams
</h2>
<p className="mt-2 text-sm text-rose-700">
{incomingStreamsQuery.error instanceof Error
? incomingStreamsQuery.error.message
: "Please try again in a moment."}
{pageState.errorMessage || "Please try again in a moment."}
</p>
</section>
) : isLoading ? (
) : pageState.isLoading ? (
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<LoadingCard />
<LoadingCard />
<LoadingCard />
</section>
) : streams.length === 0 ? (
) : pageState.isEmpty ? (
<section className="mt-8 rounded-[2rem] border border-white/45 bg-white/75 p-10 text-center shadow-[0_24px_50px_rgba(15,23,42,0.07)]">
<h2 className="text-2xl font-semibold text-slate-950">
No incoming streams yet
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/hooks/useAsyncPageData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { renderHook } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useAsyncPageData } from "./useAsyncPageData";
import { useWallet } from "@/context/wallet-context";

vi.mock("@/context/wallet-context", () => ({
useWallet: vi.fn(),
}));

describe("useAsyncPageData", () => {
const mockUseWallet = vi.mocked(useWallet);

beforeEach(() => {
vi.clearAllMocks();
});

it("returns isLoading true when not hydrated", () => {
mockUseWallet.mockReturnValue({
session: null,
status: "disconnected",
isHydrated: false,
} as any);

Check failure on line 22 in frontend/src/hooks/useAsyncPageData.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

const { result } = renderHook(() => useAsyncPageData({ data: [] }));

expect(result.current.isHydrated).toBe(false);
expect(result.current.isConnected).toBe(false);
expect(result.current.isLoading).toBe(true);
expect(result.current.isEmpty).toBe(false);
});

it("returns isConnected false when status is disconnected", () => {
mockUseWallet.mockReturnValue({
session: null,
status: "disconnected",
isHydrated: true,
} as any);

Check failure on line 37 in frontend/src/hooks/useAsyncPageData.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

const { result } = renderHook(() => useAsyncPageData({ data: [] }));

expect(result.current.isConnected).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.isEmpty).toBe(false);
});

it("handles loading state when wallet is connected", () => {
mockUseWallet.mockReturnValue({
session: { publicKey: "G123" },
status: "connected",
isHydrated: true,
} as any);

Check failure on line 51 in frontend/src/hooks/useAsyncPageData.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

const { result } = renderHook(() =>
useAsyncPageData({ isLoading: true, data: undefined })
);

expect(result.current.isConnected).toBe(true);
expect(result.current.isLoading).toBe(true);
expect(result.current.isEmpty).toBe(false);
});

it("evaluates empty state when data is empty array", () => {
mockUseWallet.mockReturnValue({
session: { publicKey: "G123" },
status: "connected",
isHydrated: true,
} as any);

Check failure on line 67 in frontend/src/hooks/useAsyncPageData.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

const { result } = renderHook(() =>
useAsyncPageData({ isLoading: false, data: [] })
);

expect(result.current.isConnected).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(result.current.isEmpty).toBe(true);
});

it("parses Error message on error state", () => {
mockUseWallet.mockReturnValue({
session: { publicKey: "G123" },
status: "connected",
isHydrated: true,
} as any);

Check failure on line 83 in frontend/src/hooks/useAsyncPageData.test.ts

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

const { result } = renderHook(() =>
useAsyncPageData({
isLoading: false,
isError: true,
error: new Error("Network failure"),
})
);

expect(result.current.isError).toBe(true);
expect(result.current.errorMessage).toBe("Network failure");
expect(result.current.isEmpty).toBe(false);
});
});
51 changes: 51 additions & 0 deletions frontend/src/hooks/useAsyncPageData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useWallet } from "@/context/wallet-context";

export interface AsyncPageDataOptions<T> {
isLoading?: boolean;
isError?: boolean;
error?: unknown;
data?: T[];
}

export interface AsyncPageDataState {
isHydrated: boolean;
isConnected: boolean;
isLoading: boolean;
isError: boolean;
errorMessage?: string;
isEmpty: boolean;
}

/**
* Shared hook to encapsulate async page state (hydration, wallet connection,
* loading state, error parsing, and empty state evaluation).
*/
export function useAsyncPageData<T>(options: AsyncPageDataOptions<T>): AsyncPageDataState {
const { session, status, isHydrated } = useWallet();

const isConnected = status === "connected" && Boolean(session?.publicKey);
const isLoading = !isHydrated || (isConnected && Boolean(options.isLoading));
const isError = isConnected && Boolean(options.isError);
const isEmpty =
isConnected &&
!isLoading &&
!isError &&
Array.isArray(options.data) &&
options.data.length === 0;

const errorMessage =
options.error instanceof Error
? options.error.message
: typeof options.error === "string"
? options.error
: undefined;

return {
isHydrated,
isConnected,
isLoading,
isError,
errorMessage,
isEmpty,
};
}
Loading