diff --git a/app/dashboard/payer/page.tsx b/app/dashboard/payer/page.tsx new file mode 100644 index 0000000..64ff11b --- /dev/null +++ b/app/dashboard/payer/page.tsx @@ -0,0 +1,569 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState, Suspense } from "react"; +import Footer from "@/components/Footer"; +import Navbar from "@/components/Navbar"; +import { TokenAmount, TokenIcon } from "@/components/TokenSelector"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { + APPEAL_WINDOW_LEDGERS, + formatLedgerWindow, + hashEvidence, +} from "@/utils/evidence"; +import { formatAddress, formatDate, formatTokenAmount } from "@/utils/format"; +import { + Invoice, + appealDefault, + getAllInvoices, + markPaid, + submitSignedTransaction, +} from "@/utils/soroban"; + +type PayerTab = "Outstanding" | "Settled" | "Pending" | "Disputed"; + +interface AppealState { + invoice: Invoice; + evidence: string; + evidenceHash: string; + submitting: boolean; +} + +const TABS: PayerTab[] = ["Outstanding", "Settled", "Pending", "Disputed"]; + +function isOverdue(invoice: Invoice): boolean { + return ( + Number(invoice.due_date) * 1000 < Date.now() && invoice.status !== "Paid" + ); +} + +function invoiceTab(invoice: Invoice): PayerTab { + if (invoice.status === "Paid" || invoice.status === "Appealed") + return "Settled"; + if ( + invoice.status === "Disputed" || + invoice.status === "Expired" || + invoice.status === "Defaulted" + ) { + return "Disputed"; + } + if (invoice.status === "Funded") return "Outstanding"; + return "Pending"; +} + +function disputeMeta(invoice: Invoice) { + const id = invoice.id.toString().padStart(4, "0"); + const expired = + invoice.status === "Expired" || invoice.status === "Defaulted"; + return { + evidenceHash: `0x${id}evidence${id}`.padEnd(18, "0"), + voteLink: `/governance/${Number(invoice.id) || 1}`, + disputeDate: formatDate(invoice.due_date), + timeout: expired ? "Expired" : "2d 8h remaining", + ruling: expired + ? "Ruling: Dismissed" + : invoice.status === "Disputed" + ? "Resolution pending" + : "Ruling: Resolved", + }; +} + +function StatusPill({ invoice }: { invoice: Invoice }) { + const overdue = isOverdue(invoice); + const label = + overdue && invoice.status === "Funded" ? "Overdue" : invoice.status; + const color = + label === "Paid" || label === "Appealed" + ? "bg-emerald-500/15 text-emerald-600 border-emerald-500/30" + : label === "Overdue" || label === "Expired" || label === "Defaulted" + ? "bg-red-500/15 text-red-600 border-red-500/30" + : label === "Disputed" + ? "bg-amber-500/15 text-amber-600 border-amber-500/30" + : "bg-primary/15 text-primary border-primary/30"; + + return ( + + {label} + + ); +} + +function EmptyState({ connected, tab }: { connected: boolean; tab: PayerTab }) { + return ( +
+ + {connected ? "receipt_long" : "account_balance_wallet"} + +

+ {connected + ? `No ${tab.toLowerCase()} invoices found` + : "Connect your wallet to view payer invoices"} +

+
+ ); +} + +function AppealDefaultModal({ + state, + onEvidenceChange, + onSubmit, + onClose, +}: { + state: AppealState; + onEvidenceChange: (evidence: string) => void; + onSubmit: () => void; + onClose: () => void; +}) { + return ( +
+
+
+

Appeal Default

+

+ Invoice #{state.invoice.id.toString()} will be appealed with a + client-side evidence hash. +

+
+
+
+ Appeal window remaining:{" "} + + {formatLedgerWindow(APPEAL_WINDOW_LEDGERS)} + +
+