From 795ccea45e052fc611dcc2d1b9911a2670ee50a6 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 14:08:06 -0700 Subject: [PATCH] feat: build invoice submission form route --- app/governance/page.tsx | 16 ++++++--- .../new/__tests__/NewInvoicePage.test.tsx | 36 +++++++++++++++++++ app/invoices/new/page.tsx | 22 ++++++++++++ src/components/SubmitInvoiceForm.tsx | 22 +++++++++++- src/components/TokenSelector.tsx | 4 +-- .../__tests__/SubmitInvoiceForm.test.tsx | 23 ++++++++++-- src/utils/federation.ts | 3 +- src/utils/invoiceSubmission.ts | 4 +++ 8 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 app/invoices/new/__tests__/NewInvoicePage.test.tsx create mode 100644 app/invoices/new/page.tsx diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 6573981..a742347 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,9 +195,10 @@ 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(() => { @@ -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/new/__tests__/NewInvoicePage.test.tsx b/app/invoices/new/__tests__/NewInvoicePage.test.tsx new file mode 100644 index 0000000..06b4179 --- /dev/null +++ b/app/invoices/new/__tests__/NewInvoicePage.test.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import NewInvoicePage from "../page"; + +const push = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), +})); + +vi.mock("@/components/Navbar", () => ({ + default: () => , +})); + +vi.mock("@/components/Footer", () => ({ + default: () => , +})); + +vi.mock("@/components/SubmitInvoiceForm", () => ({ + default: ({ onSubmitted }: { onSubmitted?: (invoiceId: string) => void }) => ( + + ), +})); + +describe("NewInvoicePage", () => { + it("redirects successful submissions to the invoice detail page", () => { + render(); + + screen.getByRole("button", { name: "Mock submit invoice" }).click(); + + expect(push).toHaveBeenCalledWith("/invoices/42"); + }); +}); diff --git a/app/invoices/new/page.tsx b/app/invoices/new/page.tsx new file mode 100644 index 0000000..b3eb879 --- /dev/null +++ b/app/invoices/new/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import Footer from "@/components/Footer"; +import Navbar from "@/components/Navbar"; +import SubmitInvoiceForm from "@/components/SubmitInvoiceForm"; + +export default function NewInvoicePage() { + const router = useRouter(); + + return ( +
+ +
+
+ router.push(`/invoices/${invoiceId}`)} /> +
+
+
+
+ ); +} diff --git a/src/components/SubmitInvoiceForm.tsx b/src/components/SubmitInvoiceForm.tsx index 0718839..191962d 100644 --- a/src/components/SubmitInvoiceForm.tsx +++ b/src/components/SubmitInvoiceForm.tsx @@ -26,14 +26,16 @@ const INITIAL_FORM: InvoiceFormValues = { dueDate: "", discountRate: "3.00", tokenId: "", + referralCode: "", }; interface SubmitInvoiceFormProps { initialValues?: Partial; prefillId?: string; + onSubmitted?: (invoiceId: string) => void; } -export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitInvoiceFormProps) { +export default function SubmitInvoiceForm({ initialValues, prefillId, onSubmitted }: SubmitInvoiceFormProps) { const { t } = useTranslation(); const { addToast, updateToast } = useToast(); const { address, isConnected, connect, disconnect, networkMismatch, error: walletError, signTx } = useWallet(); @@ -115,6 +117,8 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn isConnected, selectedToken?.decimals ?? 7, selectedToken?.symbol ?? "token", + undefined, + address, ); if (networkMismatch) { nextErrors.wallet = t("submitForm.walletError", { network: NETWORK_NAME }); @@ -163,6 +167,7 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn message: `Invoice #${invoiceId} is now live on ${NETWORK_NAME}.`, txHash: result.txHash, }); + onSubmitted?.(invoiceId); } catch (error) { const message = error instanceof Error ? error.message : "The transaction did not complete successfully."; @@ -384,6 +389,21 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn )} + + setField("referralCode", 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="REF-2026" + autoComplete="off" + /> + + {errors.submit ? (
{errors.submit} 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__/SubmitInvoiceForm.test.tsx b/src/components/__tests__/SubmitInvoiceForm.test.tsx index 2fe1172..c0a892c 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"; @@ -135,6 +135,21 @@ describe("SubmitInvoiceForm", () => { ).toBeInTheDocument(); }); + it("rejects a payer address matching the connected freelancer wallet", async () => { + connectWallet(VALID_STELLAR_PAYER); + render(); + + fireEvent.change(screen.getByPlaceholderText("G..."), { + target: { value: VALID_STELLAR_PAYER }, + }); + fireEvent.click(screen.getByRole("button", { name: /submit invoice/i })); + + expect( + await screen.findByText(/payer address must be different from your wallet address/i), + ).toBeInTheDocument(); + expect(submitInvoiceTransaction).not.toHaveBeenCalled(); + }); + it("rejects a non-numeric invoice amount", async () => { connectWallet(); render(); @@ -243,8 +258,9 @@ describe("SubmitInvoiceForm", () => { it("submits a fully valid invoice and displays the returned invoice ID and tx hash", async () => { connectWallet(); submitInvoiceTransaction.mockResolvedValue({ invoiceId: 99n, txHash: "deadbeef" }); + const onSubmitted = vi.fn(); - render(); + render(); fireEvent.change(screen.getByPlaceholderText("G..."), { target: { value: VALID_STELLAR_PAYER }, @@ -270,6 +286,7 @@ describe("SubmitInvoiceForm", () => { expect(await screen.findByText("Returned invoice ID")).toBeInTheDocument(); expect(screen.getByText("#99")).toBeInTheDocument(); expect(screen.getByText(/Transaction hash: deadbeef/)).toBeInTheDocument(); + expect(onSubmitted).toHaveBeenCalledWith("99"); }); it("disables the submit button while the transaction is in-flight", async () => { @@ -326,7 +343,7 @@ describe("SubmitInvoiceForm", () => { 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/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/invoiceSubmission.ts b/src/utils/invoiceSubmission.ts index 098d2a1..9881e14 100644 --- a/src/utils/invoiceSubmission.ts +++ b/src/utils/invoiceSubmission.ts @@ -9,6 +9,7 @@ export interface InvoiceFormValues { dueDate: string; discountRate: string; tokenId: string; + referralCode: string; } export interface YieldPreview { @@ -122,6 +123,7 @@ export function validateInvoiceForm( decimals = 7, tokenSymbol = "USDC", nowInSeconds = Math.floor(Date.now() / 1000), + freelancerAddress?: string | null, ): Partial> { const errors: Partial> = {}; @@ -133,6 +135,8 @@ export function validateInvoiceForm( errors.payer = "Payer Stellar address is required."; } else if (!isValidStellarAccount(values.payer)) { errors.payer = "Enter a valid Stellar public key for the payer."; + } else if (freelancerAddress && values.payer.trim() === freelancerAddress) { + errors.payer = "Payer address must be different from your wallet address."; } const amountUnits = parseAmountToUnits(values.amount, decimals);