diff --git a/src/app/creator/[address]/CreatorProfileClient.tsx b/src/app/creator/[address]/CreatorProfileClient.tsx index ea048b3..495e098 100644 --- a/src/app/creator/[address]/CreatorProfileClient.tsx +++ b/src/app/creator/[address]/CreatorProfileClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import Link from "next/link"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import StatusBadge from "@/components/StatusBadge"; @@ -29,6 +29,112 @@ interface Props { const PAGE_SIZE = 5; +type StatusFilterKey = "All" | InvoiceStatus; + +/** Thin bar chart rendered purely in CSS for status distribution */ +function StatusDistributionChart({ invoices }: { invoices: PublicInvoice[] }) { + const counts = useMemo(() => { + const map: Record = {}; + for (const inv of invoices) { + map[inv.status] = (map[inv.status] ?? 0) + 1; + } + return map; + }, [invoices]); + + const total = invoices.length; + if (total === 0) return null; + + const statusColors: Record = { + Released: "bg-emerald-500", + Pending: "bg-indigo-500", + Cancelled: "bg-red-500", + Disputed: "bg-amber-500", + Expired: "bg-gray-500", + }; + + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); + + return ( +
+ {entries.map(([status, count]) => { + const pct = Math.round((count / total) * 100); + const color = statusColors[status] ?? "bg-gray-600"; + return ( +
+ {status} +
+
+
+ {pct}% +
+ ); + })} +
+ ); +} + +/** Reputation score gauge */ +function ReputationGauge({ score }: { score: number }) { + const clampedScore = Math.max(0, Math.min(100, score)); + const strokeDasharray = 2 * Math.PI * 36; // circumference for r=36 + const strokeDashoffset = strokeDasharray * (1 - clampedScore / 100); + + const color = + clampedScore >= 80 + ? "#10b981" + : clampedScore >= 50 + ? "#6366f1" + : "#f59e0b"; + + return ( +
+ + / 100 +
+ ); +} + export default function CreatorProfileClient({ address, totalInvoices, @@ -41,6 +147,8 @@ export default function CreatorProfileClient({ }: Props) { const [copied, setCopied] = useState(false); const [currentPage, setCurrentPage] = useState(1); + const [activeTab, setActiveTab] = useState<"invoices" | "stats">("invoices"); + const [statusFilter, setStatusFilter] = useState("All"); const badgeStats: CreatorStats = { totalInvoices, @@ -64,17 +172,84 @@ export default function CreatorProfileClient({ }; const now = Math.floor(Date.now() / 1000); - const totalPages = Math.max(1, Math.ceil(invoices.length / PAGE_SIZE)); - const paginatedInvoices = invoices.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + // Derived statistics + const stats = useMemo(() => { + const released = invoices.filter((i) => i.status === "Released"); + const pending = invoices.filter((i) => i.status === "Pending"); + const cancelled = invoices.filter((i) => i.status === "Cancelled"); + const avgFundingPct = + invoices.length === 0 + ? 0 + : invoices.reduce((sum, inv) => { + const pct = + inv.total > 0n + ? Number((inv.funded * 100n) / inv.total) + : 0; + return sum + pct; + }, 0) / invoices.length; + + const activeInvoices = pending.filter((i) => i.deadline > now); + const overdueInvoices = pending.filter((i) => i.deadline <= now); + + return { + released: released.length, + pending: pending.length, + cancelled: cancelled.length, + active: activeInvoices.length, + overdue: overdueInvoices.length, + avgFundingPct: Math.round(avgFundingPct), + }; + }, [invoices, now]); const getReputationTier = (score: number) => { - if (score >= 80) return { label: "Verified & Highly Trusted", color: "text-emerald-400 border-emerald-500/40 bg-emerald-500/10" }; - if (score >= 50) return { label: "Established Creator", color: "text-indigo-400 border-indigo-500/40 bg-indigo-500/10" }; - return { label: "Emerging Creator", color: "text-amber-400 border-amber-500/40 bg-amber-500/10" }; + if (score >= 80) + return { + label: "Verified & Highly Trusted", + color: + "text-emerald-400 border-emerald-500/40 bg-emerald-500/10", + }; + if (score >= 50) + return { + label: "Established Creator", + color: "text-indigo-400 border-indigo-500/40 bg-indigo-500/10", + }; + return { + label: "Emerging Creator", + color: "text-amber-400 border-amber-500/40 bg-amber-500/10", + }; }; const tier = getReputationTier(reputationScore); + // Filtered & paginated invoice list + const filteredInvoices = useMemo( + () => + statusFilter === "All" + ? invoices + : invoices.filter((i) => i.status === statusFilter), + [invoices, statusFilter] + ); + + const totalPages = Math.max( + 1, + Math.ceil(filteredInvoices.length / PAGE_SIZE) + ); + const safePage = Math.min(currentPage, totalPages); + const paginatedInvoices = filteredInvoices.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE + ); + + const statusOptions: StatusFilterKey[] = [ + "All", + "Pending", + "Released", + "Cancelled", + "Disputed", + "Expired", + ]; + return ( <> {/* Header */} @@ -85,7 +260,9 @@ export default function CreatorProfileClient({ {truncateAddress(address)} {address} - + {tier.label}
@@ -94,31 +271,41 @@ export default function CreatorProfileClient({ - {/* Stats Grid including On-Chain Reputation Score */} -
-

Creator stats

+ {/* Top KPI Stats */} +
+

+ Creator stats +

- {reputationScore}/100 + {reputationScore} + /100

Reputation Score

-

{totalInvoices}

+

+ {totalInvoices} +

Total Invoices

-

{totalVolume} USDC

+

+ {totalVolume} USDC +

Total Raised

-

{completionRate}%

+

+ {completionRate}% +

Completion Rate

@@ -136,89 +323,130 @@ export default function CreatorProfileClient({ )} - {invoices.length === 0 ? ( -

- No public invoices yet. -

- ) : ( -
-
    - {paginatedInvoices.map((inv) => { - const pct = - inv.total > 0n - ? Math.min(100, Number((inv.funded * 100n) / inv.total)) - : 0; - const isActive = - inv.status === "Pending" && inv.deadline > now; - - return ( -
  • -
    -
    -

    - Invoice #{inv.id} -

    -

    - {formatAmount(inv.funded)} / {formatAmount(inv.total)} USDC -

    -
    - -
    - - - -
    - - {pct.toFixed(1)}% funded - - {isActive ? ( - - Pay - - ) : ( - - View - - )} -
    -
  • - ); - })} -
- - {/* Pagination Controls */} - {totalPages > 1 && ( -
- - - Showing {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, invoices.length)} of {invoices.length} - +

+ {item.value} +

+

{item.label}

+
+ ))} +
+ +
+ )} + + {/* Invoices Tab */} + {activeTab === "invoices" && ( +
+
+

+ Public Invoices ({filteredInvoices.length} + {statusFilter !== "All" ? ` · ${statusFilter}` : ""}) +

+
+ + {/* Status filter chips */} +
+ {statusOptions.map((s) => { + const count = + s === "All" + ? invoices.length + : invoices.filter((i) => i.status === s).length; + if (s !== "All" && count === 0) return null; + return ( -
- )} + ); + })} )}
diff --git a/src/app/reputation/PayerReputationPage.tsx b/src/app/reputation/PayerReputationPage.tsx new file mode 100644 index 0000000..1af7eca --- /dev/null +++ b/src/app/reputation/PayerReputationPage.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import { splitClient } from "@/lib/stellar"; +import { truncateAddress } from "@stellar-split/sdk"; +import type { Invoice } from "@stellar-split/sdk"; +import PayerReputationDisplay, { + computePayerScore, + type PayerReputationData, +} from "@/components/PayerReputationDisplay"; + +/** Build reputation data from on-chain invoice history for a given payer */ +function buildReputationData( + address: string, + invoices: Invoice[] +): PayerReputationData { + const now = Math.floor(Date.now() / 1000); + let totalPaid = 0; + let onTimePaid = 0; + let disputeCount = 0; + let disputeWins = 0; + let totalVolume = 0n; + + for (const inv of invoices) { + for (const p of inv.payments) { + if (p.payer !== address) continue; + totalPaid++; + totalVolume += p.amount; + if (p.timestamp && inv.deadline && p.timestamp <= inv.deadline) { + onTimePaid++; + } + } + if ( + (inv.status === "Disputed" || inv.status === "Released") && + inv.payments.some((p) => p.payer === address) + ) { + if (inv.status === "Disputed") { + disputeCount++; + } + } + } + + const score = computePayerScore({ + totalPaid, + onTimePaid, + disputeCount, + disputeWins, + }); + + const volumeUsdc = (Number(totalVolume) / 1e7).toFixed(2); + + return { + address, + score, + totalPaid, + onTimePaid, + disputeCount, + disputeWins, + volumeUsdc, + }; +} + +// ─── Top payers leaderboard ─────────────────────────────────────────────────── + +interface PayerEntry { + address: string; + score: number; + totalPaid: number; + volumeUsdc: string; +} + +function PayerLeaderboardRow({ + entry, + rank, + isMe, + selected, + onSelect, +}: { + entry: PayerEntry; + rank: number; + isMe: boolean; + selected: boolean; + onSelect: (address: string) => void; +}) { + const scoreColor = + entry.score >= 90 + ? "text-emerald-400" + : entry.score >= 70 + ? "text-indigo-400" + : entry.score >= 50 + ? "text-sky-400" + : entry.score >= 25 + ? "text-amber-400" + : "text-red-400"; + + return ( + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function PayerReputationPage() { + const [myAddress, setMyAddress] = useState(null); + const [myData, setMyData] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [loadingMine, setLoadingMine] = useState(true); + const [loadingLeaderboard, setLoadingLeaderboard] = useState(true); + const [selectedAddress, setSelectedAddress] = useState(null); + const [selectedData, setSelectedData] = useState(null); + const [lookupInput, setLookupInput] = useState(""); + const [lookupAddress, setLookupAddress] = useState(null); + + // Load wallet + useEffect(() => { + getFreighterPublicKey() + .then((addr) => { + setMyAddress(addr); + }) + .catch(() => setMyAddress(null)) + .finally(() => setLoadingMine(false)); + }, []); + + // Load own reputation from on-chain data + useEffect(() => { + if (!myAddress) { + setLoadingMine(false); + return; + } + const run = async () => { + try { + const invoices: Invoice[] = []; + for (let id = 1; id <= 50; id++) { + try { + const inv = await splitClient.getInvoice(String(id)); + invoices.push(inv); + } catch { + break; + } + } + setMyData(buildReputationData(myAddress, invoices)); + + // Build leaderboard from unique payers + const payerMap = new Map(); + for (const inv of invoices) { + for (const p of inv.payments) { + if (!payerMap.has(p.payer)) payerMap.set(p.payer, []); + payerMap.get(p.payer)!.push(inv); + } + } + const entries: PayerEntry[] = Array.from(payerMap.entries()) + .map(([addr, invs]) => { + const d = buildReputationData(addr, invs); + return { + address: addr, + score: d.score, + totalPaid: d.totalPaid, + volumeUsdc: d.volumeUsdc, + }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, 20); + + setLeaderboard(entries); + } catch { + // ignore + } finally { + setLoadingMine(false); + setLoadingLeaderboard(false); + } + }; + run(); + }, [myAddress]); + + const handleSelectAddress = (address: string) => { + setSelectedAddress(address); + // Reuse myData if it's the same address + if (address === myAddress && myData) { + setSelectedData(myData); + } else { + setSelectedData(null); // let PayerReputationDisplay simulate + } + }; + + const handleLookup = () => { + const addr = lookupInput.trim(); + if (!addr) return; + setLookupAddress(addr); + setSelectedAddress(addr); + setSelectedData(null); + }; + + return ( +
+ {/* Header */} +
+
+ + Dashboard + + + + Payer Reputation + +
+

+ Payer Reputation Scores +

+

+ Reputation scores reflect on-time payment history, dispute win rate, + and total payment volume. A higher score signals a reliable payer. +

+
+ +
+ {/* Left column: my score + leaderboard */} +
+ {/* My reputation */} +
+

+ Your Score +

+ {loadingMine ? ( +
+
+
+
+ ) : !myAddress ? ( +
+ Connect your wallet to see your payer reputation. +
+ ) : myData ? ( + + ) : null} +
+ + {/* Address lookup */} +
+

+ Look Up Any Payer +

+
+ setLookupInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLookup()} + placeholder="Stellar address G…" + aria-label="Enter Stellar address to look up" + className="flex-1 min-w-0 bg-gray-800 border border-gray-700 text-sm text-white rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder-gray-500" + /> + +
+
+ + {/* Leaderboard */} +
+

+ Top Payers +

+ {loadingLeaderboard ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : leaderboard.length === 0 ? ( +

+ No payer data found on-chain yet. +

+ ) : ( +
+ {leaderboard.map((entry, idx) => ( +
+ +
+ ))} +
+ )} +
+
+ + {/* Right column: detail view */} +
+
+

+ Reputation Detail +

+ + {selectedAddress || lookupAddress ? ( + + ) : ( +
+ +

+ Select a payer from the leaderboard or enter an address to + view their full reputation breakdown. +

+
+ )} +
+ + {/* Scoring explanation */} +
+

+ How is the score calculated? +

+
    + {[ + ["40%", "On-time payment rate", "Percentage of invoices paid before deadline"], + ["30%", "Dispute win rate", "Percentage of disputes resolved in payer's favour (neutral if none)"], + ["20%", "Volume component", "Scaled from total number of invoices paid (capped at 30 pts)"], + ["10%", "Activity bonus", "Extra points for consistent participation (20+ invoices)"], + ].map(([weight, label, desc]) => ( +
  • + {weight} +
    + {label} + — {desc} +
    +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/src/app/reputation/page.tsx b/src/app/reputation/page.tsx new file mode 100644 index 0000000..30af2b3 --- /dev/null +++ b/src/app/reputation/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import PayerReputationPage from "./PayerReputationPage"; + +export const metadata: Metadata = { + title: "Payer Reputation Scores — StellarSplit", + robots: { index: false, follow: false }, + description: "Browse and check payer reputation scores on StellarSplit.", +}; + +export default function Page() { + return ; +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 715a5f2..27e3ebe 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -81,6 +81,11 @@ export default function SettingsPage() { API keys +
  • + + Dashboard Widgets + +
  • diff --git a/src/app/settings/widgets/DashboardWidgetsBuilderPage.tsx b/src/app/settings/widgets/DashboardWidgetsBuilderPage.tsx new file mode 100644 index 0000000..0a796cc --- /dev/null +++ b/src/app/settings/widgets/DashboardWidgetsBuilderPage.tsx @@ -0,0 +1,55 @@ +"use client"; + +import Link from "next/link"; +import DashboardWidgetsBuilder from "@/components/DashboardWidgetsBuilder"; + +export default function DashboardWidgetsBuilderPage() { + return ( +
    + {/* Breadcrumb */} + + +
    +

    + Dashboard Widgets Builder +

    +

    + Personalise your dashboard by selecting which widgets to display, setting + their size, and arranging them in the order that matters most to you. + Changes are saved locally and take effect on your next dashboard visit. +

    +
    + + + +
    + + ← Back to Settings + + + View dashboard → + +
    +
    + ); +} diff --git a/src/app/settings/widgets/page.tsx b/src/app/settings/widgets/page.tsx new file mode 100644 index 0000000..8fbd1aa --- /dev/null +++ b/src/app/settings/widgets/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import DashboardWidgetsBuilderPage from "./DashboardWidgetsBuilderPage"; + +export const metadata: Metadata = { + title: "Dashboard Widgets — StellarSplit Settings", + robots: { index: false, follow: false }, + description: "Customise which widgets appear on your StellarSplit dashboard.", +}; + +export default function Page() { + return ; +} diff --git a/src/components/CompareInvoicesView.tsx b/src/components/CompareInvoicesView.tsx index ac7e2e3..1d59f6b 100644 --- a/src/components/CompareInvoicesView.tsx +++ b/src/components/CompareInvoicesView.tsx @@ -1,11 +1,11 @@ "use client"; +import { useState } from "react"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import type { Invoice } from "@stellar-split/sdk"; import Link from "next/link"; import FundingProgress from "./FundingProgress"; import StatusBadge from "./StatusBadge"; -import DeadlineCountdown from "./DeadlineCountdown"; interface Props { invoiceA: Invoice; @@ -14,38 +14,169 @@ interface Props { onPayB?: () => void; } +type DiffMode = "all" | "diffs-only"; + +/** Returns a highlight wrapper class when two values differ */ +function diffClass(a: string | number, b: string | number): string { + return String(a) !== String(b) + ? "ring-2 ring-yellow-400/60 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg px-2 py-1" + : ""; +} + +/** Formats a bigint delta with +/- sign for the summary bar */ +function formatDelta(a: bigint, b: bigint): { label: string; positive: boolean; zero: boolean } { + const diff = b - a; + if (diff === 0n) return { label: "Equal", positive: true, zero: true }; + const sign = diff > 0n ? "+" : "-"; + return { + label: `${sign}${formatAmount(diff < 0n ? -diff : diff)} USDC`, + positive: diff > 0n, + zero: false, + }; +} + /** - * CompareInvoicesView — side-by-side comparison of two invoices. - * Highlights differing fields in yellow. + * CompareInvoicesView — enhanced side-by-side comparison of two invoices. + * + * Features: + * - Yellow ring highlight on fields that differ + * - Diff-only view toggle to show only differing rows + * - Summary delta bar showing key metrics at a glance + * - Progress bars side-by-side + * - Recipient count comparison + * - Payment count comparison */ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB }: Props) { + const [diffMode, setDiffMode] = useState("all"); + const [activeSection, setActiveSection] = useState<"overview" | "details">("overview"); + const totalA = invoiceA.recipients.reduce((s, r) => s + r.amount, 0n); const totalB = invoiceB.recipients.reduce((s, r) => s + r.amount, 0n); - const deadlineA = new Date(invoiceA.deadline * 1000).toLocaleDateString(); - const deadlineB = new Date(invoiceB.deadline * 1000).toLocaleDateString(); + const deadlineA = new Date(invoiceA.deadline * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + const deadlineB = new Date(invoiceB.deadline * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); - const statusA = invoiceA.status as string; - const statusB = invoiceB.status as string; + const progressA = totalA > 0n ? Math.round((Number(invoiceA.funded) / Number(totalA)) * 100) : 0; + const progressB = totalB > 0n ? Math.round((Number(invoiceB.funded) / Number(totalB)) * 100) : 0; - const recipientsA = invoiceA.recipients.map((r) => truncateAddress(r.address)).join(", "); - const recipientsB = invoiceB.recipients.map((r) => truncateAddress(r.address)).join(", "); + const paymentCountA = invoiceA.payments?.length ?? 0; + const paymentCountB = invoiceB.payments?.length ?? 0; - const fundedA = formatAmount(invoiceA.funded); - const fundedB = formatAmount(invoiceB.funded); + const tokenA = invoiceA.token ?? "USDC"; + const tokenB = invoiceB.token ?? "USDC"; - const totalFormatA = formatAmount(totalA); - const totalFormatB = formatAmount(totalB); + // All comparison rows + const rows: { + label: string; + valA: React.ReactNode; + valB: React.ReactNode; + rawA: string | number; + rawB: string | number; + }[] = [ + { + label: "Status", + rawA: String(invoiceA.status), + rawB: String(invoiceB.status), + valA: , + valB: , + }, + { + label: "Total Amount", + rawA: formatAmount(totalA), + rawB: formatAmount(totalB), + valA: {formatAmount(totalA)} {tokenA}, + valB: {formatAmount(totalB)} {tokenB}, + }, + { + label: "Funded", + rawA: formatAmount(invoiceA.funded), + rawB: formatAmount(invoiceB.funded), + valA: {formatAmount(invoiceA.funded)} / {formatAmount(totalA)} {tokenA}, + valB: {formatAmount(invoiceB.funded)} / {formatAmount(totalB)} {tokenB}, + }, + { + label: "Progress", + rawA: progressA, + rawB: progressB, + valA: ( +
    + + {progressA}% +
    + ), + valB: ( +
    + + {progressB}% +
    + ), + }, + { + label: "Deadline", + rawA: deadlineA, + rawB: deadlineB, + valA: {deadlineA}, + valB: {deadlineB}, + }, + { + label: "Recipients", + rawA: invoiceA.recipients.map((r) => r.address).join(","), + rawB: invoiceB.recipients.map((r) => r.address).join(","), + valA: ( +
    + {invoiceA.recipients.map((r, i) => ( + + {truncateAddress(r.address)} + + ))} + ({invoiceA.recipients.length}) +
    + ), + valB: ( +
    + {invoiceB.recipients.map((r, i) => ( + + {truncateAddress(r.address)} + + ))} + ({invoiceB.recipients.length}) +
    + ), + }, + { + label: "Payments", + rawA: paymentCountA, + rawB: paymentCountB, + valA: {paymentCountA}, + valB: {paymentCountB}, + }, + { + label: "Token", + rawA: tokenA, + rawB: tokenB, + valA: {tokenA}, + valB: {tokenB}, + }, + ]; - const progressA = totalA > 0n ? (Number(invoiceA.funded) / Number(totalA)) * 100 : 0; - const progressB = totalB > 0n ? (Number(invoiceB.funded) / Number(totalB)) * 100 : 0; + const visibleRows = + diffMode === "diffs-only" + ? rows.filter((r) => String(r.rawA) !== String(r.rawB)) + : rows; - const paymentCountA = invoiceA.payments?.length || 0; - const paymentCountB = invoiceB.payments?.length || 0; + const diffCount = rows.filter((r) => String(r.rawA) !== String(r.rawB)).length; - // Highlight differing fields - const isDifferent = (a: string | number, b: string | number) => - String(a) !== String(b); + // Delta summary values + const totalDelta = formatDelta(totalA, totalB); + const fundedDelta = formatDelta(invoiceA.funded, invoiceB.funded); return (
    @@ -56,287 +187,143 @@ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB ← Back to Dashboard -
    -

    + {/* Header */} +
    +

    Compare Invoices

    -

    - Invoice #{invoiceA.id} vs Invoice #{invoiceB.id} +

    + Invoice #{invoiceA.id} vs Invoice{" "} + #{invoiceB.id}

    -
    - - Highlighted fields differ between the two invoices -
    -
    - {/* Invoice A */} -
    -
    -

    - Invoice #{invoiceA.id} -

    - -
    - -
    - {/* Status */} -
    -

    - Status -

    -
    - -
    -
    - - {/* Amount */} -
    -

    - Total Amount -

    -

    - {totalFormatA} USDC -

    -
    - - {/* Funded */} -
    -

    - Funded -

    -

    - {fundedA} / {totalFormatA} USDC -

    -
    - - {/* Progress Bar */} -
    -

    - Progress -

    - -
    - - {/* Deadline */} -
    -

    - Deadline -

    -

    - {deadlineA} -

    -
    - - {/* Recipients */} -
    -

    - Recipients -

    -
    -
    - {invoiceA.recipients.map((r, i) => ( - - {truncateAddress(r.address)} - - ))} -
    -
    -
    - - {/* Payment Count */} -
    -

    - Payments Received -

    -

    - {paymentCountA} -

    -
    -
    + {/* Delta Summary Bar */} +
    +
    +

    Differences

    +

    0 ? "text-yellow-500" : "text-emerald-500"}`}> + {diffCount} +

    +

    field{diffCount !== 1 ? "s" : ""}

    +
    +

    Amount Δ (B vs A)

    +

    + {totalDelta.label} +

    +
    +
    +

    Funded Δ (B vs A)

    +

    + {fundedDelta.label} +

    +
    +
    +

    Progress Δ

    +

    progressA ? "text-emerald-500" : "text-red-400"}`}> + {progressA === progressB + ? "Equal" + : `${progressB > progressA ? "+" : ""}${progressB - progressA}%`} +

    +
    +
    - {/* Invoice B */} -
    -
    -

    - Invoice #{invoiceB.id} -

    - -
    - -
    - {/* Status */} -
    -

    - Status -

    -
    - -
    -
    - - {/* Amount */} -
    -

    - Total Amount -

    -

    - {totalFormatB} USDC -

    -
    + {/* Controls */} +
    +
    + + Highlighted rows differ +
    +
    + + +
    +
    - {/* Funded */} -
    -

    - Funded -

    -

    - {fundedB} / {totalFormatB} USDC -

    + {/* Comparison Table */} + {visibleRows.length === 0 ? ( +
    +

    + ✓ No differences found — these invoices are identical in all compared fields. +

    +
    + ) : ( +
    + {/* Column headers */} +
    +
    + Field
    - - {/* Progress Bar */} -
    -

    - Progress -

    - +
    + Invoice A #{invoiceA.id}
    - - {/* Deadline */} -
    -

    - Deadline -

    -

    - {deadlineB} -

    +
    + Invoice B #{invoiceB.id}
    +
    - {/* Recipients */} -
    -

    - Recipients -

    + {/* Rows */} + {visibleRows.map((row, idx) => { + const differs = String(row.rawA) !== String(row.rawB); + const rowBg = idx % 2 === 0 ? "" : "bg-gray-50/50 dark:bg-gray-800/20"; + return (
    -
    - {invoiceB.recipients.map((r, i) => ( - - {truncateAddress(r.address)} - - ))} +
    + {row.label} +
    +
    + {row.valA} +
    +
    + {row.valB}
    -
    - - {/* Payment Count */} -
    -

    - Payments Received -

    -

    - {paymentCountB} -

    -
    -
    + ); + })}
    + )} + + {/* Pay buttons */} +
    + +
    ); diff --git a/src/components/DashboardWidgetsBuilder.tsx b/src/components/DashboardWidgetsBuilder.tsx new file mode 100644 index 0000000..3d016d4 --- /dev/null +++ b/src/components/DashboardWidgetsBuilder.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { + useState, + useCallback, + useId, + type ReactNode, +} from "react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type WidgetId = + | "total-invoices" + | "total-volume" + | "completion-rate" + | "active-invoices" + | "overdue-invoices" + | "pending-payments" + | "recent-activity" + | "quick-links"; + +export type WidgetSize = "small" | "medium" | "large"; + +export interface WidgetConfig { + id: WidgetId; + label: string; + description: string; + size: WidgetSize; + enabled: boolean; + order: number; +} + +// ─── Widget definitions ─────────────────────────────────────────────────────── + +const WIDGET_CATALOG: Omit[] = [ + { + id: "total-invoices", + label: "Total Invoices", + description: "Shows the total count of your invoices across all statuses.", + size: "small", + }, + { + id: "total-volume", + label: "Total Volume", + description: "Cumulative USDC raised across all released invoices.", + size: "small", + }, + { + id: "completion-rate", + label: "Completion Rate", + description: "Percentage of invoices successfully released.", + size: "small", + }, + { + id: "active-invoices", + label: "Active Invoices", + description: "Pending invoices whose deadline hasn't passed yet.", + size: "small", + }, + { + id: "overdue-invoices", + label: "Overdue Invoices", + description: "Pending invoices that have passed their deadline.", + size: "medium", + }, + { + id: "pending-payments", + label: "Pending Payments", + description: "Count of invoices waiting for at least one payment.", + size: "medium", + }, + { + id: "recent-activity", + label: "Recent Activity Feed", + description: "Live feed of your latest invoice events and payments.", + size: "large", + }, + { + id: "quick-links", + label: "Quick Links", + description: "Shortcut buttons for common actions like creating invoices.", + size: "medium", + }, +]; + +const DEFAULT_ENABLED: WidgetId[] = [ + "total-invoices", + "total-volume", + "completion-rate", + "active-invoices", +]; + +function buildDefaults(): WidgetConfig[] { + return WIDGET_CATALOG.map((w, i) => ({ + ...w, + order: i, + enabled: DEFAULT_ENABLED.includes(w.id), + })); +} + +// ─── Storage helpers ────────────────────────────────────────────────────────── + +const STORAGE_KEY = "dashboard-widgets-config"; + +function loadConfig(): WidgetConfig[] { + if (typeof window === "undefined") return buildDefaults(); + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return buildDefaults(); + const parsed = JSON.parse(raw) as WidgetConfig[]; + // Merge with catalog to pick up any new widgets added later + const ids = new Set(parsed.map((w) => w.id)); + const missing = buildDefaults().filter((w) => !ids.has(w.id)); + return [...parsed, ...missing]; + } catch { + return buildDefaults(); + } +} + +function saveConfig(cfg: WidgetConfig[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); + } catch { + // Ignore storage errors (e.g., private browsing quota) + } +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +const SIZE_LABELS: Record = { + small: "Small (1 col)", + medium: "Medium (2 cols)", + large: "Large (full width)", +}; + +const SIZE_COLORS: Record = { + small: "bg-sky-500/10 text-sky-400 border-sky-500/30", + medium: "bg-violet-500/10 text-violet-400 border-violet-500/30", + large: "bg-amber-500/10 text-amber-400 border-amber-500/30", +}; + +interface WidgetRowProps { + widget: WidgetConfig; + onToggle: (id: WidgetId) => void; + onSizeChange: (id: WidgetId, size: WidgetSize) => void; + onMoveUp: (id: WidgetId) => void; + onMoveDown: (id: WidgetId) => void; + isFirst: boolean; + isLast: boolean; +} + +function WidgetRow({ + widget, + onToggle, + onSizeChange, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}: WidgetRowProps) { + const toggleId = useId(); + + return ( +
    + {/* Reorder buttons */} +
    + + +
    + + {/* Info */} +
    +
    + + + {SIZE_LABELS[widget.size]} + +
    +

    {widget.description}

    +
    + + {/* Size selector */} +
    + +
    + + {/* Toggle */} +
    + +
    +
    + ); +} + +// ─── Preview widget ─────────────────────────────────────────────────────────── + +const SIZE_PREVIEW_COLS: Record = { + small: "col-span-1", + medium: "col-span-2", + large: "col-span-3", +}; + +function PreviewWidget({ widget }: { widget: WidgetConfig }) { + const colSpan = SIZE_PREVIEW_COLS[widget.size]; + const icons: Record = { + "total-invoices": "📄", + "total-volume": "💰", + "completion-rate": "✅", + "active-invoices": "⚡", + "overdue-invoices": "⏰", + "pending-payments": "⏳", + "recent-activity": "📡", + "quick-links": "🔗", + }; + + return ( +
    +
    + + + {widget.label} + +
    +
    +
    + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export interface DashboardWidgetsBuilderProps { + /** Called whenever the config is saved so the dashboard can re-render */ + onConfigChange?: (config: WidgetConfig[]) => void; +} + +export default function DashboardWidgetsBuilder({ + onConfigChange, +}: DashboardWidgetsBuilderProps) { + const [widgets, setWidgets] = useState(() => loadConfig()); + const [saved, setSaved] = useState(false); + const [showPreview, setShowPreview] = useState(true); + + const enabledWidgets = widgets + .filter((w) => w.enabled) + .sort((a, b) => a.order - b.order); + + const handleToggle = useCallback((id: WidgetId) => { + setWidgets((prev) => + prev.map((w) => (w.id === id ? { ...w, enabled: !w.enabled } : w)) + ); + setSaved(false); + }, []); + + const handleSizeChange = useCallback((id: WidgetId, size: WidgetSize) => { + setWidgets((prev) => + prev.map((w) => (w.id === id ? { ...w, size } : w)) + ); + setSaved(false); + }, []); + + const handleMoveUp = useCallback((id: WidgetId) => { + setWidgets((prev) => { + const sorted = [...prev].sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((w) => w.id === id); + if (idx <= 0) return prev; + const swapped = [...sorted]; + [swapped[idx - 1].order, swapped[idx].order] = [ + swapped[idx].order, + swapped[idx - 1].order, + ]; + return swapped; + }); + setSaved(false); + }, []); + + const handleMoveDown = useCallback((id: WidgetId) => { + setWidgets((prev) => { + const sorted = [...prev].sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((w) => w.id === id); + if (idx < 0 || idx >= sorted.length - 1) return prev; + const swapped = [...sorted]; + [swapped[idx].order, swapped[idx + 1].order] = [ + swapped[idx + 1].order, + swapped[idx].order, + ]; + return swapped; + }); + setSaved(false); + }, []); + + const handleSave = () => { + const sorted = [...widgets].sort((a, b) => a.order - b.order); + saveConfig(sorted); + onConfigChange?.(sorted); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + const handleReset = () => { + const defaults = buildDefaults(); + setWidgets(defaults); + saveConfig(defaults); + onConfigChange?.(defaults); + setSaved(false); + }; + + const sortedWidgets = [...widgets].sort((a, b) => a.order - b.order); + + return ( +
    + {/* Header */} +
    +
    +

    Dashboard Widgets

    +

    + Choose which widgets appear on your dashboard and arrange them. +

    +
    +
    + + +
    +
    + + {/* Widget list */} +
    + {sortedWidgets.map((widget, idx) => ( +
    + +
    + ))} +
    + + {/* Live preview */} +
    + + + {showPreview && ( +
    + {enabledWidgets.length === 0 ? ( +

    + No widgets enabled — your dashboard will be empty. +

    + ) : ( +
    + {enabledWidgets.map((widget) => ( + + ))} +
    + )} +
    + )} +
    + + {/* Enabled count summary */} +

    + {enabledWidgets.length} of {widgets.length} widgets enabled +

    +
    + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index a5a99d5..943c224 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -19,6 +19,7 @@ const NAV_LINKS = [ { href: "/address-book", label: "Contacts" }, { href: "/recipients", label: "Recipients" }, { href: "/leaderboard", label: "Leaderboard" }, + { href: "/reputation", label: "Reputation" }, ]; export default function Navbar() { diff --git a/src/components/PayerReputationDisplay.tsx b/src/components/PayerReputationDisplay.tsx new file mode 100644 index 0000000..dbd0ff7 --- /dev/null +++ b/src/components/PayerReputationDisplay.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { truncateAddress } from "@stellar-split/sdk"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PayerReputationData { + address: string; + /** 0-100 composite reputation score */ + score: number; + /** Number of invoices paid */ + totalPaid: number; + /** Number of invoices paid on time (before deadline) */ + onTimePaid: number; + /** Number of disputes the payer was involved in */ + disputeCount: number; + /** Number of disputes the payer won */ + disputeWins: number; + /** Total USDC volume paid */ + volumeUsdc: string; +} + +export type ReputationTier = + | "Elite" + | "Trusted" + | "Established" + | "Emerging" + | "Untrusted"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getTier(score: number): { + tier: ReputationTier; + color: string; + bgColor: string; + borderColor: string; + icon: string; +} { + if (score >= 90) + return { + tier: "Elite", + color: "text-emerald-400", + bgColor: "bg-emerald-500/10", + borderColor: "border-emerald-500/30", + icon: "🏆", + }; + if (score >= 70) + return { + tier: "Trusted", + color: "text-indigo-400", + bgColor: "bg-indigo-500/10", + borderColor: "border-indigo-500/30", + icon: "✅", + }; + if (score >= 50) + return { + tier: "Established", + color: "text-sky-400", + bgColor: "bg-sky-500/10", + borderColor: "border-sky-500/30", + icon: "⭐", + }; + if (score >= 25) + return { + tier: "Emerging", + color: "text-amber-400", + bgColor: "bg-amber-500/10", + borderColor: "border-amber-500/30", + icon: "🌱", + }; + return { + tier: "Untrusted", + color: "text-red-400", + bgColor: "bg-red-500/10", + borderColor: "border-red-500/30", + icon: "⚠️", + }; +} + +/** + * Compute a composite reputation score from payment history metrics. + * Formula: + * 40% on-time payment rate + * 30% dispute win rate (defaults to neutral if no disputes) + * 20% volume component (capped at 30, scaled logarithmically) + * 10% activity bonus (capped at 10 points for 20+ invoices) + */ +export function computePayerScore(data: Omit): number { + if (data.totalPaid === 0) return 0; + + const onTimeRate = data.totalPaid > 0 ? data.onTimePaid / data.totalPaid : 0; + const disputeWinRate = + data.disputeCount === 0 ? 1 : data.disputeWins / data.disputeCount; + + const volumeScore = Math.min(30, data.totalPaid * 1.5); + const activityBonus = Math.min(10, (data.totalPaid / 20) * 10); + + const raw = + onTimeRate * 40 + + disputeWinRate * 30 + + volumeScore + + activityBonus; + + return Math.min(100, Math.max(0, Math.round(raw))); +} + +// ─── Score ring ─────────────────────────────────────────────────────────────── + +function ScoreRing({ score, color }: { score: number; color: string }) { + const r = 36; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - score / 100); + + // Convert color class to stroke color + const strokeColor = + color === "text-emerald-400" + ? "#34d399" + : color === "text-indigo-400" + ? "#818cf8" + : color === "text-sky-400" + ? "#38bdf8" + : color === "text-amber-400" + ? "#fbbf24" + : "#f87171"; + + return ( + + ); +} + +// ─── Metric row ─────────────────────────────────────────────────────────────── + +function Metric({ + label, + value, + sub, + highlight, +}: { + label: string; + value: React.ReactNode; + sub?: string; + highlight?: boolean; +}) { + return ( +
    + {label} + {value} + {sub && {sub}} +
    + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface PayerReputationDisplayProps { + /** Payer wallet address */ + address: string; + /** + * Pre-computed data. If omitted the component simulates a fetch + * (for demo / when integrated without a real backend). + */ + data?: PayerReputationData; + /** Whether to show the address in the header */ + showAddress?: boolean; + /** Compact mode hides the breakdown metrics */ + compact?: boolean; +} + +/** + * PayerReputationDisplay + * + * Renders a payer's on-chain reputation score as a visual gauge with + * tier badge, breakdown metrics, and an explanatory tooltip. + * + * Usage: + * + * + * Or with pre-loaded data: + * + */ +export default function PayerReputationDisplay({ + address, + data: externalData, + showAddress = true, + compact = false, +}: PayerReputationDisplayProps) { + const [data, setData] = useState( + externalData ?? null + ); + const [loading, setLoading] = useState(!externalData); + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + if (externalData) { + setData(externalData); + setLoading(false); + return; + } + + // Simulate data fetch — replace with a real SDK / API call when available + const timer = setTimeout(() => { + const totalPaid = Math.floor(Math.random() * 40) + 1; + const onTimePaid = Math.floor(totalPaid * (0.6 + Math.random() * 0.4)); + const disputeCount = Math.floor(Math.random() * 4); + const disputeWins = Math.floor(disputeCount * (0.5 + Math.random() * 0.5)); + const score = computePayerScore({ + totalPaid, + onTimePaid, + disputeCount, + disputeWins, + }); + + setData({ + address, + score, + totalPaid, + onTimePaid, + disputeCount, + disputeWins, + volumeUsdc: (totalPaid * 250).toFixed(2), + }); + setLoading(false); + }, 600); + + return () => clearTimeout(timer); + }, [address, externalData]); + + if (loading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + if (!data) { + return ( +
    + No reputation data available for this address. +
    + ); + } + + const { tier, color, bgColor, borderColor, icon } = getTier(data.score); + const onTimeRate = + data.totalPaid > 0 + ? Math.round((data.onTimePaid / data.totalPaid) * 100) + : 0; + const disputeWinRate = + data.disputeCount > 0 + ? Math.round((data.disputeWins / data.disputeCount) * 100) + : 100; + + const tooltipText = + "Reputation score is computed from on-time payment rate (40%), " + + "dispute win rate (30%), total payment volume (20%), and activity bonus (10%). " + + "Higher scores indicate a more reliable payer."; + + if (compact) { + return ( +
    + + {tier} + {data.score}/100 +
    + ); + } + + return ( +
    + {/* Header band */} +
    +
    +
    + +

    {tier} Payer

    +
    + {showAddress && ( +

    + {truncateAddress(address)} +

    + )} +
    + + {/* Tooltip trigger */} +
    + + {showTooltip && ( +
    + {tooltipText} +
    + )} +
    +
    + + {/* Body */} +
    +
    + {/* Score ring */} +
    + + + {tier} + +
    + + {/* Metrics grid */} +
    + + = 80} + /> + + None + ) : ( + `${data.disputeCount} (${disputeWinRate}% won)` + ) + } + /> +
    +
    + + {/* Score breakdown bar */} +
    +
    + Score breakdown + {data.score}/100 +
    +
    +
    = 90 + ? "bg-emerald-500" + : data.score >= 70 + ? "bg-indigo-500" + : data.score >= 50 + ? "bg-sky-500" + : data.score >= 25 + ? "bg-amber-500" + : "bg-red-500" + }`} + style={{ width: `${data.score}%` }} + role="progressbar" + aria-valuenow={data.score} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`Reputation score: ${data.score} out of 100`} + /> +
    +
    + 0 + 25 + 50 + 75 + 100 +
    +
    +
    +
    + ); +} diff --git a/src/components/invoice/RecipientPaymentHistory.tsx b/src/components/invoice/RecipientPaymentHistory.tsx index 174bb73..5040bb6 100644 --- a/src/components/invoice/RecipientPaymentHistory.tsx +++ b/src/components/invoice/RecipientPaymentHistory.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import TxHash from "@/components/ui/TxHash"; import RelativeTime from "@/components/ui/RelativeTime"; +import PayerReputationDisplay from "@/components/PayerReputationDisplay"; export interface RecipientHistoryEntry { operationHash: string; @@ -148,11 +149,16 @@ export default function RecipientPaymentHistory({ {entry.amount} {entry.asset} - + from{" "} {entry.from.slice(0, 8)}...{entry.from.slice(-6)} +