From fcf0b47496f9862c47633152511e81ea83343d2b Mon Sep 17 00:00:00 2001 From: Johnalex-hub Date: Mon, 19 Oct 2026 02:32:53 +0100 Subject: [PATCH] feat: issuer reputation, timelock banner, transfer blocked UI, access denied (#490 #491 #492 #494) --- app/(dashboard)/investor/portfolio/page.tsx | 4 + app/marketplace/page.tsx | 2 + components/admin/TimelockProposalsPanel.tsx | 13 +- components/common/AccessDenied.tsx | 60 +++++++ .../common/__tests__/AccessDenied.test.tsx | 32 ++++ components/common/index.ts | 1 + .../governance/TimelockPendingBanner.tsx | 153 +++++++++++++++++ .../__tests__/TimelockPendingBanner.test.tsx | 92 ++++++++++ components/governance/index.ts | 1 + components/issuers/IssuerProfile.tsx | 4 + components/issuers/IssuerReputationPanel.tsx | 160 ++++++++++++++++++ .../__tests__/IssuerReputationPanel.test.tsx | 86 ++++++++++ components/issuers/index.ts | 6 + components/marketplace/BuyFractionModal.tsx | 15 ++ components/marketplace/TransferBlocked.tsx | 63 +++++++ .../BuyFractionModal.restriction.test.tsx | 55 ++++++ .../__tests__/TransferBlocked.test.tsx | 31 ++++ components/marketplace/index.ts | 1 + hooks/useIssuerReputation.ts | 13 ++ hooks/usePendingTimelockChanges.ts | 15 ++ hooks/useTransferRestriction.ts | 14 ++ lib/api/index.ts | 79 +++++++++ lib/reputation.ts | 59 +++++++ lib/restrictions.ts | 67 ++++++++ 24 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 components/common/AccessDenied.tsx create mode 100644 components/common/__tests__/AccessDenied.test.tsx create mode 100644 components/common/index.ts create mode 100644 components/governance/TimelockPendingBanner.tsx create mode 100644 components/governance/__tests__/TimelockPendingBanner.test.tsx create mode 100644 components/governance/index.ts create mode 100644 components/issuers/IssuerReputationPanel.tsx create mode 100644 components/issuers/__tests__/IssuerReputationPanel.test.tsx create mode 100644 components/marketplace/TransferBlocked.tsx create mode 100644 components/marketplace/__tests__/BuyFractionModal.restriction.test.tsx create mode 100644 components/marketplace/__tests__/TransferBlocked.test.tsx create mode 100644 hooks/useIssuerReputation.ts create mode 100644 hooks/usePendingTimelockChanges.ts create mode 100644 hooks/useTransferRestriction.ts create mode 100644 lib/reputation.ts create mode 100644 lib/restrictions.ts diff --git a/app/(dashboard)/investor/portfolio/page.tsx b/app/(dashboard)/investor/portfolio/page.tsx index 36f261b..bd1d412 100644 --- a/app/(dashboard)/investor/portfolio/page.tsx +++ b/app/(dashboard)/investor/portfolio/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useStellarWallet } from "@/hooks/useStellarWallet"; import { usePageTitle } from "@/hooks/usePageTitle"; import { usePageLoadPerformanceLog } from "@/hooks/usePageLoadPerformanceLog"; +import { TimelockPendingBanner } from "@/components/governance"; import { InvestorPortfolioPage } from "@/components/dashboard/InvestorPortfolioPage"; export default function PortfolioPage() { @@ -25,6 +26,9 @@ export default function PortfolioPage() { return (
+
+ +

My Portfolio

diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 0bb49fb..2b0699a 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -5,6 +5,7 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchInvoices, resolveInvoiceCategory, type Invoice, type InvoiceCategory } from "@/lib/api"; import { usePageTitle } from "@/hooks/usePageTitle"; +import { TimelockPendingBanner } from "@/components/governance"; import { FundingProgressBar } from "@/components/invoices"; import { ActiveTaxonomyChips, @@ -723,6 +724,7 @@ export default function MarketplacePage() { return (
+

Invoice Marketplace

+ + + + + ); + } + 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 ( +
    +
    +
    +
    + ); +} 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 ( +
    1. +
      +

      {formatReputationEvent(event.event_type)}

      +

      {formatEventDate(event.date)}

      +
      + + +
    2. + ); + })} +
    + )} +
    +
    + ); +} 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} + /> + +
    ) : ( <> + + ); +} diff --git a/components/marketplace/__tests__/BuyFractionModal.restriction.test.tsx b/components/marketplace/__tests__/BuyFractionModal.restriction.test.tsx new file mode 100644 index 0000000..1a4b531 --- /dev/null +++ b/components/marketplace/__tests__/BuyFractionModal.restriction.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BuyFractionModal } from "../BuyFractionModal"; +import * as api from "@/lib/api"; +import type { ResaleListing } from "@/lib/api"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const listing = { + id: "l1", + invoice_title: "Invoice A", + ask_price: 10, + remaining_quantity: 5, +} as unknown as ResaleListing; + +function renderModal() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {}} /> + , + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("BuyFractionModal transfer restriction", () => { + it("replaces the buy button when blocked and restores it after retry succeeds", async () => { + const spy = vi + .spyOn(api, "fetchTransferRestriction") + .mockResolvedValueOnce({ allowed: false, restriction: "kyc_required" }) + .mockResolvedValue({ allowed: true, restriction: null }); + + renderModal(); + expect(await screen.findByTestId("transfer-blocked")).toBeInTheDocument(); + expect(screen.queryByTestId("buy-confirm-btn")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("transfer-blocked-retry")); + await waitFor(() => expect(screen.getByTestId("buy-confirm-btn")).toBeInTheDocument()); + expect(screen.queryByTestId("transfer-blocked")).not.toBeInTheDocument(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("shows the normal buy button when allowed", async () => { + vi.spyOn(api, "fetchTransferRestriction").mockResolvedValue({ + allowed: true, + restriction: null, + }); + renderModal(); + expect(await screen.findByTestId("buy-confirm-btn")).toBeInTheDocument(); + expect(screen.queryByTestId("transfer-blocked")).not.toBeInTheDocument(); + }); +}); diff --git a/components/marketplace/__tests__/TransferBlocked.test.tsx b/components/marketplace/__tests__/TransferBlocked.test.tsx new file mode 100644 index 0000000..c35c7d0 --- /dev/null +++ b/components/marketplace/__tests__/TransferBlocked.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TransferBlocked } from "../TransferBlocked"; +import { ACCESS_RESTRICTIONS } from "@/lib/restrictions"; + +describe("TransferBlocked", () => { + it.each([ + ["kyc_required", "KYCRequired"], + ["jurisdiction_blocked", "JurisdictionBlocked"], + ["accreditation_required", "AccreditationRequired"], + ] as const)("shows chip and help for %s", (restriction, key) => { + render( {}} />); + expect(screen.getByTestId("transfer-blocked-chip")).toHaveTextContent( + ACCESS_RESTRICTIONS[key].chipLabel, + ); + expect(screen.getByTestId("transfer-blocked-help")).toHaveTextContent( + ACCESS_RESTRICTIONS[key].guidance, + ); + }); + + it("calls onRetry and disables the button while retrying", async () => { + const onRetry = vi.fn(); + const { rerender } = render(); + await userEvent.click(screen.getByTestId("transfer-blocked-retry")); + expect(onRetry).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByTestId("transfer-blocked-retry")).toBeDisabled(); + }); +}); diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts index 3151490..4f82063 100644 --- a/components/marketplace/index.ts +++ b/components/marketplace/index.ts @@ -26,3 +26,4 @@ export { InvestorDemandMetrics } from "./InvestorDemandMetrics"; export { FeaturedInvoicesCarousel } from "./FeaturedInvoicesCarousel"; export { YieldCalculator } from "./YieldCalculator"; export { MyListings } from "./MyListings"; +export { TransferBlocked } from "./TransferBlocked"; diff --git a/hooks/useIssuerReputation.ts b/hooks/useIssuerReputation.ts new file mode 100644 index 0000000..37dae8a --- /dev/null +++ b/hooks/useIssuerReputation.ts @@ -0,0 +1,13 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchIssuerReputation, type IssuerReputation } from "@/lib/api"; + +export function useIssuerReputation(wallet: string | null | undefined) { + return useQuery({ + queryKey: ["issuer-reputation", wallet], + queryFn: () => fetchIssuerReputation(wallet as string), + enabled: Boolean(wallet), + staleTime: 60 * 1000, + }); +} diff --git a/hooks/usePendingTimelockChanges.ts b/hooks/usePendingTimelockChanges.ts new file mode 100644 index 0000000..fe99f18 --- /dev/null +++ b/hooks/usePendingTimelockChanges.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchPendingTimelockChanges } from "@/lib/api"; + +export const PENDING_TIMELOCK_QUERY_KEY = ["governance", "timelock", "pending"] as const; + +export function usePendingTimelockChanges() { + return useQuery({ + queryKey: PENDING_TIMELOCK_QUERY_KEY, + queryFn: fetchPendingTimelockChanges, + staleTime: 60 * 1000, + retry: false, + }); +} diff --git a/hooks/useTransferRestriction.ts b/hooks/useTransferRestriction.ts new file mode 100644 index 0000000..ab26bc0 --- /dev/null +++ b/hooks/useTransferRestriction.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchTransferRestriction } from "@/lib/api"; + +export function useTransferRestriction(listingId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ["secondary-market", "transfer-check", listingId], + queryFn: () => fetchTransferRestriction(listingId as string), + enabled: enabled && Boolean(listingId), + staleTime: 0, + retry: false, + }); +} diff --git a/lib/api/index.ts b/lib/api/index.ts index 7e09841..4dc4fa7 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1597,6 +1597,7 @@ export async function fetchTimelockProposals( const res = await fetch(`${API_BASE}/admin/timelock/proposals`, { headers: authHeaders(token), }); + if (res.status === 403) throw new ApiForbiddenError("Not a signer"); if (!res.ok) throw new Error("Failed to fetch timelock proposals"); const payload = await res.json(); const proposals = Array.isArray(payload) @@ -2300,3 +2301,81 @@ export async function confirmTwoFactorSetup(code: string, token?: string): Promi }); if (!res.ok) return readAccountError(res, "Invalid verification code"); } + +// ─── Issuer reputation (#490) ──────────────────────────────────────────── + +export interface IssuerReputationEvent { + id: string; + /** e.g. "settled_on_time", "settled_late", "defaulted". */ + event_type: string; + date: string; + score_delta: number; +} + +export interface IssuerReputation { + wallet: string; + /** Null when the issuer has no settlement history yet. */ + score: number | null; + history: IssuerReputationEvent[]; +} + +export async function fetchIssuerReputation(wallet: string): Promise { + const res = await fetch(`${API_BASE}/issuers/${encodeURIComponent(wallet)}/reputation`); + if (res.status === 404) return { wallet, score: null, history: [] }; + if (!res.ok) throw new Error("Failed to fetch issuer reputation"); + const raw = await res.json(); + return { + wallet: raw.wallet ?? wallet, + score: typeof raw.score === "number" ? raw.score : null, + history: Array.isArray(raw.history) ? raw.history : [], + }; +} + +// ─── Pending timelock changes (#492) ───────────────────────────────────── + +export interface PendingTimelockChange { + id: string; + parameter: string; + current_value: string; + proposed_value: string; + /** ISO timestamp at which the change can be executed. */ + eta: string; +} + +export async function fetchPendingTimelockChanges(): Promise { + const res = await fetch(`${API_BASE}/governance/timelock/pending`); + if (!res.ok) throw new Error("Failed to fetch pending timelock changes"); + const payload = await res.json(); + const changes = Array.isArray(payload) ? payload : (payload?.changes ?? []); + return changes.map((c: any) => ({ + id: String(c.id), + parameter: String(c.parameter), + current_value: String(c.current_value ?? c.currentValue ?? "—"), + proposed_value: String(c.proposed_value ?? c.proposedValue ?? "—"), + eta: String(c.eta), + })); +} + +// ─── Transfer restriction pre-flight (#491) ────────────────────────────── + +export type TransferRestrictionType = + | "kyc_required" + | "jurisdiction_blocked" + | "accreditation_required"; + +export interface TransferRestrictionCheck { + allowed: boolean; + restriction: TransferRestrictionType | null; +} + +export async function fetchTransferRestriction( + listingId: string, +): Promise { + const res = await fetch( + `${API_BASE}/secondary-market/listings/${encodeURIComponent(listingId)}/transfer-check`, + ); + if (!res.ok) throw new Error("Failed to check transfer restrictions"); + const raw = await res.json(); + const restriction = (raw.restriction ?? null) as TransferRestrictionType | null; + return { allowed: raw.allowed !== false && !restriction, restriction }; +} diff --git a/lib/reputation.ts b/lib/reputation.ts new file mode 100644 index 0000000..085c1fe --- /dev/null +++ b/lib/reputation.ts @@ -0,0 +1,59 @@ +/** Issuer reputation tiers (#490). Thresholds are inclusive bounds. */ + +export type ReputationTierId = "unrated" | "bronze" | "silver" | "gold" | "platinum"; + +export interface ReputationTier { + id: ReputationTierId; + label: string; + min: number; + max: number; + /** Tailwind classes for the badge surface and icon. */ + className: string; +} + +export const REPUTATION_TIERS: ReputationTier[] = [ + { id: "bronze", label: "Bronze", min: 0, max: 39, className: "border-orange-300 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-200" }, + { id: "silver", label: "Silver", min: 40, max: 59, className: "border-slate-300 bg-slate-100 text-slate-800 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100" }, + { id: "gold", label: "Gold", min: 60, max: 79, className: "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200" }, + { id: "platinum", label: "Platinum", min: 80, max: 100, className: "border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-200" }, +]; + +export const UNRATED_TIER: ReputationTier = { + id: "unrated", + label: "Unrated", + min: 0, + max: 0, + className: "border-border bg-muted text-muted-foreground", +}; + +export function getReputationTier(score: number | null | undefined): ReputationTier { + if (score === null || score === undefined || Number.isNaN(score)) return UNRATED_TIER; + const rounded = Math.round(Math.min(100, Math.max(0, score))); + return ( + REPUTATION_TIERS.find((t) => rounded >= t.min && rounded <= t.max) ?? + REPUTATION_TIERS[REPUTATION_TIERS.length - 1] + ); +} + +const EVENT_LABELS: Record = { + settled_on_time: "Settled on time", + settled_late: "Settled late", + defaulted: "Defaulted", + invoice_funded: "Invoice fully funded", + kyc_verified: "KYC verified", +}; + +export function formatReputationEvent(eventType: string): string { + if (EVENT_LABELS[eventType]) return EVENT_LABELS[eventType]; + const spaced = eventType.replace(/[_-]+/g, " ").trim(); + return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : "Event"; +} + +/** Newest first; invalid dates sink to the end. */ +export function sortReputationHistory(history: T[]): T[] { + const time = (d: string) => { + const t = new Date(d).getTime(); + return Number.isNaN(t) ? -Infinity : t; + }; + return [...history].sort((a, b) => time(b.date) - time(a.date)); +} diff --git a/lib/restrictions.ts b/lib/restrictions.ts new file mode 100644 index 0000000..c30b50e --- /dev/null +++ b/lib/restrictions.ts @@ -0,0 +1,67 @@ +/** + * Access and transfer restriction metadata shared by the AccessDenied page + * and the secondary-market TransferBlocked state. + */ + +export type AccessRestrictionType = + | "KYCRequired" + | "JurisdictionBlocked" + | "AccreditationRequired" + | "SignerOnly"; + +export interface AccessRestrictionCta { + label: string; + href: string; +} + +export interface AccessRestrictionConfig { + heading: string; + explanation: string; + /** Short label used by compact surfaces such as the restriction chip. */ + chipLabel: string; + /** Resolution steps, shown where there is room for guidance. */ + guidance: string; + cta: AccessRestrictionCta | null; +} + +export const KYC_UPGRADE_ROUTE = "/kyc/upgrade"; +export const SUPPORT_ROUTE = "/help"; +export const ACCREDITATION_ROUTE = "/kyc/accreditation"; + +export const ACCESS_RESTRICTIONS: Record = { + KYCRequired: { + heading: "Identity verification required", + explanation: + "Your wallet has not completed the KYC tier needed for this feature.", + chipLabel: "KYC Required", + guidance: + "Complete or upgrade your KYC verification, then come back and retry. Approval usually appears within a few minutes.", + cta: { label: "Upgrade KYC", href: KYC_UPGRADE_ROUTE }, + }, + JurisdictionBlocked: { + heading: "Not available in your region", + explanation: + "This feature is restricted for the jurisdiction linked to your wallet.", + chipLabel: "Jurisdiction Blocked", + guidance: + "If your registered jurisdiction is wrong, contact support with proof of residence so it can be reviewed.", + cta: { label: "Contact Support", href: SUPPORT_ROUTE }, + }, + AccreditationRequired: { + heading: "Accreditation required", + explanation: + "This feature is limited to accredited investors, and your wallet is not accredited yet.", + chipLabel: "Accreditation Required", + guidance: + "Submit an accreditation application with supporting documents. Once it is approved, retry.", + cta: { label: "Apply for Accreditation", href: ACCREDITATION_ROUTE }, + }, + SignerOnly: { + heading: "Signers only", + explanation: + "Only wallets in the governance signer set can use this area. Connect a signer wallet to continue.", + chipLabel: "Signer Only", + guidance: "Connect a wallet that is in the governance signer set.", + cta: null, + }, +};