Skip to content
Closed
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
8 changes: 6 additions & 2 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" },
Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" },
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" },
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" },
};
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);
return () => {
window.clearTimeout(timeout);
clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
73 changes: 40 additions & 33 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
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';
import type { Invoice } from '@/utils/soroban';

// 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(),
submitSignedTransaction: vi.fn(),
}));

vi.mock('@/components/RemindMeButton', () => ({
default: () => <div>Reminder prompt</div>,
}));

type ParamsPromise = Promise<{ id: string }> & { _resolvedValue?: { id: string } };

function createParams(): ParamsPromise {
const params = Promise.resolve({ id: '1' }) as ParamsPromise;
params._resolvedValue = { id: '1' };
return params;
}

describe('PayInvoicePage', () => {
const mockInvoice = {
const mockInvoice: Invoice = {
id: 1n,
freelancer: 'GFREELANCER',
payer: 'GPAYER',
amount: 1000000000n,
amount_paid: 0n,
due_date: 1713960000n,
status: 'Funded',
discount_rate: 300,
};

const mockToast = {
Expand All @@ -38,35 +52,32 @@ 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' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument();
expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument();
expect(screen.getByText('Reminder prompt')).toBeInTheDocument();
});
});

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' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Address Mismatch')).toBeInTheDocument();
Expand All @@ -75,18 +86,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' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Invoice settled')).toBeInTheDocument();
Expand Down Expand Up @@ -130,17 +139,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');
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' });

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

// Open modal
await waitFor(() => {
Expand Down
37 changes: 21 additions & 16 deletions app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { formatAddress } from "@/utils/format";
import { formatUsdcFromStroops } from "@/utils/invoiceSubmission";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants";
import { NETWORK_NAME } from "@/constants";
import ActivityFeed from "@/components/ActivityFeed";
import RemindMeButton from "@/components/RemindMeButton";
import PartialPaymentModal from "@/components/PartialPaymentModal";

type LoadState = "loading" | "success" | "error";
Expand Down Expand Up @@ -39,7 +40,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
}, [invoiceId]);

useEffect(() => {
fetchInvoice();
const timeout = window.setTimeout(fetchInvoice, 0);
return () => window.clearTimeout(timeout);
}, [fetchInvoice]);

const handlePaymentConfirm = async (amount: bigint) => {
Expand All @@ -51,25 +53,26 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
try {
const tx = await markPaid(address, invoiceId, amount);
updateToast(toastId, { message: "Transaction prepared. Signing..." });

const { txHash } = await submitSignedTransaction({ tx, signTx });
updateToast(toastId, {
type: "success",
title: "Payment Successful",

updateToast(toastId, {
type: "success",
title: "Payment Successful",
message: "Your payment has been processed on-chain.",
txHash
txHash
});

// Close modal and refresh invoice state
setIsPaymentModalOpen(false);
fetchInvoice();
} catch (err: any) {
} catch (err: unknown) {
console.error(err);
updateToast(toastId, {
type: "error",
title: "Payment Failed",
message: err.message || "An unexpected error occurred during payment."
const message = err instanceof Error ? err.message : "An unexpected error occurred during payment.";
updateToast(toastId, {
type: "error",
title: "Payment Failed",
message,
});
} finally {
setIsPaying(false);
Expand Down Expand Up @@ -133,17 +136,19 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
</div>
)}

<RemindMeButton invoice={invoice} viewerAddress={address} />

{/* ── Invoice Summary Card ───────────────────────────────────────── */}
<section className="rounded-[24px] border border-outline-variant/15 bg-surface-container-lowest p-6 shadow-xl">
<div className="mb-6">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-on-surface-variant mb-4">Invoice Summary</p>

<div className="flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-outline-variant/10 pb-4">
<span className="text-sm text-on-surface-variant font-medium">Amount Due</span>
<span className="text-2xl font-bold text-on-surface">{formatUsdcFromStroops(invoice.amount)} USDC</span>
</div>

<div className="flex justify-between items-center border-b border-outline-variant/10 pb-4">
<span className="text-sm text-on-surface-variant font-medium">Due Date</span>
<span className="text-sm font-semibold text-on-surface">{new Date(Number(invoice.due_date) * 1000).toLocaleDateString(undefined, { dateStyle: 'long' })}</span>
Expand Down
88 changes: 88 additions & 0 deletions src/components/RemindMeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import type { Invoice } from "@/utils/soroban";
import {
getInvoiceReminderPreference,
scheduleInvoiceReminder,
setInvoiceReminderPreference,
} from "@/utils/invoiceReminders";

interface RemindMeButtonProps {
invoice: Invoice;
viewerAddress: string | null;
}

export default function RemindMeButton({ invoice, viewerAddress }: RemindMeButtonProps) {
const [preference, setPreference] = useState<"enabled" | "denied" | null>(null);
const [isRequesting, setIsRequesting] = useState(false);
const isInvoiceParty = viewerAddress === invoice.payer || viewerAddress === invoice.freelancer;
const canUseNotifications = typeof window !== "undefined" && "Notification" in window;

useEffect(() => {
const timeout = window.setTimeout(() => {
setPreference(getInvoiceReminderPreference(invoice.id));
}, 0);
return () => window.clearTimeout(timeout);
}, [invoice.id]);

useEffect(() => {
if (!canUseNotifications || preference !== "enabled") return;
const timeoutId = scheduleInvoiceReminder(invoice);
return () => {
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, [canUseNotifications, invoice, preference]);

const shouldPrompt = useMemo(() => {
if (!isInvoiceParty || !canUseNotifications || preference !== null) return false;
return Notification.permission !== "denied";
}, [canUseNotifications, isInvoiceParty, preference]);

const enableReminder = async () => {
if (!canUseNotifications) return;

setIsRequesting(true);
try {
const permission = Notification.permission === "default"
? await Notification.requestPermission()
: Notification.permission;

if (permission === "granted") {
setInvoiceReminderPreference(invoice.id, "enabled");
setPreference("enabled");
} else {
setInvoiceReminderPreference(invoice.id, "denied");
setPreference("denied");
}
} finally {
setIsRequesting(false);
}
};

if (preference === "enabled") {
return (
<div className="mb-6 rounded-2xl border border-primary/20 bg-primary/10 px-5 py-4 text-sm text-primary">
Reminder enabled: Invoice #{invoice.id.toString()} is due tomorrow.
</div>
);
}

if (!shouldPrompt) return null;

return (
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-outline-variant/20 bg-surface-container-lowest px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-medium text-on-surface">
Get notified 24 hours before this invoice expires?
</p>
<button
onClick={() => void enableReminder()}
disabled={isRequesting}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2 text-sm font-bold text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">notifications_active</span>
{isRequesting ? "Enabling..." : "Remind me"}
</button>
</div>
);
}
Loading