+
Invoice Marketplace
diff --git a/components/admin/TimelockProposalsPanel.tsx b/components/admin/TimelockProposalsPanel.tsx
index 567fb70..1706700 100644
--- a/components/admin/TimelockProposalsPanel.tsx
+++ b/components/admin/TimelockProposalsPanel.tsx
@@ -12,7 +12,8 @@ import {
useExecuteTimelockProposalMutation,
useTimelockProposals,
} from "@/hooks/useTimelockProposals";
-import type { TimelockProposal } from "@/lib/api";
+import { AccessDenied } from "@/components/common";
+import { ApiForbiddenError, type TimelockProposal } from "@/lib/api";
function formatDateTime(value: string): string {
if (!value) return "—";
@@ -185,6 +186,16 @@ export function TimelockProposalsPanel() {
);
}
+ if (proposalsQuery.error instanceof ApiForbiddenError) {
+ return (
+
+
+
+
+
+ );
+ }
+
const confirmingProposal =
pending.find((proposal) => proposal.id === confirmingId) ?? null;
diff --git a/components/common/AccessDenied.tsx b/components/common/AccessDenied.tsx
new file mode 100644
index 0000000..3993691
--- /dev/null
+++ b/components/common/AccessDenied.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import Link from "next/link";
+import { Globe, KeyRound, ShieldAlert, ShieldCheck, type LucideIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import {
+ ACCESS_RESTRICTIONS,
+ type AccessRestrictionType,
+} from "@/lib/restrictions";
+
+const ICONS: Record
= {
+ KYCRequired: ShieldAlert,
+ JurisdictionBlocked: Globe,
+ AccreditationRequired: ShieldCheck,
+ SignerOnly: KeyRound,
+};
+
+interface AccessDeniedProps {
+ restriction: AccessRestrictionType;
+ /** Fill the surrounding card/panel instead of a full-page min height. */
+ compact?: boolean;
+ className?: string;
+}
+
+export function AccessDenied({ restriction, compact = false, className }: AccessDeniedProps) {
+ const config = ACCESS_RESTRICTIONS[restriction];
+ const Icon = ICONS[restriction];
+
+ return (
+
+
+
+
+
+ {config.heading}
+
+
+ {config.explanation}
+
+ {config.cta && (
+
+ )}
+
+ );
+}
diff --git a/components/common/__tests__/AccessDenied.test.tsx b/components/common/__tests__/AccessDenied.test.tsx
new file mode 100644
index 0000000..87dffdf
--- /dev/null
+++ b/components/common/__tests__/AccessDenied.test.tsx
@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AccessDenied } from "../AccessDenied";
+import { ACCESS_RESTRICTIONS, type AccessRestrictionType } from "@/lib/restrictions";
+
+const CASES: [AccessRestrictionType, string | null, string | null][] = [
+ ["KYCRequired", "Upgrade KYC", "/kyc/upgrade"],
+ ["JurisdictionBlocked", "Contact Support", "/help"],
+ ["AccreditationRequired", "Apply for Accreditation", "/kyc/accreditation"],
+ ["SignerOnly", null, null],
+];
+
+describe("AccessDenied", () => {
+ it.each(CASES)("renders %s content and CTA", (type, label, href) => {
+ render();
+ expect(screen.getByTestId("access-denied-heading")).toHaveTextContent(
+ ACCESS_RESTRICTIONS[type].heading,
+ );
+ expect(screen.getByTestId("access-denied-explanation")).toHaveTextContent(
+ ACCESS_RESTRICTIONS[type].explanation,
+ );
+ expect(screen.getByTestId("access-denied-icon")).toBeInTheDocument();
+
+ if (label && href) {
+ const cta = screen.getByRole("link", { name: label });
+ expect(cta).toHaveAttribute("href", href);
+ } else {
+ expect(screen.queryByTestId("access-denied-cta")).not.toBeInTheDocument();
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ }
+ });
+});
diff --git a/components/common/index.ts b/components/common/index.ts
new file mode 100644
index 0000000..4bd50d7
--- /dev/null
+++ b/components/common/index.ts
@@ -0,0 +1 @@
+export { AccessDenied } from "./AccessDenied";
diff --git a/components/governance/TimelockPendingBanner.tsx b/components/governance/TimelockPendingBanner.tsx
new file mode 100644
index 0000000..c3bf5ee
--- /dev/null
+++ b/components/governance/TimelockPendingBanner.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { ChevronDown, Clock, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { usePendingTimelockChanges } from "@/hooks/usePendingTimelockChanges";
+import type { PendingTimelockChange } from "@/lib/api";
+
+export const TIMELOCK_BANNER_DISMISS_KEY = "timelock-banner-dismissed";
+
+function readDismissed(): boolean {
+ try {
+ return window.sessionStorage.getItem(TIMELOCK_BANNER_DISMISS_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+
+export function formatEtaCountdown(etaMs: number, nowMs: number): string {
+ const total = Math.max(Math.floor((etaMs - nowMs) / 1000), 0);
+ if (total === 0) return "Ready to execute";
+ const d = Math.floor(total / 86400);
+ const h = Math.floor((total % 86400) / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const s = total % 60;
+ if (d > 0) return `${d}d ${h}h ${m}m ${s}s`;
+ if (h > 0) return `${h}h ${m}m ${s}s`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+function ChangeRow({ change, now }: { change: PendingTimelockChange; now: number }) {
+ const etaMs = new Date(change.eta).getTime();
+ return (
+
+
+
{change.parameter}
+
+ {change.current_value} →{" "}
+ {change.proposed_value}
+
+
+
+ {Number.isNaN(etaMs) ? "ETA unknown" : formatEtaCountdown(etaMs, now)}
+
+
+ );
+}
+
+export function TimelockPendingBanner() {
+ const { data } = usePendingTimelockChanges();
+ const [dismissed, setDismissed] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ setDismissed(readDismissed());
+ }, []);
+
+ const hasChanges = Boolean(data && data.length > 0);
+
+ useEffect(() => {
+ if (!hasChanges || dismissed) return;
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [hasChanges, dismissed]);
+
+ if (!data || data.length === 0 || dismissed) return null;
+
+ const dismiss = () => {
+ setDismissed(true);
+ try {
+ window.sessionStorage.setItem(TIMELOCK_BANNER_DISMISS_KEY, "1");
+ } catch {
+ // Dismissal still applies for this page view.
+ }
+ };
+
+ const soonest = [...data].sort(
+ (a, b) => new Date(a.eta).getTime() - new Date(b.eta).getTime(),
+ )[0];
+ const multiple = data.length > 1;
+
+ return (
+
+
+
+
+
+ {multiple
+ ? `${data.length} protocol changes are scheduled`
+ : "A protocol change is scheduled"}
+
+ {!multiple && (
+
+ )}
+ {multiple && (
+ <>
+
+ {expanded && (
+
+ {data.map((change) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/components/governance/__tests__/TimelockPendingBanner.test.tsx b/components/governance/__tests__/TimelockPendingBanner.test.tsx
new file mode 100644
index 0000000..2eda7ac
--- /dev/null
+++ b/components/governance/__tests__/TimelockPendingBanner.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, act, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { TimelockPendingBanner, formatEtaCountdown } from "../TimelockPendingBanner";
+import * as api from "@/lib/api";
+
+function renderBanner() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+const inFuture = (ms: number) => new Date(Date.now() + ms).toISOString();
+
+beforeEach(() => window.sessionStorage.clear());
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe("formatEtaCountdown", () => {
+ it("formats remaining time", () => {
+ expect(formatEtaCountdown(90_061_000, 0)).toBe("1d 1h 1m 1s");
+ expect(formatEtaCountdown(61_000, 0)).toBe("1m 1s");
+ expect(formatEtaCountdown(0, 5000)).toBe("Ready to execute");
+ });
+});
+
+describe("TimelockPendingBanner", () => {
+ it("renders nothing when no changes are pending", async () => {
+ const spy = vi.spyOn(api, "fetchPendingTimelockChanges").mockResolvedValue([]);
+ renderBanner();
+ await waitFor(() => expect(spy).toHaveBeenCalled());
+ expect(screen.queryByTestId("timelock-banner")).not.toBeInTheDocument();
+ });
+
+ it("shows a single change with parameter, values and ETA", async () => {
+ vi.spyOn(api, "fetchPendingTimelockChanges").mockResolvedValue([
+ { id: "1", parameter: "Protocol fee", current_value: "1%", proposed_value: "2%", eta: inFuture(3_700_000) },
+ ]);
+ renderBanner();
+ expect(await screen.findByTestId("timelock-banner")).toBeInTheDocument();
+ expect(screen.getByText("Protocol fee")).toBeInTheDocument();
+ expect(screen.getByText("2%")).toBeInTheDocument();
+ expect(screen.getByTestId("timelock-change-eta")).toHaveTextContent(/1h 1m/);
+ });
+
+ it("updates the countdown every second", async () => {
+ vi.spyOn(api, "fetchPendingTimelockChanges").mockResolvedValue([
+ { id: "1", parameter: "Fee", current_value: "1", proposed_value: "2", eta: inFuture(120_000) },
+ ]);
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
+ renderBanner();
+ await screen.findByTestId("timelock-banner");
+ const before = screen.getByTestId("timelock-change-eta").textContent;
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(screen.getByTestId("timelock-change-eta").textContent).not.toBe(before);
+ });
+
+ it("expands a list for multiple changes", async () => {
+ vi.spyOn(api, "fetchPendingTimelockChanges").mockResolvedValue([
+ { id: "1", parameter: "Fee", current_value: "1", proposed_value: "2", eta: inFuture(100_000) },
+ { id: "2", parameter: "Min investment", current_value: "10", proposed_value: "20", eta: inFuture(200_000) },
+ ]);
+ renderBanner();
+ await screen.findByTestId("timelock-banner");
+ expect(screen.queryAllByTestId("timelock-change-item")).toHaveLength(0);
+ await userEvent.click(screen.getByTestId("timelock-banner-toggle"));
+ expect(screen.getAllByTestId("timelock-change-item")).toHaveLength(2);
+ });
+
+ it("hides on dismiss and stays hidden for the session", async () => {
+ vi.spyOn(api, "fetchPendingTimelockChanges").mockResolvedValue([
+ { id: "1", parameter: "Fee", current_value: "1", proposed_value: "2", eta: inFuture(100_000) },
+ ]);
+ const first = renderBanner();
+ await userEvent.click(await screen.findByTestId("timelock-banner-dismiss"));
+ expect(screen.queryByTestId("timelock-banner")).not.toBeInTheDocument();
+ first.unmount();
+
+ const spy = api.fetchPendingTimelockChanges as unknown as { mock: { calls: unknown[] } };
+ renderBanner();
+ await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1));
+ expect(screen.queryByTestId("timelock-banner")).not.toBeInTheDocument();
+ });
+});
diff --git a/components/governance/index.ts b/components/governance/index.ts
new file mode 100644
index 0000000..ff9939f
--- /dev/null
+++ b/components/governance/index.ts
@@ -0,0 +1 @@
+export { TimelockPendingBanner } from "./TimelockPendingBanner";
diff --git a/components/issuers/IssuerProfile.tsx b/components/issuers/IssuerProfile.tsx
index 9998237..a6e23e8 100644
--- a/components/issuers/IssuerProfile.tsx
+++ b/components/issuers/IssuerProfile.tsx
@@ -9,6 +9,7 @@ import { KycStatusNotice, VerifiedBadge } from "./VerifiedBadge";
import { IssuerScoreCard, TotalFunded } from "./IssuerScoreCard";
import { InvoiceHistoryTable } from "./InvoiceHistoryTable";
import { ActiveInvoices } from "./ActiveInvoices";
+import { IssuerReputationHeaderBadge, IssuerReputationPanel } from "./IssuerReputationPanel";
function IssuerProfileSkeleton() {
return (
@@ -80,6 +81,7 @@ export function IssuerProfile({ issuerId, now }: IssuerProfileProps) {
+
@@ -92,6 +94,8 @@ export function IssuerProfile({ issuerId, now }: IssuerProfileProps) {
+
+
diff --git a/components/issuers/IssuerReputationPanel.tsx b/components/issuers/IssuerReputationPanel.tsx
new file mode 100644
index 0000000..8142844
--- /dev/null
+++ b/components/issuers/IssuerReputationPanel.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { Award, ShieldQuestion, TrendingDown, TrendingUp } from "lucide-react";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useIssuerReputation } from "@/hooks/useIssuerReputation";
+import {
+ REPUTATION_TIERS,
+ formatReputationEvent,
+ getReputationTier,
+ sortReputationHistory,
+} from "@/lib/reputation";
+import { cn } from "@/lib/utils";
+
+export function IssuerReputationSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+function formatDelta(delta: number): string {
+ return delta > 0 ? `+${delta}` : String(delta);
+}
+
+function formatEventDate(value: string): string {
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return value;
+ return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+export function ReputationBadge({ score }: { score: number | null }) {
+ const tier = getReputationTier(score);
+ const Icon = tier.id === "unrated" ? ShieldQuestion : Award;
+
+ return (
+
+
+
+
+
+ Reputation tiers
+
+ {REPUTATION_TIERS.map((t) => (
+ -
+ {t.label}: {t.min}–{t.max}
+
+ ))}
+ - Unrated: no settlement history yet
+
+
+
+ );
+}
+
+/** Badge for the profile header; shares the panel's cached query. */
+export function IssuerReputationHeaderBadge({ wallet }: { wallet: string }) {
+ const { data, isLoading, isError } = useIssuerReputation(wallet);
+ if (isLoading) {
+ return ;
+ }
+ if (isError || !data) return null;
+ return ;
+}
+
+export function IssuerReputationPanel({ wallet }: { wallet: string }) {
+ const { data, isLoading, isError } = useIssuerReputation(wallet);
+
+ if (isLoading) return ;
+
+ if (isError || !data) {
+ return (
+
+
+ Reputation data is unavailable right now.
+
+
+ );
+ }
+
+ const history = sortReputationHistory(data.history);
+
+ return (
+
+
+
+
Score history
+
+ {data.score === null && (
+
+ This issuer has no settled invoices yet, so no score is available.
+
+ )}
+
+
+ {history.length === 0 ? (
+
+ No score changes recorded.
+
+ ) : (
+
+ {history.map((event) => {
+ const positive = event.score_delta >= 0;
+ const DeltaIcon = positive ? TrendingUp : TrendingDown;
+ return (
+ -
+
+
{formatReputationEvent(event.event_type)}
+
{formatEventDate(event.date)}
+
+
+
+ {formatDelta(event.score_delta)}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/components/issuers/__tests__/IssuerReputationPanel.test.tsx b/components/issuers/__tests__/IssuerReputationPanel.test.tsx
new file mode 100644
index 0000000..76ed0d1
--- /dev/null
+++ b/components/issuers/__tests__/IssuerReputationPanel.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { IssuerReputationHeaderBadge, IssuerReputationPanel } from "../IssuerReputationPanel";
+import { getReputationTier } from "@/lib/reputation";
+import * as api from "@/lib/api";
+
+function renderWith(ui: React.ReactElement) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("getReputationTier", () => {
+ it.each([
+ [null, "unrated"],
+ [0, "bronze"],
+ [39, "bronze"],
+ [40, "silver"],
+ [59, "silver"],
+ [60, "gold"],
+ [79, "gold"],
+ [80, "platinum"],
+ [100, "platinum"],
+ ])("maps %s to %s", (score, tier) => {
+ expect(getReputationTier(score).id).toBe(tier);
+ });
+});
+
+describe("IssuerReputationPanel", () => {
+ it("shows a skeleton while loading", () => {
+ vi.spyOn(api, "fetchIssuerReputation").mockReturnValue(new Promise(() => {}));
+ renderWith();
+ expect(screen.getByTestId("issuer-reputation-loading")).toBeInTheDocument();
+ });
+
+ it("renders history newest first with deltas", async () => {
+ vi.spyOn(api, "fetchIssuerReputation").mockResolvedValue({
+ wallet: "G1",
+ score: 72,
+ history: [
+ { id: "a", event_type: "settled_on_time", date: "2026-01-10T00:00:00Z", score_delta: 5 },
+ { id: "b", event_type: "defaulted", date: "2026-03-10T00:00:00Z", score_delta: -20 },
+ { id: "c", event_type: "settled_late", date: "2026-02-10T00:00:00Z", score_delta: -2 },
+ ],
+ });
+ renderWith();
+ const items = await screen.findAllByTestId("reputation-history-item");
+ expect(items.map((i) => i.textContent)).toEqual([
+ expect.stringContaining("Defaulted"),
+ expect.stringContaining("Settled late"),
+ expect.stringContaining("Settled on time"),
+ ]);
+ const deltas = screen.getAllByTestId("reputation-delta").map((d) => d.textContent);
+ expect(deltas).toEqual(["-20", "-2", "+5"]);
+ });
+});
+
+describe("IssuerReputationHeaderBadge", () => {
+ it("shows tier name, score and tier thresholds on hover", async () => {
+ vi.spyOn(api, "fetchIssuerReputation").mockResolvedValue({ wallet: "G1", score: 85, history: [] });
+ renderWith();
+ const badge = await screen.findByTestId("reputation-badge");
+ expect(badge).toHaveAttribute("data-tier", "platinum");
+ expect(screen.getByTestId("reputation-tier-name")).toHaveTextContent("Platinum");
+ expect(screen.getByTestId("reputation-score")).toHaveTextContent("85");
+
+ await userEvent.hover(badge);
+ const tips = await screen.findAllByTestId("tier-threshold-gold");
+ expect(tips[0]).toHaveTextContent("Gold: 60–79");
+ });
+
+ it("shows an Unrated badge when there is no score", async () => {
+ vi.spyOn(api, "fetchIssuerReputation").mockResolvedValue({ wallet: "G1", score: null, history: [] });
+ renderWith();
+ expect(await screen.findByTestId("reputation-tier-name")).toHaveTextContent("Unrated");
+ expect(screen.queryByTestId("reputation-score")).not.toBeInTheDocument();
+ });
+});
diff --git a/components/issuers/index.ts b/components/issuers/index.ts
index ad03f93..5090bdd 100644
--- a/components/issuers/index.ts
+++ b/components/issuers/index.ts
@@ -3,3 +3,9 @@ export { IssuerScoreCard, TotalFunded } from "./IssuerScoreCard";
export { InvoiceHistoryTable } from "./InvoiceHistoryTable";
export { ActiveInvoices } from "./ActiveInvoices";
export { VerifiedBadge, KycStatusNotice } from "./VerifiedBadge";
+export {
+ IssuerReputationHeaderBadge,
+ IssuerReputationPanel,
+ IssuerReputationSkeleton,
+ ReputationBadge,
+} from "./IssuerReputationPanel";
diff --git a/components/marketplace/BuyFractionModal.tsx b/components/marketplace/BuyFractionModal.tsx
index 55a7ed8..907e82e 100644
--- a/components/marketplace/BuyFractionModal.tsx
+++ b/components/marketplace/BuyFractionModal.tsx
@@ -14,6 +14,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TxHash } from "@/components/ui/tx-hash";
import { useBuyFractionMutation } from "@/hooks/useSecondaryMarket";
+import { useTransferRestriction } from "@/hooks/useTransferRestriction";
+import { TransferBlocked } from "./TransferBlocked";
import { listingImpliedYield, totalCost } from "@/lib/secondaryMarket";
import type { ResaleListing } from "@/lib/api";
@@ -41,6 +43,8 @@ export function BuyFractionModal({
// lands so the buyer can verify it on-chain.
const [txHash, setTxHash] = useState(null);
const buyMutation = useBuyFractionMutation();
+ const restrictionCheck = useTransferRestriction(listing?.id, open);
+ const blockedBy = restrictionCheck.data?.restriction ?? null;
// Reset whenever a different listing is opened.
useEffect(() => {
@@ -151,6 +155,17 @@ export function BuyFractionModal({
+ ) : blockedBy ? (
+
+ void restrictionCheck.refetch()}
+ isRetrying={restrictionCheck.isFetching}
+ />
+
+
) : (
<>