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
10 changes: 7 additions & 3 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-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -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(() => {
Expand Down
47 changes: 47 additions & 0 deletions src/components/StepIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

interface StepIndicatorProps {
steps: readonly string[];
currentStep: number;
}

export default function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
return (
<ol className="grid gap-3 sm:grid-cols-3" aria-label="Invoice submission progress">
{steps.map((step, index) => {
const stepNumber = index + 1;
const isCurrent = index === currentStep;
const isComplete = index < currentStep;

return (
<li
key={step}
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
isCurrent
? "border-primary bg-primary/10 text-primary"
: isComplete
? "border-primary/30 bg-primary-container/40 text-on-primary-container"
: "border-outline-variant/15 bg-surface-container-low text-on-surface-variant"
}`}
aria-current={isCurrent ? "step" : undefined}
>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-black ${
isCurrent || isComplete
? "bg-primary text-surface-container-lowest"
: "bg-surface-container-high text-on-surface-variant"
}`}
>
{isComplete ? (
<span className="material-symbols-outlined text-[16px]">check</span>
) : (
stepNumber
)}
</span>
<span className="text-sm font-bold">{step}</span>
</li>
);
})}
</ol>
);
}
20 changes: 6 additions & 14 deletions src/components/SubmitInvoiceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,19 +175,13 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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;
Expand Down Expand Up @@ -364,9 +358,7 @@ export default function SubmitInvoiceForm({ initialValues, prefillId }: SubmitIn
</div>
)}
</div>
)}
</div>
</Field>
</Field>

<TokenSelector
label={t("submitForm.tokenLabel")}
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, "logo" | "symbol">): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

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

Expand Down
108 changes: 81 additions & 27 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 @@ -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", () => {
Expand All @@ -102,7 +139,7 @@ describe("SubmitInvoiceForm", () => {

it("shows the wallet error banner when submitting without a connected wallet", async () => {
render(<SubmitInvoiceForm />);
fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
completeStepperAndSubmit();

expect(
await screen.findByText(/connect your freighter wallet to submit an invoice/i),
Expand All @@ -117,7 +154,7 @@ describe("SubmitInvoiceForm", () => {
render(<SubmitInvoiceForm />);

// 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();
});

Expand All @@ -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),
Expand All @@ -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();
});
Expand All @@ -152,7 +189,7 @@ describe("SubmitInvoiceForm", () => {
render(<SubmitInvoiceForm />);

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();
});
Expand All @@ -162,16 +199,18 @@ describe("SubmitInvoiceForm", () => {
render(<SubmitInvoiceForm />);

// Leave dueDate empty
fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
clickNext();
expect(await screen.findByText(/select a valid due date/i)).toBeInTheDocument();
});

it("rejects a discount rate of 0", async () => {
connectWallet();
render(<SubmitInvoiceForm />);

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),
Expand All @@ -182,8 +221,10 @@ describe("SubmitInvoiceForm", () => {
connectWallet();
render(<SubmitInvoiceForm />);

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),
Expand All @@ -197,7 +238,7 @@ describe("SubmitInvoiceForm", () => {
walletState.networkMismatch = true;
render(<SubmitInvoiceForm />);

fireEvent.click(screen.getByRole("button", { name: /submit invoice/i }));
completeStepperAndSubmit();

expect(
await screen.findByText(/freighter must be connected to testnet/i),
Expand All @@ -218,7 +259,10 @@ describe("SubmitInvoiceForm", () => {
it("updates the live yield preview as the user types amount and discount rate", () => {
render(<SubmitInvoiceForm />);

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
Expand All @@ -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(<SubmitInvoiceForm />);

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 () => {
Expand All @@ -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
Expand Down Expand Up @@ -279,11 +345,7 @@ describe("SubmitInvoiceForm", () => {

render(<SubmitInvoiceForm />);

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(),
Expand All @@ -298,11 +360,7 @@ describe("SubmitInvoiceForm", () => {

render(<SubmitInvoiceForm />);

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(
Expand All @@ -317,16 +375,12 @@ describe("SubmitInvoiceForm", () => {

render(<SubmitInvoiceForm />);

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",
Expand Down
Loading