diff --git a/app/dashboard/invoices/__tests__/FreelancerInvoicesPage.test.tsx b/app/dashboard/invoices/__tests__/FreelancerInvoicesPage.test.tsx new file mode 100644 index 0000000..654974d --- /dev/null +++ b/app/dashboard/invoices/__tests__/FreelancerInvoicesPage.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import FreelancerInvoicesPage from "../page"; +import type { Invoice } from "@/utils/soroban"; + +const SUBMITTER = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +let isConnected = true; +let mockInvoices: Invoice[] = []; +const connect = vi.fn(); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: () => ({ + address: SUBMITTER, + isConnected, + connect, + }), +})); + +vi.mock("@/hooks/useInvoices", () => ({ + useSubmitterInvoices: () => ({ + data: mockInvoices, + isLoading: false, + }), +})); + +const usdcToken = { + contractId: "token-usdc", + name: "USD Coin", + symbol: "USDC", + decimals: 6, + iconLabel: "US", + logo: "/tokens/usdc.svg", + isAllowed: true, +}; + +vi.mock("@/hooks/useApprovedTokens", () => ({ + useApprovedTokens: () => ({ + tokenMap: new Map([["token-usdc", usdcToken]]), + defaultToken: usdcToken, + }), +})); + +function invoice(overrides: Partial & Pick): Invoice { + return { + id: overrides.id, + freelancer: SUBMITTER, + payer: `GPAYER${overrides.id.toString().padStart(2, "0")}`, + amount: 1_000_000n * overrides.id, + due_date: 1_800_000_000n + overrides.id, + discount_rate: 300, + status: "Pending", + token: "token-usdc", + ...overrides, + }; +} + +describe("FreelancerInvoicesPage", () => { + beforeEach(() => { + isConnected = true; + connect.mockReset(); + mockInvoices = [ + invoice({ id: 1n, payer: "GPAYER1111111111111111111111111111111111111111111111111", status: "Pending" }), + invoice({ id: 2n, payer: "GPAYER2222222222222222222222222222222222222222222222222", status: "Paid" }), + ]; + }); + + it("renders the connected submitter invoice table", () => { + render(); + + expect(screen.getByRole("heading", { name: /submitted invoice dashboard/i })).toBeInTheDocument(); + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.queryByText("#3")).not.toBeInTheDocument(); + expect(screen.getByText("2 invoices")).toBeInTheDocument(); + expect(screen.getAllByText("View")).toHaveLength(2); + }); + + it("filters rows by invoice status", () => { + render(); + + fireEvent.change(screen.getByLabelText(/status/i), { target: { value: "Paid" } }); + + expect(screen.queryByText("#1")).not.toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.getByText("1 invoice")).toBeInTheDocument(); + }); + + it("prompts disconnected users to connect Freighter", () => { + isConnected = false; + + render(); + + fireEvent.click(screen.getByRole("button", { name: /connect freighter/i })); + expect(connect).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/dashboard/invoices/page.tsx b/app/dashboard/invoices/page.tsx new file mode 100644 index 0000000..880782b --- /dev/null +++ b/app/dashboard/invoices/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import InvoiceStatusBadge from "@/components/InvoiceStatusBadge"; +import { useWallet } from "@/context/WalletContext"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { useSubmitterInvoices } from "@/hooks/useInvoices"; +import { + buildFreelancerInvoiceDashboard, + FREELANCER_INVOICE_STATUSES, + type FreelancerInvoiceSortKey, + type FreelancerInvoiceStatusFilter, + type SortDirection, +} from "@/utils/freelancerInvoiceDashboard"; +import { formatAddress, formatDate, formatTokenAmount } from "@/utils/format"; + +const PAGE_SIZE = 20; + +const SORT_LABELS: Record = { + due_date: "Due Date", + amount: "Amount", + status: "Status", +}; + +export default function FreelancerInvoicesPage() { + const { address, isConnected, connect } = useWallet(); + const { data: invoices = [], isLoading } = useSubmitterInvoices(address); + const { tokenMap, defaultToken } = useApprovedTokens(); + const [statusFilter, setStatusFilter] = useState("All"); + const [sortKey, setSortKey] = useState("due_date"); + const [sortDirection, setSortDirection] = useState("asc"); + const [page, setPage] = useState(1); + + const dashboard = useMemo( + () => + buildFreelancerInvoiceDashboard({ + invoices, + submitterAddress: null, + statusFilter, + sortKey, + sortDirection, + page, + pageSize: PAGE_SIZE, + }), + [invoices, page, sortDirection, sortKey, statusFilter], + ); + + const skeletonRows = Array.from({ length: 5 }, (_, index) => index); + + const setFilter = (value: FreelancerInvoiceStatusFilter) => { + setStatusFilter(value); + setPage(1); + }; + + const setSort = (value: FreelancerInvoiceSortKey) => { + setSortKey(value); + setPage(1); + }; + + const setDirection = (value: SortDirection) => { + setSortDirection(value); + setPage(1); + }; + + if (!isConnected) { + return ( +
+
+
+

Freelancer invoices

+

Connect your wallet to view submitted invoices

+

+ The invoice dashboard is scoped to the connected submitter address. +

+
+ +
+
+ ); + } + + return ( +
+
+
+
+

Freelancer invoices

+

Submitted invoice dashboard

+

+ Track submitted invoices for {address ? formatAddress(address) : "your wallet"} with status filters, + sorting, and page-based pagination. +

+
+ + Submit invoice + +
+ +
+ + + + + +
+ +
+
+

+ {dashboard.total} {dashboard.total === 1 ? "invoice" : "invoices"} +

+

+ Page {dashboard.page} of {dashboard.pageCount} +

+
+ +
+ + + + {["Invoice ID", "Payer", "Amount", "Token", "Status", "Due Date", "Action"].map((header) => ( + + ))} + + + + {isLoading + ? skeletonRows.map((row) => ( + + {Array.from({ length: 7 }, (_, cell) => ( + + ))} + + )) + : dashboard.items.map((invoice) => { + const token = tokenMap.get(invoice.token ?? "") ?? defaultToken; + const tokenSymbol = token?.symbol ?? "TOKEN"; + return ( + + + + + + + + + + ); + })} + +
+ {header} +
+
+
#{invoice.id.toString()}{formatAddress(invoice.payer)} + {token ? formatTokenAmount(invoice.amount, token) : invoice.amount.toString()} + {tokenSymbol} + + {formatDate(invoice.due_date)} + + View + +
+
+ + {!isLoading && dashboard.items.length === 0 ? ( +
+

No submitted invoices found

+

+ Try a different status filter or submit a new invoice from the freelancer flow. +

+
+ ) : null} +
+ +
+ + +
+
+
+ ); +} diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..f1c5ee5 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; import VoteProgressBar from "@/components/VoteProgressBar"; @@ -29,6 +29,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-red-500/15 text-red-500 border-red-500/30", icon: "gavel" }, }; const { color, icon } = config[status]; return ( @@ -193,25 +194,43 @@ export default function GovernancePage() { const [page, setPage] = useState(1); const [votingPower, setVotingPower] = useState(0); - const load = useCallback(async () => { - const data = await fetchProposals(); - setProposals(data); - setLoading(false); - }, []); - useEffect(() => { - load(); + let cancelled = false; + + async function loadProposals() { + const data = await fetchProposals(); + if (cancelled) return; + setProposals(data); + setLoading(false); + } + + void loadProposals(); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); - }, [load]); + const interval = setInterval(() => { + void loadProposals(); + }, 30_000); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); useEffect(() => { - if (!isConnected || !address) { - setVotingPower(0); - return; + let cancelled = false; + + async function loadVotingPower() { + const power = isConnected && address ? await getVotingPower(address) : 0; + if (!cancelled) { + setVotingPower(power); + } } - getVotingPower(address).then(setVotingPower); + + void loadVotingPower(); + + return () => { + cancelled = true; + }; }, [address, isConnected]); const sorted = useMemo( diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..7b56e7f 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick & Partial>): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick & Partial>): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/hooks/useInvoices.ts b/src/hooks/useInvoices.ts index 091b9ef..23956ff 100644 --- a/src/hooks/useInvoices.ts +++ b/src/hooks/useInvoices.ts @@ -1,7 +1,13 @@ "use client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getAllInvoices, getInvoice, fundInvoice, submitSignedTransaction, Invoice } from "@/utils/soroban"; +import { + getAllInvoices, + getInvoice, + fundInvoice, + submitSignedTransaction, + Invoice, +} from "@/utils/soroban"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; @@ -37,6 +43,28 @@ export function useInvoice(id: bigint | null) { }); } +export function useSubmitterInvoices(submitter: string | null | undefined) { + return useQuery({ + queryKey: ["invoices", "submitter", submitter], + queryFn: async () => { + const normalizedSubmitter = submitter?.toLowerCase(); + const invoices = await getAllInvoices(); + return invoices.filter((invoice) => invoice.freelancer.toLowerCase() === normalizedSubmitter); + }, + enabled: Boolean(submitter), + refetchInterval: (query) => { + const data = query.state.data as Invoice[] | undefined; + if (!data) return 15000; + + const hasActiveInvoices = data.some( + (invoice) => !TERMINAL_STATUSES.includes(invoice.status) + ); + + return hasActiveInvoices ? 15000 : false; + }, + }); +} + export function useFundInvoice() { const queryClient = useQueryClient(); const { address, signTx } = useWallet(); diff --git a/src/utils/__tests__/freelancerInvoiceDashboard.test.ts b/src/utils/__tests__/freelancerInvoiceDashboard.test.ts new file mode 100644 index 0000000..d5f5bbd --- /dev/null +++ b/src/utils/__tests__/freelancerInvoiceDashboard.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + buildFreelancerInvoiceDashboard, + type FreelancerInvoiceStatusFilter, +} from "../freelancerInvoiceDashboard"; +import type { Invoice } from "../soroban"; + +const SUBMITTER = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const OTHER = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBRY"; + +function invoice(overrides: Partial & Pick): Invoice { + return { + id: overrides.id, + freelancer: SUBMITTER, + payer: `GPAYER${overrides.id.toString().padStart(2, "0")}`, + amount: 100n * overrides.id, + due_date: 1_800_000_000n + overrides.id, + discount_rate: 300, + status: "Pending", + token: "token-usdc", + ...overrides, + }; +} + +function build(statusFilter: FreelancerInvoiceStatusFilter = "All", page = 1) { + return buildFreelancerInvoiceDashboard({ + invoices: [ + invoice({ id: 1n, amount: 300n, due_date: 30n, status: "Paid" }), + invoice({ id: 2n, amount: 100n, due_date: 10n, status: "Pending" }), + invoice({ id: 3n, amount: 200n, due_date: 20n, status: "Funded", freelancer: OTHER }), + invoice({ id: 4n, amount: 400n, due_date: 40n, status: "Pending" }), + ], + submitterAddress: SUBMITTER, + statusFilter, + sortKey: "due_date", + sortDirection: "asc", + page, + pageSize: 2, + }); +} + +describe("buildFreelancerInvoiceDashboard", () => { + it("filters invoices to the connected submitter and sorts by due date", () => { + const result = build(); + + expect(result.total).toBe(3); + expect(result.pageCount).toBe(2); + expect(result.items.map((item) => item.id)).toEqual([2n, 1n]); + }); + + it("filters by status and paginates safely", () => { + const result = build("Pending", 5); + + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.items.map((item) => item.id)).toEqual([2n, 4n]); + }); + + it("sorts by amount descending", () => { + const result = buildFreelancerInvoiceDashboard({ + invoices: [invoice({ id: 1n, amount: 300n }), invoice({ id: 2n, amount: 100n })], + submitterAddress: SUBMITTER, + statusFilter: "All", + sortKey: "amount", + sortDirection: "desc", + page: 1, + pageSize: 20, + }); + + expect(result.items.map((item) => item.id)).toEqual([1n, 2n]); + }); +}); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..b1d734b 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -11,7 +11,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const accountWithHomeDomain = account as { home_domain?: string; homeDomain?: string }; + const homeDomain = accountWithHomeDomain.home_domain ?? accountWithHomeDomain.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/freelancerInvoiceDashboard.ts b/src/utils/freelancerInvoiceDashboard.ts new file mode 100644 index 0000000..2545df2 --- /dev/null +++ b/src/utils/freelancerInvoiceDashboard.ts @@ -0,0 +1,86 @@ +import type { Invoice } from "@/utils/soroban"; + +export const FREELANCER_INVOICE_STATUSES = [ + "All", + "Pending", + "Funded", + "Paid", + "Cancelled", + "Expired", + "Disputed", +] as const; + +export type FreelancerInvoiceStatusFilter = (typeof FREELANCER_INVOICE_STATUSES)[number]; +export type FreelancerInvoiceSortKey = "due_date" | "amount" | "status"; +export type SortDirection = "asc" | "desc"; + +interface DashboardOptions { + invoices: Invoice[]; + submitterAddress: string | null | undefined; + statusFilter: FreelancerInvoiceStatusFilter; + sortKey: FreelancerInvoiceSortKey; + sortDirection: SortDirection; + page: number; + pageSize: number; +} + +export interface DashboardInvoiceResult { + items: Invoice[]; + total: number; + page: number; + pageCount: number; +} + +const STATUS_ORDER = new Map([ + ["Pending", 0], + ["Funded", 1], + ["Paid", 2], + ["Cancelled", 3], + ["Expired", 4], + ["Disputed", 5], +]); + +function compareStatus(left: string, right: string): number { + const leftRank = STATUS_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER; + const rightRank = STATUS_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER; + return leftRank === rightRank ? left.localeCompare(right) : leftRank - rightRank; +} + +export function buildFreelancerInvoiceDashboard({ + invoices, + submitterAddress, + statusFilter, + sortKey, + sortDirection, + page, + pageSize, +}: DashboardOptions): DashboardInvoiceResult { + const normalizedSubmitter = submitterAddress?.toLowerCase(); + const direction = sortDirection === "asc" ? 1 : -1; + + const filtered = invoices + .filter((invoice) => !normalizedSubmitter || invoice.freelancer.toLowerCase() === normalizedSubmitter) + .filter((invoice) => statusFilter === "All" || invoice.status === statusFilter) + .sort((left, right) => { + if (sortKey === "amount") { + return left.amount === right.amount ? 0 : left.amount > right.amount ? direction : -direction; + } + + if (sortKey === "status") { + return compareStatus(left.status, right.status) * direction; + } + + return left.due_date === right.due_date ? 0 : left.due_date > right.due_date ? direction : -direction; + }); + + const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize)); + const safePage = Math.min(Math.max(1, page), pageCount); + const offset = (safePage - 1) * pageSize; + + return { + items: filtered.slice(offset, offset + pageSize), + total: filtered.length, + page: safePage, + pageCount, + }; +}