From 9f1ef66f0f8dc930ec70027ac33e245b783d33c8 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 14:34:38 -0700 Subject: [PATCH] feat: add invoice share link --- app/governance/page.tsx | 18 ++-- app/invoices/[id]/page.tsx | 5 + app/pay/[id]/__tests__/PayInvoice.test.tsx | 94 +++++++++++++------ app/pay/[id]/page.tsx | 15 ++- src/components/ShareInvoiceButton.tsx | 54 +++++++++++ src/components/TokenSelector.tsx | 4 +- .../__tests__/ShareInvoiceButton.test.tsx | 41 ++++++++ src/utils/__tests__/invoiceSharing.test.ts | 18 ++++ src/utils/federation.ts | 3 +- src/utils/invoiceSharing.ts | 15 +++ 10 files changed, 224 insertions(+), 43 deletions(-) create mode 100644 app/invoices/[id]/page.tsx create mode 100644 src/components/ShareInvoiceButton.tsx create mode 100644 src/components/__tests__/ShareInvoiceButton.test.tsx create mode 100644 src/utils/__tests__/invoiceSharing.test.ts create mode 100644 src/utils/invoiceSharing.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..cc5086c 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,13 +195,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); @@ -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]/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 f4c9ee7..fbc9ec0 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', @@ -37,35 +51,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(); @@ -74,18 +84,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(); @@ -93,19 +101,45 @@ describe('PayInvoicePage', () => { }); }); + it('shows sharing controls to the invoice submitter', async () => { + mockWallet({ + address: 'GFREELANCER', + connect: vi.fn(), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /share invoice/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /share via email/i })).toBeInTheDocument(); + }); + }); + + it('does not show sharing controls to non-submitters', async () => { + mockWallet({ + address: 'GPAYER', + connect: vi.fn(), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Settle Invoice Now')).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /share invoice/i })).not.toBeInTheDocument(); + }); + it('should call markPaid when Settle button is clicked', 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(); await waitFor(() => { expect(screen.getByText('Settle Invoice Now')).toBeInTheDocument(); diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index 668b449..3cdeb66 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -7,8 +7,9 @@ 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 ShareInvoiceButton from "@/components/ShareInvoiceButton"; type LoadState = "loading" | "success" | "error"; @@ -37,7 +38,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + void Promise.resolve().then(fetchInvoice); }, [fetchInvoice]); const handlePay = async () => { @@ -61,12 +62,12 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin // Refresh invoice state 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); @@ -93,6 +94,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"; @@ -106,6 +108,11 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin

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

+ {isSubmitter && ( +
+ +
+ )} {/* ── Status Banners ────────────────────────────────────────────── */} diff --git a/src/components/ShareInvoiceButton.tsx b/src/components/ShareInvoiceButton.tsx new file mode 100644 index 0000000..75351da --- /dev/null +++ b/src/components/ShareInvoiceButton.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { buildInvoiceCanonicalUrl, buildInvoiceMailtoUrl } from "@/utils/invoiceSharing"; + +export default function ShareInvoiceButton({ invoiceId }: { invoiceId: string | bigint }) { + const [copied, setCopied] = useState(false); + const invoiceUrl = useMemo(() => { + const origin = typeof window === "undefined" ? "" : window.location.origin; + return buildInvoiceCanonicalUrl(invoiceId, origin); + }, [invoiceId]); + const mailtoUrl = useMemo( + () => buildInvoiceMailtoUrl(invoiceId, invoiceUrl), + [invoiceId, invoiceUrl], + ); + + async function copyLink() { + await navigator.clipboard.writeText(invoiceUrl); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } + + return ( +
+ + + + Share via email + + {copied && ( + + Link copied! + + )} +
+ ); +} 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/components/__tests__/ShareInvoiceButton.test.tsx b/src/components/__tests__/ShareInvoiceButton.test.tsx new file mode 100644 index 0000000..5603a66 --- /dev/null +++ b/src/components/__tests__/ShareInvoiceButton.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ShareInvoiceButton from "../ShareInvoiceButton"; + +describe("ShareInvoiceButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "location", { + configurable: true, + value: { origin: "https://iln.example" }, + }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("copies the canonical invoice link and shows confirmation", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /share invoice/i })); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "https://iln.example/invoices/23", + ); + expect(screen.getByRole("status")).toHaveTextContent("Link copied!"); + }); + }); + + it("renders a mailto link with the canonical invoice URL", () => { + render(); + + const link = screen.getByRole("link", { name: /share via email/i }); + + expect(decodeURIComponent(link.getAttribute("href") ?? "")).toContain( + "https://iln.example/invoices/23", + ); + }); +}); 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)}`; +}