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
78 changes: 58 additions & 20 deletions app/freelancer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, Suspense } fr
import { useTranslation } from "react-i18next";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import CancelInvoiceButton from "@/components/CancelInvoiceButton";
import InvoiceFilterBar from "@/components/InvoiceFilterBar";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
Expand All @@ -21,13 +22,19 @@ import {
} from "@/utils/format";
import { rpc, TransactionBuilder } from "@stellar/stellar-sdk";
import { RPC_URL, NETWORK_PASSPHRASE } from "@/constants";
import SkeletonRow, { FREELANCER_COLUMNS } from "@/components/SkeletonRow";
import { ExportButton } from "@/components/ExportButton";
import { EmptyState } from "@/components/EmptyState";
import { FreelancerEmptyIllustration } from "@/components/illustrations/EmptyIllustrations";

const server = new rpc.Server(RPC_URL);

interface SendTransactionResult {
status?: string;
hash?: string;
}

interface TransactionStatusResult {
status?: string;
}

// ─── Types ────────────────────────────────────────────────────────────────────

type Screen = "submit" | "my-invoices";
Expand Down Expand Up @@ -55,6 +62,8 @@ function StatusBadge({ status }: { status: string }) {
Funded:
"bg-[#dbeafe] text-[#1d4ed8] dark:bg-[#1e3a8a]/30 dark:text-[#93c5fd]",
Paid: "bg-[#dcfce7] text-[#15803d] dark:bg-[#14532d]/30 dark:text-[#86efac]",
Cancelled:
"bg-surface-container text-on-surface-variant",
Defaulted:
"bg-error-container text-on-error-container",
};
Expand Down Expand Up @@ -95,7 +104,7 @@ function FreelancerPageContent() {

const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loadingInvoices, setLoadingInvoices] = useState(false);
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshIntervalRef = useRef<number | null>(null);
const {
filters,
setFilters,
Expand All @@ -119,16 +128,28 @@ function FreelancerPageContent() {

useEffect(() => {
if (screen === "my-invoices") {
fetchMyInvoices();
refreshIntervalRef.current = setInterval(fetchMyInvoices, 30_000);
const timeout = window.setTimeout(() => {
void fetchMyInvoices();
}, 0);
refreshIntervalRef.current = window.setInterval(() => {
void fetchMyInvoices();
}, 30_000);
return () => {
window.clearTimeout(timeout);
if (refreshIntervalRef.current) {
window.clearInterval(refreshIntervalRef.current);
}
};
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
window.clearInterval(refreshIntervalRef.current);
}
};
}, [screen, fetchMyInvoices]);

const [minDueDate] = useState(() => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10));

const filteredInvoices = useMemo(
() =>
applyInvoiceFilters(invoices, filters, {
Expand Down Expand Up @@ -195,12 +216,14 @@ function FreelancerPageContent() {
TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE)
);

if ((sendResult as any).status === "PENDING") {
let txStatus = await server.getTransaction((sendResult as any).hash);
const sent = sendResult as SendTransactionResult;

if (sent.status === "PENDING" && sent.hash) {
let txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult;
let tries = 0;
while ((txStatus as any).status === "NOT_FOUND" && tries < 20) {
while (txStatus.status === "NOT_FOUND" && tries < 20) {
await new Promise((r) => setTimeout(r, 1500));
txStatus = await server.getTransaction((sendResult as any).hash);
txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult;
tries++;
}

Expand All @@ -210,16 +233,16 @@ function FreelancerPageContent() {
updateToast(toastId, {
type: "success",
title: t("freelancer.toast.submitted"),
txHash: (sendResult as any).hash,
txHash: sent.hash,
});
} else {
throw new Error(`Transaction rejected: ${(sendResult as any).status}`);
throw new Error(`Transaction rejected: ${sent.status ?? "unknown"}`);
}
} catch (err: any) {
} catch (err) {
updateToast(toastId, {
type: "error",
title: t("freelancer.toast.submissionFailed"),
message: err?.message ?? t("freelancer.toast.unknownError"),
message: err instanceof Error ? err.message : t("freelancer.toast.unknownError"),
});
} finally {
setIsSubmitting(false);
Expand All @@ -234,8 +257,17 @@ function FreelancerPageContent() {
t("freelancer.invoices.headers.discount"),
t("freelancer.invoices.headers.dueDate"),
t("freelancer.invoices.headers.status"),
"Actions",
];

const markInvoiceCancelled = (invoiceId: bigint) => {
setInvoices((current) =>
current.map((invoice) =>
invoice.id === invoiceId ? { ...invoice, status: "Cancelled" } : invoice,
),
);
};

return (
<>
<Navbar />
Expand Down Expand Up @@ -433,9 +465,7 @@ function FreelancerPageContent() {
id="field-due-date"
type="date"
value={form.dueDate}
min={new Date(Date.now() + 86_400_000)
.toISOString()
.slice(0, 10)}
min={minDueDate}
onChange={(e) =>
setForm({ ...form, dueDate: e.target.value })
}
Expand Down Expand Up @@ -649,7 +679,7 @@ function FreelancerPageContent() {
{loadingInvoices ? (
<tr>
<td
colSpan={6}
colSpan={7}
className="px-6 py-14 text-center text-on-surface-variant italic"
>
<span className="flex items-center justify-center gap-2">
Expand All @@ -661,7 +691,7 @@ function FreelancerPageContent() {
) : invoices.length === 0 ? (
<tr>
<td
colSpan={6}
colSpan={7}
className="px-6 py-14 text-center"
>
<div className="flex flex-col items-center gap-3">
Expand Down Expand Up @@ -708,6 +738,14 @@ function FreelancerPageContent() {
<td className="px-6 py-5 whitespace-nowrap">
<StatusBadge status={inv.status} />
</td>
<td className="px-6 py-5 whitespace-nowrap">
<CancelInvoiceButton
invoiceId={inv.id}
freelancer={inv.freelancer}
status={inv.status}
onCancelled={() => markInvoiceCancelled(inv.id)}
/>
</td>
</tr>
))
)}
Expand Down
14 changes: 11 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,17 @@ export default function GovernancePage() {
}, []);

useEffect(() => {
load();
const timeout = window.setTimeout(() => {
void load();
}, 0);
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
const interval = window.setInterval(() => {
void load();
}, 30_000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
58 changes: 30 additions & 28 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PayInvoicePage from '../page';
import * as soroban from '../../../../utils/soroban';
import { useWallet } from '../../../../context/WalletContext';
import { useToast } from '../../../../context/ToastContext';
import * as soroban from '@/utils/soroban';
import { useWallet } from '@/context/WalletContext';
import { useToast } from '@/context/ToastContext';

// Mock context and utils
vi.mock('../../../../context/WalletContext', () => ({
vi.mock('@/context/WalletContext', () => ({
useWallet: vi.fn(),
}));

vi.mock('../../../../context/ToastContext', () => ({
vi.mock('@/context/ToastContext', () => ({
useToast: vi.fn(),
}));

vi.mock('../../../../utils/soroban', () => ({
vi.mock('@/utils/soroban', () => ({
getInvoice: vi.fn(),
markPaid: vi.fn(),
cancelInvoice: vi.fn(),
submitSignedTransaction: vi.fn(),
}));

describe('PayInvoicePage', () => {
type ResolvedParamsPromise = Promise<{ id: string }> & { _resolvedValue: { id: string } };

const makeParams = (id: string): ResolvedParamsPromise =>
Object.assign(Promise.resolve({ id }), { _resolvedValue: { id } });

const mockInvoice = {
id: 1n,
freelancer: 'GFREELANCER',
Expand All @@ -38,18 +44,17 @@ describe('PayInvoicePage', () => {

beforeEach(() => {
vi.clearAllMocks();
(useToast as any).mockReturnValue(mockToast);
(soroban.getInvoice as any).mockResolvedValue(mockInvoice);
vi.mocked(useToast).mockReturnValue(mockToast);
vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice);
});

it('should render invoice summary without wallet connection', async () => {
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: null,
connect: vi.fn(),
});
} as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
const params = makeParams('1');
render(<PayInvoicePage params={params} />);

await waitFor(() => {
Expand All @@ -59,13 +64,12 @@ describe('PayInvoicePage', () => {
});

it('should show warning if connected wallet is not the payer', async () => {
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: 'GWRONGWALLET',
connect: vi.fn(),
});
} as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
const params = makeParams('1');
render(<PayInvoicePage params={params} />);

await waitFor(() => {
Expand All @@ -75,17 +79,16 @@ describe('PayInvoicePage', () => {
});

it('should show confirmation if invoice is already paid', async () => {
(soroban.getInvoice as any).mockResolvedValue({
vi.mocked(soroban.getInvoice).mockResolvedValue({
...mockInvoice,
status: 'Paid',
});

(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: 'GPAYER',
});
} as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
const params = makeParams('1');
render(<PayInvoicePage params={params} />);

await waitFor(() => {
Expand Down Expand Up @@ -130,16 +133,15 @@ describe('PayInvoicePage', () => {

it('should call markPaid with correct amount when payment is confirmed', async () => {
const mockSignTx = vi.fn();
(useWallet as any).mockReturnValue({
vi.mocked(useWallet).mockReturnValue({
address: 'GPAYER',
signTx: mockSignTx,
});
} as ReturnType<typeof useWallet>);

(soroban.markPaid as any).mockResolvedValue('mock-tx');
(soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' });
vi.mocked(soroban.markPaid).mockResolvedValue('mock-tx' as Awaited<ReturnType<typeof soroban.markPaid>>);
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' });

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
const params = makeParams('1');
render(<PayInvoicePage params={params} />);

// Open modal
Expand Down
Loading