diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..630389e 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -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" }, }; @@ -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(() => { diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..942b07a 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,27 +1,40 @@ 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: () => , +})); + +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', @@ -29,6 +42,7 @@ describe('PayInvoicePage', () => { amount_paid: 0n, due_date: 1713960000n, status: 'Funded', + discount_rate: 300, }; const mockToast = { @@ -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); - 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('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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Address Mismatch')).toBeInTheDocument(); @@ -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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Invoice settled')).toBeInTheDocument(); @@ -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); - (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(); + render(); // Open modal await waitFor(() => { diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..4b8bca2 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,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) => { @@ -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); @@ -136,7 +138,10 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin {/* ── Invoice Summary Card ───────────────────────────────────────── */}
-

Invoice Summary

+
+

Invoice Summary

+ +
diff --git a/src/components/InvoicePdfDownloadButton.tsx b/src/components/InvoicePdfDownloadButton.tsx new file mode 100644 index 0000000..933c3a9 --- /dev/null +++ b/src/components/InvoicePdfDownloadButton.tsx @@ -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(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 ( + <> +