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
8 changes: 6 additions & 2 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 }) {
Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" },
Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" },
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" },
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" },
};
Expand Down Expand Up @@ -202,10 +203,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);
return () => {
window.clearTimeout(timeout);
clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
73 changes: 40 additions & 33 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
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';
import type { Invoice } from '@/utils/soroban';

// 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(),
}));

vi.mock('@/components/InvoicePdfDownloadButton', () => ({
default: () => <button>Download PDF</button>,
}));

type ParamsPromise = Promise<{ id: string }> & { _resolvedValue?: { id: string } };

function createParams(): ParamsPromise {
const params = Promise.resolve({ id: '1' }) as ParamsPromise;
params._resolvedValue = { id: '1' };
return params;
}

describe('PayInvoicePage', () => {
const mockInvoice = {
const mockInvoice: Invoice = {
id: 1n,
freelancer: 'GFREELANCER',
payer: 'GPAYER',
amount: 1000000000n,
amount_paid: 0n,
due_date: 1713960000n,
status: 'Funded',
discount_rate: 300,
};

const mockToast = {
Expand All @@ -38,35 +52,32 @@ describe('PayInvoicePage', () => {

beforeEach(() => {
vi.clearAllMocks();
(useToast as any).mockReturnValue(mockToast);
(soroban.getInvoice as any).mockResolvedValue(mockInvoice);
vi.mocked(useToast).mockReturnValue(mockToast);
vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice);
});

it('should render invoice summary without wallet connection', async () => {
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: null,
connect: vi.fn(),
});
} as ReturnType<typeof useWallet>);

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

await waitFor(() => {
expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument();
expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /download pdf/i })).toBeInTheDocument();
});
});

it('should show warning if connected wallet is not the payer', async () => {
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: 'GWRONGWALLET',
connect: vi.fn(),
});
} as ReturnType<typeof useWallet>);

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

await waitFor(() => {
expect(screen.getByText('Address Mismatch')).toBeInTheDocument();
Expand All @@ -75,18 +86,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({
vi.mocked(useWallet).mockReturnValue({
address: 'GPAYER',
});
} as ReturnType<typeof useWallet>);

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

await waitFor(() => {
expect(screen.getByText('Invoice settled')).toBeInTheDocument();
Expand Down Expand Up @@ -130,17 +139,15 @@ describe('PayInvoicePage', () => {

it('should call markPaid with correct amount when payment is confirmed', async () => {
const mockSignTx = vi.fn();
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: 'GPAYER',
signTx: mockSignTx,
});
} as ReturnType<typeof useWallet>);

(soroban.markPaid as any).mockResolvedValue('mock-tx');
(soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' });
vi.mocked(soroban.markPaid).mockResolvedValue('mock-tx');
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={createParams()} />);

// Open modal
await waitFor(() => {
Expand Down
15 changes: 10 additions & 5 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,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
}, [invoiceId]);

useEffect(() => {
fetchInvoice();
const timeout = window.setTimeout(fetchInvoice, 0);
return () => window.clearTimeout(timeout);
}, [fetchInvoice]);

const handlePaymentConfirm = async (amount: bigint) => {
Expand All @@ -64,12 +65,13 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
// Close modal and refresh invoice state
setIsPaymentModalOpen(false);
fetchInvoice();
} catch (err: any) {
} catch (err: unknown) {
console.error(err);
const message = err instanceof Error ? err.message : "An unexpected error occurred during payment.";
updateToast(toastId, {
type: "error",
title: "Payment Failed",
message: err.message || "An unexpected error occurred during payment."
message,
});
} finally {
setIsPaying(false);
Expand Down Expand Up @@ -136,7 +138,10 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
{/* ── Invoice Summary Card ───────────────────────────────────────── */}
<section className="rounded-[24px] border border-outline-variant/15 bg-surface-container-lowest p-6 shadow-xl">
<div className="mb-6">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-on-surface-variant mb-4">Invoice Summary</p>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-on-surface-variant">Invoice Summary</p>
<InvoicePdfDownloadButton invoice={invoice} />
</div>

<div className="flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-outline-variant/10 pb-4">
Expand Down
106 changes: 106 additions & 0 deletions src/components/InvoicePdfDownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { useRef, useState } from "react";
import { QRCodeCanvas } from "qrcode.react";
import type { Invoice } from "@/utils/soroban";
import {
getInvoicePageUrl,
getInvoicePdfFilename,
getInvoicePdfRows,
} from "@/utils/invoicePdf";

interface InvoicePdfDownloadButtonProps {
invoice: Invoice;
}

export default function InvoicePdfDownloadButton({ invoice }: InvoicePdfDownloadButtonProps) {
const qrCanvasRef = useRef<HTMLCanvasElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const invoiceUrl = getInvoicePageUrl(invoice.id, origin);

const downloadPdf = async () => {
setIsGenerating(true);
try {
const { jsPDF } = await import("jspdf");
const doc = new jsPDF({ unit: "pt", format: "letter" });
const rows = getInvoicePdfRows(invoice);
const qrDataUrl = qrCanvasRef.current?.toDataURL("image/png");

doc.setFillColor(16, 24, 39);
doc.rect(0, 0, 612, 112, "F");
doc.setTextColor(255, 255, 255);
doc.setFont("helvetica", "bold");
doc.setFontSize(24);
doc.text("ILN Invoice", 48, 52);
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
doc.text("Invoice Liquidity Network", 48, 76);

doc.setTextColor(17, 24, 39);
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text(`Invoice #${invoice.id.toString()}`, 48, 154);

doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 116, 139);
doc.text("Generated client-side from the ILN invoice detail page.", 48, 174);

let y = 220;
rows.forEach((row) => {
doc.setTextColor(100, 116, 139);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text(row.label.toUpperCase(), 48, y);

doc.setTextColor(17, 24, 39);
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
const wrapped = doc.splitTextToSize(row.value, 360);
doc.text(wrapped, 48, y + 18);
y += 42 + Math.max(0, wrapped.length - 1) * 14;
});

if (qrDataUrl) {
doc.setDrawColor(226, 232, 240);
doc.roundedRect(424, 142, 140, 166, 12, 12);
doc.addImage(qrDataUrl, "PNG", 444, 160, 100, 100);
doc.setTextColor(100, 116, 139);
doc.setFontSize(8);
doc.text("Scan to open invoice", 446, 282);
}

doc.setTextColor(71, 85, 105);
doc.setFontSize(9);
doc.text("Invoice page", 48, 720);
doc.setTextColor(37, 99, 235);
doc.text(invoiceUrl, 48, 736);

doc.save(getInvoicePdfFilename(invoice.id));
} finally {
setIsGenerating(false);
}
};

return (
<>
<QRCodeCanvas
ref={qrCanvasRef}
value={invoiceUrl}
size={160}
includeMargin
className="hidden"
aria-hidden="true"
/>
<button
onClick={() => void downloadPdf()}
disabled={isGenerating}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-outline-variant/30 px-4 py-3 text-sm font-bold text-on-surface-variant transition-all hover:bg-surface-container-high disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">picture_as_pdf</span>
{isGenerating ? "Generating PDF..." : "Download PDF"}
</button>
</>
);
}
65 changes: 65 additions & 0 deletions src/components/__tests__/InvoicePdfDownloadButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import InvoicePdfDownloadButton from "../InvoicePdfDownloadButton";
import type { Invoice } from "@/utils/soroban";

const save = vi.fn();
const text = vi.fn();
const addImage = vi.fn();

vi.mock("qrcode.react", async () => {
const ReactModule = await vi.importActual<typeof React>("react");
const MockQRCodeCanvas = ReactModule.forwardRef<HTMLCanvasElement>((_props, ref) => (
<canvas ref={ref} aria-hidden="true" />
));
MockQRCodeCanvas.displayName = "MockQRCodeCanvas";
return { QRCodeCanvas: MockQRCodeCanvas };
});

vi.mock("jspdf", () => ({
jsPDF: vi.fn(function MockJsPDF(this: Record<string, unknown>) {
this.setFillColor = vi.fn();
this.rect = vi.fn();
this.setTextColor = vi.fn();
this.setFont = vi.fn();
this.setFontSize = vi.fn();
this.text = text;
this.splitTextToSize = vi.fn((value: string) => [value]);
this.setDrawColor = vi.fn();
this.roundedRect = vi.fn();
this.addImage = addImage;
this.save = save;
}),
}));

const invoice: Invoice = {
id: 7n,
freelancer: "GFREELANCERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
payer: "GPAYERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
amount: 1_000_000_000n,
due_date: 1_900_000_000n,
discount_rate: 300,
status: "Pending",
};

describe("InvoicePdfDownloadButton", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window.HTMLCanvasElement.prototype, "toDataURL", {
configurable: true,
value: vi.fn(() => "data:image/png;base64,qr"),
});
});

it("generates and saves the required invoice PDF filename", async () => {
render(<InvoicePdfDownloadButton invoice={invoice} />);

fireEvent.click(screen.getByRole("button", { name: /download pdf/i }));

await waitFor(() => expect(save).toHaveBeenCalledWith("ILN-Invoice-7.pdf"));
expect(addImage).toHaveBeenCalledWith("data:image/png;base64,qr", "PNG", 444, 160, 100, 100);
expect(text).toHaveBeenCalledWith("ILN Invoice", 48, 52);
expect(text).toHaveBeenCalledWith("Invoice page", 48, 720);
});
});
Loading