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
107 changes: 107 additions & 0 deletions app/dashboard/lp/__tests__/LPPortfolioDashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LPPortfolioDashboardPage from "../page";
import { getLPPortfolioStats, listInvoicesByLP } from "@/utils/soroban";

const LP_ADDRESS = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6";

vi.mock("@/components/Navbar", () => ({
default: () => <nav>Navbar</nav>,
}));

vi.mock("@/components/Footer", () => ({
default: () => <footer>Footer</footer>,
}));

vi.mock("@/hooks/useDocumentTitle", () => ({
useDocumentTitle: vi.fn(),
}));

vi.mock("@/context/WalletContext", () => ({
useWallet: vi.fn(() => ({
address: LP_ADDRESS,
isConnected: true,
connect: vi.fn(),
})),
}));

vi.mock("@/hooks/useApprovedTokens", () => ({
useApprovedTokens: () => {
const token = {
contractId: "USDC",
name: "USD Coin",
symbol: "USDC",
decimals: 7,
iconLabel: "US",
logo: "/tokens/usdc.svg",
isAllowed: true,
};
return {
tokenMap: new Map([["USDC", token]]),
defaultToken: token,
};
},
}));

vi.mock("@/utils/soroban", async () => {
const actual = await vi.importActual<typeof import("@/utils/soroban")>("@/utils/soroban");
return {
...actual,
getLPPortfolioStats: vi.fn(),
listInvoicesByLP: vi.fn(),
};
});

const mockStats = {
total_deployed_by_token: [{ token: "USDC", amount: 250_000_000n }],
total_earned: 12_500_000n,
active_positions_count: 1,
average_yield_bps: 500,
};

const mockPosition = {
id: 42n,
status: "Funded",
freelancer: "GFREELANCER",
payer: "GPAYER",
amount: 250_000_000n,
due_date: 1_800_000_000n,
discount_rate: 500,
funder: LP_ADDRESS,
token: "USDC",
};

describe("LPPortfolioDashboardPage", () => {
beforeEach(() => {
vi.mocked(getLPPortfolioStats).mockResolvedValue(mockStats);
vi.mocked(listInvoicesByLP).mockResolvedValue([mockPosition]);
});

it("loads LP stats and positions for the connected wallet", async () => {
render(<LPPortfolioDashboardPage />);

expect(await screen.findByText("LP Portfolio Dashboard")).toBeInTheDocument();
await waitFor(() => expect(getLPPortfolioStats).toHaveBeenCalledWith(LP_ADDRESS));
expect(listInvoicesByLP).toHaveBeenCalledWith(LP_ADDRESS, 0, 10);

expect(screen.getAllByText("25 USDC")).toHaveLength(2);
expect(screen.getAllByText("1.25 USDC")).toHaveLength(2);
expect(screen.getByText((_content, element) => element?.textContent === "5.00%")).toBeInTheDocument();
expect(screen.getByText("#42")).toBeInTheDocument();
expect(screen.getByText("Transfer Position")).toBeInTheDocument();
});

it("paginates LP positions through list_invoices_by_lp", async () => {
vi.mocked(listInvoicesByLP).mockResolvedValue(Array.from({ length: 10 }, (_, index) => ({
...mockPosition,
id: BigInt(index + 1),
})));

render(<LPPortfolioDashboardPage />);
const nextButton = await screen.findByRole("button", { name: /next/i });
await waitFor(() => expect(nextButton).not.toBeDisabled());
fireEvent.click(nextButton);

await waitFor(() => expect(listInvoicesByLP).toHaveBeenLastCalledWith(LP_ADDRESS, 1, 10));
});
});
126 changes: 126 additions & 0 deletions app/dashboard/lp/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import Footer from "@/components/Footer";
import LPPositionTable from "@/components/LPPositionTable";
import LPStatsCards from "@/components/LPStatsCards";
import Navbar from "@/components/Navbar";
import { useWallet } from "@/context/WalletContext";
import { useApprovedTokens } from "@/hooks/useApprovedTokens";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import {
getLPPortfolioStats,
listInvoicesByLP,
type Invoice,
type LPPortfolioStats,
} from "@/utils/soroban";

const PAGE_SIZE = 10;

export default function LPPortfolioDashboardPage() {
useDocumentTitle({ pageTitle: "LP Portfolio" });

const { address, isConnected, connect } = useWallet();
const { tokenMap, defaultToken } = useApprovedTokens();
const [stats, setStats] = useState<LPPortfolioStats | null>(null);
const [positions, setPositions] = useState<Invoice[]>([]);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

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

setIsLoading(true);
setError(null);
try {
const [nextStats, nextPositions] = await Promise.all([
getLPPortfolioStats(address),
listInvoicesByLP(address, page, PAGE_SIZE),
]);
setStats(nextStats);
setPositions(nextPositions.filter((position) => position.status === "Funded"));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load LP portfolio.");
setStats(null);
setPositions([]);
} finally {
setIsLoading(false);
}
}, [address, page]);

useEffect(() => {
const timeout = window.setTimeout(() => {
void loadPortfolio();
}, 0);

return () => window.clearTimeout(timeout);
}, [loadPortfolio]);

return (
<main className="min-h-screen bg-surface">
<Navbar />

<section className="border-b border-outline-variant/10 bg-surface-container-lowest px-6 pb-8 pt-28">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Liquidity Provider</p>
<h1 className="mt-2 text-3xl font-headline text-on-surface md:text-4xl">LP Portfolio Dashboard</h1>
<p className="mt-2 max-w-2xl text-sm text-on-surface-variant">
Track deployed capital, yield, and active funded invoices for the connected wallet.
</p>
</div>
{!isConnected && (
<button
type="button"
onClick={() => void connect()}
className="inline-flex items-center justify-center rounded-lg bg-primary px-5 py-3 text-sm font-bold text-white shadow-md transition-colors hover:bg-primary/90"
>
Connect Wallet
</button>
)}
</div>
</div>
</section>

<section className="mx-auto max-w-7xl space-y-6 px-6 py-8">
{!address ? (
<div className="rounded-lg border border-outline-variant/20 bg-surface-container-lowest p-8 text-center">
<span className="material-symbols-outlined text-5xl text-primary">account_balance_wallet</span>
<h2 className="mt-3 text-xl font-bold text-on-surface">Connect an LP wallet</h2>
<p className="mx-auto mt-2 max-w-xl text-sm text-on-surface-variant">
Portfolio stats and active positions are scoped to the wallet that funded invoices.
</p>
</div>
) : (
<>
{error && (
<div className="rounded-lg border border-error/25 bg-error-container p-4 text-sm text-on-error-container">
{error}
</div>
)}
<LPStatsCards
stats={stats}
tokenMap={tokenMap}
defaultToken={defaultToken}
isLoading={isLoading}
/>
<LPPositionTable
positions={positions}
isLoading={isLoading}
page={page}
pageSize={PAGE_SIZE}
hasNextPage={positions.length === PAGE_SIZE}
tokenMap={tokenMap}
defaultToken={defaultToken}
onPageChange={(nextPage) => setPage(Math.max(0, nextPage))}
/>
</>
)}
</section>

<Footer />
</main>
);
}
12 changes: 9 additions & 3 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
Vetoed: { color: "bg-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -202,10 +203,15 @@ export default function GovernancePage() {
}, []);

useEffect(() => {
load();
const timeout = window.setTimeout(() => {
void load();
}, 0);
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
const interval = window.setInterval(load, 30_000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
117 changes: 117 additions & 0 deletions src/components/LPPositionTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use client";

import Link from "next/link";
import type { ApprovedToken } from "@/hooks/useApprovedTokens";
import type { Invoice } from "@/utils/soroban";
import { calculateYield, formatDate, formatTokenAmount } from "@/utils/format";
import InvoiceStatusBadge from "./InvoiceStatusBadge";
import SkeletonRow from "./SkeletonRow";

interface LPPositionTableProps {
positions: Invoice[];
isLoading: boolean;
page: number;
pageSize: number;
hasNextPage: boolean;
tokenMap: Map<string, ApprovedToken>;
defaultToken: ApprovedToken | null;
onPageChange: (page: number) => void;
}

const POSITION_COLUMNS = ["w-8", "w-24", "w-16", "w-20", "w-20", "w-20", "w-28"];

function resolveToken(invoice: Invoice, tokenMap: Map<string, ApprovedToken>, defaultToken: ApprovedToken | null) {
return tokenMap.get(invoice.token ?? "") ?? defaultToken ?? { symbol: "USDC", decimals: 7 };
}

export default function LPPositionTable({
positions,
isLoading,
page,
pageSize,
hasNextPage,
tokenMap,
defaultToken,
onPageChange,
}: LPPositionTableProps) {
return (
<section className="rounded-lg border border-outline-variant/20 bg-surface-container-lowest">
<div className="flex flex-col gap-2 border-b border-outline-variant/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-bold text-on-surface">Active Funded Invoices</h2>
<p className="text-sm text-on-surface-variant">Positions funded by the connected LP wallet.</p>
</div>
<div className="flex items-center gap-2 text-sm text-on-surface-variant">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page === 0 || isLoading}
className="rounded-lg border border-outline-variant/20 px-3 py-2 font-bold disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<span className="min-w-16 text-center">Page {page + 1}</span>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={!hasNextPage || isLoading}
className="rounded-lg border border-outline-variant/20 px-3 py-2 font-bold disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>

<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-surface-container-low">
<tr>
{["Invoice ID", "Amount Funded", "Token", "Effective Yield", "Due Date", "Status", ""].map((heading) => (
<th key={heading} className="px-5 py-3 text-[11px] font-bold uppercase tracking-[0.16em] text-on-surface-variant">
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-surface-dim">
{isLoading ? (
Array.from({ length: Math.min(pageSize, 5) }).map((_, index) => (
<SkeletonRow key={index} columns={POSITION_COLUMNS} rowClass="py-4" />
))
) : positions.length > 0 ? (
positions.map((position) => {
const token = resolveToken(position, tokenMap, defaultToken);
return (
<tr key={position.id.toString()} className="text-sm text-on-surface">
<td className="px-5 py-4 font-bold text-primary">#{position.id.toString()}</td>
<td className="px-5 py-4 font-bold">{formatTokenAmount(position.amount, token)}</td>
<td className="px-5 py-4">{token.symbol}</td>
<td className="px-5 py-4">{formatTokenAmount(calculateYield(position.amount, position.discount_rate), token)}</td>
<td className="px-5 py-4">{formatDate(position.due_date)}</td>
<td className="px-5 py-4">
<InvoiceStatusBadge status={position.status} />
</td>
<td className="px-5 py-4 text-right">
<Link
href={`/pay/${position.id.toString()}?action=transfer-position`}
className="inline-flex items-center rounded-lg bg-primary px-3 py-2 text-xs font-bold text-white transition-colors hover:bg-primary/90"
>
Transfer Position
</Link>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-on-surface-variant">
No active funded invoices found for this LP wallet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
);
}
Loading