diff --git a/invofi/apps/frontend/src/app/dashboard/page.tsx b/invofi/apps/frontend/src/app/dashboard/page.tsx index f8369a8d7..d60e708c9 100644 --- a/invofi/apps/frontend/src/app/dashboard/page.tsx +++ b/invofi/apps/frontend/src/app/dashboard/page.tsx @@ -19,6 +19,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage'; import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; import type { UserProfile, Invoice } from '@/types'; +import { ReputationCard } from '@/components/reputation/ReputationCard'; import { SupabaseUser } from '@/lib/types/supabase-auth'; export default function DashboardPage() { @@ -237,6 +238,15 @@ export default function DashboardPage() { )} + {/* Reputation card — shown for business users with a connected wallet */} + {isBusiness && publicKey && ( + + )} + {!isBusiness && (

Your Investments

diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index 81a8d8f8a..6eeabdc1c 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -17,6 +17,8 @@ import { formatAmount, formatDate, interestRateLabel, durationLabel, toStroopsBi import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; import type { FinancingOffer } from '@/types'; +import { InsurancePanel } from '@/components/insurance/InsurancePanel'; +import { PayoutHistory } from '@/components/insurance/PayoutHistory'; import { SupabaseUser } from '@/lib/types/supabase-auth'; @@ -277,6 +279,7 @@ function CopyId({ id }: { id: string }) { } export default function PortfolioPage() { + const { publicKey } = useWallet(); const [offers, setOffers] = useState([]); const [loading, setLoading] = useState(true); @@ -462,6 +465,9 @@ export default function PortfolioPage() { })} + {/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */} + + {/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */} diff --git a/invofi/apps/frontend/src/components/insurance/InsurancePanel.tsx b/invofi/apps/frontend/src/components/insurance/InsurancePanel.tsx new file mode 100644 index 000000000..ae27153cc --- /dev/null +++ b/invofi/apps/frontend/src/components/insurance/InsurancePanel.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState } from 'react'; +import { Shield, RefreshCw, Coins, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useInsurancePool } from '@/hooks/useInsurance'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const STROOPS = 10_000_000; + +function formatStroops(v: bigint | null): string { + if (v === null) return '—'; + return (Number(v) / STROOPS).toFixed(7).replace(/\.?0+$/, ''); +} + +/** Parse human-unit decimal into stroops bigint. Returns null on invalid input. */ +function parseToStroops(v: string): bigint | null { + if (!/^\d+(\.\d{1,7})?$/.test(v.trim())) return null; + const [whole, frac = ''] = v.trim().split('.'); + const padded = frac.padEnd(7, '0').slice(0, 7); + try { + return BigInt(whole + padded); + } catch { + return null; + } +} + +// ── InsurancePanel ─────────────────────────────────────────────────────────── + +interface InsurancePanelProps { + /** Connected wallet address (null = wallet not connected). */ + walletAddress: string | null; +} + +/** + * Full insurance-pool section for the portfolio page. + * + * Displays: + * - Pool total (from the on-chain insurance contract) + * - Connected wallet's staked balance (on-chain read, refreshed after mutations) + * - Stake / unstake form + */ +export function InsurancePanel({ walletAddress }: InsurancePanelProps) { + const { poolTotal, stakedBalance, loading, staking, unstaking, stake, unstake, refresh } = + useInsurancePool(walletAddress); + + const [amount, setAmount] = useState(''); + + const parsedAmount = parseToStroops(amount); + const hasSufficientStake = + parsedAmount !== null && stakedBalance !== null && parsedAmount <= stakedBalance; + + const handleStake = async () => { + if (!parsedAmount || parsedAmount <= 0n) return; + await stake(parsedAmount); + setAmount(''); + }; + + const handleUnstake = async () => { + if (!parsedAmount || parsedAmount <= 0n) return; + await unstake(parsedAmount); + setAmount(''); + }; + + return ( + + + {/* Header */} +
+
+ +

Insurance Pool

+
+ +
+ +

+ Stake tokens into the protocol insurance pool to back payouts when invoices default. + Your staked balance earns exposure to protocol yield and can be withdrawn at any time. +

+ + {/* Pool stats */} +
+
+
+ + Pool Total +
+ {loading ? ( + + ) : ( +

+ {formatStroops(poolTotal)} +

+ )} +
+ +
+
+ + Your Staked +
+ {loading ? ( + + ) : !walletAddress ? ( +

+ ) : ( +

+ {formatStroops(stakedBalance)} +

+ )} +
+
+ + {/* Stake / unstake form */} + {!walletAddress ? ( +

Connect a wallet to stake or unstake.

+ ) : ( +
+
+ + setAmount(e.target.value)} + placeholder="0.0" + inputMode="decimal" + className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50" + /> +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/invofi/apps/frontend/src/components/insurance/PayoutHistory.tsx b/invofi/apps/frontend/src/components/insurance/PayoutHistory.tsx new file mode 100644 index 000000000..18e57b48d --- /dev/null +++ b/invofi/apps/frontend/src/components/insurance/PayoutHistory.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { ExternalLink } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { usePayoutHistory } from '@/hooks/useInsurance'; +import { formatAddress } from '@/lib/utils'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const STROOPS = 10_000_000; +const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; +const EXPLORER = `https://stellar.expert/explorer/${NETWORK}`; + +function fmtAmount(v: bigint): string { + return (Number(v) / STROOPS).toFixed(7).replace(/\.?0+$/, ''); +} + +const EVENT_LABELS: Record = { + pool_stk: { label: 'Staked', color: 'bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300' }, + pool_un: { label: 'Unstaked', color: 'bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300' }, + pool_pay: { label: 'Payout', color: 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300' }, +}; + +// ── PayoutHistory ───────────────────────────────────────────────────────────── + +/** + * Displays a live feed of insurance pool events: + * - pool_pay — default payout (highlighted in red) + * - pool_stk — stake events + * - pool_un — unstake events + * + * Events are sourced from the Soroban RPC event stream via `usePayoutHistory`. + */ +export function PayoutHistory() { + const { payouts, allEvents, loadingEvents } = usePayoutHistory(); + + const hasInsuranceContract = Boolean(process.env.NEXT_PUBLIC_INSURANCE_CONTRACT_ID); + + return ( + + +
+

Insurance Pool Events

+ Live +
+ +

+ Live stream of stake, unstake, and payout events from the insurance contract. + Payouts are triggered when an invoice defaults and the lender reclaims. +

+ + {!hasInsuranceContract ? ( +

+ Insurance contract not configured on this deployment. +

+ ) : loadingEvents ? ( +
+ {[1, 2, 3].map(i => )} +
+ ) : allEvents.length === 0 ? ( +

No pool events yet. Events appear here in real time.

+ ) : ( +
+ {allEvents.map((ev, i) => { + const meta = EVENT_LABELS[ev.type] ?? { label: ev.type, color: 'bg-muted text-muted-foreground' }; + // Type-narrow for amount display + const amount: bigint | undefined = + (ev.type === 'pool_pay' || ev.type === 'pool_stk' || ev.type === 'pool_un') + ? (ev.data as { amount: bigint }).amount + : undefined; + const party: string | undefined = + (ev.type === 'pool_pay' || ev.type === 'pool_stk' || ev.type === 'pool_un') + ? ((ev.data as { recipient?: string; staker?: string }).recipient ?? + (ev.data as { staker?: string }).staker) + : undefined; + + return ( +
+
+ + {meta.label} + + {party && ( + + {formatAddress(party)} + + )} + {amount !== undefined && ( + + {fmtAmount(amount)} + + )} +
+
+ ledger {ev.ledger} + + + +
+
+ ); + })} +
+ )} + + {/* Dedicated payout summary */} + {payouts.length > 0 && ( +
+

+ Default payouts ({payouts.length}) +

+
+ {payouts.map((p, i) => ( +
+ {formatAddress(p.recipient)} + + {fmtAmount(p.amount)} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx b/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx index 20a5102ee..41c81db9d 100644 --- a/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx +++ b/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { formatAmount, formatDate, formatAddress, INVOICE_STATUS_COLORS } from '@/lib/utils'; import type { Invoice } from '@/types'; +import { ReputationScoreBadge } from '@/components/reputation/ReputationCard'; const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; const STELLAR_EXPERT = `https://stellar.expert/explorer/${NETWORK}`; @@ -74,17 +75,22 @@ export function MarketplaceCard({ invoice }: MarketplaceCardProps) { -

- Originator:{' '} - - {formatAddress(invoice.originator)} - -

+ {/* Originator row: address + reputation score badge */} +
+

+ Originator:{' '} + + {formatAddress(invoice.originator)} + +

+ {/* Reputation score badge — renders null when contract not configured */} + +