diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..537d740 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -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-red-500/15 text-red-500 border-red-500/30", icon: "gavel" }, }; const { color, icon } = config[status]; return ( @@ -196,13 +197,14 @@ 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(() => { - load(); + void load(); // Refresh every 30 s for real-time vote counts const interval = setInterval(load, 30_000); return () => clearInterval(interval); @@ -210,10 +212,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]/page.tsx b/app/invoices/[id]/page.tsx new file mode 100644 index 0000000..954d6b8 --- /dev/null +++ b/app/invoices/[id]/page.tsx @@ -0,0 +1,5 @@ +"use client"; + +import PayInvoicePage from "../../pay/[id]/page"; + +export default PayInvoicePage; diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..463f560 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,27 +1,41 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import PayInvoicePage from '../page'; -import * as soroban from '../../../../utils/soroban'; -import { useWallet } from '../../../../context/WalletContext'; -import { useToast } from '../../../../context/ToastContext'; +import * as soroban from '@/utils/soroban'; +import { useWallet } from '@/context/WalletContext'; +import { useToast } from '@/context/ToastContext'; // Mock context and utils -vi.mock('../../../../context/WalletContext', () => ({ +vi.mock('@/context/WalletContext', () => ({ useWallet: vi.fn(), })); -vi.mock('../../../../context/ToastContext', () => ({ +vi.mock('@/context/ToastContext', () => ({ useToast: vi.fn(), })); -vi.mock('../../../../utils/soroban', () => ({ +vi.mock('@/utils/soroban', () => ({ getInvoice: vi.fn(), markPaid: vi.fn(), submitSignedTransaction: vi.fn(), })); +type TestParams = Promise<{ id: string }> & { _resolvedValue: { id: string } }; +type WalletMock = Partial>; +type ToastMock = ReturnType; + +function params(id = '1'): TestParams { + const value = Promise.resolve({ id }) as TestParams; + value._resolvedValue = { id }; + return value; +} + +function mockWallet(value: WalletMock) { + vi.mocked(useWallet).mockReturnValue(value as ReturnType); +} + describe('PayInvoicePage', () => { - const mockInvoice = { + const mockInvoice: soroban.Invoice = { id: 1n, freelancer: 'GFREELANCER', payer: 'GPAYER', @@ -38,35 +52,31 @@ describe('PayInvoicePage', () => { beforeEach(() => { vi.clearAllMocks(); - (useToast as any).mockReturnValue(mockToast); - (soroban.getInvoice as any).mockResolvedValue(mockInvoice); + vi.mocked(useToast).mockReturnValue(mockToast as ToastMock); + vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice); }); it('should render invoice summary without wallet connection', async () => { - (useWallet as any).mockReturnValue({ + mockWallet({ address: null, connect: vi.fn(), }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument(); + expect(screen.getByText(/1,000\s+USDC/)).toBeInTheDocument(); expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument(); }); }); it('should show warning if connected wallet is not the payer', async () => { - (useWallet as any).mockReturnValue({ + mockWallet({ address: 'GWRONGWALLET', connect: vi.fn(), }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Address Mismatch')).toBeInTheDocument(); @@ -75,18 +85,16 @@ describe('PayInvoicePage', () => { }); it('should show confirmation if invoice is already paid', async () => { - (soroban.getInvoice as any).mockResolvedValue({ + vi.mocked(soroban.getInvoice).mockResolvedValue({ ...mockInvoice, status: 'Paid', }); - (useWallet as any).mockReturnValue({ + mockWallet({ address: 'GPAYER', }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Invoice settled')).toBeInTheDocument(); @@ -130,17 +138,15 @@ describe('PayInvoicePage', () => { it('should call markPaid with correct amount when payment is confirmed', async () => { const mockSignTx = vi.fn(); - (useWallet as any).mockReturnValue({ + mockWallet({ address: 'GPAYER', signTx: mockSignTx, }); - (soroban.markPaid as any).mockResolvedValue('mock-tx'); - (soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' }); + vi.mocked(soroban.markPaid).mockResolvedValue('mock-tx' as unknown as Awaited>); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); // Open modal await waitFor(() => { diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..75570f3 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -7,7 +7,7 @@ import { formatAddress } from "@/utils/format"; import { formatUsdcFromStroops } from "@/utils/invoiceSubmission"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; -import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants"; +import { NETWORK_NAME } from "@/constants"; import ActivityFeed from "@/components/ActivityFeed"; import PartialPaymentModal from "@/components/PartialPaymentModal"; @@ -39,7 +39,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + void Promise.resolve().then(fetchInvoice); }, [fetchInvoice]); const handlePaymentConfirm = async (amount: bigint) => { @@ -64,12 +64,12 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin // Close modal and refresh invoice state setIsPaymentModalOpen(false); fetchInvoice(); - } catch (err: any) { + } catch (err) { console.error(err); updateToast(toastId, { type: "error", title: "Payment Failed", - message: err.message || "An unexpected error occurred during payment." + message: err instanceof Error ? err.message : "An unexpected error occurred during payment." }); } finally { setIsPaying(false); @@ -96,6 +96,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin ); } + const isSubmitter = address === invoice.freelancer; const isPayer = address === invoice.payer; const isPaid = invoice.status === "Paid"; const isFunded = invoice.status === "Funded"; @@ -110,6 +111,11 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin

Settle Invoice #{invoice.id.toString()}

+ {isSubmitter && ( +
+ +
+ )} {/* ── Status Banners ────────────────────────────────────────────── */} 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/__tests__/invoiceSharing.test.ts b/src/utils/__tests__/invoiceSharing.test.ts new file mode 100644 index 0000000..d253749 --- /dev/null +++ b/src/utils/__tests__/invoiceSharing.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildInvoiceCanonicalUrl, buildInvoiceMailtoUrl } from "../invoiceSharing"; + +describe("invoiceSharing", () => { + it("builds canonical invoice URLs under /invoices/[id]", () => { + expect(buildInvoiceCanonicalUrl(42n, "https://iln.example/")).toBe( + "https://iln.example/invoices/42", + ); + }); + + it("builds a prefilled mailto URL with the invoice link", () => { + const mailto = buildInvoiceMailtoUrl(42n, "https://iln.example/invoices/42"); + + expect(mailto).toContain("mailto:?"); + expect(decodeURIComponent(mailto)).toContain("Invoice #42 for review"); + expect(decodeURIComponent(mailto)).toContain("https://iln.example/invoices/42"); + }); +}); 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/invoiceSharing.ts b/src/utils/invoiceSharing.ts new file mode 100644 index 0000000..125f7c5 --- /dev/null +++ b/src/utils/invoiceSharing.ts @@ -0,0 +1,15 @@ +export function buildInvoiceCanonicalUrl(invoiceId: string | bigint, origin: string): string { + const cleanOrigin = origin.replace(/\/$/, ""); + return `${cleanOrigin}/invoices/${invoiceId.toString()}`; +} + +export function buildInvoiceMailtoUrl(invoiceId: string | bigint, invoiceUrl: string): string { + const subject = `Invoice #${invoiceId.toString()} for review`; + const body = [ + `Please review Invoice #${invoiceId.toString()} on ILN:`, + "", + invoiceUrl, + ].join("\n"); + + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; +}