diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..fec8162 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,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); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/src/components/StepIndicator.tsx b/src/components/StepIndicator.tsx new file mode 100644 index 0000000..2a2fbb9 --- /dev/null +++ b/src/components/StepIndicator.tsx @@ -0,0 +1,47 @@ +"use client"; + +interface StepIndicatorProps { + steps: readonly string[]; + currentStep: number; +} + +export default function StepIndicator({ steps, currentStep }: StepIndicatorProps) { + return ( +
    + {steps.map((step, index) => { + const stepNumber = index + 1; + const isCurrent = index === currentStep; + const isComplete = index < currentStep; + + return ( +
  1. + + {isComplete ? ( + check + ) : ( + stepNumber + )} + + {step} +
  2. + ); + })} +
+ ); +} diff --git a/src/components/SubmitInvoiceForm.tsx b/src/components/SubmitInvoiceForm.tsx index 18fbbea..0c01162 100644 --- a/src/components/SubmitInvoiceForm.tsx +++ b/src/components/SubmitInvoiceForm.tsx @@ -175,19 +175,13 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - const nextErrors = validateInvoiceForm( - { ...form, tokenId: effectiveTokenId }, - isConnected, - selectedToken?.decimals ?? 7, - selectedToken?.symbol ?? "token", - ); - if (networkMismatch) { - nextErrors.wallet = t("submitForm.walletError", { network: NETWORK_NAME }); - } - if (!selectedToken && !tokensLoading) { - nextErrors.tokenId = t("submitForm.noTokensAvailable"); + if (currentStep !== 2) { + goToNextStep(); + return; } + const nextErrors = getValidationErrors(); + if (Object.keys(nextErrors).length > 0) { setErrors(nextErrors); return; @@ -364,9 +358,7 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn )} - )} - - + ): 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__/SubmitInvoiceForm.test.tsx b/src/components/__tests__/SubmitInvoiceForm.test.tsx index 2fe1172..d4d9da0 100644 --- a/src/components/__tests__/SubmitInvoiceForm.test.tsx +++ b/src/components/__tests__/SubmitInvoiceForm.test.tsx @@ -12,7 +12,7 @@ */ import React from "react"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import SubmitInvoiceForm from "../SubmitInvoiceForm"; @@ -77,6 +77,43 @@ function connectWallet(address = VALID_STELLAR_FREELANCER) { walletState.isConnected = true; } +function clickNext() { + fireEvent.click(screen.getByRole("button", { name: /next/i })); +} + +function fillInvoiceDetails({ + payer = VALID_STELLAR_PAYER, + amount = "2000", + dueDate = "2099-06-15", +} = {}) { + fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: payer } }); + fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: amount } }); + fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: dueDate } }); +} + +function goToTokenAndRate() { + clickNext(); + expect(screen.getByRole("region", { name: "Token & Rate" })).toBeInTheDocument(); +} + +function goToReviewAndSubmit(discountRate = "3.5") { + fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: discountRate } }); + clickNext(); + expect(screen.getByRole("region", { name: "Review & Submit" })).toBeInTheDocument(); +} + +function completeStepperAndSubmit({ + payer = VALID_STELLAR_PAYER, + amount = "2000", + dueDate = "2099-06-15", + discountRate = "3.5", +} = {}) { + fillInvoiceDetails({ payer, amount, dueDate }); + goToTokenAndRate(); + goToReviewAndSubmit(discountRate); + fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe("SubmitInvoiceForm", () => { @@ -102,7 +139,7 @@ describe("SubmitInvoiceForm", () => { it("shows the wallet error banner when submitting without a connected wallet", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + completeStepperAndSubmit(); expect( await screen.findByText(/connect your freighter wallet to submit an invoice/i), @@ -117,7 +154,7 @@ describe("SubmitInvoiceForm", () => { render(); // Amount and dueDate are also empty so multiple errors fire – we only care about payer - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect(await screen.findByText(/payer stellar address is required/i)).toBeInTheDocument(); }); @@ -128,7 +165,7 @@ describe("SubmitInvoiceForm", () => { fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: "not-a-stellar-key" }, }); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect( await screen.findByText(/enter a valid stellar public key for the payer/i), @@ -142,7 +179,7 @@ describe("SubmitInvoiceForm", () => { fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "not-a-number" }, }); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect(await screen.findByText(/enter a valid invoice amount in usdc/i)).toBeInTheDocument(); }); @@ -152,7 +189,7 @@ describe("SubmitInvoiceForm", () => { render(); fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "0" } }); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect(await screen.findByText(/enter a valid invoice amount in usdc/i)).toBeInTheDocument(); }); @@ -162,7 +199,7 @@ describe("SubmitInvoiceForm", () => { render(); // Leave dueDate empty - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect(await screen.findByText(/select a valid due date/i)).toBeInTheDocument(); }); @@ -170,8 +207,10 @@ describe("SubmitInvoiceForm", () => { connectWallet(); render(); + fillInvoiceDetails(); + goToTokenAndRate(); fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "0" } }); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect( await screen.findByText(/discount rate must be between 0\.01% and 50%/i), @@ -182,8 +221,10 @@ describe("SubmitInvoiceForm", () => { connectWallet(); render(); + fillInvoiceDetails(); + goToTokenAndRate(); fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "51" } }); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + clickNext(); expect( await screen.findByText(/discount rate must be between 0\.01% and 50%/i), @@ -197,7 +238,7 @@ describe("SubmitInvoiceForm", () => { walletState.networkMismatch = true; render(); - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + completeStepperAndSubmit(); expect( await screen.findByText(/freighter must be connected to testnet/i), @@ -218,7 +259,10 @@ describe("SubmitInvoiceForm", () => { it("updates the live yield preview as the user types amount and discount rate", () => { render(); + fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } }); fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "10000" } }); + fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-06-15" } }); + goToTokenAndRate(); fireEvent.change(screen.getByPlaceholderText("3.00"), { target: { value: "5" } }); // Face value @@ -238,6 +282,27 @@ describe("SubmitInvoiceForm", () => { expect(screen.getAllByText("0 USDC").length).toBeGreaterThanOrEqual(3); }); + it("moves through steps, supports Back, and keeps entered details", () => { + connectWallet(); + render(); + + fillInvoiceDetails({ amount: "2500", dueDate: "2099-08-20" }); + goToTokenAndRate(); + fireEvent.change(screen.getByDisplayValue("3.00"), { target: { value: "4.25" } }); + goToReviewAndSubmit("4.25"); + + expect(screen.getByText("You will receive")).toBeInTheDocument(); + expect(screen.getAllByText(/4\.25%/).length).toBeGreaterThan(0); + expect(screen.getByText(/LP yield is/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /back/i })); + expect(screen.getByDisplayValue("4.25")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /back/i })); + expect(screen.getByDisplayValue(VALID_STELLAR_PAYER)).toBeInTheDocument(); + expect(screen.getByDisplayValue("2500")).toBeInTheDocument(); + expect(screen.getByDisplayValue("2099-08-20")).toBeInTheDocument(); + }); + // ── Successful submission ───────────────────────────────────────────────── it("submits a fully valid invoice and displays the returned invoice ID and tx hash", async () => { @@ -250,8 +315,9 @@ describe("SubmitInvoiceForm", () => { target: { value: VALID_STELLAR_PAYER }, }); fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "2000" } }); - fireEvent.change(screen.getByDisplayValue("3.00"), { target: { value: "3.5" } }); fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-06-15" } }); + goToTokenAndRate(); + goToReviewAndSubmit("3.5"); fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); // Contract call is made with correctly parsed values @@ -279,11 +345,7 @@ describe("SubmitInvoiceForm", () => { render(); - fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } }); - fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "500" } }); - fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-01-01" } }); - - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + completeStepperAndSubmit({ amount: "500", dueDate: "2099-01-01" }); await waitFor(() => expect(screen.getByRole("button", { name: /submitting invoice/i })).toBeDisabled(), @@ -298,11 +360,7 @@ describe("SubmitInvoiceForm", () => { render(); - fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } }); - fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "1000" } }); - fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-03-01" } }); - - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + completeStepperAndSubmit({ amount: "1000", dueDate: "2099-03-01" }); expect(await screen.findByText("contract: insufficient gas")).toBeInTheDocument(); expect(updateToast).toHaveBeenCalledWith( @@ -317,16 +375,12 @@ describe("SubmitInvoiceForm", () => { render(); - fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER } }); - fireEvent.change(screen.getByPlaceholderText("5000.00"), { target: { value: "1200" } }); - fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: "2099-09-09" } }); - - fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + completeStepperAndSubmit({ amount: "1200", dueDate: "2099-09-09" }); await waitFor(() => expect(updateToast).toHaveBeenCalled()); expect(addToast).toHaveBeenCalledWith( - expect.objectContaining({ type: "pending", title: /submitting invoice/i }), + expect.objectContaining({ type: "pending", title: expect.stringMatching(/submitting invoice/i) }), ); expect(updateToast).toHaveBeenCalledWith( "toast-id-1", diff --git a/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx b/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx index e3822c2..949dc24 100644 --- a/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx +++ b/src/components/__tests__/SubmitInvoiceFormAddressBook.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import SubmitInvoiceForm from "../SubmitInvoiceForm"; @@ -24,12 +24,11 @@ const mockAddressBook = [ { id: "1", address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC", nickname: "Acme Corp" }, { id: "2", address: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBD", nickname: "Beta LLC" }, ]; +let currentAddressBook = mockAddressBook; vi.mock("../../context/WalletContext", () => ({ useWallet: () => ({ - ...walletAddress, - address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6", // Freelancer address - isConnected: true, + ...walletState, }), })); @@ -41,9 +40,9 @@ vi.mock("../../hooks/useAddressBook", () => ({ default: () => ({ addressBook: mockAddressBook, searchAddresses: (query: string) => { - if (!query) return mockAddressBook; + if (!query) return currentAddressBook; const lowerQuery = query.toLowerCase(); - return mockAddressBook.filter( + return currentAddressBook.filter( (entry) => entry.nickname.toLowerCase().includes(lowerQuery) || entry.address.toLowerCase().includes(lowerQuery) @@ -73,6 +72,7 @@ vi.mock("../../utils/soroban", () => ({ describe("SubmitInvoiceForm Address Book Integration", () => { beforeEach(() => { + currentAddressBook = mockAddressBook; walletState.address = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC6"; walletState.isConnected = true; walletState.error = null; @@ -92,7 +92,7 @@ describe("SubmitInvoiceForm Address Book Integration", () => { // Should show dropdown with matching addresses expect(screen.getByText("Acme Corp")).toBeInTheDocument(); - expect(screen.getByText("G...ABC")).toBeInTheDocument(); + expect(screen.getByText("GAAAAA...AABC")).toBeInTheDocument(); }); it("selects address from dropdown when clicking", async () => { @@ -118,14 +118,18 @@ describe("SubmitInvoiceForm Address Book Integration", () => { // Press ArrowDown to highlight first item fireEvent.keyDown(payerInput, { key: "ArrowDown" }); - expect(screen.getByText("Acme Corp")).toHaveClass("bg-primary text-surface-container-lowest"); + const acmeOption = screen.getByText("Acme Corp").closest(".cursor-pointer"); + if (!acmeOption) { + throw new Error("Acme option was not rendered"); + } + expect(acmeOption).toHaveClass("bg-primary", "text-surface-container-lowest"); // Press Enter to select fireEvent.keyDown(payerInput, { key: "Enter" }); expect(payerInput).toHaveValue("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC"); // Dropdown should be closed - expect(screen.queryByText("Acme Corp")).not.toHaveClass("bg-primary text-surface-container-lowest"); + expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument(); }); it("clears address book query when Escape is pressed", async () => { @@ -137,23 +141,14 @@ describe("SubmitInvoiceForm Address Book Integration", () => { // Press Escape fireEvent.keyDown(payerInput, { key: "Escape" }); - // Dropdown should be closed and query cleared - expect(payerInput).toHaveValue(""); + // Dropdown should be closed while preserving the typed payer text. + expect(payerInput).toHaveValue("acme"); + expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument(); }); it("does not show dropdown when address book is empty", async () => { - // Mock empty address book - vi.mock("../../hooks/useAddressBook", () => ({ - default: () => ({ - addressBook: [], - searchAddresses: () => [], - }), - })); - - // Need to re-render with new mock - await waitFor(() => { - render(); - }); + currentAddressBook = []; + render(); const payerInput = screen.getByPlaceholderText("G..."); fireEvent.change(payerInput, { target: { value: "acme" } }); @@ -161,4 +156,4 @@ describe("SubmitInvoiceForm Address Book Integration", () => { // Should not show any dropdown items expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument(); }); -}); \ No newline at end of file +}); 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`);