Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions invofi/apps/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -237,6 +238,15 @@ export default function DashboardPage() {
</section>
)}

{/* Reputation card — shown for business users with a connected wallet */}
{isBusiness && publicKey && (
<ReputationCard
address={publicKey}
showHistory
className="mt-8"
/>
)}

{!isBusiness && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">Your Investments</h2>
Expand Down
6 changes: 6 additions & 0 deletions invofi/apps/frontend/src/app/portfolio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -277,6 +279,7 @@ function CopyId({ id }: { id: string }) {
}

export default function PortfolioPage() {
const { publicKey } = useWallet();
const [offers, setOffers] = useState<FinancingOffer[]>([]);
const [loading, setLoading] = useState(true);

Expand Down Expand Up @@ -462,6 +465,9 @@ export default function PortfolioPage() {
})}
</div>

{/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */}
<InsurancePanel walletAddress={publicKey} />
<PayoutHistory />
{/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */}
<Suspense fallback={null}>
<TransferPositionCard />
Expand Down
186 changes: 186 additions & 0 deletions invofi/apps/frontend/src/components/insurance/InsurancePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="mt-6" id="insurance">
<CardContent className="pt-5">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-emerald-500" />
<h2 className="text-lg font-semibold text-foreground">Insurance Pool</h2>
</div>
<Button
size="sm"
variant="ghost"
onClick={refresh}
disabled={loading}
aria-label="Refresh insurance data"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>

<p className="text-xs text-muted-foreground mb-4">
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.
</p>

{/* Pool stats */}
<div className="grid grid-cols-2 gap-4 mb-5">
<div className="rounded-xl bg-muted/40 p-3">
<div className="flex items-center gap-1.5 mb-0.5">
<Coins className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Pool Total</span>
</div>
{loading ? (
<Skeleton className="h-5 w-28 mt-1" />
) : (
<p className="text-base font-semibold font-mono text-foreground tabular-nums">
{formatStroops(poolTotal)}
</p>
)}
</div>

<div className="rounded-xl bg-muted/40 p-3">
<div className="flex items-center gap-1.5 mb-0.5">
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Your Staked</span>
</div>
{loading ? (
<Skeleton className="h-5 w-28 mt-1" />
) : !walletAddress ? (
<p className="text-sm text-muted-foreground mt-0.5">—</p>
) : (
<p className="text-base font-semibold font-mono text-foreground tabular-nums">
{formatStroops(stakedBalance)}
</p>
)}
</div>
</div>

{/* Stake / unstake form */}
{!walletAddress ? (
<p className="text-sm text-muted-foreground">Connect a wallet to stake or unstake.</p>
) : (
<div className="grid md:grid-cols-[1fr_auto] gap-3 items-end">
<div>
<label
htmlFor="insurance-amount"
className="text-xs font-medium text-muted-foreground mb-1 block"
>
Amount
{stakedBalance !== null && (
<span className="ml-1 text-muted-foreground/70">
(staked: {formatStroops(stakedBalance)})
</span>
)}
</label>
<input
id="insurance-amount"
value={amount}
onChange={e => 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"
/>
</div>

<div className="flex gap-2">
<Button
variant="outline"
onClick={handleUnstake}
disabled={
unstaking ||
staking ||
!parsedAmount ||
parsedAmount <= 0n ||
!hasSufficientStake
}
title={
!hasSufficientStake && parsedAmount
? 'Insufficient staked balance'
: undefined
}
>
<ArrowDownToLine className="h-3.5 w-3.5 mr-1.5" />
{unstaking ? 'Unstaking…' : 'Unstake'}
</Button>
<Button
onClick={handleStake}
disabled={staking || unstaking || !parsedAmount || parsedAmount <= 0n}
>
<ArrowUpFromLine className="h-3.5 w-3.5 mr-1.5" />
{staking ? 'Staking…' : 'Stake'}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
138 changes: 138 additions & 0 deletions invofi/apps/frontend/src/components/insurance/PayoutHistory.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; color: string }> = {
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 (
<Card className="mt-6">
<CardContent className="pt-5">
<div className="flex items-center justify-between mb-1">
<h2 className="text-lg font-semibold text-foreground">Insurance Pool Events</h2>
<span className="text-xs text-muted-foreground">Live</span>
</div>

<p className="text-xs text-muted-foreground mb-4">
Live stream of stake, unstake, and payout events from the insurance contract.
Payouts are triggered when an invoice defaults and the lender reclaims.
</p>

{!hasInsuranceContract ? (
<p className="text-sm text-muted-foreground">
Insurance contract not configured on this deployment.
</p>
) : loadingEvents ? (
<div className="space-y-2">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}
</div>
) : allEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">No pool events yet. Events appear here in real time.</p>
) : (
<div className="space-y-2">
{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 (
<div
key={`${ev.txHash}-${i}`}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-muted/30 border border-border text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<Badge className={`${meta.color} shrink-0 text-[10px] px-1.5 py-0`}>
{meta.label}
</Badge>
{party && (
<span className="font-mono text-muted-foreground truncate max-w-[120px]">
{formatAddress(party)}
</span>
)}
{amount !== undefined && (
<span className="font-mono text-foreground tabular-nums">
{fmtAmount(amount)}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<span className="text-muted-foreground/70">ledger {ev.ledger}</span>
<a
href={`${EXPLORER}/tx/${ev.txHash}`}
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 hover:text-blue-600"
title="View on Stellar Expert"
>
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
);
})}
</div>
)}

{/* Dedicated payout summary */}
{payouts.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
Default payouts ({payouts.length})
</p>
<div className="space-y-1.5">
{payouts.map((p, i) => (
<div key={`${p.txHash}-${i}`} className="flex items-center justify-between text-xs">
<span className="font-mono text-muted-foreground">{formatAddress(p.recipient)}</span>
<span className="font-mono text-red-600 dark:text-red-400 tabular-nums">
{fmtAmount(p.amount)}
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
Loading
Loading