From ac9f9768fb136de72958e3dfd2da877f47b0b1e5 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 11:42:58 -0700 Subject: [PATCH] feat: add multi-step invoice submission --- app/governance/page.tsx | 12 +- src/components/StepIndicator.tsx | 47 ++ src/components/SubmitInvoiceForm.tsx | 405 ++++++++++++------ src/components/TokenSelector.tsx | 4 +- .../__tests__/SubmitInvoiceForm.test.tsx | 108 +++-- .../SubmitInvoiceFormAddressBook.test.tsx | 43 +- src/utils/evidence.ts | 2 +- src/utils/federation.ts | 8 +- 8 files changed, 430 insertions(+), 199 deletions(-) create mode 100644 src/components/StepIndicator.tsx diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..4ccaeea 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,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 ( @@ -121,7 +122,7 @@ function ProposalCard({ proposal }: { proposal: Proposal }) { // ─── Filter tab ─────────────────────────────────────────────────────────────── -const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending"]; +const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending", "Vetoed"]; function FilterTabs({ active, @@ -172,10 +173,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]); const filtered = 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 0718839..455e496 100644 --- a/src/components/SubmitInvoiceForm.tsx +++ b/src/components/SubmitInvoiceForm.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { NETWORK_NAME } from "@/constants"; import TokenSelector, { TokenAmount } from "../components/TokenSelector"; import FieldTooltip from "./FieldTooltip"; +import StepIndicator from "./StepIndicator"; import { useToast } from "@/context/ToastContext"; import { useWallet } from "@/context/WalletContext"; import { useApprovedTokens } from "@/hooks/useApprovedTokens"; @@ -28,6 +29,16 @@ const INITIAL_FORM: InvoiceFormValues = { tokenId: "", }; +const FORM_STEPS = ["Invoice Details", "Token & Rate", "Review & Submit"] as const; +type FormStepIndex = 0 | 1 | 2; +type FormErrorKey = keyof InvoiceFormValues | "wallet" | "submit"; + +const STEP_ERROR_KEYS: Record = { + 0: ["payer", "amount", "dueDate"], + 1: ["tokenId", "discountRate"], + 2: ["payer", "amount", "dueDate", "tokenId", "discountRate", "wallet", "submit"], +}; + interface SubmitInvoiceFormProps { initialValues?: Partial; prefillId?: string; @@ -45,7 +56,8 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn ...initialValues, dueDate: "", }); - const [errors, setErrors] = useState>>({}); + const [currentStep, setCurrentStep] = useState(0); + const [errors, setErrors] = useState>>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submittedInvoiceId, setSubmittedInvoiceId] = useState(null); const [lastTxHash, setLastTxHash] = useState(null); @@ -65,6 +77,42 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn setSubmittedInvoiceId(null); }; + const getValidationErrors = () => { + 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"); + } + return nextErrors; + }; + + const validateCurrentStep = () => { + const nextErrors = getValidationErrors(); + const stepKeys = STEP_ERROR_KEYS[currentStep]; + const stepErrors = Object.fromEntries( + Object.entries(nextErrors).filter(([key]) => stepKeys.includes(key as FormErrorKey)), + ); + setErrors(stepErrors); + return Object.keys(stepErrors).length === 0; + }; + + const goToNextStep = () => { + if (!validateCurrentStep()) return; + setCurrentStep((step) => Math.min(2, step + 1) as FormStepIndex); + }; + + const goToPreviousStep = () => { + setCurrentStep((step) => Math.max(0, step - 1) as FormStepIndex); + setErrors((current) => ({ ...current, submit: undefined })); + }; + const handleCopyInvoiceId = async () => { if (!submittedInvoiceId) return; @@ -110,19 +158,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; @@ -252,137 +294,179 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn )} + +
- -
- { - setField("payer", event.target.value); - setAddressBookQuery(event.target.value); - setAddressBookOpen(true); - setHighlightedIndex(-1); - }} - onKeyDown={handleAddressBookKeyDown} - className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" - placeholder="G..." - autoComplete="off" - spellCheck={false} - /> - {addressBookOpen && ( -
- {addressBookQuery ? ( - searchAddresses(addressBookQuery).map((entry, index) => ( -
handleSelectAddress(entry.address)} - > -
- {entry.nickname} - - {entry.address.slice(0, 6)}...{entry.address.slice(-4)} - + {currentStep === 0 && ( +
+
+

Invoice Details

+

Enter the payer, face value, and due date before choosing funding terms.

+
+ +
+ { + setField("payer", event.target.value); + setAddressBookQuery(event.target.value); + setAddressBookOpen(true); + setHighlightedIndex(-1); + }} + onKeyDown={handleAddressBookKeyDown} + className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" + placeholder="G..." + autoComplete="off" + spellCheck={false} + /> + {addressBookOpen && ( +
+ {addressBookQuery ? ( + searchAddresses(addressBookQuery).map((entry, index) => ( +
handleSelectAddress(entry.address)} + > +
+ {entry.nickname} + + {entry.address.slice(0, 6)}...{entry.address.slice(-4)} + +
+
+ )) + ) : ( +
+ {t("addressBook.noMatches")}
-
- )) - ) : ( -
- {t("addressBook.noMatches")} + )}
)}
- )} -
- - - setField("tokenId", value)} - hint={ - tokensError - ? tokensError - : tokensLoading - ? t("submitForm.loadingTokens") - : t("submitForm.tokensHint") - } - /> - -
- - setField("amount", event.target.value)} - className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" - placeholder="5000.00" - inputMode="decimal" - /> - + - - setField("dueDate", event.target.value)} - min={getMinimumDueDate()} - className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" - type="date" - /> - -
+
+ + setField("amount", event.target.value)} + className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" + placeholder="5000.00" + inputMode="decimal" + /> + + + + setField("dueDate", event.target.value)} + min={getMinimumDueDate()} + className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" + type="date" + /> + +
+ + )} - - How much of the invoice value you give up in exchange for instant payment. 300 basis points = 3%. A lower rate attracts more LPs; a higher rate means you receive less upfront. -
Typical value: 100–500 bps
- - } - error={errors.discountRate} - hint={t("submitForm.discountRateHint")} - > -
- setField("discountRate", event.target.value)} - className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" - placeholder="3.00" - inputMode="decimal" + {currentStep === 1 && ( +
+
+

Token & Rate

+

Select the settlement token and discount rate, then check the LP preview.

+
+ + setField("tokenId", value)} + hint={ + tokensError + ? tokensError + : tokensLoading + ? t("submitForm.loadingTokens") + : t("submitForm.tokensHint") + } /> -
- {preview.discountRatePercent.toFixed(2)}% + + + How much of the invoice value you give up in exchange for instant payment. 300 basis points = 3%. A lower rate attracts more LPs; a higher rate means you receive less upfront. +
Typical value: 100-500 bps
+ + } + error={errors.discountRate} + hint={t("submitForm.discountRateHint")} + > +
+ setField("discountRate", event.target.value)} + className="w-full rounded-2xl bg-surface-container-low px-4 py-3.5 text-sm border border-outline-variant/15 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none" + placeholder="3.00" + inputMode="decimal" + /> +
+ {preview.discountRatePercent.toFixed(2)}% +
+
+ {form.amount && selectedToken && ( +

+ You'll receive {preview.payoutFormatted} {selectedToken.symbol} instantly if funded at this rate +

+ )} +
+
+ )} + + {currentStep === 2 && ( +
+
+

Review & Submit

+

Confirm the invoice summary and sign from your connected wallet.

-
- {form.amount && selectedToken && ( -

- You'll receive {preview.payoutFormatted} {selectedToken.symbol} instantly if funded at this rate -

- )} -
+
+
+ + + + + + + +
+
+ Wallet confirmation is required on {NETWORK_NAME} before this invoice is submitted. +
+
+ + )} {errors.submit ? (
@@ -412,13 +496,29 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
) : null} - +
+ {currentStep > 0 && ( + + )} + +
); } + +function ReviewRow({ + label, + value, + mono, + accent, +}: { + label: string; + value: string; + mono?: boolean; + accent?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} 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__/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`);