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
172 changes: 172 additions & 0 deletions frontend/src/app/activity/activity-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import { useWallet } from "@/context/wallet-context";
import { ActivityHistory } from "@/components/dashboard/ActivityHistory";
import { BackendStreamEvent } from "@/lib/api-types";
import { Button } from "@/components/ui/Button";
import { Loader2, Download } from "lucide-react";
import { formatAmount } from "@/lib/amount";
import { downloadCSV } from "@/utils/csvExport";

const PAGE_SIZE = 10;
const API_BASE_URL = (
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"
).replace(/\/+$/, "");

const TABS = [
{ id: "ALL", label: "All" },
{ id: "CREATED", label: "Created" },
{ id: "WITHDRAWN", label: "Withdrawals" },
{ id: "TOPPED_UP", label: "Top-ups" },
{ id: "CANCELLED", label: "Cancellations" },
{ id: "PAUSED", label: "Paused/Resumed" },
];

export default function ActivityContent() {
const { session, status } = useWallet();
const [events, setEvents] = useState<BackendStreamEvent[]>([]);
const [activeTab, setActiveTab] = useState("ALL");
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

const fetchActivity = useCallback(
async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => {
if (!session?.publicKey) return;
setLoading(true);

try {
const typeFilter = tab === "PAUSED" ? "PAUSED,RESUMED" : tab;
const typeQuery = tab === "ALL" ? "" : `&type=${encodeURIComponent(typeFilter)}`;
const url =
`${API_BASE_URL}/v1/events?address=${encodeURIComponent(session.publicKey)}` +
`&page=${pageNum}&limit=${PAGE_SIZE}${typeQuery}`;

const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`Failed to fetch activity (${response.status})`);
}
const data = await response.json();

const next: BackendStreamEvent[] = Array.isArray(data?.events)
? data.events
: [];

setEvents((prev) => (append ? [...prev, ...next] : next));

if (typeof data?.hasMore === "boolean") {
setHasMore(data.hasMore);
} else {
setHasMore(next.length === PAGE_SIZE);
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.error("Failed to fetch activity:", error);
if (!append) setEvents([]);
setHasMore(false);
} finally {
setLoading(false);
}
},
[session],
);

useEffect(() => {
if (status !== "connected") return;
const controller = new AbortController();
const initLoad = async () => {
await fetchActivity(1, activeTab, false, controller.signal);
};
void initLoad();
return () => controller.abort();
}, [activeTab, status, fetchActivity]);

const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchActivity(nextPage, activeTab, true);
};

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,
}));
downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`);
};

if (status !== "connected") {
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>
<p className="text-slate-400">
Please connect your wallet to view your stream history.
</p>
</div>
);
}

return (
<main className="max-w-4xl mx-auto py-8 px-4 sm:px-6">
<header className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Stream Activity</h1>
<p className="text-slate-400">
Track all your incoming and outgoing payment stream events.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleExportCSV}
disabled={events.length === 0}
className="sm:w-auto w-full"
>
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</header>

{/* Tabs */}
<div className="flex gap-2 overflow-x-auto pb-4 mb-6 no-scrollbar">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setPage(1);
}}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all whitespace-nowrap border ${
activeTab === tab.id
? "bg-accent text-white border-accent"
: "bg-white/5 text-slate-400 border-white/10 hover:bg-white/10"
}`}
>
{tab.label}
</button>
))}
</div>

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

{hasMore && (
<div className="mt-12 flex justify-center">
<Button
variant="secondary"
onClick={loadMore}
disabled={loading}
className="w-full sm:w-auto min-w-[200px]"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Load More Activity
</Button>
</div>
)}
</main>
);
}
176 changes: 7 additions & 169 deletions frontend/src/app/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -1,173 +1,11 @@
"use client";
import type { Metadata } from "next";
import ActivityContent from "./activity-content";

import { useState, useEffect, useCallback } from "react";
import { useWallet } from "@/context/wallet-context";
import { ActivityHistory } from "@/components/dashboard/ActivityHistory";
import { BackendStreamEvent } from "@/lib/api-types";
import { Button } from "@/components/ui/Button";
import { Loader2, Download } from "lucide-react";
import { formatAmount } from "@/lib/amount";
import { downloadCSV } from "@/utils/csvExport";

const PAGE_SIZE = 10;
const API_BASE_URL = (
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"
).replace(/\/+$/, "");

const TABS = [
{ id: "ALL", label: "All" },
{ id: "CREATED", label: "Created" },
{ id: "WITHDRAWN", label: "Withdrawals" },
{ id: "TOPPED_UP", label: "Top-ups" },
{ id: "CANCELLED", label: "Cancellations" },
{ id: "PAUSED", label: "Paused/Resumed" },
];
export const metadata: Metadata = {
title: "Activity | FlowFi",
description: "View your stream activity and event history.",
};

export default function ActivityPage() {
const { session, status } = useWallet();
const [events, setEvents] = useState<BackendStreamEvent[]>([]);
const [activeTab, setActiveTab] = useState("ALL");
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

const fetchActivity = useCallback(
async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => {
if (!session?.publicKey) return;
setLoading(true);

try {
const typeFilter = tab === "PAUSED" ? "PAUSED,RESUMED" : tab;
const typeQuery = tab === "ALL" ? "" : `&type=${encodeURIComponent(typeFilter)}`;
const url =
`${API_BASE_URL}/v1/events?address=${encodeURIComponent(session.publicKey)}` +
`&page=${pageNum}&limit=${PAGE_SIZE}${typeQuery}`;

const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`Failed to fetch activity (${response.status})`);
}
const data = await response.json();

const next: BackendStreamEvent[] = Array.isArray(data?.events)
? data.events
: [];

setEvents((prev) => (append ? [...prev, ...next] : next));

// Prefer the server-provided hasMore; fall back to a length heuristic.
if (typeof data?.hasMore === "boolean") {
setHasMore(data.hasMore);
} else {
setHasMore(next.length === PAGE_SIZE);
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.error("Failed to fetch activity:", error);
if (!append) setEvents([]);
setHasMore(false);
} finally {
setLoading(false);
}
},
[session],
);

useEffect(() => {
if (status !== "connected") return;
const controller = new AbortController();
const initLoad = async () => {
await fetchActivity(1, activeTab, false, controller.signal);
};
void initLoad();
return () => controller.abort();
}, [activeTab, status, fetchActivity]);

const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchActivity(nextPage, activeTab, true);
};

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,
}));
downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`);
};

if (status !== "connected") {
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>
<p className="text-slate-400">
Please connect your wallet to view your stream history.
</p>
</div>
);
}

return (
<main className="max-w-4xl mx-auto py-8 px-4 sm:px-6">
<header className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Stream Activity</h1>
<p className="text-slate-400">
Track all your incoming and outgoing payment stream events.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleExportCSV}
disabled={events.length === 0}
className="sm:w-auto w-full"
>
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</header>

{/* Tabs */}
<div className="flex gap-2 overflow-x-auto pb-4 mb-6 no-scrollbar">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setPage(1);
}}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all whitespace-nowrap border ${
activeTab === tab.id
? "bg-accent text-white border-accent"
: "bg-white/5 text-slate-400 border-white/10 hover:bg-white/10"
}`}
>
{tab.label}
</button>
))}
</div>

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

{hasMore && (
<div className="mt-12 flex justify-center">
<Button
variant="secondary"
onClick={loadMore}
disabled={loading}
className="w-full sm:w-auto min-w-[200px]"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Load More Activity
</Button>
</div>
)}
</main>
);
return <ActivityContent />;
}
Loading
Loading