diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..d6c83b2 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -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-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -200,10 +201,13 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(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(() => { diff --git a/app/payer/page.tsx b/app/payer/page.tsx index 600916c..d3a116d 100644 --- a/app/payer/page.tsx +++ b/app/payer/page.tsx @@ -3,11 +3,13 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState, Suspense } from "react"; import Footer from "@/components/Footer"; +import InvoiceFilterBar from "@/components/InvoiceFilterBar"; import Navbar from "@/components/Navbar"; import { TokenAmount, TokenIcon } from "@/components/TokenSelector"; import { useToast } from "@/context/ToastContext"; import { useWallet } from "@/context/WalletContext"; import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { applyInvoiceFilters, useInvoiceFilters } from "@/hooks/useInvoiceFilters"; import { APPEAL_WINDOW_LEDGERS, formatLedgerWindow, hashEvidence } from "@/utils/evidence"; import { formatAddress, formatDate, formatTokenAmount } from "@/utils/format"; import { @@ -166,6 +168,12 @@ function PayerDashboardContent() { const [loading, setLoading] = useState(false); const [settlingId, setSettlingId] = useState(null); const [appealState, setAppealState] = useState(null); + const { + filters, + setFilters, + clearFilters, + activeFilterCount, + } = useInvoiceFilters({ namespace: "payerInvoices" }); const loadInvoices = useCallback(async () => { if (!isConnected || !address) return; @@ -185,7 +193,10 @@ function PayerDashboardContent() { }, [addToast, address, isConnected]); useEffect(() => { - void loadInvoices(); + const timeout = window.setTimeout(() => { + void loadInvoices(); + }, 0); + return () => window.clearTimeout(timeout); }, [loadInvoices]); const totalsByToken = useMemo(() => { @@ -198,9 +209,18 @@ function PayerDashboardContent() { }, new Map()); }, [defaultToken?.contractId, invoices]); + const filteredInvoices = useMemo( + () => + applyInvoiceFilters(invoices, filters, { + resolveTokenSymbol: (invoice) => + tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "")?.symbol ?? defaultToken?.symbol ?? "USDC", + }), + [defaultToken?.contractId, defaultToken?.symbol, filters, invoices, tokenMap], + ); + const visibleInvoices = useMemo( - () => invoices.filter((invoice) => invoiceTab(invoice) === activeTab), - [activeTab, invoices], + () => filteredInvoices.filter((invoice) => invoiceTab(invoice) === activeTab), + [activeTab, filteredInvoices], ); const handleSettle = async (invoice: Invoice) => { @@ -308,6 +328,20 @@ function PayerDashboardContent() {
+ {isConnected && ( +
+ +

+ Showing {visibleInvoices.length} of {invoices.length} invoices addressed to you. +

+
+ )} +
{TABS.map((tab) => ( ))}
diff --git a/src/components/InvoiceFilterBar.tsx b/src/components/InvoiceFilterBar.tsx index 217c51d..9675e67 100644 --- a/src/components/InvoiceFilterBar.tsx +++ b/src/components/InvoiceFilterBar.tsx @@ -27,6 +27,7 @@ export default function InvoiceFilterBar({ const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const containerClass = className ? `space-y-3 ${className}` : "space-y-3"; + const selectedTokens = new Set(filters.token.split(",").filter(Boolean)); return (
@@ -149,18 +150,28 @@ export default function InvoiceFilterBar({

Token

- + onFiltersChange((current) => { + const tokens = new Set(current.token.split(",").filter(Boolean)); + if (event.target.checked) { + tokens.add(token); + } else { + tokens.delete(token); + } + return { ...current, token: Array.from(tokens).join(",") }; + }) + } + /> + {token} + ))} - +
diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 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): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/hooks/__tests__/useInvoiceFilters.test.ts b/src/hooks/__tests__/useInvoiceFilters.test.ts new file mode 100644 index 0000000..d2c55ba --- /dev/null +++ b/src/hooks/__tests__/useInvoiceFilters.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { applyInvoiceFilters, EMPTY_INVOICE_FILTERS, type InvoiceFilters } from "../useInvoiceFilters"; +import type { Invoice } from "@/utils/soroban"; + +function invoice(overrides: Partial): Invoice { + return { + id: 1n, + freelancer: "GFREELANCER1111111111111111111111111111111111111111", + payer: "GPAYER11111111111111111111111111111111111111111111", + amount: 100_000000n, + due_date: 1_767_225_600n, + discount_rate: 300, + status: "Pending", + token: "usdc-contract", + ...overrides, + }; +} + +function filters(overrides: Partial): InvoiceFilters { + return { ...EMPTY_INVOICE_FILTERS, ...overrides }; +} + +function unixDate(year: number, month: number, day: number): bigint { + return BigInt(Date.UTC(year, month - 1, day) / 1000); +} + +describe("applyInvoiceFilters", () => { + it("matches invoice ID, payer, and freelancer search terms", () => { + const invoices = [ + invoice({ id: 42n, payer: "GPAYERABC", freelancer: "GFREELANCERXYZ" }), + invoice({ id: 7n, payer: "GOTHER", freelancer: "GWORKER" }), + ]; + + expect(applyInvoiceFilters(invoices, filters({ search: "42" })).map((item) => item.id)).toEqual([42n]); + expect(applyInvoiceFilters(invoices, filters({ search: "payerabc" })).map((item) => item.id)).toEqual([42n]); + expect(applyInvoiceFilters(invoices, filters({ search: "worker" })).map((item) => item.id)).toEqual([7n]); + }); + + it("filters by amount range, status, due date, and discount bps", () => { + const invoices = [ + invoice({ id: 1n, amount: 50_000000n, status: "Pending", due_date: unixDate(2026, 1, 15), discount_rate: 200 }), + invoice({ id: 2n, amount: 150_000000n, status: "Paid", due_date: unixDate(2026, 2, 15), discount_rate: 500 }), + invoice({ id: 3n, amount: 250_000000n, status: "Defaulted", due_date: unixDate(2026, 3, 1), discount_rate: 800 }), + ]; + + const result = applyInvoiceFilters( + invoices, + filters({ + statuses: ["Paid", "Defaulted"], + minAmount: "100", + maxAmount: "260", + startDate: "2026-02-01", + endDate: "2026-03-15", + minDiscountBps: "400", + maxDiscountBps: "900", + }), + ); + + expect(result.map((item) => item.id)).toEqual([2n, 3n]); + }); + + it("supports multi-token filters through a comma-separated token query value", () => { + const invoices = [ + invoice({ id: 1n, token: "usdc-contract" }), + invoice({ id: 2n, token: "eurc-contract" }), + invoice({ id: 3n, token: "xlm-contract" }), + ]; + + const result = applyInvoiceFilters( + invoices, + filters({ token: "USDC,EURC" }), + { + resolveTokenSymbol: (item) => { + if (item.token === "eurc-contract") return "EURC"; + if (item.token === "xlm-contract") return "XLM"; + return "USDC"; + }, + }, + ); + + expect(result.map((item) => item.id)).toEqual([1n, 2n]); + }); +}); diff --git a/src/hooks/useInvoiceFilters.ts b/src/hooks/useInvoiceFilters.ts index d8af16d..21365ea 100644 --- a/src/hooks/useInvoiceFilters.ts +++ b/src/hooks/useInvoiceFilters.ts @@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Invoice } from "@/utils/soroban"; import { tokenAmountToNumber } from "@/utils/format"; -export const INVOICE_STATUSES = ["Pending", "Funded", "Paid", "Defaulted", "Cancelled"] as const; +export const INVOICE_STATUSES = ["Pending", "Funded", "Paid", "Disputed", "Defaulted", "Expired", "Appealed", "Cancelled"] as const; export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]; export type InvoiceFilters = { @@ -122,7 +122,12 @@ export function applyInvoiceFilters( const statuses = new Set(filters.statuses); const start = filters.startDate ? new Date(`${filters.startDate}T00:00:00.000Z`) : null; const end = filters.endDate ? new Date(`${filters.endDate}T23:59:59.999Z`) : null; - const selectedToken = filters.token.trim().toUpperCase(); + const selectedTokens = new Set( + filters.token + .split(",") + .map((token) => token.trim().toUpperCase()) + .filter(Boolean), + ); return invoices.filter((invoice) => { if (search) { @@ -146,9 +151,9 @@ export function applyInvoiceFilters( if (start && invoiceDate < start) return false; if (end && invoiceDate > end) return false; - if (selectedToken) { + if (selectedTokens.size > 0) { const tokenSymbol = options?.resolveTokenSymbol?.(invoice).toUpperCase() ?? "USDC"; - if (tokenSymbol !== selectedToken) return false; + if (!selectedTokens.has(tokenSymbol)) return false; } if (minDiscount !== null && invoice.discount_rate < minDiscount) return false; diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,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 { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);