Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-red-500/15 text-red-500 border-red-500/30", icon: "gavel" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -196,9 +197,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(() => {
Expand All @@ -210,10 +212,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(
Expand Down
36 changes: 36 additions & 0 deletions app/invoices/new/__tests__/NewInvoicePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <nav>Navbar</nav>,
}));

vi.mock("@/components/Footer", () => ({
default: () => <footer>Footer</footer>,
}));

vi.mock("@/components/SubmitInvoiceForm", () => ({
default: ({ onSubmitted }: { onSubmitted?: (invoiceId: string) => void }) => (
<button type="button" onClick={() => onSubmitted?.("42")}>
Mock submit invoice
</button>
),
}));

describe("NewInvoicePage", () => {
it("redirects successful submissions to the invoice detail page", () => {
render(<NewInvoicePage />);

screen.getByRole("button", { name: "Mock submit invoice" }).click();

expect(push).toHaveBeenCalledWith("/invoices/42");
});
});
22 changes: 22 additions & 0 deletions app/invoices/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen">
<Navbar />
<section className="px-4 pb-16 pt-32 sm:px-8">
<div className="mx-auto max-w-4xl">
<SubmitInvoiceForm onSubmitted={(invoiceId) => router.push(`/invoices/${invoiceId}`)} />
</div>
</section>
<Footer />
</main>
);
}
21 changes: 20 additions & 1 deletion src/components/SubmitInvoiceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const INITIAL_FORM: InvoiceFormValues = {
dueDate: "",
discountRate: "3.00",
tokenId: "",
referralCode: "",
};

type FormAction =
Expand All @@ -52,9 +53,10 @@ const STEPS = [
interface SubmitInvoiceFormProps {
initialValues?: Partial<InvoiceFormValues>;
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 { execute, loading: txLoading, error: txError, signingModal } = useTransaction();
const { address, isConnected, connect, disconnect, networkMismatch, error: walletError } = useWallet();
Expand Down Expand Up @@ -180,6 +182,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 });
Expand Down Expand Up @@ -461,6 +465,21 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
</div>
) : null}

<Field
label="Referral code (optional)"
tooltip="Optional campaign, partner, or referral identifier for your records. It is not submitted to the current contract."
hint="Leave blank if this invoice has no referral source."
>
<input
aria-label="Referral code"
value={form.referralCode}
onChange={(event) => 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"
/>
</Field>

{errors.submit ? (
<div className="rounded-2xl border border-error/15 bg-error-container/70 px-4 py-3 text-sm text-on-error-container">
{errors.submit}
Expand Down
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "logo">>): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "iconLabel">>): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
23 changes: 20 additions & 3 deletions src/components/__tests__/SubmitInvoiceForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -135,6 +135,21 @@ describe("SubmitInvoiceForm", () => {
).toBeInTheDocument();
});

it("rejects a payer address matching the connected freelancer wallet", async () => {
connectWallet(VALID_STELLAR_PAYER);
render(<SubmitInvoiceForm />);

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(<SubmitInvoiceForm />);
Expand Down Expand Up @@ -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(<SubmitInvoiceForm />);
render(<SubmitInvoiceForm onSubmitted={onSubmitted} />);

fireEvent.change(screen.getByPlaceholderText("G..."), {
target: { value: VALID_STELLAR_PAYER },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/utils/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export async function resolveFederatedAddress(address: string): Promise<string>

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`);
Expand Down
4 changes: 4 additions & 0 deletions src/utils/invoiceSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface InvoiceFormValues {
dueDate: string;
discountRate: string;
tokenId: string;
referralCode: string;
}

export interface YieldPreview {
Expand Down Expand Up @@ -122,6 +123,7 @@ export function validateInvoiceForm(
decimals = 7,
tokenSymbol = "USDC",
nowInSeconds = Math.floor(Date.now() / 1000),
freelancerAddress?: string | null,
): Partial<Record<keyof InvoiceFormValues | "wallet", string>> {
const errors: Partial<Record<keyof InvoiceFormValues | "wallet", string>> = {};

Expand All @@ -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);
Expand Down