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
10 changes: 7 additions & 3 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(() => {
Expand Down
42 changes: 38 additions & 4 deletions app/payer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -166,6 +168,12 @@ function PayerDashboardContent() {
const [loading, setLoading] = useState(false);
const [settlingId, setSettlingId] = useState<string | null>(null);
const [appealState, setAppealState] = useState<AppealState | null>(null);
const {
filters,
setFilters,
clearFilters,
activeFilterCount,
} = useInvoiceFilters({ namespace: "payerInvoices" });

const loadInvoices = useCallback(async () => {
if (!isConnected || !address) return;
Expand All @@ -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(() => {
Expand All @@ -198,9 +209,18 @@ function PayerDashboardContent() {
}, new Map<string, bigint>());
}, [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) => {
Expand Down Expand Up @@ -308,6 +328,20 @@ function PayerDashboardContent() {

<section className="px-8 py-8">
<div className="mx-auto max-w-7xl overflow-hidden rounded-2xl border border-outline-variant/10 bg-surface-container-lowest">
{isConnected && (
<div className="border-b border-outline-variant/10 p-4">
<InvoiceFilterBar
filters={filters}
onFiltersChange={setFilters}
onClearFilters={clearFilters}
activeFilterCount={activeFilterCount}
/>
<p className="text-xs text-on-surface-variant">
Showing {visibleInvoices.length} of {invoices.length} invoices addressed to you.
</p>
</div>
)}

<div className="flex flex-wrap gap-2 border-b border-outline-variant/10 p-4">
{TABS.map((tab) => (
<button
Expand All @@ -317,7 +351,7 @@ function PayerDashboardContent() {
activeTab === tab ? "bg-primary text-white" : "bg-surface-container text-on-surface-variant"
}`}
>
{tab} ({invoices.filter((invoice) => invoiceTab(invoice) === tab).length})
{tab} ({filteredInvoices.filter((invoice) => invoiceTab(invoice) === tab).length})
</button>
))}
</div>
Expand Down
31 changes: 21 additions & 10 deletions src/components/InvoiceFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={containerClass}>
Expand Down Expand Up @@ -149,18 +150,28 @@ export default function InvoiceFilterBar({

<div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-wide text-on-surface-variant">Token</p>
<select
value={filters.token}
onChange={(event) => onFiltersChange((current) => ({ ...current, token: event.target.value }))}
className="w-full rounded-lg border border-outline-variant/30 bg-surface-container-lowest px-3 py-2 text-sm"
>
<option value="">All</option>
<div className="grid grid-cols-3 gap-2">
{TOKEN_OPTIONS.map((token) => (
<option key={token} value={token}>
{token}
</option>
<label key={token} className="inline-flex items-center gap-2 text-xs text-on-surface">
<input
type="checkbox"
checked={selectedTokens.has(token)}
onChange={(event) =>
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(",") };
})
}
/>
<span>{token}</span>
</label>
))}
</select>
</div>
</div>

<div className="space-y-2">
Expand Down
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "logo" | "symbol">): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "iconLabel" | "symbol">): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
83 changes: 83 additions & 0 deletions src/hooks/__tests__/useInvoiceFilters.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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>): 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]);
});
});
13 changes: 9 additions & 4 deletions src/hooks/useInvoiceFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise<string> {

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("");
Expand Down
8 changes: 7 additions & 1 deletion src/utils/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import { RPC_URL } from "@/constants";
const horizonServer = new rpc.Server(RPC_URL);
const federationCache = new Map<string, string>();

interface AccountHomeDomain {
home_domain?: string;
homeDomain?: string;
}

export async function resolveFederatedAddress(address: string): Promise<string> {
if (!address) return address;
const cached = federationCache.get(address);
if (cached) return cached;

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`);
Expand Down