diff --git a/components/invoices/ImpactReport.tsx b/components/invoices/ImpactReport.tsx new file mode 100644 index 0000000..91ad278 --- /dev/null +++ b/components/invoices/ImpactReport.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { fetchIpfsText } from "@/lib/ipfs"; +import type { Invoice } from "@/lib/api"; + +export function ImpactReport({ report }: { report?: Invoice["impact_report"] }) { + const [content, setContent] = useState(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + let active = true; + if (report?.metrics_hash) fetchIpfsText(report.metrics_hash).then((value) => { if (active) setContent(value); }).catch(() => { if (active) setFailed(true); }); + return () => { active = false; }; + }, [report?.metrics_hash]); + if (!report) return null; + let formatted: string; + try { formatted = JSON.stringify(JSON.parse(content ?? ""), null, 2); } catch { formatted = content ?? ""; } + return

Impact report

{report.type}{report.verified && Admin verified}
{failed ?

The report could not be loaded from IPFS.

: content === null ?

Loading report…

:
{formatted}
}
; +} diff --git a/components/invoices/InvestmentModal.tsx b/components/invoices/InvestmentModal.tsx index 0a6240c..57ada7c 100644 --- a/components/invoices/InvestmentModal.tsx +++ b/components/invoices/InvestmentModal.tsx @@ -36,6 +36,7 @@ interface InvestmentModalProps { /** Total funding cap used for price-impact calculation (issue #344). */ fundingCap?: number; onSuccess?: () => void; + trancheType?: 1 | 2; } export function InvestmentModal({ @@ -44,6 +45,7 @@ export function InvestmentModal({ maxInvestment, fundingCap, onSuccess, + trancheType, }: InvestmentModalProps) { const [isOpen, setIsOpen] = useState(false); const [validAmount, setValidAmount] = useState(null); @@ -77,6 +79,7 @@ export function InvestmentModal({ await investMutation.mutateAsync({ invoiceId, amount: validAmount, + ...(trancheType ? { trancheType } : {}), ...(isBaseCurrency ? {} : { currency }), }); setIsOpen(false); diff --git a/components/invoices/InvoiceDetailView.tsx b/components/invoices/InvoiceDetailView.tsx index 9e340e7..4872f98 100644 --- a/components/invoices/InvoiceDetailView.tsx +++ b/components/invoices/InvoiceDetailView.tsx @@ -10,6 +10,7 @@ */ import Link from "next/link"; +import { useState } from "react"; import type { ReactNode } from "react"; import { FileQuestion, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -56,6 +57,8 @@ import { truncateAddress } from "@/lib/stellar"; import { DEFAULT_MIN_INVESTMENT } from "@/lib/invoiceDefaults"; import { Money } from "@/components/currency"; import { CurrencyToggle } from "@/components/layout/CurrencyToggle"; +import { InvoiceGreylistStatus } from "@/components/invoices/InvoiceGreylistStatus"; +import { ImpactReport } from "@/components/invoices/ImpactReport"; function InvoiceDetailSkeleton() { return ( @@ -126,6 +129,7 @@ function InvestCallToAction({ yieldPercentage, maturityDate, fractionPrice, + trancheStatus, }: { invoiceId: string; minInvestment: number; @@ -134,7 +138,9 @@ function InvestCallToAction({ yieldPercentage?: number | null; maturityDate: string; fractionPrice?: number; + trancheStatus?: NonNullable; }) { + const [selectedTranche, setSelectedTranche] = useState<1 | 2>(1); const { isConnected } = useWallet(); const { isApproved, isLoading, status } = useIsKycApproved({ enabled: isConnected, @@ -176,6 +182,7 @@ function InvestCallToAction({ minInvestment={minInvestment} maxInvestment={maxInvestment} fundingCap={fundingCap} + trancheType={trancheStatus ? selectedTranche : undefined} /> + {trancheStatus &&
{([1, 2] as const).map((type) => { const senior = type === 1; const fill = senior ? trancheStatus.senior_filled : trancheStatus.junior_filled; const pct = senior ? trancheStatus.senior_pct : trancheStatus.junior_pct; const cap = fundingCap * pct / 100; const rate = (yieldPercentage ?? 0) + (senior ? 0 : trancheStatus.junior_yield_premium_bps / 100); const progress = cap > 0 ? Math.min(100, fill / cap * 100) : 0; return ; })}
} {body}
@@ -219,7 +227,8 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) { const expired = isExpired(invoice.due_date); const remaining = Math.max(0, invoice.amount - invoice.raised); - const isOpenForInvestment = invoice.status === "open" && !expired && remaining > 0; + const isGreylisted = Boolean(invoice.greylist && (!invoice.greylist.clears_at || new Date(invoice.greylist.clears_at).getTime() > Date.now())); + const isOpenForInvestment = invoice.status === "open" && !expired && remaining > 0 && !isGreylisted; // Qualifying investor: connected wallet appears in the investor list (#348). const isQualifyingInvestor = Boolean( address && invoice.investors.some((inv) => inv.address === address) @@ -308,6 +317,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) { + + {invoice.status === "settled" && } {/* Repayment ledger (#478): interim instalments and the final settlement, shown once the invoice has been funded. */} @@ -363,6 +374,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) { /> + {invoice.tranche_status &&

Tranche status

Senior {invoice.tranche_status.senior_filled.toLocaleString()} / {invoice.amount * invoice.tranche_status.senior_pct / 100} XLM · Junior {invoice.tranche_status.junior_filled.toLocaleString()} / {invoice.amount * invoice.tranche_status.junior_pct / 100} XLM

} @@ -434,6 +446,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) { yieldPercentage={invoice.yield_percentage} maturityDate={invoice.maturity_date ?? invoice.due_date} fractionPrice={invoice.fraction_price} + trancheStatus={invoice.tranche_status ?? undefined} /> ) : ( diff --git a/components/invoices/InvoiceGreylistStatus.tsx b/components/invoices/InvoiceGreylistStatus.tsx new file mode 100644 index 0000000..9293e5b --- /dev/null +++ b/components/invoices/InvoiceGreylistStatus.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AlertTriangle } from "lucide-react"; +import type { Invoice } from "@/lib/api"; + +function remaining(until?: string | null, now = Date.now()) { + if (!until) return null; + const seconds = Math.max(0, Math.floor((new Date(until).getTime() - now) / 1000)); + return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; +} + +export function InvoiceGreylistStatus({ greylist, compact = false }: { greylist?: Invoice["greylist"]; compact?: boolean }) { + const [now, setNow] = useState(Date.now()); + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(timer); }, []); + if (!greylist || (greylist.clears_at && new Date(greylist.clears_at).getTime() <= now)) return null; + if (compact) return Compliance hold; + return

Compliance hold — new investments are paused

{greylist.reason &&

{greylist.reason}

}{greylist.clears_at &&

Auto-clear in {remaining(greylist.clears_at, now)}

}

Existing holders can still exit their positions.

; +} diff --git a/components/issuers/IssuerCreditTierBadge.tsx b/components/issuers/IssuerCreditTierBadge.tsx new file mode 100644 index 0000000..0b088bb --- /dev/null +++ b/components/issuers/IssuerCreditTierBadge.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchIssuerCreditScore } from "@/lib/api"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +const colors = { + C: "bg-red-100 text-red-800", B: "bg-amber-100 text-amber-800", + A: "bg-slate-100 text-slate-800", AA: "bg-blue-100 text-blue-800", + AAA: "bg-yellow-100 text-yellow-900", +} as const; + +export function IssuerCreditTierBadge({ wallet }: { wallet: string }) { + const { data } = useQuery({ queryKey: ["issuer-credit-score", wallet], queryFn: () => fetchIssuerCreditScore(wallet), staleTime: 60_000 }); + if (!data) return null; + return {data.tier} · {data.score}

Credit score factors

{Object.entries(data.factors).map(([name, score]) =>

{name.replaceAll("_", " ")}: {score}

)}
; +} diff --git a/components/issuers/IssuerProfile.tsx b/components/issuers/IssuerProfile.tsx index a6e23e8..d57e339 100644 --- a/components/issuers/IssuerProfile.tsx +++ b/components/issuers/IssuerProfile.tsx @@ -10,6 +10,7 @@ import { IssuerScoreCard, TotalFunded } from "./IssuerScoreCard"; import { InvoiceHistoryTable } from "./InvoiceHistoryTable"; import { ActiveInvoices } from "./ActiveInvoices"; import { IssuerReputationHeaderBadge, IssuerReputationPanel } from "./IssuerReputationPanel"; +import { IssuerCreditTierBadge } from "./IssuerCreditTierBadge"; function IssuerProfileSkeleton() { return ( @@ -80,6 +81,7 @@ export function IssuerProfile({ issuerId, now }: IssuerProfileProps) { {issuer.name} + diff --git a/components/marketplace/invoice-card.tsx b/components/marketplace/invoice-card.tsx index af17bb7..d635120 100644 --- a/components/marketplace/invoice-card.tsx +++ b/components/marketplace/invoice-card.tsx @@ -21,6 +21,8 @@ import type { WatchlistStateChange } from "@/lib/watchlist"; import { Money } from "@/components/currency"; import { useSuitabilityTier } from "@/hooks/useSuitabilityTier"; import { REQUIRED_TIER, TIER_LABELS, canInvestInGrade } from "@/lib/suitability"; +import { InvoiceGreylistStatus } from "@/components/invoices/InvoiceGreylistStatus"; +import { IssuerCreditTierBadge } from "@/components/issuers/IssuerCreditTierBadge"; const statusVariant: Record = { open: "default", @@ -66,6 +68,7 @@ export function InvoiceCard({ {invoice.title} +
Issuer {invoice.seller.slice(0, 6)}…{invoice.seller.slice(-4)}
{stateChange && }
@@ -76,6 +79,7 @@ export function InvoiceCard({
+ @@ -148,7 +152,7 @@ export function InvoiceCard({