diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..630389e 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -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" }, }; @@ -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(() => { diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index 9742df0..8287ee4 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,27 +1,40 @@ 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: () =>
Reminder prompt
, +})); + +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', @@ -29,6 +42,7 @@ describe('PayInvoicePage', () => { amount_paid: 0n, due_date: 1713960000n, status: 'Funded', + discount_rate: 300, }; const mockToast = { @@ -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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); 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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Address Mismatch')).toBeInTheDocument(); @@ -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); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText('Invoice settled')).toBeInTheDocument(); @@ -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); - (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(); + render(); // Open modal await waitFor(() => { diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index e93c036..abb98b5 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -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"; @@ -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) => { @@ -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); @@ -133,17 +136,19 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin )} + + {/* ── Invoice Summary Card ───────────────────────────────────────── */}

Invoice Summary

- +
Amount Due {formatUsdcFromStroops(invoice.amount)} USDC
- +
Due Date {new Date(Number(invoice.due_date) * 1000).toLocaleDateString(undefined, { dateStyle: 'long' })} diff --git a/src/components/RemindMeButton.tsx b/src/components/RemindMeButton.tsx new file mode 100644 index 0000000..c18f883 --- /dev/null +++ b/src/components/RemindMeButton.tsx @@ -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 ( +
+ Reminder enabled: Invoice #{invoice.id.toString()} is due tomorrow. +
+ ); + } + + if (!shouldPrompt) return null; + + return ( +
+

+ Get notified 24 hours before this invoice expires? +

+ +
+ ); +} diff --git a/src/components/__tests__/RemindMeButton.test.tsx b/src/components/__tests__/RemindMeButton.test.tsx new file mode 100644 index 0000000..7795fde --- /dev/null +++ b/src/components/__tests__/RemindMeButton.test.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import RemindMeButton from "../RemindMeButton"; +import type { Invoice } from "@/utils/soroban"; + +const invoice: Invoice = { + id: 24n, + freelancer: "GFREELANCER", + payer: "GPAYER", + amount: 1000n, + due_date: BigInt(Math.floor(Date.now() / 1000) + 48 * 60 * 60), + discount_rate: 300, + status: "Funded", +}; + +function installNotification(permission: NotificationPermission, requestPermission = vi.fn()) { + Object.defineProperty(window, "Notification", { + configurable: true, + value: Object.assign(vi.fn(), { permission, requestPermission }), + }); +} + +describe("RemindMeButton", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + installNotification("default", vi.fn().mockResolvedValue("granted")); + }); + + it("prompts invoice parties to opt into 24-hour due date reminders", () => { + render(); + + expect(screen.getByText("Get notified 24 hours before this invoice expires?")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /remind me/i })).toBeInTheDocument(); + }); + + it("does not prompt wallets that are not parties to the invoice", () => { + render(); + + expect(screen.queryByText("Get notified 24 hours before this invoice expires?")).not.toBeInTheDocument(); + }); + + it("stores enabled preference after granted permission", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /remind me/i })); + + await waitFor(() => { + expect(window.localStorage.getItem("iln_invoice_reminder_24")).toBe("enabled"); + }); + expect(screen.getByText("Reminder enabled: Invoice #24 is due tomorrow.")).toBeInTheDocument(); + }); + + it("stores denied preference and hides the prompt when permission is denied", async () => { + installNotification("default", vi.fn().mockResolvedValue("denied")); + render(); + + fireEvent.click(screen.getByRole("button", { name: /remind me/i })); + + await waitFor(() => { + expect(window.localStorage.getItem("iln_invoice_reminder_24")).toBe("denied"); + }); + expect(screen.queryByText("Get notified 24 hours before this invoice expires?")).not.toBeInTheDocument(); + }); +}); diff --git a/src/utils/__tests__/invoiceReminders.test.ts b/src/utils/__tests__/invoiceReminders.test.ts new file mode 100644 index 0000000..1d81258 --- /dev/null +++ b/src/utils/__tests__/invoiceReminders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getInvoiceReminderKey, + getReminderDelayMs, + scheduleInvoiceReminder, +} from "@/utils/invoiceReminders"; +import type { Invoice } from "@/utils/soroban"; + +const invoice: Invoice = { + id: 9n, + freelancer: "GFREELANCER", + payer: "GPAYER", + amount: 1000n, + due_date: 1_900_000_000n, + discount_rate: 300, + status: "Funded", +}; + +describe("invoice reminder helpers", () => { + it("keys localStorage preferences by invoice ID", () => { + expect(getInvoiceReminderKey(9n)).toBe("iln_invoice_reminder_9"); + }); + + it("schedules reminders 24 hours before the due date", () => { + const nowMs = Number(invoice.due_date) * 1000 - 30 * 60 * 60 * 1000; + expect(getReminderDelayMs(invoice, nowMs)).toBe(6 * 60 * 60 * 1000); + }); + + it("does not schedule reminders for past-due invoices", () => { + expect(getReminderDelayMs(invoice, Number(invoice.due_date) * 1000 + 1)).toBeNull(); + }); + + it("fires the required browser notification message", () => { + vi.useFakeTimers(); + const notification = vi.fn(); + Object.defineProperty(window, "Notification", { + configurable: true, + value: Object.assign(notification, { permission: "granted" }), + }); + + const nowMs = Number(invoice.due_date) * 1000 - 24 * 60 * 60 * 1000; + scheduleInvoiceReminder(invoice, nowMs); + vi.runOnlyPendingTimers(); + + expect(notification).toHaveBeenCalledWith("Invoice #9 is due tomorrow"); + vi.useRealTimers(); + }); +}); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..391a8eb 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,10 +16,11 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; - if (!homeDomain) return address; + const { home_domain: homeDomain, homeDomain: camelHomeDomain } = account as AccountHomeDomain; + const resolvedHomeDomain = homeDomain ?? camelHomeDomain; + if (!resolvedHomeDomain) return address; - const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); + const stellarTomlResponse = await fetch(`https://${resolvedHomeDomain}/.well-known/stellar.toml`); if (!stellarTomlResponse.ok) return address; const toml = await stellarTomlResponse.text(); diff --git a/src/utils/invoiceReminders.ts b/src/utils/invoiceReminders.ts new file mode 100644 index 0000000..7db39e5 --- /dev/null +++ b/src/utils/invoiceReminders.ts @@ -0,0 +1,37 @@ +import type { Invoice } from "@/utils/soroban"; + +export type ReminderPreference = "enabled" | "denied"; + +export const REMINDER_ADVANCE_MS = 24 * 60 * 60 * 1000; + +export function getInvoiceReminderKey(invoiceId: bigint): string { + return `iln_invoice_reminder_${invoiceId.toString()}`; +} + +export function getInvoiceReminderPreference(invoiceId: bigint): ReminderPreference | null { + if (typeof window === "undefined") return null; + const value = window.localStorage.getItem(getInvoiceReminderKey(invoiceId)); + return value === "enabled" || value === "denied" ? value : null; +} + +export function setInvoiceReminderPreference(invoiceId: bigint, preference: ReminderPreference): void { + window.localStorage.setItem(getInvoiceReminderKey(invoiceId), preference); +} + +export function getReminderDelayMs(invoice: Invoice, nowMs = Date.now()): number | null { + const dueMs = Number(invoice.due_date) * 1000; + if (!Number.isFinite(dueMs) || dueMs <= nowMs) return null; + return Math.max(0, dueMs - REMINDER_ADVANCE_MS - nowMs); +} + +export function scheduleInvoiceReminder(invoice: Invoice, nowMs = Date.now()): number | null { + if (typeof window === "undefined" || typeof Notification === "undefined") return null; + const delay = getReminderDelayMs(invoice, nowMs); + if (delay === null) return null; + + return window.setTimeout(() => { + if (Notification.permission === "granted") { + new Notification(`Invoice #${invoice.id.toString()} is due tomorrow`); + } + }, delay); +}