diff --git a/app/freelancer/page.tsx b/app/freelancer/page.tsx index f121e20..3bd5e26 100644 --- a/app/freelancer/page.tsx +++ b/app/freelancer/page.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, Suspense } fr import { useTranslation } from "react-i18next"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import CancelInvoiceButton from "@/components/CancelInvoiceButton"; import InvoiceFilterBar from "@/components/InvoiceFilterBar"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; @@ -21,13 +22,19 @@ import { } from "@/utils/format"; import { rpc, TransactionBuilder } from "@stellar/stellar-sdk"; import { RPC_URL, NETWORK_PASSPHRASE } from "@/constants"; -import SkeletonRow, { FREELANCER_COLUMNS } from "@/components/SkeletonRow"; import { ExportButton } from "@/components/ExportButton"; -import { EmptyState } from "@/components/EmptyState"; -import { FreelancerEmptyIllustration } from "@/components/illustrations/EmptyIllustrations"; const server = new rpc.Server(RPC_URL); +interface SendTransactionResult { + status?: string; + hash?: string; +} + +interface TransactionStatusResult { + status?: string; +} + // ─── Types ──────────────────────────────────────────────────────────────────── type Screen = "submit" | "my-invoices"; @@ -55,6 +62,8 @@ function StatusBadge({ status }: { status: string }) { Funded: "bg-[#dbeafe] text-[#1d4ed8] dark:bg-[#1e3a8a]/30 dark:text-[#93c5fd]", Paid: "bg-[#dcfce7] text-[#15803d] dark:bg-[#14532d]/30 dark:text-[#86efac]", + Cancelled: + "bg-surface-container text-on-surface-variant", Defaulted: "bg-error-container text-on-error-container", }; @@ -95,7 +104,7 @@ function FreelancerPageContent() { const [invoices, setInvoices] = useState([]); const [loadingInvoices, setLoadingInvoices] = useState(false); - const refreshIntervalRef = useRef | null>(null); + const refreshIntervalRef = useRef(null); const { filters, setFilters, @@ -119,16 +128,28 @@ function FreelancerPageContent() { useEffect(() => { if (screen === "my-invoices") { - fetchMyInvoices(); - refreshIntervalRef.current = setInterval(fetchMyInvoices, 30_000); + const timeout = window.setTimeout(() => { + void fetchMyInvoices(); + }, 0); + refreshIntervalRef.current = window.setInterval(() => { + void fetchMyInvoices(); + }, 30_000); + return () => { + window.clearTimeout(timeout); + if (refreshIntervalRef.current) { + window.clearInterval(refreshIntervalRef.current); + } + }; } return () => { if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + window.clearInterval(refreshIntervalRef.current); } }; }, [screen, fetchMyInvoices]); + const [minDueDate] = useState(() => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)); + const filteredInvoices = useMemo( () => applyInvoiceFilters(invoices, filters, { @@ -195,12 +216,14 @@ function FreelancerPageContent() { TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE) ); - if ((sendResult as any).status === "PENDING") { - let txStatus = await server.getTransaction((sendResult as any).hash); + const sent = sendResult as SendTransactionResult; + + if (sent.status === "PENDING" && sent.hash) { + let txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult; let tries = 0; - while ((txStatus as any).status === "NOT_FOUND" && tries < 20) { + while (txStatus.status === "NOT_FOUND" && tries < 20) { await new Promise((r) => setTimeout(r, 1500)); - txStatus = await server.getTransaction((sendResult as any).hash); + txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult; tries++; } @@ -210,16 +233,16 @@ function FreelancerPageContent() { updateToast(toastId, { type: "success", title: t("freelancer.toast.submitted"), - txHash: (sendResult as any).hash, + txHash: sent.hash, }); } else { - throw new Error(`Transaction rejected: ${(sendResult as any).status}`); + throw new Error(`Transaction rejected: ${sent.status ?? "unknown"}`); } - } catch (err: any) { + } catch (err) { updateToast(toastId, { type: "error", title: t("freelancer.toast.submissionFailed"), - message: err?.message ?? t("freelancer.toast.unknownError"), + message: err instanceof Error ? err.message : t("freelancer.toast.unknownError"), }); } finally { setIsSubmitting(false); @@ -234,8 +257,17 @@ function FreelancerPageContent() { t("freelancer.invoices.headers.discount"), t("freelancer.invoices.headers.dueDate"), t("freelancer.invoices.headers.status"), + "Actions", ]; + const markInvoiceCancelled = (invoiceId: bigint) => { + setInvoices((current) => + current.map((invoice) => + invoice.id === invoiceId ? { ...invoice, status: "Cancelled" } : invoice, + ), + ); + }; + return ( <> @@ -433,9 +465,7 @@ function FreelancerPageContent() { id="field-due-date" type="date" value={form.dueDate} - min={new Date(Date.now() + 86_400_000) - .toISOString() - .slice(0, 10)} + min={minDueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value }) } @@ -649,7 +679,7 @@ function FreelancerPageContent() { {loadingInvoices ? ( @@ -661,7 +691,7 @@ function FreelancerPageContent() { ) : invoices.length === 0 ? (
@@ -708,6 +738,14 @@ function FreelancerPageContent() { + + markInvoiceCancelled(inv.id)} + /> + )) )} diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..1387794 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-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -202,10 +203,17 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(() => { + void load(); + }, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(() => { + void load(); + }, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..2a574be 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,26 +1,32 @@ 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(), + cancelInvoice: vi.fn(), submitSignedTransaction: vi.fn(), })); describe('PayInvoicePage', () => { + type ResolvedParamsPromise = Promise<{ id: string }> & { _resolvedValue: { id: string } }; + + const makeParams = (id: string): ResolvedParamsPromise => + Object.assign(Promise.resolve({ id }), { _resolvedValue: { id } }); + const mockInvoice = { id: 1n, freelancer: 'GFREELANCER', @@ -38,18 +44,17 @@ 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' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -59,13 +64,12 @@ describe('PayInvoicePage', () => { }); 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' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -75,17 +79,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' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -130,16 +133,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' as Awaited>); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; + const params = makeParams('1'); render(); // Open modal diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..49a2db1 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,10 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + const timeout = window.setTimeout(() => { + void fetchInvoice(); + }, 0); + return () => window.clearTimeout(timeout); }, [fetchInvoice]); const handlePaymentConfirm = async (amount: bigint) => { @@ -69,7 +72,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin 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); @@ -97,6 +100,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin } const isPayer = address === invoice.payer; + const isSubmitter = address === invoice.freelancer; const isPaid = invoice.status === "Paid"; const isFunded = invoice.status === "Funded"; @@ -123,7 +127,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
)} - {address && !isPayer && !isPaid && ( + {address && !isPayer && !isPaid && !isCancelled && (
warning
@@ -133,6 +137,16 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
)} + {isCancelled && ( +
+ block +
+

Invoice cancelled

+

This invoice was cancelled by the submitter and can no longer be funded or settled.

+
+
+ )} + {/* ── Invoice Summary Card ───────────────────────────────────────── */}
@@ -207,6 +221,18 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin Restricted to Registered Payer
)} + + {isSubmitter && invoice.status === "Pending" && ( +
+ setInvoice((current) => current ? { ...current, status: "Cancelled" } : current)} + className="w-full rounded-2xl py-4 text-base" + /> +
+ )}
diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 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): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/__tests__/CancelInvoiceButton.test.tsx b/src/components/__tests__/CancelInvoiceButton.test.tsx new file mode 100644 index 0000000..50e752a --- /dev/null +++ b/src/components/__tests__/CancelInvoiceButton.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CancelInvoiceButton from "../CancelInvoiceButton"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { cancelInvoice, submitSignedTransaction } from "@/utils/soroban"; + +vi.mock("@/context/ToastContext", () => ({ + useToast: vi.fn(), +})); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: vi.fn(), +})); + +vi.mock("@/utils/soroban", () => ({ + cancelInvoice: vi.fn(), + submitSignedTransaction: vi.fn(), +})); + +describe("CancelInvoiceButton", () => { + const toast = { + addToast: vi.fn(() => "toast-id"), + updateToast: vi.fn(), + }; + const onCancelled = vi.fn(); + const signTx = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useToast).mockReturnValue(toast); + vi.mocked(useWallet).mockReturnValue({ + address: "GFREELANCER", + isConnected: true, + isInstalled: true, + error: null, + networkMismatch: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTx, + }); + vi.mocked(cancelInvoice).mockResolvedValue({ tx: "prepared-tx" }); + vi.mocked(submitSignedTransaction).mockResolvedValue({ txHash: "hash-123" }); + }); + + it("only renders for the submitter while the invoice is pending", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: /cancel invoice/i })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("button", { name: /cancel invoice/i })).not.toBeInTheDocument(); + + vi.mocked(useWallet).mockReturnValue({ + address: "GOTHER", + isConnected: true, + isInstalled: true, + error: null, + networkMismatch: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTx, + }); + rerender( + , + ); + expect(screen.queryByRole("button", { name: /cancel invoice/i })).not.toBeInTheDocument(); + }); + + it("confirms, submits cancellation, and reports success", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel invoice/i })); + expect(screen.getByText("Are you sure? This cannot be undone.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /confirm cancel/i })); + + await waitFor(() => { + expect(cancelInvoice).toHaveBeenCalledWith("GFREELANCER", 7n); + expect(submitSignedTransaction).toHaveBeenCalledWith({ tx: "prepared-tx", signTx }); + expect(onCancelled).toHaveBeenCalledOnce(); + expect(toast.updateToast).toHaveBeenCalledWith( + "toast-id", + expect.objectContaining({ type: "success", title: "Invoice Cancelled", txHash: "hash-123" }), + ); + }); + }); + + it("shows the failure reason when cancellation fails", async () => { + vi.mocked(cancelInvoice).mockRejectedValue(new Error("contract rejected")); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel invoice/i })); + fireEvent.click(screen.getByRole("button", { name: /confirm cancel/i })); + + await waitFor(() => { + expect(onCancelled).not.toHaveBeenCalled(); + expect(toast.updateToast).toHaveBeenCalledWith( + "toast-id", + expect.objectContaining({ type: "error", title: "Cancellation Failed", message: "contract rejected" }), + ); + }); + }); +}); diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,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 { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);