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
18 changes: 12 additions & 6 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -196,24 +197,29 @@ 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);
}, [load]);

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(
Expand Down
5 changes: 5 additions & 0 deletions app/invoices/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";

import PayInvoicePage from "../../pay/[id]/page";

export default PayInvoicePage;
66 changes: 36 additions & 30 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useWallet>>;
type ToastMock = ReturnType<typeof useToast>;

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<typeof useWallet>);
}

describe('PayInvoicePage', () => {
const mockInvoice = {
const mockInvoice: soroban.Invoice = {
id: 1n,
freelancer: 'GFREELANCER',
payer: 'GPAYER',
Expand All @@ -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(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={params()} />);

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(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={params()} />);

await waitFor(() => {
expect(screen.getByText('Address Mismatch')).toBeInTheDocument();
Expand All @@ -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(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={params()} />);

await waitFor(() => {
expect(screen.getByText('Invoice settled')).toBeInTheDocument();
Expand Down Expand Up @@ -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<ReturnType<typeof soroban.markPaid>>);
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' });

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={params()} />);

// Open modal
await waitFor(() => {
Expand Down
14 changes: 10 additions & 4 deletions app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -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";
Expand All @@ -110,6 +111,11 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
<h1 className="font-headline text-3xl sm:text-4xl">
Settle Invoice #{invoice.id.toString()}
</h1>
{isSubmitter && (
<div className="mt-4">
<ShareInvoiceButton invoiceId={invoice.id} />
</div>
)}
</div>

{/* ── Status Banners ────────────────────────────────────────────── */}
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, "symbol"> & Partial<Pick<TokenLike, "logo">>): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

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

Expand Down
18 changes: 18 additions & 0 deletions src/utils/__tests__/invoiceSharing.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
3 changes: 2 additions & 1 deletion src/utils/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export async function resolveFederatedAddress(address: string): Promise<string>

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`);
Expand Down
15 changes: 15 additions & 0 deletions src/utils/invoiceSharing.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}