diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..a742347 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-red-500/15 text-red-500 border-red-500/30", icon: "gavel" }, }; const { color, icon } = config[status]; return ( @@ -194,9 +195,10 @@ export default function GovernancePage() { const [votingPower, setVotingPower] = useState(0); const load = useCallback(async () => { - const data = await fetchProposals(); - setProposals(data); - setLoading(false); + fetchProposals().then((data) => { + setProposals(data); + setLoading(false); + }); }, []); useEffect(() => { @@ -208,10 +210,14 @@ export default function GovernancePage() { useEffect(() => { if (!isConnected || !address) { - setVotingPower(0); + Promise.resolve().then(() => { + setVotingPower(0); + }); return; } - getVotingPower(address).then(setVotingPower); + getVotingPower(address).then((power) => { + setVotingPower(power); + }); }, [address, isConnected]); const sorted = useMemo( diff --git a/app/invoices/[id]/__tests__/InvoiceDetailPage.test.tsx b/app/invoices/[id]/__tests__/InvoiceDetailPage.test.tsx new file mode 100644 index 0000000..73cb8f5 --- /dev/null +++ b/app/invoices/[id]/__tests__/InvoiceDetailPage.test.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import InvoiceDetailPage from "../page"; + +const addToast = vi.fn(() => "toast-id"); +const updateToast = vi.fn(); +const connect = vi.fn(); +const signTx = vi.fn().mockResolvedValue("signed-xdr"); +const getInvoice = vi.fn(); +const markPaid = vi.fn(); +const cancelInvoice = vi.fn(); +const submitSignedTransaction = vi.fn(); + +const walletState = { + address: null as string | null, + connect, + signTx, +}; + +vi.mock("@/context/WalletContext", () => ({ + useWallet: () => walletState, +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ addToast, updateToast }), +})); + +vi.mock("@/utils/soroban", () => ({ + getInvoice: (...args: unknown[]) => getInvoice(...args), + markPaid: (...args: unknown[]) => markPaid(...args), + cancelInvoice: (...args: unknown[]) => cancelInvoice(...args), + submitSignedTransaction: (...args: unknown[]) => submitSignedTransaction(...args), +})); + +vi.mock("@/hooks/useApprovedTokens", () => ({ + useApprovedTokens: () => ({ + tokenMap: new Map([ + [ + "token-usdc", + { + contractId: "token-usdc", + name: "USD Coin", + symbol: "USDC", + decimals: 7, + }, + ], + ]), + defaultToken: { + contractId: "token-usdc", + name: "USD Coin", + symbol: "USDC", + decimals: 7, + }, + }), +})); + +vi.mock("@/components/InvoiceEventHistory", () => ({ + default: ({ invoiceId }: { invoiceId: bigint }) => ( +
Events for {invoiceId.toString()}
+ ), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +const mockInvoice = { + id: 12n, + status: "Funded", + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBRY", + amount: 10_000_000_000n, + due_date: 1_900_000_000n, + discount_rate: 250, + funder: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6", + token: "token-usdc", +}; + +function renderPage(id = "12") { + const params = Promise.resolve({ id }) as Promise<{ id: string }> & { + _resolvedValue?: { id: string }; + }; + params._resolvedValue = { id }; + return render(); +} + +describe("InvoiceDetailPage", () => { + beforeEach(() => { + walletState.address = null; + connect.mockReset(); + signTx.mockClear(); + addToast.mockClear(); + updateToast.mockClear(); + getInvoice.mockReset(); + markPaid.mockReset(); + cancelInvoice.mockReset(); + submitSignedTransaction.mockReset(); + getInvoice.mockResolvedValue(mockInvoice); + markPaid.mockResolvedValue("prepared-mark-paid-tx"); + cancelInvoice.mockResolvedValue({ tx: "prepared-cancel-tx" }); + submitSignedTransaction.mockResolvedValue({ txHash: "hash-123" }); + }); + + it("renders invoice fields, lifecycle state, and event history for public viewers", async () => { + renderPage(); + + expect(await screen.findByRole("heading", { name: "Invoice #12" })).toBeInTheDocument(); + expect(screen.getAllByText("Funded").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("1,000 USDC")).toBeInTheDocument(); + expect(screen.getByText("2.50%")).toBeInTheDocument(); + expect(screen.getByText("token-usdc")).toBeInTheDocument(); + expect(screen.getByText("Events for 12")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect wallet" })).toBeInTheDocument(); + }); + + it("lets the payer mark a funded invoice paid", async () => { + walletState.address = mockInvoice.payer; + renderPage(); + + fireEvent.click(await screen.findByRole("button", { name: "Mark Paid" })); + + await waitFor(() => { + expect(markPaid).toHaveBeenCalledWith(mockInvoice.payer, 12n); + expect(submitSignedTransaction).toHaveBeenCalledWith({ + tx: "prepared-mark-paid-tx", + signTx, + }); + }); + expect(updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" })); + }); + + it("lets the freelancer cancel a pending invoice", async () => { + getInvoice.mockResolvedValue({ ...mockInvoice, status: "Pending", funder: undefined }); + walletState.address = mockInvoice.freelancer; + renderPage(); + + fireEvent.click(await screen.findByRole("button", { name: "Cancel Invoice" })); + + await waitFor(() => { + expect(cancelInvoice).toHaveBeenCalledWith(mockInvoice.freelancer, 12n); + expect(submitSignedTransaction).toHaveBeenCalledWith({ + tx: "prepared-cancel-tx", + signTx, + }); + }); + }); + + it("shows a transfer position entry point to the funder", async () => { + walletState.address = mockInvoice.funder; + renderPage(); + + expect(await screen.findByRole("link", { name: "Transfer Position" })).toHaveAttribute("href", "/lp"); + }); + + it("shows not found state when the invoice cannot be loaded", async () => { + getInvoice.mockRejectedValue(new Error("missing")); + renderPage(); + + expect(await screen.findByRole("heading", { name: "Invoice Not Found" })).toBeInTheDocument(); + expect(screen.getByText("Failed to load invoice details.")).toBeInTheDocument(); + }); +}); diff --git a/app/invoices/[id]/page.tsx b/app/invoices/[id]/page.tsx new file mode 100644 index 0000000..d2642ee --- /dev/null +++ b/app/invoices/[id]/page.tsx @@ -0,0 +1,300 @@ +"use client"; + +import Link from "next/link"; +import { use, useCallback, useEffect, useMemo, useState } from "react"; +import InvoiceEventHistory from "@/components/InvoiceEventHistory"; +import InvoiceStatusBadge from "@/components/InvoiceStatusBadge"; +import InvoiceStatusTimeline from "@/components/InvoiceStatusTimeline"; +import { NETWORK_NAME, TESTNET_USDC_TOKEN_ID } from "@/constants"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { formatAddress, formatDate, formatTokenAmount } from "@/utils/format"; +import { + cancelInvoice, + getInvoice, + markPaid, + submitSignedTransaction, + type Invoice, +} from "@/utils/soroban"; + +type LoadState = "loading" | "success" | "error"; +type ActionState = "idle" | "canceling" | "marking-paid"; +type InvoiceWithPaidAmount = Invoice & { amount_paid?: bigint }; +type PreparedTransaction = Awaited>; + +function parseInvoiceId(id: string): bigint | null { + try { + const parsed = BigInt(id); + return parsed >= 0n ? parsed : null; + } catch { + return null; + } +} + +function formatRate(bps: number): string { + return `${(bps / 100).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}%`; +} + +function statusPaidAmount(invoice: Invoice): bigint { + return (invoice as InvoiceWithPaidAmount).amount_paid ?? (invoice.status === "Paid" ? invoice.amount : 0n); +} + +function unwrapPreparedTransaction(prepared: PreparedTransaction | { tx: PreparedTransaction }) { + return typeof prepared === "object" && prepared !== null && "tx" in prepared ? prepared.tx : prepared; +} + +function DetailRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const invoiceId = useMemo(() => parseInvoiceId(id), [id]); + const { address, connect, signTx } = useWallet(); + const { addToast, updateToast } = useToast(); + const { tokenMap, defaultToken } = useApprovedTokens(); + const [invoice, setInvoice] = useState(null); + const [loadState, setLoadState] = useState("loading"); + const [error, setError] = useState(null); + const [actionState, setActionState] = useState("idle"); + + const fetchInvoice = useCallback(async () => { + if (invoiceId === null) { + setInvoice(null); + setError("The invoice id is invalid."); + setLoadState("error"); + return; + } + + try { + setLoadState("loading"); + setError(null); + setInvoice(await getInvoice(invoiceId)); + setLoadState("success"); + } catch (loadError) { + console.error(loadError); + setInvoice(null); + setError("Failed to load invoice details."); + setLoadState("error"); + } + }, [invoiceId]); + + useEffect(() => { + void Promise.resolve().then(fetchInvoice); + }, [fetchInvoice]); + + const token = invoice?.token ? tokenMap.get(invoice.token) : undefined; + const displayToken = token ?? defaultToken ?? { + contractId: invoice?.token ?? TESTNET_USDC_TOKEN_ID, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + }; + const isPayer = Boolean(address && invoice && address === invoice.payer); + const isFreelancer = Boolean(address && invoice && address === invoice.freelancer); + const isFunder = Boolean(address && invoice?.funder && address === invoice.funder); + const canMarkPaid = Boolean(isPayer && invoice?.status === "Funded"); + const canCancel = Boolean(isFreelancer && invoice?.status === "Pending"); + + const runAction = async (kind: Exclude) => { + if (!address || !invoice || invoiceId === null) return; + + const isCancel = kind === "canceling"; + setActionState(kind); + const toastId = addToast({ + type: "pending", + title: isCancel ? "Canceling invoice..." : "Marking invoice paid...", + message: "Please sign the transaction in Freighter.", + }); + + try { + const prepared = isCancel + ? await cancelInvoice(address, invoiceId) + : await markPaid(address, invoiceId); + const tx = unwrapPreparedTransaction(prepared); + + updateToast(toastId, { message: "Transaction prepared. Signing..." }); + const { txHash } = await submitSignedTransaction({ tx, signTx }); + + updateToast(toastId, { + type: "success", + title: isCancel ? "Invoice Canceled" : "Invoice Marked Paid", + message: isCancel + ? "The invoice was canceled on-chain." + : "The invoice was marked paid on-chain.", + txHash, + }); + await fetchInvoice(); + } catch (actionError) { + console.error(actionError); + updateToast(toastId, { + type: "error", + title: isCancel ? "Cancel Failed" : "Payment Update Failed", + message: actionError instanceof Error ? actionError.message : "The transaction could not be completed.", + }); + } finally { + setActionState("idle"); + } + }; + + if (loadState === "loading") { + return ( +
+
+
+ ); + } + + if (loadState === "error" || !invoice) { + return ( +
+
+ error_outline +

Invoice Not Found

+

{error ?? "The requested invoice does not exist."}

+
+
+ ); + } + + const amountPaid = statusPaidAmount(invoice); + + return ( +
+
+
+
+

+ Invoice Detail ยท {NETWORK_NAME} +

+
+

Invoice #{invoice.id.toString()}

+ +
+
+ +
+
+
+

Lifecycle

+

Status progress

+
+ Due {formatDate(invoice.due_date)} +
+ +
+ +
+

Invoice fields

+
+ + {formatTokenAmount(invoice.amount, displayToken)} + + + {formatTokenAmount(amountPaid, displayToken)} + + {formatRate(invoice.discount_rate)} + {displayToken.symbol} + {invoice.token ?? displayToken.contractId} + {formatDate(invoice.due_date)} + + + {formatAddress(invoice.freelancer)} + + + + + {formatAddress(invoice.payer)} + + + + {invoice.funder ? ( + + {formatAddress(invoice.funder)} + + ) : ( + "Not funded yet" + )} + +
+
+ + +
+ + +
+
+ ); +} diff --git a/src/components/InvoiceEventHistory.tsx b/src/components/InvoiceEventHistory.tsx new file mode 100644 index 0000000..40b8725 --- /dev/null +++ b/src/components/InvoiceEventHistory.tsx @@ -0,0 +1,15 @@ +"use client"; + +import ActivityFeed from "@/components/ActivityFeed"; + +export default function InvoiceEventHistory({ invoiceId }: { invoiceId: bigint }) { + return ( +
+
+

Event history

+

Contract activity

+
+ +
+ ); +} diff --git a/src/components/InvoiceStatusTimeline.tsx b/src/components/InvoiceStatusTimeline.tsx new file mode 100644 index 0000000..2560b4d --- /dev/null +++ b/src/components/InvoiceStatusTimeline.tsx @@ -0,0 +1,43 @@ +"use client"; + +import type { Invoice } from "@/utils/soroban"; + +const STEPS = ["Pending", "Funded", "Paid"] as const; + +function statusIndex(status: string): number { + if (status === "Paid") return 2; + if (status === "Funded" || status === "Disputed" || status === "Expired") return 1; + return 0; +} + +export default function InvoiceStatusTimeline({ invoice }: { invoice: Invoice }) { + const currentIndex = statusIndex(invoice.status); + + return ( +
    + {STEPS.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + return ( +
  1. +
    + + {isComplete ? "check_circle" : isCurrent ? "radio_button_checked" : "radio_button_unchecked"} + + {step} +
    +
  2. + ); + })} +
+ ); +} 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/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`);